Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,16 @@
},
{
"id": "test-count",
"claim": "349 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.",
"value": "349",
"metric": "passing node:test cases (# tests 349 / # pass 349 / # fail 0)",
"method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('349 tests on Node's built-in runner (`npm test`, 2026-10-17)') is verified by the run, not read back from the prose.",
"claim": "356 tests pass on Node's built-in test runner, the count docs/ROADMAP.md records for `npm test`.",
"value": "356",
"metric": "passing node:test cases (# tests 356 / # pass 356 / # fail 0)",
"method": "npm test → node --test test/*.test.js, run in the claims job after npm ci, and the TAP summary is asserted. The roadmap sentence ('356 tests on Node's built-in runner (`npm test`, 2026-10-17)') is verified by the run, not read back from the prose.",
"repro": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'",
"evidence": "docs/ROADMAP.md",
"as_of": "2026-10-17",
"check": {
"cmd": "npm test 2>&1 | grep -E '^# (tests|pass|fail)'",
"expect": { "contains": ["# tests 349", "# pass 349", "# fail 0"] },
"expect": { "contains": ["# tests 356", "# pass 356", "# fail 0"] },
"timeout": 120
}
},
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- **Session browser** with tool-call inspection, trace/waterfall view, spawn tracking and message timeline
- **Prompt tooling** — extraction (noise filtered), template clustering with outcome attribution, Claude-powered rewrites, and a prompt library that installs entries as native slash commands
- **Global search** across all platforms, insights dashboard, incremental session backup
- **React + Vite frontend** served by an Express backend; 349 tests on Node's built-in runner (`npm test`, 2026-10-17), CI on Node 22
- **React + Vite frontend** served by an Express backend; 356 tests on Node's built-in runner (`npm test`, 2026-10-17), CI on Node 22
- **Evidence-backed failure events and local review** with full-result invalidation, evidence navigation and narrow-screen session layout

