diff --git a/src/agents-detect.js b/src/agents-detect.js index 29c6f9f..bec455d 100644 --- a/src/agents-detect.js +++ b/src/agents-detect.js @@ -33,7 +33,7 @@ const AGENT_DEFS = Object.freeze([ { id: 'codex', label: 'Codex', bin: 'codex' }, { id: 'cursor', label: 'Cursor', bin: 'cursor-agent', appBundle: 'Cursor.app' }, { id: 'qwen', label: 'Qwen Code', bin: 'qwen' }, - { id: 'pi', label: 'OhMyPi', customCheck: 'piPath' }, + { id: 'pi', label: 'Pi/OhMyPi', customCheck: 'piPath' }, { id: 'kilo', label: 'Kilo', bin: 'kilo' }, { id: 'kiro', label: 'Kiro CLI', bin: 'kiro-cli' }, { id: 'opencode', label: 'OpenCode', bin: 'opencode' }, diff --git a/src/data.js b/src/data.js index a3b4472..016c202 100644 --- a/src/data.js +++ b/src/data.js @@ -655,6 +655,7 @@ function listPiSessionFiles(agentDir) { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); + if (entry.isSymbolicLink()) continue; if (entry.isFile() && entry.name.endsWith('.jsonl')) { files.push(fullPath); } else if (entry.isDirectory() && depth < 3) { @@ -705,6 +706,9 @@ function normalizePiUsage(usage) { }; } +const SAFE_PI_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; + + function parsePiSessionFile(sessionFile) { if (!fs.existsSync(sessionFile)) return null; @@ -723,6 +727,7 @@ function parsePiSessionFile(sessionFile) { if (!header || header.type !== 'session' || !header.id) return null; let sessionId = String(header.id); + if (!SAFE_PI_SESSION_ID.test(sessionId)) return null; let projectPath = typeof header.cwd === 'string' ? header.cwd : ''; let title = typeof header.title === 'string' ? header.title.trim().slice(0, 200) : ''; let msgCount = 0; @@ -2832,8 +2837,8 @@ let _codexSessionsDirMtimes = {}; // { dayDirPath: mtimeMs } — shallow leaf di // check. Reused by _updateScanMarkers() to avoid a second filesystem walk // (which would race against the first and yield inconsistent snapshots). let _codexDayDirMtimesPending = null; -let _ompSessionDirMtimes = {}; -let _ompSessionDirMtimesPending = null; +let _piOmpSessionDirMtimes = {}; +let _piOmpSessionDirMtimesPending = null; function _piSessionDirMtimes(agentDirs) { const out = {}; @@ -2843,15 +2848,17 @@ function _piSessionDirMtimes(agentDirs) { function walk(dir, depth) { let entries; try { - const st = fs.statSync(dir); + const st = fs.lstatSync(dir); + if (st.isSymbolicLink()) return; out[dir] = st.mtimeMs; entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); + if (entry.isSymbolicLink()) continue; if (entry.isFile() && entry.name.endsWith('.jsonl')) { try { - const st = fs.statSync(fullPath); + const st = fs.lstatSync(fullPath); out[fullPath] = st.mtimeMs + ':' + st.size; } catch {} } else if (entry.isDirectory() && depth < 3) { @@ -2950,12 +2957,12 @@ function _sessionsNeedRescan() { if (dayMtimes[k] !== _codexSessionsDirMtimes[k]) return true; } const piDirMtimes = _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS)); - _ompSessionDirMtimesPending = piDirMtimes; - const prevPiKeys = Object.keys(_ompSessionDirMtimes); + _piOmpSessionDirMtimesPending = piDirMtimes; + const prevPiKeys = Object.keys(_piOmpSessionDirMtimes); const curPiKeys = Object.keys(piDirMtimes); if (prevPiKeys.length !== curPiKeys.length) return true; for (const k of curPiKeys) { - if (piDirMtimes[k] !== _ompSessionDirMtimes[k]) return true; + if (piDirMtimes[k] !== _piOmpSessionDirMtimes[k]) return true; } } catch {} return false; @@ -3011,8 +3018,8 @@ function _updateScanMarkers() { // otherwise (first call / direct invocation) walk now. _codexSessionsDirMtimes = _codexDayDirMtimesPending || _codexDayDirMtimes(); _codexDayDirMtimesPending = null; - _ompSessionDirMtimes = _ompSessionDirMtimesPending || _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS)); - _ompSessionDirMtimesPending = null; + _piOmpSessionDirMtimes = _piOmpSessionDirMtimesPending || _piSessionDirMtimes([PI_AGENT_DIR, OMP_AGENT_DIR].concat(EXTRA_PI_AGENT_DIRS, EXTRA_OMP_AGENT_DIRS)); + _piOmpSessionDirMtimesPending = null; } catch {} } diff --git a/src/frontend/app.js b/src/frontend/app.js index 7597f5e..ffeb8cb 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -547,7 +547,7 @@ function getResumeCommand(tool, sessionId, project, session) { if (tool === 'qwen') return 'qwen -r ' + sessionId; if (tool === 'pi') { var target = session && session.resume_target ? session.resume_target : sessionId; - return getPiCommand() === 'omp' + return session && session.agent_variant === 'ohmypi' ? 'omp --resume ' + quoteShellArg(target) : 'pi --session ' + quoteShellArg(target); } diff --git a/src/frontend/detail.js b/src/frontend/detail.js index bed5fc9..6563e95 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -76,7 +76,11 @@ async function openDetail(s) { } else if (activeSessions[s.id]) { infoHtml += ''; } else { - infoHtml += ''; + if (s.tool === 'pi') { + infoHtml += ''; + } else { + infoHtml += ''; + } if (s.tool === 'claude') { infoHtml += ''; } diff --git a/src/server.js b/src/server.js index fc25aae..0bf294d 100644 --- a/src/server.js +++ b/src/server.js @@ -22,14 +22,22 @@ const pathLib = require('path'); const { repoRefreshManager } = require('./repo-refresh'); const { handleRepoRefreshRoute } = require('./repo-refresh-routes'); -function isValidPiResumeTarget(sessionId, resumeTarget) { - if (typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) return false; - if (typeof resumeTarget !== 'string' || !resumeTarget.endsWith('.jsonl')) return false; - if (/['`$\\\n\r\0]/.test(resumeTarget)) return false; - const resolvedTarget = pathLib.resolve(resumeTarget); - const found = dataApi.findSessionFile(sessionId); - if (!found || found.format !== 'pi' || !found.file) return false; - return pathLib.resolve(found.file) === resolvedTarget; +const SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; + +function getValidatedPiResumeTarget(sessionId, resumeTarget, project) { + if (typeof sessionId !== 'string' || !SAFE_SESSION_ID.test(sessionId)) return ''; + if (typeof resumeTarget !== 'string' || !resumeTarget.endsWith('.jsonl')) return ''; + if (/['`$\\\n\r\0]/.test(resumeTarget)) return ''; + const found = dataApi.findSessionFile(sessionId, project); + if (!found || found.format !== 'pi' || !found.file) return ''; + try { + if (fs.lstatSync(found.file).isSymbolicLink()) return ''; + } catch { + return ''; + } + const resolvedFound = pathLib.resolve(found.file); + if (pathLib.resolve(resumeTarget) !== resolvedFound) return ''; + return resolvedFound; } // ── Logging ────────────────────────────────── @@ -178,11 +186,12 @@ function startServer(host, port, openBrowser = true) { const parsed = JSON.parse(body); const { sessionId, resumeTarget, tool, flags, project, terminal, mode, autoRegister } = parsed; const fresh = mode === 'fresh'; + let piResumeTarget = ''; if (!fresh) { - const isSafeId = /^[A-Za-z0-9._-]{1,128}$/.test(String(sessionId || '')); + const isSafeId = SAFE_SESSION_ID.test(String(sessionId || '')); const hasResumeTarget = resumeTarget !== undefined && resumeTarget !== null && resumeTarget !== ''; - const isSafePiTarget = tool === 'pi' && hasResumeTarget && isValidPiResumeTarget(sessionId, resumeTarget); - if (!isSafeId || (hasResumeTarget && !isSafePiTarget)) throw new Error('invalid sessionId'); + piResumeTarget = tool === 'pi' && hasResumeTarget ? getValidatedPiResumeTarget(sessionId, resumeTarget, project) : ''; + if (!isSafeId || (hasResumeTarget && !piResumeTarget)) throw new Error('invalid sessionId'); } if (fresh && !project) { throw new Error('project path required for fresh session'); @@ -199,7 +208,7 @@ function startServer(host, port, openBrowser = true) { const knownTool = settingsApi.isKnownAgent(tool); const detectedAgent = detection.agents.find(a => a.id === tool); if (knownTool && !detectedAgent) { - throw new Error('agent not installed: ' + tool); + throw new Error('agent not installed'); } const resolvedTool = knownTool ? tool : 'claude'; // Explicit allowlist for flags — element-level. Defense-in-depth in @@ -212,7 +221,7 @@ function startServer(host, port, openBrowser = true) { ? detectedAgent.command : undefined; log('LAUNCH', `mode=${fresh ? 'fresh' : 'resume'} session=${sessionId || '(none)'} tool=${resolvedTool} terminal=${terminal || 'default'} project=${project || '(none)'} flags=${safeFlags.join(',') || '(none)'}`); - openInTerminal(fresh ? '' : sessionId, resolvedTool, safeFlags, project || '', terminal || '', fresh ? 'fresh' : 'resume', launchCommand, fresh ? '' : (resumeTarget || '')); + openInTerminal(fresh ? '' : sessionId, resolvedTool, safeFlags, project || '', terminal || '', fresh ? 'fresh' : 'resume', launchCommand, fresh ? '' : piResumeTarget); // Auto-register: when a fresh launch fires for a path under $HOME // that is either a git repo or has been launched ≥2 times, add it diff --git a/test/agents-detect.test.js b/test/agents-detect.test.js index 24f76b8..19ba197 100644 --- a/test/agents-detect.test.js +++ b/test/agents-detect.test.js @@ -106,6 +106,7 @@ test('detect prefers pi and falls back to omp for Pi', async () => { assert.ok(fallbackPi, 'Pi should be detected by omp fallback binary'); assert.equal(fallbackPi.binPath, '/usr/local/bin/omp'); assert.equal(fallbackPi.command, 'omp'); + assert.equal(fallbackPi.label, 'Pi/OhMyPi'); assert.deepEqual(fallbackPi.commands, ['omp']); }); diff --git a/test/frontend-escaping.test.js b/test/frontend-escaping.test.js index b9f1bc5..275129e 100644 --- a/test/frontend-escaping.test.js +++ b/test/frontend-escaping.test.js @@ -24,20 +24,23 @@ test('detail resume button uses JS-string escaping for project paths', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8'); assert.match(source, /var jsProject = escJsString\(s\.project \|\| ''\);/); assert.ok( - source.includes("launchPiSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"), + source.includes("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')"), 'Resume onclick should pass jsProject, not raw escHtml(project)' ); }); -test('detail Pi resume button routes through launchPiSession for resume_target support', () => { +test('detail only Pi resume button routes through launchPiSession for resume_target support', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'detail.js'), 'utf8'); + assert.ok(source.includes("if (s.tool === 'pi')")); assert.ok(source.includes("launchPiSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')")); + assert.ok(source.includes("launchSession(\\'' + jsId + '\\',\\'' + jsTool + '\\',\\'' + jsProject + '\\')")); assert.match(source, /resumeTarget: resumeTarget \|\| ''/); }); test('frontend Pi resume commands use variant-specific shell-safe syntax', () => { const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'frontend', 'app.js'), 'utf8'); assert.match(source, /function quoteShellArg\(value\)/); + assert.match(source, /session && session\.agent_variant === 'ohmypi'/); assert.match(source, /'omp --resume ' \+ quoteShellArg\(target\)/); assert.match(source, /'pi --session ' \+ quoteShellArg\(target\)/); assert.doesNotMatch(source, /JSON\.stringify\(target\)/); diff --git a/test/pi-session.test.js b/test/pi-session.test.js index 16bd703..bc31295 100644 --- a/test/pi-session.test.js +++ b/test/pi-session.test.js @@ -84,6 +84,18 @@ test('parsePiSessionFile reads OMP header and message summary', () => { assert.equal(summary.lastTs, Date.parse('2026-05-24T10:00:04.000Z')); }); +test('parsePiSessionFile rejects unsafe header ids', () => { + const dir = tmpDir(); + const file = path.join(dir, 'sessions', '--tmp--project--', 'bad.jsonl'); + writeJsonl(file, [ + { type: 'session', id: '../../claude-session', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' }, + { type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'bad id' } }, + ]); + + assert.equal(parsePiSessionFile(file), null); +}); + + test('loadPiDetail returns role-compatible display messages with tokens', () => { const dir = tmpDir(); const file = path.join(dir, 'sessions', '--tmp--project--', '2026_pi-session-2.jsonl'); @@ -125,6 +137,20 @@ test('scanPiSessions ignores malformed and non-OMP files', () => { assert.equal(sessions[0].agent_variant, 'pi'); }); +test('scanPiSessions ignores symlinked session files', () => { + const agentDir = tmpDir(); + const outside = path.join(tmpDir(), 'outside.jsonl'); + writeJsonl(outside, [ + { type: 'session', id: 'pi-symlink', cwd: '/tmp/project', timestamp: '2026-05-24T10:00:00.000Z' }, + { type: 'message', timestamp: '2026-05-24T10:00:01.000Z', message: { role: 'user', content: 'outside' } }, + ]); + const link = path.join(agentDir, 'sessions', '--tmp--project--', 'link.jsonl'); + fs.mkdirSync(path.dirname(link), { recursive: true }); + fs.symlinkSync(outside, link); + + assert.deepEqual(scanPiSessions(agentDir), []); +}); + test('scanPiSessions marks OhMyPi variant when scanning omp directory', () => { const agentDir = tmpDir(); const file = path.join(agentDir, 'sessions', '--tmp--project--', '2026_omp-session-1.jsonl');