From 422deea939dfa89b5c495c228d99e03dc4153ad2 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 2 Oct 2026 19:17:01 +0200 Subject: [PATCH 1/3] (sessions): trust a transcript cwd only if it encodes back to its folder A sandboxed session can forge a transcript in its own project folder whose cwd points below the project. The cwd fed the sidebar project path, the resume and fork spawn directory, the sandbox project folder and the schedule registration, so a schedule planted there could run outside the sandbox. verifiedTranscriptCwd accepts a cwd only when it encodes back to the folder holding the transcript. deriveProjectPath, the cold-start scan worker, resolveSessionRealCwd and the reuse of a stored cache_meta project path go through it; remote hosts keep their recorded cwd. The schedule seed uses the same function. Closes #385 --- .ai/contexts/schedule-runner.md | 2 +- .ai/contexts/session-cache.md | 19 ++ derive-project-path.js | 22 +- docs/sandbox.md | 9 + encode-project-path.js | 11 +- schedule-runner.js | 6 +- session-cache.js | 14 +- .../build-projects-cold-scan-fallback.test.js | 8 +- test/derive-project-path.test.js | 37 +-- test/remote-scan-file-granularity.test.js | 15 +- test/scan-projects-worker.test.js | 32 ++- test/session-cache-bridge-dedup.test.js | 11 +- .../session-cache-cold-start-progress.test.js | 25 +- test/session-cache-refresh.test.js | 9 +- test/transcript-cwd-trust.test.js | 225 ++++++++++++++++++ workers/scan-projects.js | 4 +- 16 files changed, 383 insertions(+), 66 deletions(-) create mode 100644 test/transcript-cwd-trust.test.js diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index fd4262de..21000573 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -17,7 +17,7 @@ From `schedule-runner.js`: - `claimScheduleMinute(stateDir, key, minuteMs, info)` — exclusive-create one record file; `false` when it already exists. Exported for tests. - `scanSchedules(log, projectPaths)` — scan the given projects for `/.claude/commands/schedule-*.md`, parse frontmatter, return `Schedule[]`. Without `projectPaths` it scans nothing. `startScheduler` passes its `projects()` option, which `main.js` fills from the registry below. - `scheduleRegistry(getSetting, setSetting)` — the `scheduleProjects` setting: `list()`, `add(path)`, `remove(path)`. `main.js` adds a project when it spawns a Claude session in it and on `add-project`, removes it on `remove-project`. Transcripts never add one: their `cwd`, and a folder name derived from it, are written by whoever ran claude, a sandboxed session included (see [docs/sandbox.md](../../docs/sandbox.md), "Schedules"). -- `initialScheduleProjects(listProjectSettingKeys)` — the seed, read once while the setting has never been written. It is the union of the `project:` settings keys whose path is an existing local directory (`main.js` passes `db.listSettingKeys('project:')`; a key carries no host alias, so a remote project whose path also exists locally registers that local directory — the user's own directory, low harm), and of the `~/.claude/projects` folders whose recorded path, resolved, encodes back to the folder's name, holds a schedule and contains a `.git` entry (directory or file). The second source exists because v0.0.85 ran schedules from every transcript folder, so dropping it would silently stop the schedules of an upgrading user. The `.git` requirement only keeps out schedule directories that are not repositories: a folder planted before the upgrade (under v0.0.85 a sandboxed session could write both `~/.claude/projects` and its project directory) that holds a `.git` and a schedule is still registered, once, at the first read, and inherits the enclosing project's sandbox setting. Accepted residual (maintainer decision 2026-10-02); after the upgrade `~/.claude/projects` is read-only to sandboxed sessions except their own folder, so nothing new is planted. Any other project is registered at its first launch from the app. +- `initialScheduleProjects(listProjectSettingKeys)` — the seed, read once while the setting has never been written. It is the union of the `project:` settings keys whose path is an existing local directory (`main.js` passes `db.listSettingKeys('project:')`; a key carries no host alias, so a remote project whose path also exists locally registers that local directory — the user's own directory, low harm), and of the `~/.claude/projects` folders whose recorded path, resolved, encodes back to the folder's name, holds a schedule and contains a `.git` entry (directory or file). Its recorded path comes through `verifiedTranscriptCwd`, the same rule as every other transcript cwd (`.ai/contexts/session-cache.md`, "Transcript cwd trust"). The second source exists because v0.0.85 ran schedules from every transcript folder, so dropping it would silently stop the schedules of an upgrading user. The `.git` requirement only keeps out schedule directories that are not repositories: a folder planted before the upgrade (under v0.0.85 a sandboxed session could write both `~/.claude/projects` and its project directory) that holds a `.git` and a schedule is still registered, once, at the first read, and inherits the enclosing project's sandbox setting. Accepted residual (maintainer decision 2026-10-02); after the upgrade `~/.claude/projects` is read-only to sandboxed sessions except their own folder, so nothing new is planted. Any other project is registered at its first launch from the app. - `refusedScheduleBinds(addDirs, projects, home, baseDir)` / `scheduleBindRefusals` (with the reason, used by `main.js`) — the `add-dirs` that are at or inside a `.claude` or `.git` (judged as spelled and by real path, anywhere), or under `home` and neither a registered project nor inside one (real paths). `main.js` skips a sandboxed run when it is not empty. - `createScheduleSession(schedule, dueMs)` — write a pre-seeded JSONL into `~/.claude/projects//.jsonl` with the schedule's prompt as the first user message, prefixed `Scheduled Task (catch-up: due …, started …): ` when `dueMs` is set. Returns the session UUID. - `buildScheduleCommand(sessionId, schedule)` — assemble the shell command (`claude --resume "" -p "..." --permission-mode acceptEdits --allowedTools "..."`). diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 4e4f168c..7900fd88 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -46,6 +46,25 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - **`get-projects` never awaits the cold-start scan.** `main.js`'s handler fires `populateCacheViaWorker()` without `await` when the cache is empty, returning whatever's cached right now (still non-empty for project *names* — `buildProjectsFromCache` lists on-disk directories synchronously even with zero indexed sessions). Progressive fill-in relies entirely on `notifyRendererProjectsChanged()` firing per folder. Don't reintroduce the `await` — it's what caused the multi-minute blocking "Loading…" on a large `~/.claude/projects/`. - **"Cache has rows" does not mean "initial scan finished".** The worker streams one DB write per folder, so killing the app mid-first-scan leaves `session_cache` partially populated. The authoritative signal is the `initial_scan_complete` settings key: written by `session-cache.js` only on the worker's final successful `done` message, backfilled once by migration v8 for pre-marker installs (their populated caches could only come from completed batch-write scans), cleared whenever the schema-reconciliation pass wipes the cache. `get-projects` treats "rows present but marker absent" as an interrupted scan: it resumes the background worker (safe — each folder message is delete-then-insert, so re-scanned folders never duplicate) and must NOT run the synchronous `reconcileCacheFromFilesystem()` sweep, which would re-parse every missing folder on the main thread. While the marker is absent, `buildProjectsFromCache`'s empty-dir fallback also skips `deriveProjectPath()` (per-folder readdir + 256 KB read) in favor of a zero-I/O best-effort decode of the folder name (`decodeProjectFolderBestEffort`), never persisted to `cache_meta`. +## Transcript cwd trust (issue #385) + +A transcript's `cwd` is trusted for a filesystem decision only if it encodes back to the name of the folder holding the transcript: `verifiedTranscriptCwd(cwd, folderName)` in `encode-project-path.js` returns the resolved absolute cwd when `encodeProjectPath(path.resolve(cwd)) === folderName`, else `null`. The seed of the schedule registry uses the same function. + +Why: a sandboxed session can write its own transcript folder `~/.claude/projects//` and the subtree of P, so it can forge a JSONL there whose `cwd` is `P/evil` and plant `P/evil/.claude/commands/schedule-x.md`. An unverified cwd became the sidebar project path, the resume/fork spawn directory, the sandbox's `SWITCHBOARD_SANDBOX_PROJECT_FOLDER`, the target of the schedule creator's `mkdir`, and a schedule-registry entry at the next launch, where a per-launch unsandboxed choice runs the planted schedule outside the sandbox. The sandbox cannot write any other encoded folder, so a cwd that encodes to the folder it sits in is one the session could not have chosen freely. + +Where it applies: + +| Consumer of a transcript cwd | Status | +|---|---| +| `deriveProjectPath` (sidebar project path, `session.projectPath`, `cache_meta`, `getKnownProjectPaths`, and the cold-start scan in `workers/scan-projects.js`) | Verified: a JSONL whose cwd does not verify is skipped, then the worktree collapse applies to the verified cwd; no verified JSONL gives `null` | +| `refreshFolder` reuse of a stored `cache_meta.projectPath` | Verified by `storedProjectPathMatchesFolder` (the path itself, or the repository of a worktree folder), so a value stored before the upgrade is re-derived | +| `resolveSessionRealCwd` (resume and fork spawn cwd in `open-terminal`, Changes panel, panel terminal, terminal path links, subagent worktree discovery, the sandbox bind folder, which follows the spawn cwd) | Verified against the folder holding the session's JSONL, which main finds on disk; a transcript that does not verify is skipped for the next folder holding the same id; none left means resume starts in the requested project path as for a session without a recorded cwd | +| `create-schedule-session` `mkdir enc(projectPath)` and `open-terminal` registration of `projectPath` | The path comes from the sidebar, so from `deriveProjectPath`; the registration stays a plain launch registration | +| Remote hosts | Not verified: a remote cwd is a path on the host, `deriveProjectPath` takes `{ remote: true }` there, and nothing local is opened from it | +| `resolveSessionRealCwd` for a worktree session | Kept: its JSONL lives in `enc(P/.claude/worktrees/x)` and its cwd encodes to that folder | + +Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit hash, so a path of 200 characters or more can collide (`enc(P/long) === enc(P)`), seed included. A transcript written by the CLI whose cwd does not encode to its folder (a symlinked cwd named by its real path, say) no longer gives a project path or a resume directory. A `projectPath` persisted by session restore before the upgrade is not re-verified; the renderer's own strings are out of scope. + ## Non-obvious behaviors - **`resolveWorktreePath` collapses `/.worktrees/` → ``** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern). diff --git a/derive-project-path.js b/derive-project-path.js index a19dd113..4918e08a 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const { encodeProjectPath, verifiedTranscriptCwd } = require('./encode-project-path'); // Only the head of the file is scanned: every session/subagent transcript // carries `cwd` on its first JSONL line. Reading the whole file here froze @@ -44,13 +45,16 @@ function resolveWorktreePath(cwd) { return cwd; } -function deriveProjectPath(folderPath) { +function deriveProjectPath(folderPath, folderName, opts) { + const name = folderName || path.basename(folderPath); + const remote = !!(opts && opts.remote); + const trusted = (cwd) => (remote ? (typeof cwd === 'string' && cwd ? cwd : null) : verifiedTranscriptCwd(cwd, name)); try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); // Check direct .jsonl files first for (const e of entries) { if (e.isFile() && e.name.endsWith('.jsonl')) { - const cwd = extractCwdFromJsonl(path.join(folderPath, e.name)); + const cwd = trusted(extractCwdFromJsonl(path.join(folderPath, e.name))); if (cwd) return resolveWorktreePath(cwd); } } @@ -69,7 +73,7 @@ function deriveProjectPath(folderPath) { if (agentFiles.length > 0) jsonlPath = path.join(subDir, 'subagents', agentFiles[0]); } if (jsonlPath) { - const cwd = extractCwdFromJsonl(jsonlPath); + const cwd = trusted(extractCwdFromJsonl(jsonlPath)); if (cwd) return resolveWorktreePath(cwd); } } @@ -132,6 +136,13 @@ function sessionTranscriptExists(projectsDir, sessionId) { return false; } +function storedProjectPathMatchesFolder(projectPath, folder) { + if (verifiedTranscriptCwd(projectPath, folder)) return true; + if (typeof projectPath !== 'string' || !path.isAbsolute(projectPath)) return false; + const base = encodeProjectPath(path.resolve(projectPath)); + return folder.startsWith(base) && /^--(?:claude-)?worktrees-./.test(folder.slice(base.length)); +} + function resolveSessionRealCwd(projectsDir, sessionId, preferredFolder) { try { const folders = fs.readdirSync(projectsDir); @@ -145,10 +156,11 @@ function resolveSessionRealCwd(projectsDir, sessionId, preferredFolder) { for (const folder of folders) { const jsonl = path.join(projectsDir, folder, sessionId + '.jsonl'); if (!fs.existsSync(jsonl)) continue; - return extractCwdFromJsonl(jsonl); + const cwd = verifiedTranscriptCwd(extractCwdFromJsonl(jsonl), folder); + if (cwd) return cwd; } } catch {} return null; } -module.exports = { deriveProjectPath, resolveWorktreePath, extractCwdFromJsonl, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo }; +module.exports = { deriveProjectPath, storedProjectPathMatchesFolder, resolveWorktreePath, extractCwdFromJsonl, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo }; diff --git a/docs/sandbox.md b/docs/sandbox.md index 98355c38..8d55919f 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -270,6 +270,15 @@ the upgrade `~/.claude/projects` is read-only to a sandboxed session except its own folder, so nothing new can be planted. Any other project enters the registry the normal way, when a session is launched in it from the app or when you add it. +A project path is never read from a transcript on trust. The sidebar project, +the directory a resumed or forked session starts in and the folder the sandbox +binds as its own transcript folder all come from a transcript's `cwd` only when +that `cwd`, with every character but letters and digits replaced by `-`, is the +name of the folder holding the transcript. A transcript forged in the session's +own folder with a `cwd` below the project therefore moves none of them, and +registers nothing. Paths of 200 characters or more are shortened and hashed in +that name, so two of them can share a folder name; this is not closed. + A sandboxed session can still create a `.claude` below a bound directory after launch: the mount plan is fixed when the sandbox starts, and a new directory is not in it. The registry is what keeps a diff --git a/encode-project-path.js b/encode-project-path.js index c4fd3f24..912f0f7b 100644 --- a/encode-project-path.js +++ b/encode-project-path.js @@ -1,3 +1,5 @@ +const path = require('path'); + // Mirror Claude CLI's project-folder naming so Switchboard-created folders // match the ones the CLI writes for the same project path. // Reverse-engineered from claude CLI 2.1.126. @@ -11,6 +13,13 @@ function encodeProjectPath(projectPath) { return sanitized.slice(0, 200) + '-' + Math.abs(h).toString(36); } +// see .ai/contexts/session-cache.md ("Transcript cwd trust") +function verifiedTranscriptCwd(cwd, folderName) { + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return null; + const resolved = path.resolve(cwd); + return encodeProjectPath(resolved) === folderName ? resolved : null; +} + // Best-effort inverse of encodeProjectPath, for DISPLAY ONLY while the // initial scan is still running and cache_meta has no real projectPath for a // folder yet. The encoding is lossy (every non-alphanumeric became '-'), so @@ -25,4 +34,4 @@ function decodeProjectFolderBestEffort(folder) { return folder.replace(/-/g, '/'); } -module.exports = { encodeProjectPath, decodeProjectFolderBestEffort }; +module.exports = { encodeProjectPath, decodeProjectFolderBestEffort, verifiedTranscriptCwd }; diff --git a/schedule-runner.js b/schedule-runner.js index 741b5cc8..71ebd45c 100644 --- a/schedule-runner.js +++ b/schedule-runner.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); -const { encodeProjectPath } = require('./encode-project-path'); +const { encodeProjectPath, verifiedTranscriptCwd } = require('./encode-project-path'); const CLAUDE_DIR = path.join(os.homedir(), '.claude'); const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); @@ -188,8 +188,8 @@ function initialScheduleProjects(listProjectSettingKeys) { if (!folder.isDirectory()) continue; const recorded = folderMeta.get(folder.name) || readProjectPathFromJsonl(path.join(PROJECTS_DIR, folder.name)); if (!recorded) continue; - const projectPath = path.resolve(recorded); - if (encodeProjectPath(projectPath) !== folder.name) continue; + const projectPath = verifiedTranscriptCwd(recorded, folder.name); + if (!projectPath) continue; try { if (!fs.existsSync(path.join(projectPath, '.git'))) continue; const commandsDir = path.join(projectPath, '.claude', 'commands'); diff --git a/session-cache.js b/session-cache.js index 21cba67b..263dd8dc 100644 --- a/session-cache.js +++ b/session-cache.js @@ -2,7 +2,7 @@ const path = require('path'); const fs = require('fs'); const { Worker } = require('worker_threads'); const { getFolderIndexMtimeMs } = require('./folder-index-state'); -const { deriveProjectPath } = require('./derive-project-path'); +const { deriveProjectPath, storedProjectPathMatchesFolder } = require('./derive-project-path'); const { readSessionFile, readSessionDisplayHeader, enumerateSessionFiles, resolveJsonlPath, mergeBridgeGroups } = require('./read-session-file'); const { encodeProjectPath, decodeProjectFolderBestEffort } = require('./encode-project-path'); const { parseFolderKey, joinFolderKey } = require('./remote-hosts'); @@ -65,11 +65,16 @@ function resolveFolderDir(folderKey) { // readSessionFile is imported from read-session-file.js (shared with worker) +function deriveFolderProjectPath(folderPath, folderKey) { + const { alias, folder } = parseFolderKey(folderKey); + return deriveProjectPath(folderPath, folder, { remote: alias !== null }); +} + /** Read one folder from filesystem by scanning .jsonl files directly */ function readFolderFromFilesystem(folder) { const folderPath = resolveFolderDir(folder); if (!folderPath) return { projectPath: null, sessions: [] }; - const projectPath = deriveProjectPath(folderPath, folder); + const projectPath = deriveFolderProjectPath(folderPath, folder); if (!projectPath) return { projectPath: null, sessions: [] }; const sessions = []; @@ -112,9 +117,10 @@ function refreshFolder(folder, opts = {}) { // project remap detection keeps working. const knownMeta = getFolderMeta ? getFolderMeta(folder) : null; let projectPath = knownMeta && knownMeta.projectPath && fs.existsSync(knownMeta.projectPath) + && (parseFolderKey(folder).alias !== null || storedProjectPathMatchesFolder(knownMeta.projectPath, folder)) ? knownMeta.projectPath : null; - if (!projectPath) projectPath = deriveProjectPath(folderPath, folder); + if (!projectPath) projectPath = deriveFolderProjectPath(folderPath, folder); if (!projectPath) { setFolderMeta(folder, null, getFolderIndexMtimeMs(folderPath)); return; @@ -517,7 +523,7 @@ function buildProjectsFromCache(showArchived) { let placeholder = false; if (!projectPath) { if (scanComplete) { - projectPath = deriveProjectPath(path.join(dir, d.name), d.name); + projectPath = deriveProjectPath(path.join(dir, d.name), d.name, { remote: alias !== null }); if (projectPath) setFolderMeta(folderKey, projectPath, 0); } else { projectPath = decodeProjectFolderBestEffort(d.name); diff --git a/test/build-projects-cold-scan-fallback.test.js b/test/build-projects-cold-scan-fallback.test.js index 9dd0c5d1..a1ec0ff6 100644 --- a/test/build-projects-cold-scan-fallback.test.js +++ b/test/build-projects-cold-scan-fallback.test.js @@ -35,6 +35,7 @@ let deriveCalls = 0; deriveModule.deriveProjectPath = (...args) => { deriveCalls++; return realDerive(...args); }; const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); function writeSession(folderPath, cwd) { fs.mkdirSync(folderPath, { recursive: true }); @@ -117,7 +118,8 @@ test('cold start in flight: the empty-dir fallback does zero per-folder I/O and test('scan complete: the fallback still derives real paths and backfills cache_meta (warm path unchanged)', () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-bpfc-warm-')); try { - writeSession(path.join(projectsDir, '-tmp-proj-a'), '/tmp/proj-a'); + const warmCwd = path.join(projectsDir, 'proj-a'); + writeSession(path.join(projectsDir, encodeProjectPath(warmCwd)), warmCwd); const setFolderMetaCalls = []; initCache(projectsDir, { initialScanComplete: true, setFolderMetaCalls }); @@ -126,9 +128,9 @@ test('scan complete: the fallback still derives real paths and backfills cache_m const projects = sessionCache.buildProjectsFromCache(false); assert.equal(deriveCalls, 1, 'a folder unknown to cache_meta is derived for real once the scan is complete'); - assert.deepEqual(setFolderMetaCalls.map(c => c.projectPath), ['/tmp/proj-a'], + assert.deepEqual(setFolderMetaCalls.map(c => c.projectPath), [warmCwd], 'the real derived path is backfilled so subsequent renders are pure DB reads'); - assert.deepEqual(projects.map(p => p.projectPath), ['/tmp/proj-a']); + assert.deepEqual(projects.map(p => p.projectPath), [warmCwd]); } finally { fs.rmSync(projectsDir, { recursive: true, force: true }); } diff --git a/test/derive-project-path.test.js b/test/derive-project-path.test.js index 35d992f9..e0d41e09 100644 --- a/test/derive-project-path.test.js +++ b/test/derive-project-path.test.js @@ -4,6 +4,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { encodeProjectPath } = require('../encode-project-path'); const { deriveProjectPath, resolveWorktreePath, resolveSessionRealCwd } = require('../derive-project-path'); function mkTmp() { @@ -92,7 +93,7 @@ test('deriveProjectPath end-to-end: jsonl with worktree cwd resolves to parent r // The folder we feed deriveProjectPath is a "projects/foo" style dir // containing a single jsonl whose first cwd line points at the worktree. - const folder = path.join(tmp, 'project-folder'); + const folder = path.join(tmp, encodeProjectPath(worktreeCwd)); fs.mkdirSync(folder); fs.writeFileSync( path.join(folder, 'session-1.jsonl'), @@ -118,7 +119,7 @@ const CWD_SCAN_BYTES = 256 * 1024; test('extractCwdFromJsonl: finds cwd on line 1 of a small file (< scan window)', () => { const tmp = mkTmp(); try { - const folder = path.join(tmp, 'folder'); + const folder = path.join(tmp, encodeProjectPath(path.join(tmp, 'myproject'))); fs.mkdirSync(folder); const cwd = path.join(tmp, 'myproject'); // Small file: one header line with cwd, then a few regular lines @@ -137,7 +138,7 @@ test('extractCwdFromJsonl: finds cwd on line 1 of a small file (< scan window)', test('extractCwdFromJsonl: finds cwd when file is larger than 256 KB and cwd is on line 1', () => { const tmp = mkTmp(); try { - const folder = path.join(tmp, 'folder'); + const folder = path.join(tmp, encodeProjectPath(path.join(tmp, 'bigproject'))); fs.mkdirSync(folder); const cwd = path.join(tmp, 'bigproject'); // First line: the header with cwd @@ -168,7 +169,7 @@ test('extractCwdFromJsonl: returns null when cwd only appears beyond the 256 KB // file that we deliberately do not support to avoid the re-read hot-loop. const tmp = mkTmp(); try { - const folder = path.join(tmp, 'folder'); + const folder = path.join(tmp, encodeProjectPath(path.join(tmp, 'hidden-project'))); fs.mkdirSync(folder); const cwd = path.join(tmp, 'hidden-project'); // Fill more than 256 KB with lines that have NO cwd field, then append @@ -196,7 +197,7 @@ test('extractCwdFromJsonl: does not throw when the 256 KB boundary cuts a line m // the cut produces a partial UTF-8 / partial JSON fragment. const tmp = mkTmp(); try { - const folder = path.join(tmp, 'folder'); + const folder = path.join(tmp, encodeProjectPath(path.join(tmp, 'trunctest'))); fs.mkdirSync(folder); const cwd = path.join(tmp, 'trunctest'); // First line has a cwd so we get a real return value; the truncation @@ -229,14 +230,15 @@ test('resolveSessionRealCwd: finds the cwd of a session in any project folder', const tmp = mkTmp(); try { const cwd = path.join(tmp, 'repo', '.claude', 'worktrees', 'agent-xyz'); + const wtFolder = encodeProjectPath(cwd); fs.mkdirSync(path.join(tmp, '-other-project')); - fs.mkdirSync(path.join(tmp, '-home-user-repo')); + fs.mkdirSync(path.join(tmp, wtFolder)); fs.writeFileSync( path.join(tmp, '-other-project', 'aaaa.jsonl'), JSON.stringify({ type: 'summary', cwd: '/elsewhere' }) + '\n', 'utf8' ); fs.writeFileSync( - path.join(tmp, '-home-user-repo', 'sess-1.jsonl'), + path.join(tmp, wtFolder, 'sess-1.jsonl'), JSON.stringify({ type: 'summary', cwd }) + '\n' + JSON.stringify({ type: 'user', message: 'hello' }) + '\n', 'utf8' ); @@ -281,9 +283,9 @@ test('resolveSessionRealCwd: returns null when the projects dir does not exist', test('resolveSessionRealCwd: uses the bounded scan — cwd on line 1 of a >256 KB transcript is found', () => { const tmp = mkTmp(); try { - fs.mkdirSync(path.join(tmp, '-big')); const cwd = path.join(tmp, 'bigproject'); - const filePath = path.join(tmp, '-big', 'sess-big.jsonl'); + fs.mkdirSync(path.join(tmp, encodeProjectPath(cwd))); + const filePath = path.join(tmp, encodeProjectPath(cwd), 'sess-big.jsonl'); const header = JSON.stringify({ type: 'summary', cwd }) + '\n'; const fillerLine = JSON.stringify({ type: 'assistant', message: 'x'.repeat(80) }) + '\n'; const fillerCount = Math.ceil((300 * 1024 - header.length) / fillerLine.length) + 10; @@ -307,17 +309,19 @@ test('resolveSessionRealCwd: checks the preferredFolder hint before the alphabet // alphabetically-first one would win a naive scan. The hint must win. const decoyCwd = path.join(tmp, 'decoy'); const realCwd = path.join(tmp, 'real'); - fs.mkdirSync(path.join(tmp, '-aaa-decoy')); - fs.mkdirSync(path.join(tmp, '-zzz-hinted')); + const decoyFolder = encodeProjectPath(decoyCwd); + const hintedFolder = encodeProjectPath(realCwd); + fs.mkdirSync(path.join(tmp, decoyFolder)); + fs.mkdirSync(path.join(tmp, hintedFolder)); fs.writeFileSync( - path.join(tmp, '-aaa-decoy', 'sess-h.jsonl'), + path.join(tmp, decoyFolder, 'sess-h.jsonl'), JSON.stringify({ type: 'summary', cwd: decoyCwd }) + '\n', 'utf8' ); fs.writeFileSync( - path.join(tmp, '-zzz-hinted', 'sess-h.jsonl'), + path.join(tmp, hintedFolder, 'sess-h.jsonl'), JSON.stringify({ type: 'summary', cwd: realCwd }) + '\n', 'utf8' ); - assert.equal(resolveSessionRealCwd(tmp, 'sess-h', '-zzz-hinted'), realCwd); + assert.equal(resolveSessionRealCwd(tmp, 'sess-h', hintedFolder), realCwd); assert.equal(resolveSessionRealCwd(tmp, 'sess-h'), decoyCwd); } finally { cleanup(tmp); @@ -330,10 +334,11 @@ test('resolveSessionRealCwd: falls back to the full scan when the preferredFolde // Fork-of-worktree-session shape: the caller hints the collapsed parent's // folder, but the fork source's transcript lives under the worktree folder. const cwd = path.join(tmp, 'repo', '.worktrees', 'agent-w'); + const wtFolder = encodeProjectPath(cwd); fs.mkdirSync(path.join(tmp, '-repo')); - fs.mkdirSync(path.join(tmp, '-repo--worktrees-agent-w')); + fs.mkdirSync(path.join(tmp, wtFolder)); fs.writeFileSync( - path.join(tmp, '-repo--worktrees-agent-w', 'sess-fork-src.jsonl'), + path.join(tmp, wtFolder, 'sess-fork-src.jsonl'), JSON.stringify({ type: 'summary', cwd }) + '\n', 'utf8' ); assert.equal(resolveSessionRealCwd(tmp, 'sess-fork-src', '-repo'), cwd); diff --git a/test/remote-scan-file-granularity.test.js b/test/remote-scan-file-granularity.test.js index de622a00..32f66f0a 100644 --- a/test/remote-scan-file-granularity.test.js +++ b/test/remote-scan-file-granularity.test.js @@ -15,6 +15,7 @@ const os = require('os'); const path = require('path'); const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); function tmp(name) { return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-' + name + '-')); @@ -113,23 +114,25 @@ test('property 1: a single changed file in a multi-file folder rereads only that test('property 2: with no fileSubsets, a folder is still scanned in full (local/default path unchanged)', async () => { const projectsDir = tmp('gran-full'); try { - const folderPath = path.join(projectsDir, 'proj'); + const cwd = path.join(projectsDir, 'proj'); + const folder = encodeProjectPath(cwd); + const folderPath = path.join(projectsDir, folder); writeJsonl(path.join(folderPath, 'a.jsonl'), [ - { type: 'user', cwd: folderPath, timestamp: '2026-09-01T10:00:00.000Z', message: { role: 'user', content: 'session a' } }, + { type: 'user', cwd, timestamp: '2026-09-01T10:00:00.000Z', message: { role: 'user', content: 'session a' } }, ]); writeJsonl(path.join(folderPath, 'b.jsonl'), [ - { type: 'user', cwd: folderPath, timestamp: '2026-09-01T11:00:00.000Z', message: { role: 'user', content: 'session b' } }, + { type: 'user', cwd, timestamp: '2026-09-01T11:00:00.000Z', message: { role: 'user', content: 'session b' } }, ]); const { db, deletedFolders, upsertedSessionIds } = makeFakeDb(); initCache(db); - console.log('[property2] mutated line under test: scanFoldersViaWorker called with folders:["proj"] and NO fileSubsets key at all'); - const scan = await sessionCache.scanFoldersViaWorker({ projectsDir, folders: ['proj'] }); + console.log('[property2] mutated line under test: scanFoldersViaWorker called with folders:[folder] and NO fileSubsets key at all'); + const scan = await sessionCache.scanFoldersViaWorker({ projectsDir, folders: [folder] }); assert.equal(scan.ok, true, scan.error); assert.deepEqual(upsertedSessionIds.sort(), ['a', 'b'], 'a whole-folder scan reads every file, exactly as before'); - assert.deepEqual(deletedFolders, ['proj'], 'the pre-existing delete-then-insert path is unchanged when no fileSubsets is given'); + assert.deepEqual(deletedFolders, [folder], 'the pre-existing delete-then-insert path is unchanged when no fileSubsets is given'); } finally { fs.rmSync(projectsDir, { recursive: true, force: true }); } diff --git a/test/scan-projects-worker.test.js b/test/scan-projects-worker.test.js index 08261350..f4bf331e 100644 --- a/test/scan-projects-worker.test.js +++ b/test/scan-projects-worker.test.js @@ -20,8 +20,10 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { Worker } = require('worker_threads'); +const { encodeProjectPath } = require('../encode-project-path'); -function writeSession(folderPath, cwd) { +function writeSession(projectsDir, name, cwd = path.join(projectsDir, name)) { + const folderPath = path.join(projectsDir, encodeProjectPath(cwd)); fs.mkdirSync(folderPath, { recursive: true }); const line = JSON.stringify({ type: 'user', cwd, message: { role: 'user', content: 'hello' } }); fs.writeFileSync(path.join(folderPath, 'session.jsonl'), line + '\n', 'utf8'); @@ -51,9 +53,9 @@ function runWorker(projectsDir) { test('scan-projects worker streams one folder message per folder, then a final done message', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-scan-')); try { - writeSession(path.join(projectsDir, 'proj-a'), '/tmp/proj-a'); - writeSession(path.join(projectsDir, 'proj-b'), '/tmp/proj-b'); - writeSession(path.join(projectsDir, 'proj-c'), '/tmp/proj-c'); + writeSession(projectsDir, 'proj-a'); + writeSession(projectsDir, 'proj-b'); + writeSession(projectsDir, 'proj-c'); const messages = await runWorker(projectsDir); @@ -76,7 +78,7 @@ test('scan-projects worker streams one folder message per folder, then a final d } const folders = folderMsgs.map(m => m.result.folder).sort(); - assert.deepEqual(folders, ['proj-a', 'proj-b', 'proj-c']); + assert.deepEqual(folders, ['proj-a', 'proj-b', 'proj-c'].map(n => encodeProjectPath(path.join(projectsDir, n))).sort()); } finally { fs.rmSync(projectsDir, { recursive: true, force: true }); } @@ -95,3 +97,23 @@ test('scan-projects worker reports done:ok even for an empty projects dir', asyn fs.rmSync(projectsDir, { recursive: true, force: true }); } }); + +test('scan-projects worker indexes no project path from a transcript whose cwd does not encode to its folder', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-scan-forged-')); + try { + const project = path.join(projectsDir, 'proj'); + writeSession(projectsDir, 'proj', path.join(project, 'evil')); + fs.renameSync( + path.join(projectsDir, encodeProjectPath(path.join(project, 'evil'))), + path.join(projectsDir, encodeProjectPath(project)) + ); + + const messages = await runWorker(projectsDir); + + const folderMsgs = messages.filter(m => m.type === 'folder'); + assert.equal(folderMsgs.length, 1); + assert.equal(folderMsgs[0].result, null); + } finally { + fs.rmSync(projectsDir, { recursive: true, force: true }); + } +}); diff --git a/test/session-cache-bridge-dedup.test.js b/test/session-cache-bridge-dedup.test.js index 1a15e287..cfc1e485 100644 --- a/test/session-cache-bridge-dedup.test.js +++ b/test/session-cache-bridge-dedup.test.js @@ -18,6 +18,7 @@ const os = require('os'); const path = require('path'); const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); function mkTmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-bridge-dedup-')); @@ -71,7 +72,7 @@ const MIRROR_NEW_USAGE = { input_tokens: 9, output_tokens: 4 }; test('refreshFolder: a compaction mirror keeps its own row (mergedIntoSessionId set), contributes only its post-cutoff tokens, and an independent session is untouched', () => { const projectsDir = mkTmp(); try { - const folder = 'proj'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; @@ -149,7 +150,7 @@ test('refreshFolder: a mirror indexed alone before its parent is known is correc // stamped with mergedIntoSessionId (see coordinator review on issue #197). const projectsDir = mkTmp(); try { - const folder = 'proj-order'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; @@ -191,7 +192,7 @@ test('refreshFolder: a mirror indexed alone before its parent is known is correc test('refreshFolder: a mirror indexed alone whose entire content turns out to be a duplicate is deleted once its parent is discovered, end to end', () => { const projectsDir = mkTmp(); try { - const folder = 'proj-order-2'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; @@ -272,7 +273,7 @@ test('buildProjectsFromCache: the mirror does not appear as its own sidebar entr test('refreshFolder: an existing row misidentified as parent is re-parented AND re-derived (not merely re-labelled) once a genuinely earlier file is discovered', () => { const projectsDir = mkTmp(); try { - const folder = 'proj2'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; @@ -332,7 +333,7 @@ test('refreshFolder: an existing row misidentified as parent is re-parented AND test('readFolderFromFilesystem: a fresh full-folder scan merges the mirror in one pass with no double-counted tokens', () => { const projectsDir = mkTmp(); try { - const folder = 'proj3'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; diff --git a/test/session-cache-cold-start-progress.test.js b/test/session-cache-cold-start-progress.test.js index 560cdfbe..32a40b0d 100644 --- a/test/session-cache-cold-start-progress.test.js +++ b/test/session-cache-cold-start-progress.test.js @@ -27,8 +27,11 @@ const os = require('os'); const path = require('path'); const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); -function writeSession(folderPath, cwd) { +function writeSession(projectsDir, name) { + const cwd = path.join(projectsDir, name); + const folderPath = path.join(projectsDir, encodeProjectPath(cwd)); fs.mkdirSync(folderPath, { recursive: true }); const line = JSON.stringify({ type: 'user', cwd, message: { role: 'user', content: 'hello' } }); fs.writeFileSync(path.join(folderPath, 'session.jsonl'), line + '\n', 'utf8'); @@ -78,9 +81,9 @@ function initCache(projectsDir, db) { test('populateCacheViaWorker writes each folder to the DB as it streams in (not batched at the end)', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cold-')); try { - writeSession(path.join(projectsDir, 'proj-a'), '/tmp/proj-a'); - writeSession(path.join(projectsDir, 'proj-b'), '/tmp/proj-b'); - writeSession(path.join(projectsDir, 'proj-c'), '/tmp/proj-c'); + writeSession(projectsDir, 'proj-a'); + writeSession(projectsDir, 'proj-b'); + writeSession(projectsDir, 'proj-c'); const db = makeFakeDb({ initialScanComplete: false }); initCache(projectsDir, db); @@ -98,8 +101,8 @@ test('populateCacheViaWorker writes each folder to the DB as it streams in (not test('cold start (empty cache) emits indexing-progress events ending in done:true, with increasing counters', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cold-2-')); try { - writeSession(path.join(projectsDir, 'proj-a'), '/tmp/proj-a'); - writeSession(path.join(projectsDir, 'proj-b'), '/tmp/proj-b'); + writeSession(projectsDir, 'proj-a'); + writeSession(projectsDir, 'proj-b'); const db = makeFakeDb({ initialScanComplete: false }); initCache(projectsDir, db); @@ -135,7 +138,7 @@ test('cold start (empty cache) emits indexing-progress events ending in done:tru test('the completeness marker is written exactly once, on the successful done message', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-marker-')); try { - writeSession(path.join(projectsDir, 'proj-a'), '/tmp/proj-a'); + writeSession(projectsDir, 'proj-a'); const db = makeFakeDb({ initialScanComplete: false }); initCache(projectsDir, db); @@ -153,7 +156,7 @@ test('the completeness marker is written exactly once, on the successful done me test('a resumed interrupted scan (marker absent, cache already has rows) still emits the banner events and re-marks completion', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-resume-')); try { - writeSession(path.join(projectsDir, 'proj-a'), '/tmp/proj-a'); + writeSession(projectsDir, 'proj-a'); // Note the fake db deliberately exposes NO row-count primitive: the // cold/warm decision must be a pure function of the marker. Before the @@ -180,7 +183,7 @@ test('indexing-progress is throttled: intermediate folder events are dropped, fi const realNow = Date.now; try { for (const name of ['proj-a', 'proj-b', 'proj-c', 'proj-d', 'proj-e']) { - writeSession(path.join(projectsDir, name), '/tmp/' + name); + writeSession(projectsDir, name); } const db = makeFakeDb({ initialScanComplete: false }); @@ -213,7 +216,7 @@ test('indexing-progress is throttled: intermediate folder events are dropped, fi test('warm start (initial scan already completed) never emits indexing-progress', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-warm-')); try { - writeSession(path.join(projectsDir, 'proj-a'), '/tmp/proj-a'); + writeSession(projectsDir, 'proj-a'); const db = makeFakeDb({ initialScanComplete: true }); initCache(projectsDir, db); @@ -238,7 +241,7 @@ test('warm start (initial scan already completed) never emits indexing-progress' test('cold start also sends indexing-finished, once, after the last progress event', async () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cold-fin-')); try { - writeSession(path.join(projectsDir, 'proj-a'), '/tmp/proj-a'); + writeSession(projectsDir, 'proj-a'); const db = makeFakeDb({ initialScanComplete: false }); initCache(projectsDir, db); const run = sessionCache.populateCacheViaWorker(); diff --git a/test/session-cache-refresh.test.js b/test/session-cache-refresh.test.js index 95804c12..622dd426 100644 --- a/test/session-cache-refresh.test.js +++ b/test/session-cache-refresh.test.js @@ -15,6 +15,7 @@ const os = require('os'); const path = require('path'); const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); // ---- Helpers ---------------------------------------------------------------- @@ -81,7 +82,7 @@ function makeFakeDb(opts = {}) { test('refreshFolder: only the changed-mtime file gets upserted; unchanged files are skipped', () => { const projectsDir = mkTmp(); try { - const folder = 'test-proj'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; // cwd points here → deriveProjectPath returns it @@ -138,7 +139,7 @@ test('refreshFolder: only the changed-mtime file gets upserted; unchanged files test('refreshFolder targeted: opts.files limits upsert to only the named file', () => { const projectsDir = mkTmp(); try { - const folder = 'targeted-proj'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; @@ -177,7 +178,7 @@ test('refreshFolder targeted: opts.files limits upsert to only the named file', test('refreshFolder: deleted file produces deleteCachedSession call (full walk)', () => { const projectsDir = mkTmp(); try { - const folder = 'delete-proj'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; @@ -221,7 +222,7 @@ test('refreshFolder: deleted file produces deleteCachedSession call (full walk)' test('refreshFolder: new session produces a searchEntriesToUpsert entry with non-empty body and correct title', () => { const projectsDir = mkTmp(); try { - const folder = 'search-proj'; + const folder = encodeProjectPath(projectsDir); const folderPath = path.join(projectsDir, folder); const projectPath = projectsDir; diff --git a/test/transcript-cwd-trust.test.js b/test/transcript-cwd-trust.test.js new file mode 100644 index 00000000..f86741b6 --- /dev/null +++ b/test/transcript-cwd-trust.test.js @@ -0,0 +1,225 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { encodeProjectPath, verifiedTranscriptCwd } = require('../encode-project-path'); +const { deriveProjectPath, resolveSessionRealCwd, storedProjectPathMatchesFolder } = require('../derive-project-path'); +const sessionCache = require('../session-cache'); + +function mkTmp() { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-tct-'))); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function writeTranscript(projectsDir, folder, sessionId, cwd) { + const dir = path.join(projectsDir, folder); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, sessionId + '.jsonl'), + JSON.stringify({ type: 'user', cwd, sessionId, message: { role: 'user', content: 'hi' } }) + '\n'); +} + +function project(tmp, ...parts) { + const dir = path.join(tmp, ...parts); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +test('verifiedTranscriptCwd: returns the resolved cwd when it encodes back to the folder', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + assert.equal(verifiedTranscriptCwd(p, encodeProjectPath(p)), p); + } finally { cleanup(tmp); } +}); + +test('verifiedTranscriptCwd: a cwd below the folder project, a relative cwd and a non-string are refused', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const folder = encodeProjectPath(p); + assert.equal(verifiedTranscriptCwd(path.join(p, 'evil'), folder), null); + assert.equal(verifiedTranscriptCwd('proj', folder), null); + assert.equal(verifiedTranscriptCwd('rel-dir', encodeProjectPath(path.resolve('rel-dir'))), null); + assert.equal(verifiedTranscriptCwd(null, folder), null); + assert.equal(verifiedTranscriptCwd(42, folder), null); + } finally { cleanup(tmp); } +}); + +test('verifiedTranscriptCwd: a cwd with .. segments that resolves to the folder project verifies', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + project(tmp, 'proj', 'sub'); + const dotted = path.join(p, 'sub', '..') + path.sep + 'sub' + path.sep + '..'; + assert.equal(verifiedTranscriptCwd(dotted, encodeProjectPath(p)), p); + } finally { cleanup(tmp); } +}); + +test('deriveProjectPath: a forged transcript cwd below the project is skipped for the genuine one', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'a-forged', evil); + writeTranscript(projectsDir, folder, 'b-genuine', p); + assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder), p); + } finally { cleanup(tmp); } +}); + +test('deriveProjectPath: with only a forged transcript the result is null, never the forged cwd', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'a-forged', evil); + assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder), null); + } finally { cleanup(tmp); } +}); + +test('deriveProjectPath: a forged subagent transcript in a session subfolder is refused too', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, path.join(folder, 'sess-1', 'subagents'), 'agent-x', evil); + assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder), null); + } finally { cleanup(tmp); } +}); + +test('deriveProjectPath: a worktree folder still collapses to its repository', () => { + const tmp = mkTmp(); + try { + const repo = project(tmp, 'repo'); + const wt = project(tmp, 'repo', '.claude', 'worktrees', 'agent-1'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(wt); + writeTranscript(projectsDir, folder, 'w', wt); + assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder), repo); + } finally { cleanup(tmp); } +}); + +test('deriveProjectPath: a remote folder keeps its recorded cwd, which is not a local path', () => { + const tmp = mkTmp(); + try { + const projectsDir = project(tmp, 'projects'); + const folder = '-home-remote-proj'; + writeTranscript(projectsDir, folder, 'r', '/home/remote/proj'); + assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder, { remote: true }), '/home/remote/proj'); + } finally { cleanup(tmp); } +}); + +test('resolveSessionRealCwd: a forged cwd is not returned, a worktree cwd is', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const wt = project(tmp, 'proj', '.claude', 'worktrees', 'w1'); + const projectsDir = project(tmp, 'projects'); + writeTranscript(projectsDir, encodeProjectPath(p), 'forged', evil); + writeTranscript(projectsDir, encodeProjectPath(wt), 'wtsess', wt); + assert.equal(resolveSessionRealCwd(projectsDir, 'forged', encodeProjectPath(p)), null); + assert.equal(resolveSessionRealCwd(projectsDir, 'wtsess', encodeProjectPath(p)), wt); + } finally { cleanup(tmp); } +}); + +test('resolveSessionRealCwd: a forged copy of a session id does not shadow the genuine transcript', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const wt = project(tmp, 'proj', '.claude', 'worktrees', 'w1'); + const projectsDir = project(tmp, 'projects'); + writeTranscript(projectsDir, encodeProjectPath(p), 'same-id', evil); + writeTranscript(projectsDir, encodeProjectPath(wt), 'same-id', wt); + assert.equal(resolveSessionRealCwd(projectsDir, 'same-id', encodeProjectPath(p)), wt); + } finally { cleanup(tmp); } +}); + +test('chain A: a schedule created from the sidebar project of a forged folder never targets the forged directory', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'forged', evil); + const sidebarProject = deriveProjectPath(path.join(projectsDir, folder), folder); + assert.notEqual(sidebarProject, evil); + assert.equal(sidebarProject, null); + } finally { cleanup(tmp); } +}); + +test('chain B: resuming a forged session spawns in the project, and a new session on the sidebar project registers the project, not the forged directory', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'forged', evil); + writeTranscript(projectsDir, folder, 'genuine', p); + const sidebarProject = deriveProjectPath(path.join(projectsDir, folder), folder); + assert.equal(sidebarProject, p); + assert.equal(resolveSessionRealCwd(projectsDir, 'forged', folder), null); + assert.equal(encodeProjectPath(sidebarProject), folder); + } finally { cleanup(tmp); } +}); + +test('first launch of a normal project: open-terminal registers the requested project without any folder precondition', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + assert.match(src, /^\s*if \(projectPath\) scheduleProjects\(\)\.add\(projectPath\);\s*$/m); +}); + +test('storedProjectPathMatchesFolder: accepts the folder project and its worktree collapse, refuses a path below it', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const wt = project(tmp, 'proj', '.claude', 'worktrees', 'w1'); + assert.equal(storedProjectPathMatchesFolder(p, encodeProjectPath(p)), true); + assert.equal(storedProjectPathMatchesFolder(p, encodeProjectPath(wt)), true); + assert.equal(storedProjectPathMatchesFolder(evil, encodeProjectPath(p)), false); + assert.equal(storedProjectPathMatchesFolder(p, encodeProjectPath(evil)), false); + } finally { cleanup(tmp); } +}); + +test('refreshFolder: a forged projectPath already stored in cache_meta is replaced by the verified one', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'genuine', p); + const metaWrites = []; + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions: new Map(), + getMainWindow: () => null, + log: console, + db: { + deleteCachedFolder: () => {}, getCachedByFolder: () => [], upsertCachedSessions: () => {}, + touchCachedModified: () => {}, deleteCachedSession: () => {}, replaceSessionMetrics: () => {}, + deleteSearchFolder: () => {}, deleteSearchSession: () => {}, upsertSearchEntries: () => {}, + getFolderMeta: () => ({ folder, projectPath: evil, indexMtimeMs: 0 }), + setFolderMeta: (f, projectPath) => metaWrites.push(projectPath), + getAllFolderMeta: () => new Map(), getAllMeta: () => new Map(), getAllCached: () => [], + getSetting: () => ({}), getMeta: () => null, setName: () => {}, + }, + }); + sessionCache.refreshFolder(folder); + assert.ok(metaWrites.length > 0, 'cache_meta is rewritten'); + assert.ok(metaWrites.every(w => w === p), 'only the verified project path is stored: ' + JSON.stringify(metaWrites)); + } finally { cleanup(tmp); } +}); diff --git a/workers/scan-projects.js b/workers/scan-projects.js index 8ce383ef..55d9fe16 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -11,7 +11,7 @@ const FOLDER_PREFIX = workerData.folderPrefix ? workerData.folderPrefix + '::' : function readFolderFromFilesystem(folder) { const folderPath = path.join(PROJECTS_DIR, folder); - const projectPath = deriveProjectPath(folderPath, folder); + const projectPath = deriveProjectPath(folderPath, folder, { remote: FOLDER_PREFIX !== '' }); if (!projectPath) return null; const key = FOLDER_PREFIX + folder; const sessions = []; @@ -49,7 +49,7 @@ function sessionIdFromRel(rel, parentSessionId) { // ("Remote hosts file-level rescan"). function readFolderFileSubsetFromFilesystem(folder, files, existingRows) { const folderPath = path.join(PROJECTS_DIR, folder); - const projectPath = deriveProjectPath(folderPath, folder); + const projectPath = deriveProjectPath(folderPath, folder, { remote: FOLDER_PREFIX !== '' }); if (!projectPath) return null; const key = FOLDER_PREFIX + folder; const indexMtimeMs = getFolderIndexMtimeMs(folderPath); From ccc03d613d31e5d7ad824c1f6756a03c39983c51 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 2 Oct 2026 19:30:35 +0200 Subject: [PATCH 2/3] (sessions): keep a remapped project and re-verify stored state The remap dialog rewrites every transcript cwd of enc(oldPath) to the new path while the folder keeps its name, so the verified-cwd rule rejected every line and the project vanished. Main now records folder -> newPath in the projectRemaps setting before rewriting, and a cwd equal to that record verifies. Stored state from before the upgrade is verified too: cache_meta in the sidebar build and in reconcile, and cached rows whose projectPath differs from the folder's are rewritten. A folder with no verifiable transcript loses its cached rows and logs one warning naming the folder and the first rejected cwd. Closes #385 --- .ai/contexts/session-cache.md | 8 +- derive-project-path.js | 16 +- docs/sandbox.md | 2 +- encode-project-path.js | 14 +- main.js | 36 +---- project-remap.js | 42 +++++ session-cache.js | 37 ++++- test/reconcile-cache.test.js | 23 +-- test/transcript-cwd-trust-cache.test.js | 199 ++++++++++++++++++++++++ workers/scan-projects.js | 12 +- 10 files changed, 332 insertions(+), 57 deletions(-) create mode 100644 project-remap.js create mode 100644 test/transcript-cwd-trust-cache.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 7900fd88..53ff58a5 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -50,6 +50,8 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree A transcript's `cwd` is trusted for a filesystem decision only if it encodes back to the name of the folder holding the transcript: `verifiedTranscriptCwd(cwd, folderName)` in `encode-project-path.js` returns the resolved absolute cwd when `encodeProjectPath(path.resolve(cwd)) === folderName`, else `null`. The seed of the schedule registry uses the same function. +The one exception is a recorded remap: `remap-project` (main process, `project-remap.js`) rewrites every transcript's cwd of the folder `enc(oldPath)` to `newPath` but cannot rename the folder, so it first records `folder -> newPath` in the `projectRemaps` setting, which only main writes and which survives a cache rebuild. `verifiedTranscriptCwd` also accepts a cwd equal to the value recorded for that folder, read through `setRemappedProjectReader` (set by `session-cache.js` `init`; the scan worker gets the map in `workerData.remaps`). No check on whether the directory exists: it would be circular. + Why: a sandboxed session can write its own transcript folder `~/.claude/projects//` and the subtree of P, so it can forge a JSONL there whose `cwd` is `P/evil` and plant `P/evil/.claude/commands/schedule-x.md`. An unverified cwd became the sidebar project path, the resume/fork spawn directory, the sandbox's `SWITCHBOARD_SANDBOX_PROJECT_FOLDER`, the target of the schedule creator's `mkdir`, and a schedule-registry entry at the next launch, where a per-launch unsandboxed choice runs the planted schedule outside the sandbox. The sandbox cannot write any other encoded folder, so a cwd that encodes to the folder it sits in is one the session could not have chosen freely. Where it applies: @@ -57,13 +59,15 @@ Where it applies: | Consumer of a transcript cwd | Status | |---|---| | `deriveProjectPath` (sidebar project path, `session.projectPath`, `cache_meta`, `getKnownProjectPaths`, and the cold-start scan in `workers/scan-projects.js`) | Verified: a JSONL whose cwd does not verify is skipped, then the worktree collapse applies to the verified cwd; no verified JSONL gives `null` | -| `refreshFolder` reuse of a stored `cache_meta.projectPath` | Verified by `storedProjectPathMatchesFolder` (the path itself, or the repository of a worktree folder), so a value stored before the upgrade is re-derived | +| `refreshFolder` reuse of a stored `cache_meta.projectPath`; `buildProjectsFromCache` (empty-folder section); `reconcileCacheFromFilesystem` | Verified by `storedProjectPathMatchesFolder` (the verified path, the repository of a worktree folder, or the remap record). A value stored before the upgrade is re-derived: `reconcileCacheFromFilesystem` refreshes a folder whose stored path fails the check even when its mtime is current, and `refreshFolder` rewrites a cached row whose `projectPath` differs from the folder's even when its file is unchanged. A folder with no verifiable transcript has its cached rows deleted | | `resolveSessionRealCwd` (resume and fork spawn cwd in `open-terminal`, Changes panel, panel terminal, terminal path links, subagent worktree discovery, the sandbox bind folder, which follows the spawn cwd) | Verified against the folder holding the session's JSONL, which main finds on disk; a transcript that does not verify is skipped for the next folder holding the same id; none left means resume starts in the requested project path as for a session without a recorded cwd | | `create-schedule-session` `mkdir enc(projectPath)` and `open-terminal` registration of `projectPath` | The path comes from the sidebar, so from `deriveProjectPath`; the registration stays a plain launch registration | | Remote hosts | Not verified: a remote cwd is a path on the host, `deriveProjectPath` takes `{ remote: true }` there, and nothing local is opened from it | | `resolveSessionRealCwd` for a worktree session | Kept: its JSONL lives in `enc(P/.claude/worktrees/x)` and its cwd encodes to that folder | -Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit hash, so a path of 200 characters or more can collide (`enc(P/long) === enc(P)`), seed included. A transcript written by the CLI whose cwd does not encode to its folder (a symlinked cwd named by its real path, say) no longer gives a project path or a resume directory. A `projectPath` persisted by session restore before the upgrade is not re-verified; the renderer's own strings are out of scope. +A folder that holds transcripts with a cwd but none that verifies is logged once per folder, `[session-cache]` prefix with the folder name and the first rejected cwd (main log; the cold-start worker reports it through its folder message), so a change of the CLI's naming shows up in the log instead of as projects silently missing. + +Residuals: `encodeProjectPath` truncates at 200 characters and appends a 32-bit hash, so a path of 200 characters or more can collide (`enc(P/long) === enc(P)`), seed included. The hash is taken over the raw characters of the path, as the CLI does; normalising before hashing would stop matching the CLI's folder names, so it was left alone (the verified cwd is already `path.resolve`d before it is encoded). A transcript written by the CLI whose cwd does not encode to its folder (a symlinked cwd named by its real path, say) no longer gives a project path or a resume directory. A `projectPath` persisted by session restore before the upgrade is not re-verified; the renderer's own strings are out of scope. ## Non-obvious behaviors diff --git a/derive-project-path.js b/derive-project-path.js index 4918e08a..c7dd3732 100644 --- a/derive-project-path.js +++ b/derive-project-path.js @@ -48,7 +48,21 @@ function resolveWorktreePath(cwd) { function deriveProjectPath(folderPath, folderName, opts) { const name = folderName || path.basename(folderPath); const remote = !!(opts && opts.remote); - const trusted = (cwd) => (remote ? (typeof cwd === 'string' && cwd ? cwd : null) : verifiedTranscriptCwd(cwd, name)); + let firstRejected = null; + const trusted = (cwd) => { + if (remote) return typeof cwd === 'string' && cwd ? cwd : null; + const verified = verifiedTranscriptCwd(cwd, name); + if (!verified && cwd && firstRejected === null) firstRejected = cwd; + return verified; + }; + const result = deriveVerified(folderPath, trusted); + if (result === null && firstRejected !== null && opts && typeof opts.onRejected === 'function') { + opts.onRejected(firstRejected); + } + return result; +} + +function deriveVerified(folderPath, trusted) { try { const entries = fs.readdirSync(folderPath, { withFileTypes: true }); // Check direct .jsonl files first diff --git a/docs/sandbox.md b/docs/sandbox.md index 8d55919f..bee6ed72 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -276,7 +276,7 @@ binds as its own transcript folder all come from a transcript's `cwd` only when that `cwd`, with every character but letters and digits replaced by `-`, is the name of the folder holding the transcript. A transcript forged in the session's own folder with a `cwd` below the project therefore moves none of them, and -registers nothing. Paths of 200 characters or more are shortened and hashed in +registers nothing. Moving a project with the remap dialog is the one case where a transcript's `cwd` differs from its folder name; Switchboard records the new path itself and accepts that one. Paths of 200 characters or more are shortened and hashed in that name, so two of them can share a folder name; this is not closed. A sandboxed session can still create a `.claude` below a bound directory diff --git a/encode-project-path.js b/encode-project-path.js index 912f0f7b..57913d5a 100644 --- a/encode-project-path.js +++ b/encode-project-path.js @@ -13,11 +13,21 @@ function encodeProjectPath(projectPath) { return sanitized.slice(0, 200) + '-' + Math.abs(h).toString(36); } +let remappedProjectReader = () => null; + +function setRemappedProjectReader(reader) { + remappedProjectReader = typeof reader === 'function' ? reader : () => null; +} + // see .ai/contexts/session-cache.md ("Transcript cwd trust") function verifiedTranscriptCwd(cwd, folderName) { if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return null; const resolved = path.resolve(cwd); - return encodeProjectPath(resolved) === folderName ? resolved : null; + if (encodeProjectPath(resolved) === folderName) return resolved; + let remapped = null; + try { remapped = remappedProjectReader(folderName); } catch {} + if (typeof remapped === 'string' && path.isAbsolute(remapped) && path.resolve(remapped) === resolved) return resolved; + return null; } // Best-effort inverse of encodeProjectPath, for DISPLAY ONLY while the @@ -34,4 +44,4 @@ function decodeProjectFolderBestEffort(folder) { return folder.replace(/-/g, '/'); } -module.exports = { encodeProjectPath, decodeProjectFolderBestEffort, verifiedTranscriptCwd }; +module.exports = { encodeProjectPath, decodeProjectFolderBestEffort, verifiedTranscriptCwd, setRemappedProjectReader }; diff --git a/main.js b/main.js index 0342632d..5cba31bd 100644 --- a/main.js +++ b/main.js @@ -463,6 +463,7 @@ ipcMain.handle('whats-new-dismissed', () => whatsNew.dismissed()); // --- Session cache helpers --- const { deriveProjectPath, resolveSessionRealCwd, sessionTranscriptExists, isGitRepo } = require('./derive-project-path'); +const { remapProjectTranscripts } = require('./project-remap'); const { resolveDeletionTargets } = require('./delete-session-target'); // Session cache → session-cache.js @@ -482,7 +483,7 @@ sessionCache.init({ const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache; -const { resolveJsonlPath, enumerateSessionFiles, readSubagentMeta } = require('./read-session-file'); +const { resolveJsonlPath, readSubagentMeta } = require('./read-session-file'); // --- Remote SSH hosts (observation only) — see .ai/contexts/session-cache.md --- const { isRemoteFolder, parseFolderKey, joinFolderKey, enabledHosts } = require('./remote-hosts'); @@ -754,34 +755,6 @@ ipcMain.handle('remove-project', (_event, projectPath, folderKey) => { // --- IPC: remap-project --- -/** - * Atomically rewrite cwd occurrences of oldPath → newPath in a single JSONL - * file. Uses a .tmp sibling + rename for crash safety. On any failure the .tmp - * orphan is cleaned up so it cannot block a future remap attempt. - */ -function rewriteJsonlAtomic(filePath, oldPath, newPath) { - const tmp = filePath + '.tmp'; - try { - const content = fs.readFileSync(filePath, 'utf8'); - const updated = content.split('\n').map(line => { - if (!line) return line; - try { - const parsed = JSON.parse(line); - if (parsed.cwd === oldPath) { - parsed.cwd = newPath; - return JSON.stringify(parsed); - } - } catch {} - return line; - }).join('\n'); - fs.writeFileSync(tmp, updated); - fs.renameSync(tmp, filePath); - } catch (err) { - try { fs.unlinkSync(tmp); } catch {} - throw err; - } -} - ipcMain.handle('remap-project', (_event, oldPath, newPath) => { try { // Validate oldPath/newPath are strings (basic sanitisation) @@ -819,10 +792,7 @@ ipcMain.handle('remap-project', (_event, oldPath, newPath) => { // Rewrite cwd in all session JSONL files (top-level + subagents) so // `claude --resume` from CLI also picks up the new path. - const sessionFiles = enumerateSessionFiles(folderPath); - for (const { filePath } of sessionFiles) { - rewriteJsonlAtomic(filePath, oldPath, newPath); - } + remapProjectTranscripts({ folder, folderPath, oldPath, newPath, getSetting, setSetting }); // Refresh the folder cache so the new path takes effect in the UI refreshFolder(folder); diff --git a/project-remap.js b/project-remap.js new file mode 100644 index 00000000..b8266ad1 --- /dev/null +++ b/project-remap.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const { enumerateSessionFiles } = require('./read-session-file'); + +/** + * Atomically rewrite cwd occurrences of oldPath → newPath in a single JSONL + * file. Uses a .tmp sibling + rename for crash safety. On any failure the .tmp + * orphan is cleaned up so it cannot block a future remap attempt. + */ +function rewriteJsonlAtomic(filePath, oldPath, newPath) { + const tmp = filePath + '.tmp'; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const updated = content.split('\n').map(line => { + if (!line) return line; + try { + const parsed = JSON.parse(line); + if (parsed.cwd === oldPath) { + parsed.cwd = newPath; + return JSON.stringify(parsed); + } + } catch {} + return line; + }).join('\n'); + fs.writeFileSync(tmp, updated); + fs.renameSync(tmp, filePath); + } catch (err) { + try { fs.unlinkSync(tmp); } catch {} + throw err; + } +} + +// see .ai/contexts/session-cache.md ("Transcript cwd trust") +function remapProjectTranscripts({ folder, folderPath, oldPath, newPath, getSetting, setSetting }) { + const recorded = getSetting('projectRemaps'); + const remaps = recorded && typeof recorded === 'object' && !Array.isArray(recorded) ? recorded : {}; + setSetting('projectRemaps', { ...remaps, [folder]: newPath }); + for (const { filePath } of enumerateSessionFiles(folderPath)) { + rewriteJsonlAtomic(filePath, oldPath, newPath); + } +} + +module.exports = { rewriteJsonlAtomic, remapProjectTranscripts }; diff --git a/session-cache.js b/session-cache.js index 263dd8dc..4d55954e 100644 --- a/session-cache.js +++ b/session-cache.js @@ -2,6 +2,7 @@ const path = require('path'); const fs = require('fs'); const { Worker } = require('worker_threads'); const { getFolderIndexMtimeMs } = require('./folder-index-state'); +const { setRemappedProjectReader } = require('./encode-project-path'); const { deriveProjectPath, storedProjectPathMatchesFolder } = require('./derive-project-path'); const { readSessionFile, readSessionDisplayHeader, enumerateSessionFiles, resolveJsonlPath, mergeBridgeGroups } = require('./read-session-file'); const { encodeProjectPath, decodeProjectFolderBestEffort } = require('./encode-project-path'); @@ -39,6 +40,7 @@ function init(ctx) { getAllMeta = ctx.db.getAllMeta; getAllCached = ctx.db.getAllCached; getSetting = ctx.db.getSetting; + setRemappedProjectReader((folder) => remappedProjectsSetting()[folder]); getMeta = ctx.db.getMeta; setName = ctx.db.setName; isInitialScanComplete = ctx.db.isInitialScanComplete; @@ -65,9 +67,25 @@ function resolveFolderDir(folderKey) { // readSessionFile is imported from read-session-file.js (shared with worker) +const warnedRejectedFolders = new Set(); + +function warnRejectedCwd(folder, cwd) { + if (warnedRejectedFolders.has(folder)) return; + warnedRejectedFolders.add(folder); + if (log && log.warn) log.warn(`[session-cache] no transcript of folder ${folder} has a cwd that encodes to it; first rejected cwd: ${cwd}`); +} + function deriveFolderProjectPath(folderPath, folderKey) { const { alias, folder } = parseFolderKey(folderKey); - return deriveProjectPath(folderPath, folder, { remote: alias !== null }); + return deriveProjectPath(folderPath, folder, { + remote: alias !== null, + onRejected: (cwd) => warnRejectedCwd(folder, cwd), + }); +} + +function remappedProjectsSetting() { + const stored = getSetting ? getSetting('projectRemaps') : null; + return stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {}; } /** Read one folder from filesystem by scanning .jsonl files directly */ @@ -122,6 +140,10 @@ function refreshFolder(folder, opts = {}) { : null; if (!projectPath) projectPath = deriveFolderProjectPath(folderPath, folder); if (!projectPath) { + if (parseFolderKey(folder).alias === null) { + deleteCachedFolder(folder); + deleteSearchFolder(folder); + } setFolderMeta(folder, null, getFolderIndexMtimeMs(folderPath)); return; } @@ -215,7 +237,7 @@ function refreshFolder(folder, opts = {}) { // file's mtime and can't serve as the change-detection key. Comparing // `modified` here would miss on nearly every row and re-read every dirty // file on every watcher flush. - if (cachedEntry && cachedEntry.fileMtime === fileMtime) { + if (cachedEntry && cachedEntry.fileMtime === fileMtime && cachedEntry.projectPath === projectPath) { continue; // unchanged, skip } @@ -388,7 +410,8 @@ function reconcileCacheFromFilesystem() { for (const folder of folders) { const meta = metaMap.get(folder); const folderPath = path.join(PROJECTS_DIR, folder); - if (!meta || getFolderIndexMtimeMs(folderPath) > (meta.indexMtimeMs || 0)) { + if (!meta || getFolderIndexMtimeMs(folderPath) > (meta.indexMtimeMs || 0) + || (meta.projectPath && !storedProjectPathMatchesFolder(meta.projectPath, folder))) { refreshFolder(folder); } } @@ -520,10 +543,11 @@ function buildProjectsFromCache(showArchived) { for (const d of dirs) { const folderKey = alias === null ? d.name : joinFolderKey(alias, d.name); let projectPath = folderMeta.get(folderKey)?.projectPath; + if (projectPath && alias === null && !storedProjectPathMatchesFolder(projectPath, d.name)) projectPath = null; let placeholder = false; if (!projectPath) { if (scanComplete) { - projectPath = deriveProjectPath(path.join(dir, d.name), d.name, { remote: alias !== null }); + projectPath = deriveFolderProjectPath(path.join(dir, d.name), folderKey); if (projectPath) setFolderMeta(folderKey, projectPath, 0); } else { projectPath = decodeProjectFolderBestEffort(d.name); @@ -769,7 +793,7 @@ function scanFoldersViaWorker({ projectsDir, folderPrefix, folders, fileSubsets try { worker = new Worker(path.join(__dirname, 'workers', 'scan-projects.js'), { - workerData: { projectsDir, folderPrefix, folders: fullFolders, targets }, + workerData: { projectsDir, folderPrefix, folders: fullFolders, targets, remaps: remappedProjectsSetting() }, }); } catch (err) { settle({ ok: false, error: err.message, folders: 0, sessions: 0 }); @@ -856,7 +880,7 @@ function populateCacheViaWorker() { }; const worker = new Worker(path.join(__dirname, 'workers', 'scan-projects.js'), { - workerData: { projectsDir: PROJECTS_DIR }, + workerData: { projectsDir: PROJECTS_DIR, remaps: remappedProjectsSetting() }, }); worker.on('message', (msg) => { @@ -865,6 +889,7 @@ function populateCacheViaWorker() { // (notifyRendererProjectsChanged is already throttled ~1.5s) so a large // history fills in progressively rather than sitting empty for minutes. if (msg.type === 'folder') { + if (msg.rejected) warnRejectedCwd(msg.rejected.folder, msg.rejected.cwd); scannedFolders = msg.current; totalFolders = msg.total; const written = writeScannedFolder(msg.result); diff --git a/test/reconcile-cache.test.js b/test/reconcile-cache.test.js index 1d599686..902fd92a 100644 --- a/test/reconcile-cache.test.js +++ b/test/reconcile-cache.test.js @@ -6,6 +6,7 @@ const path = require('path'); const sessionCache = require('../session-cache'); const { getFolderIndexMtimeMs } = require('../folder-index-state'); +const { encodeProjectPath } = require('../encode-project-path'); // Minimal valid transcript: a `cwd` line (for deriveProjectPath) and a user // message (so readSessionFile yields a non-null session). @@ -51,15 +52,17 @@ function makeFakeDb(metaMap) { test('reconcileCacheFromFilesystem indexes new and stale folders but skips up-to-date ones', () => { const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-reconcile-')); try { - writeSession(path.join(projectsDir, 'proj-new'), '/tmp/proj-new'); // never indexed (no meta) - writeSession(path.join(projectsDir, 'proj-stale'), '/tmp/proj-stale'); // meta older than disk - writeSession(path.join(projectsDir, 'proj-current'), '/tmp/proj-current'); // meta == disk + const cwdOf = (name) => path.join(projectsDir, name); + const [fNew, fStale, fCurrent] = ['proj-new', 'proj-stale', 'proj-current'].map(n => encodeProjectPath(cwdOf(n))); + writeSession(path.join(projectsDir, fNew), cwdOf('proj-new')); // never indexed (no meta) + writeSession(path.join(projectsDir, fStale), cwdOf('proj-stale')); // meta older than disk + writeSession(path.join(projectsDir, fCurrent), cwdOf('proj-current')); // meta == disk const metaMap = new Map(); - metaMap.set('proj-stale', { folder: 'proj-stale', projectPath: '/tmp/proj-stale', indexMtimeMs: 0 }); - metaMap.set('proj-current', { - folder: 'proj-current', projectPath: '/tmp/proj-current', - indexMtimeMs: getFolderIndexMtimeMs(path.join(projectsDir, 'proj-current')), + metaMap.set(fStale, { folder: fStale, projectPath: cwdOf('proj-stale'), indexMtimeMs: 0 }); + metaMap.set(fCurrent, { + folder: fCurrent, projectPath: cwdOf('proj-current'), + indexMtimeMs: getFolderIndexMtimeMs(path.join(projectsDir, fCurrent)), }); const fake = makeFakeDb(metaMap); @@ -81,9 +84,9 @@ test('reconcileCacheFromFilesystem indexes new and stale folders but skips up-to .filter(c => c.indexMtimeMs > 0) .map(c => c.folder); - assert.ok(indexedFolders.includes('proj-new'), 'new folder should be stamped with non-zero indexMtimeMs'); - assert.ok(indexedFolders.includes('proj-stale'), 'stale folder should be re-stamped with non-zero indexMtimeMs'); - assert.ok(!indexedFolders.includes('proj-current'), 'up-to-date folder must not be re-indexed'); + assert.ok(indexedFolders.includes(fNew), 'new folder should be stamped with non-zero indexMtimeMs'); + assert.ok(indexedFolders.includes(fStale), 'stale folder should be re-stamped with non-zero indexMtimeMs'); + assert.ok(!indexedFolders.includes(fCurrent), 'up-to-date folder must not be re-indexed'); } finally { fs.rmSync(projectsDir, { recursive: true, force: true }); } diff --git a/test/transcript-cwd-trust-cache.test.js b/test/transcript-cwd-trust-cache.test.js new file mode 100644 index 00000000..80942973 --- /dev/null +++ b/test/transcript-cwd-trust-cache.test.js @@ -0,0 +1,199 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { encodeProjectPath, setRemappedProjectReader } = require('../encode-project-path'); +const { deriveProjectPath } = require('../derive-project-path'); +const { getFolderIndexMtimeMs } = require('../folder-index-state'); +const { remapProjectTranscripts } = require('../project-remap'); +const sessionCache = require('../session-cache'); + +function mkTmp() { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-tcc-'))); +} + +function cleanup(dir) { + setRemappedProjectReader(null); + fs.rmSync(dir, { recursive: true, force: true }); +} + +function project(tmp, ...parts) { + const dir = path.join(tmp, ...parts); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeTranscript(projectsDir, folder, sessionId, cwd) { + const dir = path.join(projectsDir, folder); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, sessionId + '.jsonl'), + JSON.stringify({ type: 'user', cwd, sessionId, message: { role: 'user', content: 'hi' } }) + '\n'); +} + +function makeStatefulDb({ settings = {}, folderMeta = new Map(), rows = [] } = {}) { + const calls = { deletedFolders: [], metaWrites: [] }; + const db = { + deleteCachedFolder: (f) => { calls.deletedFolders.push(f); }, + getCachedByFolder: (f) => rows.filter(r => r.folder === f), + upsertCachedSessions: (list) => { + for (const s of list) { + const i = rows.findIndex(r => r.sessionId === s.sessionId); + if (i >= 0) rows[i] = s; else rows.push(s); + } + }, + touchCachedModified: () => {}, deleteCachedSession: () => {}, replaceSessionMetrics: () => {}, + deleteSearchFolder: () => {}, deleteSearchSession: () => {}, upsertSearchEntries: () => {}, + getFolderMeta: (f) => folderMeta.get(f) || null, + setFolderMeta: (folder, projectPath, indexMtimeMs) => { + calls.metaWrites.push({ folder, projectPath }); + folderMeta.set(folder, { folder, projectPath, indexMtimeMs }); + }, + getAllFolderMeta: () => folderMeta, + getAllMeta: () => new Map(), + getAllCached: () => rows, + getSetting: (k) => (k in settings ? settings[k] : {}), + getMeta: () => null, + setName: () => {}, + }; + return { db, calls, rows, folderMeta, settings }; +} + +function initCache(projectsDir, db, log = console) { + sessionCache.init({ PROJECTS_DIR: projectsDir, activeSessions: new Map(), getMainWindow: () => null, log, db }); +} + +test('remap: a remapped project keeps its folder and its project path through derive, refreshFolder and a cold sidebar build', () => { + const tmp = mkTmp(); + try { + const oldP = path.join(tmp, 'gone'); + const newP = project(tmp, 'moved'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(oldP); + writeTranscript(projectsDir, folder, 's1', oldP); + const state = makeStatefulDb(); + initCache(projectsDir, state.db); + const folderPath = path.join(projectsDir, folder); + + remapProjectTranscripts({ + folder, folderPath, oldPath: oldP, newPath: newP, + getSetting: state.db.getSetting, + setSetting: (k, v) => { state.settings[k] = v; }, + }); + + assert.equal(deriveProjectPath(folderPath, folder), newP); + sessionCache.refreshFolder(folder); + assert.ok(state.calls.metaWrites.every(w => w.projectPath === newP), JSON.stringify(state.calls.metaWrites)); + assert.equal(state.rows.length, 1); + assert.equal(state.rows[0].projectPath, newP); + + state.folderMeta.clear(); + state.rows.length = 0; + const projects = sessionCache.buildProjectsFromCache(false); + assert.deepEqual(projects.map(p => p.projectPath), [newP]); + assert.equal(projects[0].missing, false); + } finally { cleanup(tmp); } +}); + +test('remap: a cwd that was never recorded for the folder is still refused after a remap elsewhere', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'forged', evil); + const state = makeStatefulDb({ settings: { projectRemaps: { '-some-other-folder': evil } } }); + initCache(projectsDir, state.db); + assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder), null); + } finally { cleanup(tmp); } +}); + +test('buildProjectsFromCache: a forged projectPath stored in cache_meta before the upgrade is re-derived, not shown', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'genuine', p); + const state = makeStatefulDb({ folderMeta: new Map([[folder, { folder, projectPath: evil, indexMtimeMs: 1 }]]) }); + initCache(projectsDir, state.db); + const projects = sessionCache.buildProjectsFromCache(false); + assert.deepEqual(projects.map(x => x.projectPath), [p]); + assert.equal(state.folderMeta.get(folder).projectPath, p); + } finally { cleanup(tmp); } +}); + +test('reconcileCacheFromFilesystem: a folder whose stored projectPath does not verify is refreshed even when its mtime is current', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'genuine', p); + const current = getFolderIndexMtimeMs(path.join(projectsDir, folder)); + const state = makeStatefulDb({ folderMeta: new Map([[folder, { folder, projectPath: evil, indexMtimeMs: current }]]) }); + initCache(projectsDir, state.db); + sessionCache.reconcileCacheFromFilesystem(); + assert.equal(state.folderMeta.get(folder).projectPath, p); + } finally { cleanup(tmp); } +}); + +test('refreshFolder: an unchanged cached row carrying a forged projectPath is rewritten with the verified one', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'proj'); + const evil = project(tmp, 'proj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'genuine', p); + const file = path.join(projectsDir, folder, 'genuine.jsonl'); + const rows = [{ + sessionId: 'genuine', folder, projectPath: evil, fileMtime: fs.statSync(file).mtime.toISOString(), + modified: '2026-01-01T00:00:00.000Z', filePath: file, parentSessionId: null, agentId: null, messageCount: 1, + }]; + const state = makeStatefulDb({ rows }); + initCache(projectsDir, state.db); + sessionCache.refreshFolder(folder); + assert.equal(state.rows[0].projectPath, p); + } finally { cleanup(tmp); } +}); + +test('refreshFolder: a folder with no verifiable transcript loses its cached rows and warns once, naming the folder and the first rejected cwd', () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'warnproj'); + const evil = project(tmp, 'warnproj', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'forged', evil); + const warnings = []; + const state = makeStatefulDb(); + initCache(projectsDir, state.db, { info() {}, error() {}, warn: (m) => warnings.push(m) }); + sessionCache.refreshFolder(folder); + sessionCache.refreshFolder(folder); + assert.deepEqual(state.calls.deletedFolders, [folder, folder]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^\[session-cache\]/); + assert.ok(warnings[0].includes(folder)); + assert.ok(warnings[0].includes(evil)); + } finally { cleanup(tmp); } +}); + +test('remap: a folder with a recorded path still refuses a cwd that differs from the recorded one', () => { + const tmp = mkTmp(); + try { + const oldP = path.join(tmp, 'gone'); + const newP = project(tmp, 'moved'); + const evil = project(tmp, 'moved', 'evil'); + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(oldP); + writeTranscript(projectsDir, folder, 'forged', evil); + const state = makeStatefulDb({ settings: { projectRemaps: { [folder]: newP } } }); + initCache(projectsDir, state.db); + assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder), null); + } finally { cleanup(tmp); } +}); diff --git a/workers/scan-projects.js b/workers/scan-projects.js index 55d9fe16..1a7cca28 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -3,15 +3,23 @@ const fs = require('fs'); const path = require('path'); const { getFolderIndexMtimeMs } = require('../folder-index-state'); const { deriveProjectPath } = require('../derive-project-path'); +const { setRemappedProjectReader } = require('../encode-project-path'); const { readSessionFile, enumerateSessionFiles, mergeBridgeGroups, subagentSessionId } = require('../read-session-file'); const PROJECTS_DIR = workerData.projectsDir; +const REMAPS = workerData.remaps && typeof workerData.remaps === 'object' ? workerData.remaps : {}; +setRemappedProjectReader((folder) => REMAPS[folder]); +let lastRejected = null; // Non-empty only for a remote mirror root; `folder` is then ::. const FOLDER_PREFIX = workerData.folderPrefix ? workerData.folderPrefix + '::' : ''; function readFolderFromFilesystem(folder) { const folderPath = path.join(PROJECTS_DIR, folder); - const projectPath = deriveProjectPath(folderPath, folder, { remote: FOLDER_PREFIX !== '' }); + lastRejected = null; + const projectPath = deriveProjectPath(folderPath, folder, { + remote: FOLDER_PREFIX !== '', + onRejected: (cwd) => { lastRejected = { folder, cwd }; }, + }); if (!projectPath) return null; const key = FOLDER_PREFIX + folder; const sessions = []; @@ -108,7 +116,7 @@ try { for (let i = 0; i < folders.length; i++) { const result = readFolderFromFilesystem(folders[i]); current++; - parentPort.postMessage({ type: 'folder', result, current, total }); + parentPort.postMessage({ type: 'folder', result, current, total, rejected: result ? null : lastRejected }); } for (const t of targets) { const result = readFolderFileSubsetFromFilesystem(t && t.folder, t && t.files, t && t.existingRows); From 52486c9c4507019237bb14b80e2f5cf743103fcb Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Fri, 2 Oct 2026 19:40:07 +0200 Subject: [PATCH 3/3] (sessions): drop unverified rows in the cold scan and log the cwd quoted A cold or interrupted scan wrote nothing for a local folder with no verified project path, so a cached row from before the upgrade could still be served and opened by session restore. The scan now deletes that folder's cached rows and search entries, as refreshFolder does. The rejected cwd in the warning is logged through JSON.stringify so a newline in it cannot forge a log line. Closes #385 --- session-cache.js | 14 ++++++++++---- test/transcript-cwd-trust-cache.test.js | 24 ++++++++++++++++++++++-- workers/scan-projects.js | 3 ++- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/session-cache.js b/session-cache.js index 4d55954e..ec6a138d 100644 --- a/session-cache.js +++ b/session-cache.js @@ -72,7 +72,7 @@ const warnedRejectedFolders = new Set(); function warnRejectedCwd(folder, cwd) { if (warnedRejectedFolders.has(folder)) return; warnedRejectedFolders.add(folder); - if (log && log.warn) log.warn(`[session-cache] no transcript of folder ${folder} has a cwd that encodes to it; first rejected cwd: ${cwd}`); + if (log && log.warn) log.warn(`[session-cache] no transcript of folder ${folder} has a cwd that encodes to it; first rejected cwd: ${JSON.stringify(cwd)}`); } function deriveFolderProjectPath(folderPath, folderKey) { @@ -687,8 +687,14 @@ function sendIndexingFinished() { * Delete-then-insert, so re-scanning an already-written folder never * duplicates rows. `folder` already carries the `::` prefix when the * worker was pointed at a remote mirror. Returns the session count written. */ -function writeScannedFolder(r) { - if (!r) return 0; +function writeScannedFolder(r, unverifiedLocalFolder = null) { + if (!r) { + if (unverifiedLocalFolder) { + deleteCachedFolder(unverifiedLocalFolder); + deleteSearchFolder(unverifiedLocalFolder); + } + return 0; + } const { folder, projectPath, sessions, indexMtimeMs } = r; deleteCachedFolder(folder); deleteSearchFolder(folder); @@ -892,7 +898,7 @@ function populateCacheViaWorker() { if (msg.rejected) warnRejectedCwd(msg.rejected.folder, msg.rejected.cwd); scannedFolders = msg.current; totalFolders = msg.total; - const written = writeScannedFolder(msg.result); + const written = writeScannedFolder(msg.result, msg.unverifiedLocalFolder); if (written > 0) { sessionCount += written; indexedProjects++; diff --git a/test/transcript-cwd-trust-cache.test.js b/test/transcript-cwd-trust-cache.test.js index 80942973..403341fa 100644 --- a/test/transcript-cwd-trust-cache.test.js +++ b/test/transcript-cwd-trust-cache.test.js @@ -32,7 +32,7 @@ function writeTranscript(projectsDir, folder, sessionId, cwd) { JSON.stringify({ type: 'user', cwd, sessionId, message: { role: 'user', content: 'hi' } }) + '\n'); } -function makeStatefulDb({ settings = {}, folderMeta = new Map(), rows = [] } = {}) { +function makeStatefulDb({ settings = {}, folderMeta = new Map(), rows = [], scanComplete = true } = {}) { const calls = { deletedFolders: [], metaWrites: [] }; const db = { deleteCachedFolder: (f) => { calls.deletedFolders.push(f); }, @@ -56,6 +56,8 @@ function makeStatefulDb({ settings = {}, folderMeta = new Map(), rows = [] } = { getSetting: (k) => (k in settings ? settings[k] : {}), getMeta: () => null, setName: () => {}, + isInitialScanComplete: () => scanComplete, + setInitialScanComplete: () => {}, }; return { db, calls, rows, folderMeta, settings }; } @@ -179,7 +181,7 @@ test('refreshFolder: a folder with no verifiable transcript loses its cached row assert.equal(warnings.length, 1); assert.match(warnings[0], /^\[session-cache\]/); assert.ok(warnings[0].includes(folder)); - assert.ok(warnings[0].includes(evil)); + assert.ok(warnings[0].includes(JSON.stringify(evil))); } finally { cleanup(tmp); } }); @@ -197,3 +199,21 @@ test('remap: a folder with a recorded path still refuses a cwd that differs from assert.equal(deriveProjectPath(path.join(projectsDir, folder), folder), null); } finally { cleanup(tmp); } }); + +test('cold scan: a local folder with no verified path loses its cached rows, and a newline in the rejected cwd cannot forge a log line', async () => { + const tmp = mkTmp(); + try { + const p = project(tmp, 'coldproj'); + const forgedCwd = path.join(p, 'evil') + '\n[session-cache] forged line'; + const projectsDir = project(tmp, 'projects'); + const folder = encodeProjectPath(p); + writeTranscript(projectsDir, folder, 'forged', forgedCwd); + const warnings = []; + const state = makeStatefulDb({ scanComplete: false }); + initCache(projectsDir, state.db, { info() {}, error() {}, warn: (m) => warnings.push(m) }); + await sessionCache.populateCacheViaWorker(); + assert.deepEqual(state.calls.deletedFolders, [folder]); + assert.equal(warnings.length, 1); + assert.ok(!warnings[0].includes('\n')); + } finally { cleanup(tmp); } +}); diff --git a/workers/scan-projects.js b/workers/scan-projects.js index 1a7cca28..97818607 100644 --- a/workers/scan-projects.js +++ b/workers/scan-projects.js @@ -116,7 +116,8 @@ try { for (let i = 0; i < folders.length; i++) { const result = readFolderFromFilesystem(folders[i]); current++; - parentPort.postMessage({ type: 'folder', result, current, total, rejected: result ? null : lastRejected }); + parentPort.postMessage({ type: 'folder', result, current, total, rejected: result ? null : lastRejected, + unverifiedLocalFolder: !result && FOLDER_PREFIX === '' ? folders[i] : null }); } for (const t of targets) { const result = readFolderFileSubsetFromFilesystem(t && t.folder, t && t.files, t && t.existingRows);