## Current priorities
Expand Down
4 changes: 2 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ GEMINI_DIR=/custom/path/gemini/tmp \
npm start
```

**Via API:** Pass `?dir=/absolute/path` query parameter to any API endpoint.
**Via API:** Pass `?dir=/absolute/path` query parameter to any API endpoint. The override must be an absolute path without `..` whose real path (symlinks resolved) sits inside `$HOME`, one of the configured platform/storage dirs above, or a dir listed in `AGENTXRAY_ALLOWED_DIRS` (`:`-separated); anything else is rejected with `403`.

---

Expand Down Expand Up @@ -218,7 +218,7 @@ npm start
| `POST /api/backup` | Run an incremental backup into `~/.agentxray/archive` |
| `GET /api/backup/status` | Archive stats: file count, total bytes, last backup time |

All list/detail endpoints accept an optional `?dir=` parameter to override the default directory.
All list/detail endpoints accept an optional `?dir=` parameter to override the default directory (same allowed-roots rule as above; out-of-bounds, relative, `..` or NUL-containing values get `403`, and agent names / session ids that are only dots get `400`).

---

Expand Down
31 changes: 25 additions & 6 deletions lib/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const path = require('path');
const fsp = require('fs/promises');
const { resolveAllowedDir } = require('./safe-path');

const HOME = process.env.HOME || '/root';
const DATA_DIR = process.env.OPENCLAW_DIR || path.join(HOME, '.openclaw', 'agents');
Expand All @@ -25,11 +26,25 @@ const AGENT_NAME_RE = /^[A-Za-z0-9._-]+$/;
// Key: absolute file path → { mtime: number, data: sessionMetadataObject }
const sessionMetaCache = new Map();

// Directory override from a request (?dir= and friends). No override → the
// default. Otherwise it must be an absolute path whose realpath lies inside an
// allowed root (lib/safe-path.js); anything else throws PathEscapeError (403)
// instead of silently reading another directory.
function resolveDir(queryDir, defaultDir) {
if (!queryDir || typeof queryDir !== 'string') return defaultDir;
if (!path.isAbsolute(queryDir)) return defaultDir;
if (queryDir.includes('..')) return defaultDir;
return queryDir;
if (queryDir === undefined || queryDir === null || queryDir === '') return defaultDir;
return resolveAllowedDir(queryDir, [
HOME,
DATA_DIR,
CODEX_DIR,
CLAUDE_CODE_DIR,
HERMES_DIR,
OMP_DIR,
DSH_DIR,
GEMINI_DIR,
ANALYSIS_DIR,
LIBRARY_DIR,
ARCHIVE_DIR,
]);
}

function isArchivedFile(fileName) {
Expand All @@ -40,12 +55,16 @@ function isSessionLogFile(fileName) {
return fileName.endsWith('.jsonl') || isArchivedFile(fileName);
}

// Both character sets allow '.', so a name made only of dots ('.', '..') would
// pass the regex and address the parent directory once joined; reject those.
const DOTS_ONLY_RE = /^\.+$/;

function sanitizeAgentName(name) {
return AGENT_NAME_RE.test(name) ? name : null;
return typeof name === 'string' && AGENT_NAME_RE.test(name) && !DOTS_ONLY_RE.test(name) ? name : null;
}

function sanitizeSessionId(id) {
return SESSION_ID_RE.test(id) ? id : null;
return typeof id === 'string' && SESSION_ID_RE.test(id) && !DOTS_ONLY_RE.test(id) ? id : null;
}

async function ensureDirectory(dirPath) {
Expand Down
5 changes: 3 additions & 2 deletions lib/library.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { resolveInside } = require('./safe-path');
const readline = require('readline');
const { execFile } = require('child_process');
const { HOME, LIBRARY_DIR, CODEX_DIR, CLAUDE_CODE_DIR, OMP_DIR } = require('./config');
Expand All @@ -22,11 +23,11 @@ function sanitizeLibraryName(name) {
}

function libraryFilePath(name) {
return path.join(LIBRARY_DIR, `${name}.md`);
return resolveInside(LIBRARY_DIR, `${name}.md`);
}

function installedFilePath(target, name) {
return path.join(INSTALL_TARGETS[target], `${name}.md`);
return resolveInside(INSTALL_TARGETS[target], `${name}.md`);
}

function normalizeLibraryTags(tags) {
Expand Down
15 changes: 8 additions & 7 deletions lib/platforms/claude.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { resolveInside } = require('../safe-path');
const readline = require('readline');
const { CLAUDE_CODE_DIR } = require('../config');
const { withMetadataCache, makeMessage, sortSessionsByTimestampDesc, topToolsOf } = require('./shared');
Expand All @@ -23,23 +24,23 @@ async function findClaudeCodeSessionFile(baseDir, sessionId) {
const dir = baseDir || CLAUDE_CODE_DIR;
const projects = await listClaudeCodeProjects(dir);
for (const project of projects) {
const dirPath = path.join(dir, project);
const dirPath = resolveInside(dir, project);
const files = await fsp.readdir(dirPath, { withFileTypes: true }).catch(() => []);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
const base = f.name.replace(/\.jsonl$/, '');
if (base === sessionId || base.endsWith(sessionId)) {
return path.join(dirPath, f.name);
return resolveInside(dirPath, f.name);
}
}
// Check subagents subdirectory
const subDir = path.join(dirPath, 'subagents');
const subDir = resolveInside(dirPath, 'subagents');
const subFiles = await fsp.readdir(subDir, { withFileTypes: true }).catch(() => []);
for (const f of subFiles) {
if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
const base = f.name.replace(/\.jsonl$/, '');
if (base === sessionId || base.endsWith(sessionId)) {
return path.join(subDir, f.name);
return resolveInside(subDir, f.name);
}
}
}
Expand Down Expand Up @@ -177,11 +178,11 @@ async function listClaudeCodeSessions(baseDir) {
const projects = await listClaudeCodeProjects(dir);

for (const project of projects) {
const dirPath = path.join(dir, project);
const dirPath = resolveInside(dir, project);
const files = await fsp.readdir(dirPath, { withFileTypes: true }).catch(() => []);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
sessions.push(parseClaudeCodeSessionMetadata(path.join(dirPath, f.name), f.name));
sessions.push(parseClaudeCodeSessionMetadata(resolveInside(dirPath, f.name), f.name));
}
}

Expand Down Expand Up @@ -336,7 +337,7 @@ async function parseClaudeCodeSessionFile(filePath) {
async function findClaudeSpawnDir(baseDir, sessionId) {
const filePath = await findClaudeCodeSessionFile(baseDir, sessionId);
if (!filePath) return null;
const spawnDir = path.join(path.dirname(filePath), sessionId, 'subagents');
const spawnDir = resolveInside(path.dirname(filePath), sessionId, 'subagents');
try {
const st = await fsp.stat(spawnDir);
return st.isDirectory() ? spawnDir : null;
Expand Down
3 changes: 2 additions & 1 deletion lib/platforms/hermes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
const { resolveInside } = require('../safe-path');
// node:sqlite still emits an ExperimentalWarning on 22.x; it is the only warning
// we swallow, and stderr is the operator's console for this server.
const originalEmitWarning = process.emitWarning;
Expand All @@ -14,7 +15,7 @@ const { makeMessage } = require('./shared');

function getHermesDbPath(dir) {
const base = dir || HERMES_DIR;
return path.join(base, 'state.db');
return resolveInside(base, 'state.db');
}

// node:sqlite (Node >= 22.13): no native build step, so `npm i` never fails on a
Expand Down
9 changes: 5 additions & 4 deletions lib/platforms/omp.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { resolveInside } = require('../safe-path');
const readline = require('readline');
const { OMP_DIR } = require('../config');
const { ompOutcome } = require('./omp-outcome');
Expand All @@ -19,13 +20,13 @@ async function findOmpSessionFile(baseDir, sessionId) {
const slugs = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
for (const s of slugs) {
if (!s.isDirectory()) continue;
const slugDir = path.join(dir, s.name);
const slugDir = resolveInside(dir, s.name);
const files = await fsp.readdir(slugDir, { withFileTypes: true }).catch(() => []);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
const base = f.name.replace(/\.jsonl$/, '');
if (base.endsWith('_' + sessionId) || base === sessionId) {
return path.join(slugDir, f.name);
return resolveInside(slugDir, f.name);
}
}
}
Expand Down Expand Up @@ -148,11 +149,11 @@ async function listOmpSessions(baseDir) {

for (const s of entries) {
if (!s.isDirectory()) continue;
const slugDir = path.join(dir, s.name);
const slugDir = resolveInside(dir, s.name);
const files = await fsp.readdir(slugDir, { withFileTypes: true }).catch(() => []);
for (const f of files) {
if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
sessions.push(parseOmpSessionMetadata(path.join(slugDir, f.name), f.name));
sessions.push(parseOmpSessionMetadata(resolveInside(slugDir, f.name), f.name));
}
}

Expand Down
17 changes: 9 additions & 8 deletions lib/platforms/openclaw.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { resolveInside } = require('../safe-path');
const readline = require('readline');
const { DATA_DIR, HERMES_DIR, isArchivedFile, isSessionLogFile, ensureDirectory, readAgents } = require('../config');
const { withMetadataCache, sortSessionsByTimestampDesc, topToolsOf } = require('./shared');
Expand Down Expand Up @@ -140,7 +141,7 @@ async function _parseSessionMetadataRaw(filePath, fileName) {

async function listSessionsForAgent(baseDir, agentName, includeArchived) {
const dir = baseDir || DATA_DIR;
const agentDir = path.join(dir, agentName, 'sessions');
const agentDir = resolveInside(dir, agentName, 'sessions');
await ensureDirectory(agentDir);
const entries = await fsp.readdir(agentDir, { withFileTypes: true });
const sessionFiles = entries
Expand All @@ -149,15 +150,15 @@ async function listSessionsForAgent(baseDir, agentName, includeArchived) {
.map((entry) => entry.name);

const sessions = await Promise.all(
sessionFiles.map((fileName) => parseSessionMetadata(path.join(agentDir, fileName), fileName))
sessionFiles.map((fileName) => parseSessionMetadata(resolveInside(agentDir, fileName), fileName))
);

return sortSessionsByTimestampDesc(sessions);
}

async function resolveSessionFile(baseDir, agentName, sessionId) {
const dir = baseDir || DATA_DIR;
const agentDir = path.join(dir, agentName, 'sessions');
const agentDir = resolveInside(dir, agentName, 'sessions');
await ensureDirectory(agentDir);
const entries = await fsp.readdir(agentDir, { withFileTypes: true });
const candidates = entries
Expand All @@ -178,7 +179,7 @@ async function resolveSessionFile(baseDir, agentName, sessionId) {
return null;
}

return path.join(agentDir, candidates[0]);
return resolveInside(agentDir, candidates[0]);
}

function normalizeMessage(record) {
Expand Down Expand Up @@ -242,7 +243,7 @@ async function buildSpawnMap(baseDir) {
const agents = await readAgents(dir);

for (const agentName of agents) {
const agentDir = path.join(dir, agentName, 'sessions');
const agentDir = resolveInside(dir, agentName, 'sessions');
let entries;
try {
entries = await fsp.readdir(agentDir, { withFileTypes: true });
Expand All @@ -256,7 +257,7 @@ async function buildSpawnMap(baseDir) {

for (const fileName of sessionFiles) {
const sessionId = fileName.split('.jsonl')[0];
const filePath = path.join(agentDir, fileName);
const filePath = resolveInside(agentDir, fileName);
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

Expand Down Expand Up @@ -331,7 +332,7 @@ async function buildSpawnTree(baseDir) {
const spawnCalls = [];

for (const agentName of agents) {
const agentDir = path.join(dir, agentName, 'sessions');
const agentDir = resolveInside(dir, agentName, 'sessions');
let entries;
try {
entries = await fsp.readdir(agentDir, { withFileTypes: true });
Expand All @@ -343,7 +344,7 @@ async function buildSpawnTree(baseDir) {

for (const fileName of sessionFiles) {
const sessionId = fileName.split('.jsonl')[0];
const filePath = path.join(agentDir, fileName);
const filePath = resolveInside(agentDir, fileName);
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });

Expand Down
3 changes: 2 additions & 1 deletion lib/prompts.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { resolveInside } = require('./safe-path');
const readline = require('readline');
const { normalizePromptText, hashPromptText } = require('./text-utils');
const { parseLlmJson } = require('./llm-json');
Expand Down Expand Up @@ -552,7 +553,7 @@ suggestions 数组按模板顺序,每个模板一项。用中文回答。JSON
// platform (+ '-agentName') sanitized to [a-z0-9-]. Survives server restarts.
function analysisFilePath(platform, agentName) {
const key = `${platform}${agentName ? `-${agentName}` : ''}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
return path.join(ANALYSIS_DIR, `${key}.json`);
return resolveInside(ANALYSIS_DIR, `${key}.json`);
}

