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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ai/contexts/schedule-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>/.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:<path>` 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:<path>` 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/<encoded>/<uuid>.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 "<sid>" -p "..." --permission-mode acceptEdits --allowedTools "..."`).
Expand Down
23 changes: 23 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<enc(P)>/` 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 `<repo>/.worktrees/<name>` → `<repo>`** 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).
Expand Down
36 changes: 31 additions & 5 deletions derive-project-path.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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 };
9 changes: 9 additions & 0 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion encode-project-path.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -25,4 +44,4 @@ function decodeProjectFolderBestEffort(folder) {
return folder.replace(/-/g, '/');
}

module.exports = { encodeProjectPath, decodeProjectFolderBestEffort };
module.exports = { encodeProjectPath, decodeProjectFolderBestEffort, verifiedTranscriptCwd, setRemappedProjectReader };
Loading
Loading