mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
Sessions: consolidate path hardening and fallback resilience (#24657)
* Changelog: credit session path fixes * Sessions: harden path resolution for symlink and stale metadata * Tests: cover fallback for invalid absolute sessionFile * Tests: add symlink alias session path coverage * Tests: guard symlink escape in sessionFile resolution
This commit is contained in:
@@ -75,6 +75,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc.
|
||||
- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents/<id>/sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc.
|
||||
- Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions.
|
||||
- Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo.
|
||||
- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC.
|
||||
|
||||
@@ -561,15 +561,22 @@ describe("sessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects absolute sessionFile paths outside agent sessions directories", () => {
|
||||
it("falls back to derived transcript path when sessionFile is outside agent sessions directories", () => {
|
||||
withStateDir(path.resolve("/home/user/.openclaw"), () => {
|
||||
expect(() =>
|
||||
resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: path.resolve("/etc/passwd") },
|
||||
{ agentId: "bot1" },
|
||||
const sessionFile = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: path.resolve("/etc/passwd") },
|
||||
{ agentId: "bot1" },
|
||||
);
|
||||
expect(sessionFile).toBe(
|
||||
path.join(
|
||||
path.resolve("/home/user/.openclaw"),
|
||||
"agents",
|
||||
"bot1",
|
||||
"sessions",
|
||||
"sess-1.jsonl",
|
||||
),
|
||||
).toThrow(/within sessions directory/);
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
@@ -76,8 +77,10 @@ function resolvePathFromAgentSessionsDir(
|
||||
agentSessionsDir: string,
|
||||
candidateAbsPath: string,
|
||||
): string | undefined {
|
||||
const agentBase = path.resolve(agentSessionsDir);
|
||||
const relative = path.relative(agentBase, candidateAbsPath);
|
||||
const agentBase =
|
||||
safeRealpathSync(path.resolve(agentSessionsDir)) ?? path.resolve(agentSessionsDir);
|
||||
const realCandidate = safeRealpathSync(candidateAbsPath) ?? candidateAbsPath;
|
||||
const relative = path.relative(agentBase, realCandidate);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -112,6 +115,14 @@ function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string
|
||||
return agentId || undefined;
|
||||
}
|
||||
|
||||
function safeRealpathSync(filePath: string): string | undefined {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathWithinSessionsDir(
|
||||
sessionsDir: string,
|
||||
candidate: string,
|
||||
@@ -122,21 +133,28 @@ function resolvePathWithinSessionsDir(
|
||||
throw new Error("Session file path must not be empty");
|
||||
}
|
||||
const resolvedBase = path.resolve(sessionsDir);
|
||||
const realBase = safeRealpathSync(resolvedBase) ?? resolvedBase;
|
||||
// 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 realTrimmed = path.isAbsolute(trimmed) ? (safeRealpathSync(trimmed) ?? trimmed) : trimmed;
|
||||
const normalized = path.isAbsolute(realTrimmed)
|
||||
? path.relative(realBase, realTrimmed)
|
||||
: realTrimmed;
|
||||
if (normalized.startsWith("..") && path.isAbsolute(realTrimmed)) {
|
||||
const tryAgentFallback = (agentId: string): string | undefined => {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const siblingSessionsDir = resolveSiblingAgentSessionsDir(resolvedBase, normalizedAgentId);
|
||||
const siblingSessionsDir = resolveSiblingAgentSessionsDir(realBase, normalizedAgentId);
|
||||
if (siblingSessionsDir) {
|
||||
const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, trimmed);
|
||||
const siblingResolved = resolvePathFromAgentSessionsDir(siblingSessionsDir, realTrimmed);
|
||||
if (siblingResolved) {
|
||||
return siblingResolved;
|
||||
}
|
||||
}
|
||||
return resolvePathFromAgentSessionsDir(resolveAgentSessionsDir(normalizedAgentId), trimmed);
|
||||
return resolvePathFromAgentSessionsDir(
|
||||
resolveAgentSessionsDir(normalizedAgentId),
|
||||
realTrimmed,
|
||||
);
|
||||
};
|
||||
|
||||
const explicitAgentId = opts?.agentId?.trim();
|
||||
@@ -146,7 +164,7 @@ function resolvePathWithinSessionsDir(
|
||||
return resolvedFromAgent;
|
||||
}
|
||||
}
|
||||
const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(trimmed);
|
||||
const extractedAgentId = extractAgentIdFromAbsoluteSessionPath(realTrimmed);
|
||||
if (extractedAgentId) {
|
||||
const resolvedFromPath = tryAgentFallback(extractedAgentId);
|
||||
if (resolvedFromPath) {
|
||||
@@ -156,13 +174,13 @@ function resolvePathWithinSessionsDir(
|
||||
// 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);
|
||||
return path.resolve(realTrimmed);
|
||||
}
|
||||
}
|
||||
if (!normalized || normalized.startsWith("..") || path.isAbsolute(normalized)) {
|
||||
throw new Error("Session file path must be within sessions directory");
|
||||
}
|
||||
return path.resolve(resolvedBase, normalized);
|
||||
return path.resolve(realBase, normalized);
|
||||
}
|
||||
|
||||
export function resolveSessionTranscriptPathInDir(
|
||||
@@ -200,7 +218,11 @@ export function resolveSessionFilePath(
|
||||
const sessionsDir = resolveSessionsDir(opts);
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
if (candidate) {
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
|
||||
try {
|
||||
return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
|
||||
} catch {
|
||||
// Keep handlers alive when persisted metadata is stale/corrupt.
|
||||
}
|
||||
}
|
||||
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
|
||||
}
|
||||
|
||||
@@ -56,16 +56,64 @@ describe("session path safety", () => {
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl"));
|
||||
});
|
||||
|
||||
it("rejects absolute sessionFile paths outside known agent sessions dirs", () => {
|
||||
it("falls back to derived path when sessionFile is outside known agent sessions dirs", () => {
|
||||
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
||||
|
||||
expect(() =>
|
||||
resolveSessionFilePath(
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
|
||||
{ sessionsDir },
|
||||
);
|
||||
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1.jsonl"));
|
||||
});
|
||||
|
||||
it("accepts symlink-alias session paths that resolve under the sessions dir", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-symlink-session-"));
|
||||
const realRoot = path.join(tmpDir, "real-state");
|
||||
const aliasRoot = path.join(tmpDir, "alias-state");
|
||||
try {
|
||||
const sessionsDir = path.join(realRoot, "agents", "main", "sessions");
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.symlinkSync(realRoot, aliasRoot, "dir");
|
||||
const viaAlias = path.join(aliasRoot, "agents", "main", "sessions", "sess-1.jsonl");
|
||||
fs.writeFileSync(path.join(sessionsDir, "sess-1.jsonl"), "");
|
||||
const resolved = resolveSessionFilePath("sess-1", { sessionFile: viaAlias }, { sessionsDir });
|
||||
expect(fs.realpathSync(resolved)).toBe(
|
||||
fs.realpathSync(path.join(sessionsDir, "sess-1.jsonl")),
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back when sessionFile is a symlink that escapes sessions dir", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-symlink-escape-"));
|
||||
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
|
||||
const outsideDir = path.join(tmpDir, "outside");
|
||||
try {
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
const outsideFile = path.join(outsideDir, "escaped.jsonl");
|
||||
fs.writeFileSync(outsideFile, "");
|
||||
const symlinkPath = path.join(sessionsDir, "escaped.jsonl");
|
||||
fs.symlinkSync(outsideFile, symlinkPath, "file");
|
||||
|
||||
const resolved = resolveSessionFilePath(
|
||||
"sess-1",
|
||||
{ sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
|
||||
{ sessionFile: symlinkPath },
|
||||
{ sessionsDir },
|
||||
),
|
||||
).toThrow(/within sessions directory/);
|
||||
);
|
||||
expect(fs.realpathSync(path.dirname(resolved))).toBe(fs.realpathSync(sessionsDir));
|
||||
expect(path.basename(resolved)).toBe("sess-1.jsonl");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user