async function loadPersistedAnalysis(platform, agentName) {
Expand Down
11 changes: 6 additions & 5 deletions lib/routes/sessions.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fsp = require('fs/promises');
const path = require('path');
const { resolveInside } = require('../safe-path');
const {
DATA_DIR,
OMP_DIR,
Expand Down Expand Up @@ -188,7 +189,7 @@ module.exports = function mountSessionRoutes(app) {
const children = [];
for (const e of entries) {
if (!e.isFile() || !e.name.endsWith('.jsonl')) continue;
const meta = await parseOmpSessionMetadata(path.join(spawnDir, e.name), e.name).catch(() => null);
const meta = await parseOmpSessionMetadata(resolveInside(spawnDir, e.name), e.name).catch(() => null);
children.push({
name: e.name.replace(/\.jsonl$/, ''),
file: e.name,
Expand All @@ -214,7 +215,7 @@ module.exports = function mountSessionRoutes(app) {
const dir = resolveDir(req.query.dir, OMP_DIR);
const spawnDir = await findOmpSpawnDir(dir, sessionId);
if (!spawnDir) return res.status(404).json({ error: 'No subagents for this session' });
const payload = await parseOmpSessionFile(path.join(spawnDir, name + '.jsonl'));
const payload = await parseOmpSessionFile(resolveInside(spawnDir, `${name}.jsonl`));
res.json(payload);
} catch (error) {
if (error.code === 'ENOENT') return res.status(404).json({ error: 'Subagent not found' });
Expand All @@ -236,10 +237,10 @@ module.exports = function mountSessionRoutes(app) {
for (const e of entries) {
if (!e.isFile() || !e.name.startsWith('agent-') || !e.name.endsWith('.jsonl')) continue;
const stem = e.name.replace(/\.jsonl$/, '');
const meta = await parseClaudeCodeSessionMetadata(path.join(spawnDir, e.name), e.name).catch(() => null);
const meta = await parseClaudeCodeSessionMetadata(resolveInside(spawnDir, e.name), e.name).catch(() => null);
let agentMeta = null;
try {
agentMeta = JSON.parse(await fsp.readFile(path.join(spawnDir, stem + '.meta.json'), 'utf8'));
agentMeta = JSON.parse(await fsp.readFile(resolveInside(spawnDir, `${stem}.meta.json`), 'utf8'));
} catch {
/* absent or corrupt meta.json */
}
Expand Down Expand Up @@ -270,7 +271,7 @@ module.exports = function mountSessionRoutes(app) {
const dir = resolveDir(req.query.dir, CLAUDE_CODE_DIR);
const spawnDir = await findClaudeSpawnDir(dir, sessionId);
if (!spawnDir) return res.status(404).json({ error: 'No subagents for this session' });
const payload = await parseClaudeCodeSessionFile(path.join(spawnDir, name + '.jsonl'));
const payload = await parseClaudeCodeSessionFile(resolveInside(spawnDir, `${name}.jsonl`));
res.json(payload);
} catch (error) {
if (error.code === 'ENOENT') return res.status(404).json({ error: 'Subagent not found' });
Expand Down
Loading
Loading