Files
openclaw/src/config/sessions/paths.ts
Xinhua Gu 90774c098a fix(sessions): allow cross-agent session file paths in multi-agent setups
When OPENCLAW_STATE_DIR changes between session creation and resolution
(e.g., after reinstall or config change), absolute session file paths
pointing to other agents' sessions directories were rejected even though
they structurally match the valid .../agents/<agentId>/sessions/... pattern.

The existing fallback logic in resolvePathWithinSessionsDir extracts the
agent ID from the path and tries to resolve it via the current env's
state directory. When those directories differ, the containment check
fails. Now, if the path structurally matches the agent sessions pattern
(validated by extractAgentIdFromAbsoluteSessionPath), we accept it
directly as a final fallback.

Fixes #15410, Fixes #15565, Fixes #15468
2026-02-17 00:00:54 +01:00

237 lines
7.8 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 agentId = params.agentId?.trim();
const storePath = params.storePath?.trim();
if (storePath) {
const sessionsDir = path.dirname(path.resolve(storePath));
return agentId ? { sessionsDir, agentId } : { sessionsDir };
}
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 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");
}
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.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;
}
// The path structurally matches .../agents/<agentId>/sessions/...
// Accept it even if the root directory differs from the current env
// (e.g., OPENCLAW_STATE_DIR changed between session creation and resolution).
// The structural pattern provides sufficient containment guarantees.
return path.resolve(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, { agentId: opts?.agentId });
}
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);
}