Older OpenClaw versions stored absolute sessionFile paths in sessions.json. v2026.2.12 added path traversal security that rejected these absolute paths, breaking all Telegram group handlers with 'Session file path must be within sessions directory' errors. Changes: - resolvePathWithinSessionsDir() now normalizes absolute paths that resolve within the sessions directory, converting them to relative before validation - Added 3 tests for absolute path handling (within dir, with topic, outside dir) Fixes #15283 Closes #15214, #15237, #15216, #15152, #15213
159 lines
5.0 KiB
TypeScript
159 lines
5.0 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
|
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
|
import { resolveStateDir } from "../paths.js";
|
|
|
|
function resolveAgentSessionsDir(
|
|
agentId?: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
homedir: () => string = () => resolveRequiredHomeDir(env, os.homedir),
|
|
): string {
|
|
const root = resolveStateDir(env, homedir);
|
|
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
|
|
return path.join(root, "agents", id, "sessions");
|
|
}
|
|
|
|
export function resolveSessionTranscriptsDir(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
homedir: () => string = () => resolveRequiredHomeDir(env, os.homedir),
|
|
): string {
|
|
return resolveAgentSessionsDir(DEFAULT_AGENT_ID, env, homedir);
|
|
}
|
|
|
|
export function resolveSessionTranscriptsDirForAgent(
|
|
agentId?: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
homedir: () => string = () => resolveRequiredHomeDir(env, os.homedir),
|
|
): string {
|
|
return resolveAgentSessionsDir(agentId, env, homedir);
|
|
}
|
|
|
|
export function resolveDefaultSessionStorePath(agentId?: string): string {
|
|
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
|
}
|
|
|
|
export type SessionFilePathOptions = {
|
|
agentId?: string;
|
|
sessionsDir?: string;
|
|
};
|
|
|
|
export function resolveSessionFilePathOptions(params: {
|
|
agentId?: string;
|
|
storePath?: string;
|
|
}): SessionFilePathOptions | undefined {
|
|
const storePath = params.storePath?.trim();
|
|
if (storePath) {
|
|
return { sessionsDir: path.dirname(path.resolve(storePath)) };
|
|
}
|
|
const agentId = params.agentId?.trim();
|
|
if (agentId) {
|
|
return { agentId };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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?: SessionFilePathOptions): 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);
|
|
// Normalize absolute paths that are within the sessions directory.
|
|
// 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 || normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
|
throw new Error("Session file path must be within sessions directory");
|
|
}
|
|
return path.resolve(resolvedBase, normalized);
|
|
}
|
|
|
|
export function resolveSessionTranscriptPathInDir(
|
|
sessionId: string,
|
|
sessionsDir: string,
|
|
topicId?: string | number,
|
|
): string {
|
|
const safeSessionId = validateSessionId(sessionId);
|
|
const safeTopicId =
|
|
typeof topicId === "string"
|
|
? encodeURIComponent(topicId)
|
|
: typeof topicId === "number"
|
|
? String(topicId)
|
|
: undefined;
|
|
const 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?: { sessionFile?: string },
|
|
opts?: SessionFilePathOptions,
|
|
): string {
|
|
const sessionsDir = resolveSessionsDir(opts);
|
|
const candidate = entry?.sessionFile?.trim();
|
|
if (candidate) {
|
|
return resolvePathWithinSessionsDir(sessionsDir, candidate);
|
|
}
|
|
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
|
}
|
|
|
|
export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
|
|
const agentId = normalizeAgentId(opts?.agentId ?? DEFAULT_AGENT_ID);
|
|
if (!store) {
|
|
return resolveDefaultSessionStorePath(agentId);
|
|
}
|
|
if (store.includes("{agentId}")) {
|
|
const expanded = store.replaceAll("{agentId}", agentId);
|
|
if (expanded.startsWith("~")) {
|
|
return path.resolve(
|
|
expandHomePrefix(expanded, {
|
|
home: resolveRequiredHomeDir(process.env, os.homedir),
|
|
env: process.env,
|
|
homedir: os.homedir,
|
|
}),
|
|
);
|
|
}
|
|
return path.resolve(expanded);
|
|
}
|
|
if (store.startsWith("~")) {
|
|
return path.resolve(
|
|
expandHomePrefix(store, {
|
|
home: resolveRequiredHomeDir(process.env, os.homedir),
|
|
env: process.env,
|
|
homedir: os.homedir,
|
|
}),
|
|
);
|
|
}
|
|
return path.resolve(store);
|
|
}
|