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..53ff58a5 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -46,6 +46,29 @@ 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. + +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: + +| 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`; `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 | + +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 - **`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..c7dd3732 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,30 @@ function resolveWorktreePath(cwd) { return cwd; } -function deriveProjectPath(folderPath) { +function deriveProjectPath(folderPath, folderName, opts) { + const name = folderName || path.basename(folderPath); + const remote = !!(opts && opts.remote); + 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 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 +87,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 +150,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 +170,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..bee6ed72 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. 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 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..57913d5a 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,23 @@ 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); + 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 // 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 +44,4 @@ function decodeProjectFolderBestEffort(folder) { return folder.replace(/-/g, '/'); } -module.exports = { encodeProjectPath, decodeProjectFolderBestEffort }; +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/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..ec6a138d 100644 --- a/session-cache.js +++ b/session-cache.js @@ -2,7 +2,8 @@ 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 { 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'); const { parseFolderKey, joinFolderKey } = require('./remote-hosts'); @@ -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,11 +67,32 @@ 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: ${JSON.stringify(cwd)}`); +} + +function deriveFolderProjectPath(folderPath, folderKey) { + const { alias, folder } = parseFolderKey(folderKey); + 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 */ 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,10 +135,15 @@ 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) { + if (parseFolderKey(folder).alias === null) { + deleteCachedFolder(folder); + deleteSearchFolder(folder); + } setFolderMeta(folder, null, getFolderIndexMtimeMs(folderPath)); return; } @@ -209,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 } @@ -382,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); } } @@ -514,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); + projectPath = deriveFolderProjectPath(path.join(dir, d.name), folderKey); if (projectPath) setFolderMeta(folderKey, projectPath, 0); } else { projectPath = decodeProjectFolderBestEffort(d.name); @@ -657,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); @@ -763,7 +799,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 }); @@ -850,7 +886,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) => { @@ -859,9 +895,10 @@ 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); + const written = writeScannedFolder(msg.result, msg.unverifiedLocalFolder); if (written > 0) { sessionCount += written; indexedProjects++; 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/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/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-cache.test.js b/test/transcript-cwd-trust-cache.test.js new file mode 100644 index 00000000..403341fa --- /dev/null +++ b/test/transcript-cwd-trust-cache.test.js @@ -0,0 +1,219 @@ +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 = [], scanComplete = true } = {}) { + 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: () => {}, + isInitialScanComplete: () => scanComplete, + setInitialScanComplete: () => {}, + }; + 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(JSON.stringify(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); } +}); + +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/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..97818607 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); + 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 = []; @@ -49,7 +57,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); @@ -108,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 }); + 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);