mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 15:24:32 +00:00
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/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/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.
|
- 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.
|
- 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.
|
- 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.
|
- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
|
resolveSessionFilePath,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
@@ -229,8 +230,16 @@ async function buildSubagentStatsLine(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sessionId = entry?.sessionId;
|
const sessionId = entry?.sessionId;
|
||||||
const transcriptPath =
|
let transcriptPath: string | undefined;
|
||||||
sessionId && storePath ? path.join(path.dirname(storePath), `${sessionId}.jsonl`) : undefined;
|
if (sessionId && storePath) {
|
||||||
|
try {
|
||||||
|
transcriptPath = resolveSessionFilePath(sessionId, entry, {
|
||||||
|
sessionsDir: path.dirname(storePath),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
transcriptPath = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const input = entry?.inputTokens;
|
const input = entry?.inputTokens;
|
||||||
const output = entry?.outputTokens;
|
const output = entry?.outputTokens;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||||
import { jsonResult, readStringArrayParam } from "./common.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 sessionId = typeof entry.sessionId === "string" ? entry.sessionId : undefined;
|
||||||
const transcriptPath =
|
const sessionFileRaw = (entry as { sessionFile?: unknown }).sessionFile;
|
||||||
sessionId && storePath
|
const sessionFile = typeof sessionFileRaw === "string" ? sessionFileRaw : undefined;
|
||||||
? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
|
let transcriptPath: string | undefined;
|
||||||
: undefined;
|
if (sessionId && storePath) {
|
||||||
|
try {
|
||||||
|
transcriptPath = resolveSessionFilePath(
|
||||||
|
sessionId,
|
||||||
|
sessionFile ? { sessionFile } : undefined,
|
||||||
|
{ sessionsDir: path.dirname(storePath) },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
transcriptPath = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const row: SessionListRow = {
|
const row: SessionListRow = {
|
||||||
key: displayKey,
|
key: displayKey,
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ export type SessionInitResult = {
|
|||||||
|
|
||||||
function forkSessionFromParent(params: {
|
function forkSessionFromParent(params: {
|
||||||
parentEntry: SessionEntry;
|
parentEntry: SessionEntry;
|
||||||
|
sessionsDir: string;
|
||||||
}): { sessionId: string; sessionFile: string } | null {
|
}): { sessionId: string; sessionFile: string } | null {
|
||||||
const parentSessionFile = resolveSessionFilePath(
|
const parentSessionFile = resolveSessionFilePath(
|
||||||
params.parentEntry.sessionId,
|
params.parentEntry.sessionId,
|
||||||
params.parentEntry,
|
params.parentEntry,
|
||||||
|
{ sessionsDir: params.sessionsDir },
|
||||||
);
|
);
|
||||||
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
|
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -320,6 +322,7 @@ export async function initSessionState(params: {
|
|||||||
);
|
);
|
||||||
const forked = forkSessionFromParent({
|
const forked = forkSessionFromParent({
|
||||||
parentEntry: sessionStore[parentSessionKey],
|
parentEntry: sessionStore[parentSessionKey],
|
||||||
|
sessionsDir: path.dirname(storePath),
|
||||||
});
|
});
|
||||||
if (forked) {
|
if (forked) {
|
||||||
sessionId = forked.sessionId;
|
sessionId = forked.sessionId;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolveStorePath } from "./paths.js";
|
import {
|
||||||
|
resolveSessionFilePath,
|
||||||
|
resolveSessionTranscriptPath,
|
||||||
|
resolveSessionTranscriptPathInDir,
|
||||||
|
resolveStorePath,
|
||||||
|
validateSessionId,
|
||||||
|
} from "./paths.js";
|
||||||
|
|
||||||
describe("resolveStorePath", () => {
|
describe("resolveStorePath", () => {
|
||||||
afterEach(() => {
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { SessionEntry } from "./types.js";
|
|
||||||
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
||||||
import { resolveStateDir } from "../paths.js";
|
import { resolveStateDir } from "../paths.js";
|
||||||
@@ -34,11 +33,44 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
|
|||||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
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,
|
sessionId: string,
|
||||||
agentId?: string,
|
sessionsDir: string,
|
||||||
topicId?: string | number,
|
topicId?: string | number,
|
||||||
): string {
|
): string {
|
||||||
|
const safeSessionId = validateSessionId(sessionId);
|
||||||
const safeTopicId =
|
const safeTopicId =
|
||||||
typeof topicId === "string"
|
typeof topicId === "string"
|
||||||
? encodeURIComponent(topicId)
|
? encodeURIComponent(topicId)
|
||||||
@@ -46,17 +78,31 @@ export function resolveSessionTranscriptPath(
|
|||||||
? String(topicId)
|
? String(topicId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const fileName =
|
const fileName =
|
||||||
safeTopicId !== undefined ? `${sessionId}-topic-${safeTopicId}.jsonl` : `${sessionId}.jsonl`;
|
safeTopicId !== undefined
|
||||||
return path.join(resolveAgentSessionsDir(agentId), fileName);
|
? `${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(
|
export function resolveSessionFilePath(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
entry?: SessionEntry,
|
entry?: { sessionFile?: string },
|
||||||
opts?: { agentId?: string },
|
opts?: { agentId?: string; sessionsDir?: string },
|
||||||
): string {
|
): string {
|
||||||
|
const sessionsDir = resolveSessionsDir(opts);
|
||||||
const candidate = entry?.sessionFile?.trim();
|
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 }) {
|
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { SessionEntry } from "./types.js";
|
import type { SessionEntry } from "./types.js";
|
||||||
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.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";
|
import { loadSessionStore, updateSessionStore } from "./store.js";
|
||||||
|
|
||||||
function stripQuery(value: string): string {
|
function stripQuery(value: string): string {
|
||||||
@@ -103,8 +103,17 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
|||||||
return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
|
return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionFile =
|
let sessionFile: string;
|
||||||
entry.sessionFile?.trim() || resolveSessionTranscriptPath(entry.sessionId, params.agentId);
|
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 });
|
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("gateway chat.inject transcript writes", () => {
|
|||||||
return {
|
return {
|
||||||
...original,
|
...original,
|
||||||
loadSessionEntry: () => ({
|
loadSessionEntry: () => ({
|
||||||
storePath: "/tmp/store.json",
|
storePath: path.join(dir, "sessions.json"),
|
||||||
entry: {
|
entry: {
|
||||||
sessionId: "sess-1",
|
sessionId: "sess-1",
|
||||||
sessionFile: transcriptPath,
|
sessionFile: transcriptPath,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
|||||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||||
|
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||||
import {
|
import {
|
||||||
@@ -54,13 +55,19 @@ function resolveTranscriptPath(params: {
|
|||||||
sessionFile?: string;
|
sessionFile?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const { sessionId, storePath, sessionFile } = params;
|
const { sessionId, storePath, sessionFile } = params;
|
||||||
if (sessionFile) {
|
if (!storePath && !sessionFile) {
|
||||||
return sessionFile;
|
return null;
|
||||||
}
|
}
|
||||||
if (!storePath) {
|
try {
|
||||||
|
const sessionsDir = storePath ? path.dirname(storePath) : undefined;
|
||||||
|
return resolveSessionFilePath(
|
||||||
|
sessionId,
|
||||||
|
sessionFile ? { sessionFile } : undefined,
|
||||||
|
sessionsDir ? { sessionsDir } : undefined,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
|
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 () => {
|
it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => {
|
||||||
const storeKey = "agent:opus:slack:dm:u123";
|
const storeKey = "agent:opus:slack:dm:u123";
|
||||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-usage-test-"));
|
||||||
const sessionFile = path.join(tempDir, "s-opus.jsonl");
|
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||||
fs.writeFileSync(sessionFile, "", "utf-8");
|
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();
|
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"]({
|
await usageHandlers["sessions.usage"]({
|
||||||
respond,
|
respond,
|
||||||
params: {
|
params: {
|
||||||
startDate: "2026-02-01",
|
startDate: "2026-02-01",
|
||||||
endDate: "2026-02-02",
|
endDate: "2026-02-02",
|
||||||
key: "agent:opus:s-opus",
|
key: "agent:opus:../../etc/passwd",
|
||||||
limit: 10,
|
limit: 10,
|
||||||
},
|
},
|
||||||
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
|
} as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]);
|
||||||
|
|
||||||
expect(respond).toHaveBeenCalledTimes(1);
|
expect(respond).toHaveBeenCalledTimes(1);
|
||||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
expect(respond.mock.calls[0]?.[0]).toBe(false);
|
||||||
const result = respond.mock.calls[0]?.[1] as unknown as { sessions: Array<{ key: string }> };
|
const error = respond.mock.calls[0]?.[2] as { message?: string } | undefined;
|
||||||
expect(result.sessions).toHaveLength(1);
|
expect(error?.message).toContain("Invalid session reference");
|
||||||
expect(result.sessions[0]?.key).toBe(storeKey);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||||
import type {
|
import type {
|
||||||
CostUsageSummary,
|
CostUsageSummary,
|
||||||
@@ -291,7 +292,7 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
|
const specificKey = typeof p.key === "string" ? p.key.trim() : null;
|
||||||
|
|
||||||
// Load session store for named sessions
|
// Load session store for named sessions
|
||||||
const { store } = loadCombinedSessionStoreForGateway(config);
|
const { storePath, store } = loadCombinedSessionStoreForGateway(config);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Merge discovered sessions with store entries
|
// Merge discovered sessions with store entries
|
||||||
@@ -331,9 +332,21 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
const sessionId = storeEntry?.sessionId ?? keyRest;
|
const sessionId = storeEntry?.sessionId ?? keyRest;
|
||||||
|
|
||||||
// Resolve the session file path
|
// Resolve the session file path
|
||||||
const sessionFile = resolveSessionFilePath(sessionId, storeEntry, {
|
let sessionFile: string;
|
||||||
agentId: agentIdFromKey,
|
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 {
|
try {
|
||||||
const stats = fs.statSync(sessionFile);
|
const stats = fs.statSync(sessionFile);
|
||||||
@@ -756,15 +769,25 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const { entry } = loadSessionEntry(key);
|
const { entry, storePath } = loadSessionEntry(key);
|
||||||
|
|
||||||
// For discovered sessions (not in store), try using key as sessionId directly
|
// For discovered sessions (not in store), try using key as sessionId directly
|
||||||
const parsed = parseAgentSessionKey(key);
|
const parsed = parseAgentSessionKey(key);
|
||||||
const agentId = parsed?.agentId;
|
const agentId = parsed?.agentId;
|
||||||
const rawSessionId = parsed?.rest ?? key;
|
const rawSessionId = parsed?.rest ?? key;
|
||||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||||
const sessionFile =
|
let sessionFile: string;
|
||||||
entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId });
|
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({
|
const timeseries = await loadSessionUsageTimeSeries({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -798,15 +821,25 @@ export const usageHandlers: GatewayRequestHandlers = {
|
|||||||
: 200;
|
: 200;
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const { entry } = loadSessionEntry(key);
|
const { entry, storePath } = loadSessionEntry(key);
|
||||||
|
|
||||||
// For discovered sessions (not in store), try using key as sessionId directly
|
// For discovered sessions (not in store), try using key as sessionId directly
|
||||||
const parsed = parseAgentSessionKey(key);
|
const parsed = parseAgentSessionKey(key);
|
||||||
const agentId = parsed?.agentId;
|
const agentId = parsed?.agentId;
|
||||||
const rawSessionId = parsed?.rest ?? key;
|
const rawSessionId = parsed?.rest ?? key;
|
||||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||||
const sessionFile =
|
let sessionFile: string;
|
||||||
entry?.sessionFile ?? resolveSessionFilePath(rawSessionId, entry, { agentId });
|
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 { loadSessionLogs } = await import("../../infra/session-cost-usage.js");
|
||||||
const logs = await loadSessionLogs({
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
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 { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||||
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
|
||||||
import { stripEnvelope } from "./chat-sanitize.js";
|
import { stripEnvelope } from "./chat-sanitize.js";
|
||||||
@@ -61,19 +65,40 @@ export function resolveSessionTranscriptCandidates(
|
|||||||
agentId?: string,
|
agentId?: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
if (sessionFile) {
|
const pushCandidate = (resolve: () => string): void => {
|
||||||
candidates.push(sessionFile);
|
try {
|
||||||
}
|
candidates.push(resolve());
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid paths/IDs and keep scanning other safe candidates.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
const dir = path.dirname(storePath);
|
const sessionsDir = path.dirname(storePath);
|
||||||
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
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) {
|
if (agentId) {
|
||||||
candidates.push(resolveSessionTranscriptPath(sessionId, agentId));
|
pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const home = resolveRequiredHomeDir(process.env, os.homedir);
|
const home = resolveRequiredHomeDir(process.env, os.homedir);
|
||||||
candidates.push(path.join(home, ".openclaw", "sessions", `${sessionId}.jsonl`));
|
const legacyDir = path.join(home, ".openclaw", "sessions");
|
||||||
return candidates;
|
pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir));
|
||||||
|
|
||||||
|
return Array.from(new Set(candidates));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function archiveFileOnDisk(filePath: string, reason: string): string {
|
export function archiveFileOnDisk(filePath: string, reason: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user