feat(files): follow the agent into a worktree outside the session folder

#772 made the Files panel follow an agent into a worktree inside the
session's folder. A worktree beside the repository (git worktree add
../feature, or an EnterWorktree into one that already exists there)
stayed unfollowed, since serving it needs a second confinement root.

findOutsideWorktree() answers that case without running git. The
nearest .git above the pane must name <common dir>/worktrees/<id> of the
session's own repository, and that registry entry must name the
candidate back (or the .git folder must be the common dir itself). It
refuses /, the home folder and above, any folder holding workingDir and
Docker case workspaces, and multi-user mode applies the owner's
folderAllowedForUser rule. The answer is liveWorktree with outside: true
and the folder's real path.

The file routes serve an absolute path inside that worktree, and only
while the folder still resolves to the recorded path. A relative path
still resolves against workingDir alone. The browser routes such paths
to the editable preview, and root-relative markdown refs resolve from
the top of the worktree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael GrundbergandClaude Opus 5.5 committed 2026-11-06 17:54:59 +01:00
1 parent e9b2ecd552
commit c9b79097b0
18 files changed
+1030 -56

No files matched your search

+1 -1
View File
@@ -240,7 +240,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ The security argument has two halves. The lookup only ever answers a folder strictly below `workingDir`, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make `liveWorktree` a confinement root (a worktree outside the folder stays unfollowed). ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. ⚠️ The security argument has two halves. The lookup only ever answers a folder strictly below `workingDir`, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make an INSIDE `liveWorktree` a confinement root. ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `liveWorktree.outside: true`: the session's own repository must vouch for it by FILE READS (the candidate's `.git` names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back; never spawn git from the pane watcher), it is refused at `/`, home or above, around `workingDir` and at/inside/around a Docker case workspace (an unreadable Docker list follows nothing), and multi-user mode applies `folderAllowedForUser`. Its listing returns ABSOLUTE `path`s, and only an absolute path inside it is confined to it (`validateLocalSessionFilePath()`, route-helpers.ts), while a relative path still resolves against `workingDir` alone. ⚠️ It is recorded as a REAL path, and every request re-checks that the folder still resolves to exactly that path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), or a symlink swapped in would re-root the routes anywhere. Tests: `test/live-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline on its own (it never fetches; fetching is the separate path below). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
+21 -5
View File
@@ -1002,15 +1002,31 @@ EnterWorktree moved the running session:
"liveWorktree": { "name": "feature-a", "path": "/home/me/repo/.claude/worktrees/feature-a" }
```
It is derived from `paneCwd`, so it is absent wherever that is, and it is only ever a folder
strictly below `workingDir`: a worktree elsewhere is never reported. `path` is in `workingDir`'s
spelling. Like `paneCwd` it is never restored after a restart and never a confinement root.
It is derived from `paneCwd`, so it is absent wherever that is. Usually it is a folder strictly
below `workingDir`, with `path` in `workingDir`'s spelling, and then it is never a confinement
root. Like `paneCwd` it is never restored after a restart.
It can also be a worktree of the session's repository OUTSIDE `workingDir`, such as
`git worktree add ../feature`. It then carries `"outside": true`, and `path` is the folder's
real path:
```json
"liveWorktree": { "name": "repo-feature", "path": "/home/me/repo-feature", "outside": true }
```
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
`file-raw`, `file-preview`, `file-thumbnail` and `GET /api/v1/download` accept an ABSOLUTE path
inside it, while a relative path still resolves against `workingDir` only. Codeman reports it
only when the session's own repository lists it, and never for `/`, the home folder or above, a
folder holding `workingDir`, or a Docker case workspace.
`GET /api/v1/sessions/:id/files` takes an optional `root=live`, which starts the listing, or a
`q` search, at that worktree instead of at `workingDir`. The response's `root` names the folder
that was walked: it stays `workingDir` when the session is in no worktree, when the worktree has
gone, and when the folder no longer resolves inside `workingDir`. A `path` in the response stays
relative to `workingDir` either way, so it works unchanged on `file-content` and `file-raw`,
gone, and when the folder no longer resolves inside `workingDir` (for an outside worktree, when it
no longer resolves to its recorded `path`). A `path` in the response stays relative to
`workingDir` either way, except in a listing of an outside worktree, where every `path` is
absolute. Both forms work unchanged on `file-content` and `file-raw`,
while a `q` containing `/` is matched against paths relative to the folder walked. The response
also carries `worktree`, the same `{ name, path }`, whenever the session has a `liveWorktree`,
whichever folder was walked and with or without `root=live`.
+14 -2
View File
@@ -768,7 +768,7 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`
⚠️ **Globs are never compiled into a RegExp**: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking blowup evaluated synchronously against every walked path, so one pathological query would freeze the event loop for the whole server (the same reason `search-service.ts` is regex-free). `globMatch()` is a two-pointer wildcard walk instead, O(text · pattern) with both operands short by construction, and an overlong query (`MAX_QUERY_LENGTH`, 256) also compiles to `null` rather than running.
Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` listing; the returned `path`s stay relative to `workingDir` either way), otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` listing; the returned `path`s stay relative to `workingDir` either way, except for a worktree outside it, whose `path`s are absolute), otherwise the bare entry name; globs match anchored and case-insensitively (`*` spans any run, slashes included, `?` exactly one character), everything else is a plain case-insensitive substring.
### Files panel in a worktree
@@ -780,7 +780,19 @@ When `paneCwd` changes, `refreshLiveWorktree()` (server.ts) runs `findLiveWorktr
⚠️ **Only the check uses resolved paths.** The walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`, and the listing's `path`s stop working on the file routes.
⚠️ `liveWorktree` only moves where a listing STARTS, so never make it a confinement root. Returned `path`s stay relative to `workingDir`, which is why `file-content`, `file-raw` and the edit route need nothing new. Serving a worktree outside the folder (`git worktree add ../feature`) would need a second root, so those stay unfollowed, and so does a worktree of a session started below the repository's top, because Claude Code puts it at the top, outside the session's folder.
⚠️ A `liveWorktree` inside `workingDir` only moves where a listing STARTS, so never make it a confinement root. Returned `path`s stay relative to `workingDir`, which is why `file-content`, `file-raw` and the edit route need nothing new for it.
**A worktree outside the session's folder** (`git worktree add ../feature`, or an EnterWorktree that the user's own tooling places beside the repository) is the one case that IS a second confinement root, and it carries `liveWorktree.outside: true`. `refreshLiveWorktree()` passes `findLiveWorktree()` the outside rules (`outsideWorktreeOptions()`, server.ts; none for a remote or Docker session), and `findOutsideWorktree()` answers only when all of these hold:
- The session's `workingDir` is the top of its repository or of a linked worktree (its `.git` sits there), so its git common dir is known. A session started in a subfolder follows no worktree outside it.
- The nearest `.git` at or above the pane is a FILE naming `<common dir>/worktrees/<id>`, and that registry entry's own `gitdir` file names the candidate back. Or it is a `.git` FOLDER that is the common dir itself, which is the main worktree seen from a session started in a linked one. These are the files `git worktree list` reads; git is never run, because this fires from the pane watcher on every directory change. A forged `.git` file pointing at a real entry fails the back link.
- The candidate's real path is not `/`, not the home folder or above it, and does not hold `workingDir`.
- It is not at, inside or around a Docker case workspace. An unreadable `docker-cases.json` follows nothing, since it could hide a container's workspace.
- In multi-user mode, the owner's `folderAllowedForUser` rule admits it.
Every comparison uses real paths, and the recorded `path` is the real path. ⚠️ **The routes must re-check it on every request**: `outsideWorktreeRootFor()` (route-helpers.ts) and `canWalkOutsideWorktree()` (file-routes.ts) accept the folder only while `realpath` returns exactly the recorded path, so a symlink swapped in later serves nothing from it. ⚠️ Its listing returns ABSOLUTE `path`s, and only an absolute path inside it is confined to it (`validateLocalSessionFilePath()`, used by `file-content`, the edit route, `file-raw`, `file-preview`, `file-thumbnail`, `/api/download` and `html-view`). A relative path still resolves against `workingDir` alone, so `../feature/x` is refused as before. A symlink inside the worktree into `workingDir` is served, since `workingDir` is served anyway. ⚠️ The browser routes a path inside it to those routes, not to the read-only attachment routes (`_isOutsideWorktreePath()`, panels-ui.js), and the terminal opens it in the preview, because the log viewer's `tail-file` stays confined to `workingDir`.
A worktree of a session started below the repository's top is still not followed, because Claude Code puts it at the top, outside the session's folder, and that session has no common dir to check it against.
⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that report with the session's state, and an absent report would reload forever.
+9
View File
@@ -342,6 +342,15 @@ escapes the session working directory (`..`, absolute paths, and symlinks that
resolve outside). The realpath‑before‑check ordering closes the validation‑time
TOCTOU window.
A local session has one more root while its agent works in a git worktree of the
same repository OUTSIDE its folder (`liveWorktree.outside`). An absolute path inside
that worktree is confined to it by `validateLocalSessionFilePath()`, and only while the
worktree folder still `realpath`s to the path recorded for it. Codeman records such a
worktree only after the session's own repository vouches for it, and never `/`, the
home folder or above, a folder holding the working directory or a Docker case
workspace (`src/live-worktree.ts`). A relative path is still confined to the working
directory alone.
| Route | Cap | Notes |
|-------|-----|-------|
| `file-content` | 10 MB | text preview |
+10 -5
View File
@@ -53,11 +53,16 @@ Only the Files panel moves. The **Git** panel stays on the session's folder, so
folder, so an `.html` file in `.claude/worktrees/<name>` opens as source rather than as a
rendered page. A session started in the worktree does not have that limit.
A worktree outside the session's folder (`git worktree add ../feature`) is not followed: Codeman
only serves files inside the session's folder. The same goes for a session started in a subfolder
of the repository, because Claude Code puts the worktree at the top of the repository, which is
outside that session's folder. Open such a worktree as a case of its own with Add Case > Link
Existing.
A worktree beside the repository (`git worktree add ../feature`, or a setup that makes
EnterWorktree put worktrees there) is followed too, as long as the session was started at the top
of the repository or of one of its worktrees. The panel then lists that folder with full paths,
and you can preview and edit its files there. Codeman follows such a worktree only when the
session's own repository lists it, and never one that is your home folder or above it, one that
holds the session's folder, or a Docker case's workspace.
A session started in a subfolder of the repository follows no worktree outside that subfolder,
including the one Claude Code creates at the top of the repository. Open such a worktree as a case
of its own with Add Case > Link Existing.
## Removing one
+181 -11
View File
@@ -13,11 +13,15 @@
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
* changes, and a file read starts no process, no repository config and no filter.
*
* Only a folder strictly below `workingDir` is ever an answer: the file routes already serve
* A folder strictly below `workingDir` is the plain answer: the file routes already serve
* everything under it, so following a pane there widens nothing, and a planted `.git` file can
* only move where the listing STARTS inside that same folder. A worktree outside it
* (`git worktree add ../feature`) stays unfollowed, since listing it would need a second
* confinement root.
* only move where the listing STARTS inside that same folder.
*
* A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree that a
* `WorktreeCreate` hook places beside the repository) is a second confinement root, so it is
* answered only when the caller passes {@link OutsideWorktreeOptions}, and only when the session's
* own repository vouches for it ({@link findOutsideWorktree}). The answer then carries
* `outside: true`, and the file routes serve absolute paths inside it.
*
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
@@ -32,15 +36,37 @@
*/
import { constants, promises as fs } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
/** A linked worktree a session's pane is in. */
export interface LiveWorktree {
/** The worktree folder's name, for display. */
name: string;
/** Absolute path, spelled like the session's `workingDir`. */
/**
* Absolute path. Spelled like the session's `workingDir` for a worktree inside it; the real
* path (no symlink in any component) for one outside it.
*/
path: string;
/**
* The worktree lies outside `workingDir`, so it is a second root the file routes serve
* ABSOLUTE paths from. Absent for a worktree inside `workingDir`.
*/
outside?: true;
}
/** What {@link findLiveWorktree} needs to answer a worktree outside `workingDir`. */
export interface OutsideWorktreeOptions {
/** Overrides `os.homedir()` (tests). */
home?: string;
/** Host paths of every Docker case workspace: a container can write there, so none is followed. */
dockerWorkspaces?: () => Promise<string[]>;
/**
* Narrows which worktree may be followed. Multi-user mode passes the owner's folder rule
* (`folderAllowedForUser`), so a regular user's session cannot list another user's checkout.
*/
allowRoot?: (root: string) => boolean | Promise<boolean>;
}
/** More than any real `.git` file. It is read only to recognise the first line. */
@@ -82,15 +108,22 @@ async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'
}
/**
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
* read. `workingDir` itself is never an answer: a session started in a worktree is already
* listing it. Never throws.
* The linked worktree that holds `cwd`, if any. Walks up from `cwd` and stops at `workingDir`,
* at a repository or submodule of its own, or at anything it cannot read. `workingDir` itself is
* never an answer: a session started in a worktree is already listing it. When `cwd` lies outside
* `workingDir` and `outside` is given, {@link findOutsideWorktree} answers instead. Never throws.
*/
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
export async function findLiveWorktree(
workingDir: string,
cwd: string | undefined,
outside?: OutsideWorktreeOptions
): Promise<LiveWorktree | undefined> {
if (!cwd?.startsWith('/')) return undefined;
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
const under = root === '/' ? '/' : `${root}/`;
if (cwd !== root && !cwd.startsWith(under)) {
return outside ? findOutsideWorktree(root, cwd, outside).catch(() => undefined) : undefined;
}
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
const found = await classifyDotGit(dir);
if (found === 'worktree') return { name: basename(dir), path: dir };
@@ -98,3 +131,140 @@ export async function findLiveWorktree(workingDir: string, cwd: string | undefin
}
return undefined;
}
/** True when `child` is `parent` or lies below it. Both must already be real paths. */
const isSameOrUnder = (child: string, parent: string): boolean =>
child === parent || child.startsWith(parent === sep ? parent : parent + sep);
/** The real path of `p` once a bounded probe has answered for it, else null. */
async function realPathBounded(p: string): Promise<string | null> {
const kind = await probePathKind(p);
if (kind !== 'directory' && kind !== 'file') return null;
try {
return await fs.realpath(p);
} catch {
return null;
}
}
/**
* The text of a small regular file, refusing a symlink as the last component and a FIFO
* (O_NOFOLLOW, O_NONBLOCK, then judged on the handle), like {@link classifyDotGit}. Null for
* anything else.
*/
async function readSmallFile(path: string): Promise<string | null> {
if ((await probePathKind(path)) !== 'file') return null;
let handle: fs.FileHandle | undefined;
try {
handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return null;
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return buffer.toString('utf8', 0, bytesRead);
} catch {
return null;
} finally {
await handle?.close().catch(() => {});
}
}
/** The `gitdir:` value of a `.git` file's text, resolved against the folder that holds it. */
function gitDirOf(text: string, dir: string): string | null {
const raw = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
return raw ? resolve(dir, raw) : null;
}
/**
* The real path of the git common dir of the repository whose top level is `workingDir`: its
* `.git` folder, or, for a linked worktree, the folder its `commondir` file names. Null when
* `workingDir` is not the top level of a repository or worktree: a session in a subfolder
* follows no worktree outside it.
*/
async function sessionCommonDir(workingDir: string): Promise<string | null> {
const dotGit = join(workingDir, '.git');
const kind = await probePathKind(dotGit);
if (kind === 'directory') return realPathBounded(dotGit);
if (kind !== 'file') return null;
const text = await readSmallFile(dotGit);
const gitDir = text ? gitDirOf(text, workingDir) : null;
if (!gitDir) return null;
// A linked worktree's git dir names the common dir in `commondir`; a submodule's git dir
// has none and is its own common dir.
const commonDir = await readSmallFile(join(gitDir, 'commondir'));
return realPathBounded(commonDir ? resolve(gitDir, commonDir.trim()) : gitDir);
}
/**
* The worktree outside `workingDir` that holds `cwd`, when the session's own repository vouches
* for it. `cwd` comes from the pane, which a prompt-injected agent controls, so every rule below
* keeps it from widening what the file routes serve beyond a worktree of the session's repository:
*
* - The walk up from `cwd` stops at the first `.git`. That folder is a candidate only when its
* `.git` FILE names `<common dir>/worktrees/<id>` of the session's repository AND that
* entry's own `gitdir` file names the candidate back. A `.git` FOLDER is a candidate only when
* it IS the session's common dir (the main worktree, for a session started in a linked one).
* These are the files `git worktree list` reads, read here without starting git: this runs
* from the pane watcher on every directory change.
* - `/`, the home folder and anything above it, and any folder holding `workingDir` are
* refused, as is a folder at, inside or around a Docker case workspace.
* - `allowRoot`, when given, has the last word (multi-user mode).
*
* Every path is compared as a real path, and the answer is the real path. The file routes check
* on each request that the folder still resolves to itself, so a symlink swapped in later is
* refused there. A forged registry entry needs write access to the session's own `.git`, which
* an agent running as this user already has, along with read access to everything it could list.
*/
export async function findOutsideWorktree(
workingDir: string,
cwd: string,
opts: OutsideWorktreeOptions
): Promise<LiveWorktree | undefined> {
// The nearest folder at or above `cwd` that holds a `.git`, and what that `.git` is.
let dir = cwd;
let kind: 'directory' | 'file';
for (;;) {
const probed = await probePathKind(join(dir, '.git'));
if (probed === 'unknown') return undefined;
if (probed === 'directory' || probed === 'file') {
kind = probed;
break;
}
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
const commonDir = await sessionCommonDir(workingDir);
if (!commonDir) return undefined;
const real = await realPathBounded(dir);
if (!real) return undefined;
if (kind === 'directory') {
// The main worktree: its `.git` folder is the session's common dir itself.
if ((await realPathBounded(join(dir, '.git'))) !== commonDir) return undefined;
} else {
const text = await readSmallFile(join(dir, '.git'));
const gitDir = text && isLinkedWorktreeGitFile(text) ? gitDirOf(text, dir) : null;
const realGitDir = gitDir ? await realPathBounded(gitDir) : null;
if (!realGitDir || dirname(realGitDir) !== join(commonDir, 'worktrees')) return undefined;
// The registry entry must name this folder back, as `git worktree list` would report it.
const back = await readSmallFile(join(realGitDir, 'gitdir'));
const backDir = back ? await realPathBounded(dirname(resolve(realGitDir, back.trim()))) : null;
if (backDir !== real) return undefined;
}
const realWorkingDir = await realPathBounded(workingDir);
if (!realWorkingDir || isSameOrUnder(realWorkingDir, real) || isSameOrUnder(real, realWorkingDir)) return undefined;
const home = (await realPathBounded(opts.home ?? homedir())) ?? resolve(opts.home ?? homedir());
if (real === sep || isSameOrUnder(home, real)) return undefined;
if (opts.dockerWorkspaces) {
const workspaces = await opts.dockerWorkspaces().catch(() => null);
// An unreadable list could hide a container's workspace, so it fails closed.
if (!workspaces) return undefined;
for (const workspace of workspaces) {
const realWorkspace = (await realPathBounded(workspace)) ?? resolve(workspace);
if (isSameOrUnder(real, realWorkspace) || isSameOrUnder(realWorkspace, real)) return undefined;
}
}
if (opts.allowRoot && !(await opts.allowRoot(real))) return undefined;
return { name: basename(real), path: real, outside: true };
}
+5 -4
View File
@@ -1639,10 +1639,11 @@ export class Session extends EventEmitter {
}
/**
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
* never a confinement root, like `paneCwd`: it is judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request.
* The linked git worktree that {@link paneCwd} is in, when there is one (`live-worktree.ts`).
* The Files panel lists it instead of the main checkout. Runtime-only, like `paneCwd`. One inside
* `workingDir` is never a confinement root: it is judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request. One outside it (`outside: true`) is
* a second root for the file routes, re-checked on every request the same way.
*/
get liveWorktree(): LiveWorktree | undefined {
return this._liveWorktree;
+11 -7
View File
@@ -939,13 +939,17 @@ export interface SessionState {
*/
paneCwd?: string;
/**
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
* only starts its listing here, after `GET .../files` has checked that the real folder still
* lies inside `workingDir`, since a symlink can replace it without moving the pane.
* The linked git worktree that the pane is in NOW, when there is one. Derived from
* {@link paneCwd} by `live-worktree.ts`, so it is runtime-only and absent wherever `paneCwd` is.
*
* Usually it lies inside `workingDir` (Claude Code's `.claude/worktrees/<name>`), and then it is
* NOT a confinement root: the Files panel only starts its listing here, after `GET .../files`
* has checked that the real folder still lies inside `workingDir`, since a symlink can replace
* it without moving the pane.
*
* With `outside: true` it is a worktree of the session's repository outside `workingDir`, which
* the repository itself vouched for, and its `path` is a real path. The file routes then serve
* absolute paths inside it as a second root, while it still resolves to that same path.
*/
liveWorktree?: LiveWorktree;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
+26 -2
View File
@@ -4264,7 +4264,24 @@ Object.assign(CodemanApp.prototype, {
const workingDir = this.sessions.get(sessionId)?.workingDir;
if (!workingDir) return false;
const root = workingDir.endsWith('/') ? workingDir : `${workingDir}/`;
return filePath !== workingDir && !filePath.startsWith(root);
if (filePath === workingDir || filePath.startsWith(root)) return false;
// A worktree outside `workingDir` that the agent is in (#765) is a second root the file
// routes serve, so a path inside it previews (and edits) like a workspace path.
return this._isOutsideWorktreePath?.(filePath, sessionId) !== true;
},
/**
* Whether an absolute path lies inside the worktree OUTSIDE `workingDir` that the session's agent
* is in (`liveWorktree.outside`, #765). The file routes serve it as a second root, while the log
* viewer's `tail-file` stays confined to `workingDir`, so the terminal opens such a path in the
* file preview.
*/
_isOutsideWorktreePath(filePath, sessionId) {
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
const worktree = this.sessions?.get?.(sessionId)?.liveWorktree;
if (worktree?.outside !== true || typeof worktree.path !== 'string' || !worktree.path) return false;
const root = worktree.path.endsWith('/') ? worktree.path : `${worktree.path}/`;
return filePath === worktree.path || filePath.startsWith(root);
},
/**
@@ -5197,6 +5214,13 @@ Object.assign(CodemanApp.prototype, {
_rebaseFilePreviewMarkdownRefs(root, { sessionId, filePath, attachmentId }) {
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
const unresolvable = !!attachmentId && !filePath.startsWith('/');
// A root-relative ref (`/docs/x.png`) resolves from the top of the checkout the file is in, as
// on GitHub: the workspace, or the worktree outside it that the agent is in (#765).
const worktree = this.sessions?.get?.(sessionId)?.liveWorktree;
const rootPrefix =
worktree?.outside === true && this._isOutsideWorktreePath?.(filePath, sessionId)
? `${worktree.path.replace(/\/+$/, '')}/`
: '';
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
const isWorkspaceRef = (ref) =>
!!ref && !/^[a-z][a-z0-9+.-]*:/i.test(ref) && !ref.startsWith('//') && !ref.startsWith('#');
@@ -5216,7 +5240,7 @@ Object.assign(CodemanApp.prototype, {
/* malformed escape: keep the ref as written */
}
const parts = [];
for (const seg of (rel.startsWith('/') ? rel.slice(1) : dir + rel).split('/')) {
for (const seg of (rel.startsWith('/') ? rootPrefix + rel.slice(1) : dir + rel).split('/')) {
if (seg === '.' || (seg === '' && parts.length)) continue;
if (seg === '..' && parts.length && parts[parts.length - 1] !== '..' && parts[parts.length - 1] !== '') parts.pop();
else parts.push(seg);
+7 -1
View File
@@ -2367,7 +2367,13 @@ Object.assign(CodemanApp.prototype, {
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) {
// The log viewer's tail-file stays confined to workingDir, so a path in a worktree outside it previews.
if (
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId) ||
self._isOutsideWorktreePath?.(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
}
+36
View File
@@ -101,6 +101,42 @@ export function validateSessionFilePath(
return confineToRoot(resolvedWorkingDir, resolvedPath);
}
/**
* The outside worktree (#765) a LOCAL session's file routes may serve `filePath` from, else null:
* a recorded worktree with `outside` set, an absolute `filePath` lexically inside it, and a
* folder that still resolves to the very path that was recorded. `findOutsideWorktree()` records
* the real path, so a symlink that took the folder's place (or a parent's) since then fails the
* last check and the path is judged against `workingDir` alone.
*/
export function outsideWorktreeRootFor(
liveWorktree: { path: string; outside?: true } | undefined,
filePath: string
): string | null {
if (!liveWorktree?.outside || !isAbsolute(filePath)) return null;
const root = liveWorktree.path;
const rel = relative(root, resolve(filePath));
if (rel.startsWith('..') || isAbsolute(rel)) return null;
try {
return realpathSync(root) === root ? root : null;
} catch {
return null;
}
}
/**
* {@link validateSessionFilePath} for a LOCAL session that may have a worktree outside its folder
* (`liveWorktree.outside`): an absolute path inside that worktree is confined to it, and, when
* that refuses it (a worktree file that is a symlink into the session's own folder), to
* `workingDir`, which is served anyway. Every other path is judged against `workingDir` alone.
*/
export function validateLocalSessionFilePath(
scope: { workingDir: string; liveWorktree?: { path: string; outside?: true } },
filePath: string
): { resolvedPath: string; relativePath: string } | null {
const root = outsideWorktreeRootFor(scope.liveWorktree, filePath);
return (root && validateSessionFilePath(root, filePath)) || validateSessionFilePath(scope.workingDir, filePath);
}
/**
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
* WITHOUT touching the filesystem.
+54 -12
View File
@@ -58,11 +58,13 @@ import {
getAuthUser,
parseBody,
resolveCasesDir,
validateLocalSessionFilePath,
validateSessionFilePath,
validateSessionFilePathLexical,
} from '../route-helpers.js';
import type { FastifyRequest } from 'fastify';
import type { SessionAttachmentHistoryItem, SessionRemote, SessionState } from '../../types/session.js';
import type { LiveWorktree } from '../../live-worktree.js';
import {
RemoteFileAccessError,
remoteCreateReadStream,
@@ -574,6 +576,11 @@ function getKnownSessionWorkingDir(
/** Where a session's files live: this host, or a host reachable over ssh. */
interface SessionFileScope {
workingDir: string;
/**
* A live local session's worktree (#765). Only one with `outside` set changes anything here: it
* is a second root that ABSOLUTE paths inside it are confined to (`validateLocalSessionFilePath`).
*/
liveWorktree?: LiveWorktree;
remote?: SessionRemote;
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
docker?: boolean;
@@ -606,7 +613,12 @@ function getKnownSessionFileScope(
const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId);
if (liveSession && canAccessOwned(user, liveSession.owner)) {
return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
return {
workingDir: liveSession.workingDir,
liveWorktree: liveSession.liveWorktree,
remote: liveSession.remote,
docker: !!liveSession.docker,
};
}
const stored = ctx.store.getSession(sessionId);
@@ -657,7 +669,7 @@ type FileTargetResolution =
*/
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
if (!scope.remote) {
const validated = validateSessionFilePath(scope.workingDir, filePath);
const validated = validateLocalSessionFilePath(scope, filePath);
if (!validated) {
return {
ok: false,
@@ -876,6 +888,21 @@ async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Pr
}
}
/**
* Whether a `root=live` listing may start at a worktree OUTSIDE `workingDir` (`liveWorktree.outside`).
* `findOutsideWorktree()` recorded its real path, and a symlink can take the folder's place, or a
* parent's, without moving the pane, so the folder must still resolve to exactly that path. Probed
* first (#516).
*/
async function canWalkOutsideWorktree(worktreePath: string): Promise<boolean> {
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
try {
return (await fs.realpath(worktreePath)) === worktreePath;
} catch {
return false;
}
}
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
return roots
.filter((root) => isPathWithinRoot(root.path, candidate))
@@ -1708,11 +1735,20 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
// paths against the same root as any other. Only the search predicate reads a path relative
// to the listing, since that is the tree the person is looking at.
// A worktree OUTSIDE `workingDir` (`liveWorktree.outside`) is a second root: walked only while
// it still resolves to the real path recorded for it, and listed with ABSOLUTE `path`s, since a
// relative one resolves against `workingDir` on every other route. Those routes confine an
// absolute path inside it to it (`validateLocalSessionFilePath`).
const liveWorktree = session.liveWorktree;
const walkRoot =
root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
? liveWorktree.path
: workingDir;
const walksOutside =
root === 'live' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
const walksInside =
root === 'live' &&
!!liveWorktree &&
!liveWorktree.outside &&
(await canWalkLiveWorktree(workingDir, liveWorktree.path));
const walkRoot = liveWorktree && (walksOutside || walksInside) ? liveWorktree.path : workingDir;
const nodePath = (fullPath: string): string => (walksOutside ? fullPath : fullPath.slice(workingDir.length + 1));
// null for an empty/whitespace query, which is what keeps the default
// tree response byte-identical when no search is requested.
const matcher = compileFileQuery(q ?? '');
@@ -1802,7 +1838,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
const entry = cursor.entries[cursor.index++];
const fullPath = join(cursor.dirPath, entry.name);
const relativePath = relative(workingDir, fullPath);
const relativePath = walksOutside ? fullPath : relative(workingDir, fullPath);
const listedPath = relative(walkRoot, fullPath);
if (entry.isDirectory()) {
@@ -1893,7 +1929,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) {
const entry = cursor.entries[cursor.index++];
const fullPath = join(cursor.dirPath, entry.name);
const relativePath = fullPath.slice(workingDir.length + 1);
const relativePath = nodePath(fullPath);
if (entry.isDirectory()) {
totalDirectories++;
const node: FileTreeNode = {
@@ -1958,7 +1994,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
// For a remote (SSH) case the same boundary is resolved on the remote host (#415),
// where the path actually lives.
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
const resolution = await resolveFileTarget(
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
filePath
);
if (!resolution.ok) {
// An unreachable remote is thrown so the shared handler answers a real 502 (and
// the test can assert it); a refusal keeps this route's historical returned
@@ -2230,7 +2269,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
}
const validated = validateSessionFilePath(session.workingDir, body.path);
const validated = validateLocalSessionFilePath(session, body.path);
if (!validated) {
// Covers missing files, traversal, and symlink escapes alike — a write
// target that fails confinement is reported identically to a missing
@@ -2333,7 +2372,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
// Validate path is within working directory (security: resolve symlinks to prevent traversal)
const resolution = await resolveFileTarget({ workingDir: session.workingDir, remote: session.remote }, filePath);
const resolution = await resolveFileTarget(
{ workingDir: session.workingDir, liveWorktree: session.liveWorktree, remote: session.remote },
filePath
);
if (!resolution.ok) {
reply.code(resolution.status).send(createErrorResponse(resolution.errorCode, resolution.message));
return;
@@ -2972,7 +3014,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const session = findSessionOrFail(ctx, sessionId, req);
const validated = validateSessionFilePath(session.workingDir, filePath);
const validated = validateLocalSessionFilePath(session, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
+2 -2
View File
@@ -33,7 +33,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateLocalSessionFilePath } from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { DOCKER_HOME_PATH_ERROR, localFileSource, sendFileBody } from './file-routes.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
@@ -175,7 +175,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
// Inside the workspace: the same check every file route uses. Outside it: the
// attachment guard, exactly as an external file preview is admitted.
let resolvedPath: string;
const inWorkspace = validateSessionFilePath(session.workingDir, requestedPath);
const inWorkspace = validateLocalSessionFilePath(session, requestedPath);
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
} else {
+26 -4
View File
@@ -35,7 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { findLiveWorktree } from '../live-worktree.js';
import { findLiveWorktree, type OutsideWorktreeOptions } from '../live-worktree.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
@@ -80,7 +80,7 @@ import {
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { folderGrants } from '../folder-listing.js';
import { folderAllowedForUser, folderGrants } from '../folder-listing.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts, resolveCustomModelContextLength } from '../custom-model-hosts.js';
@@ -213,7 +213,7 @@ import {
sendLostWebviewFramePage,
} from './middleware/auth.js';
import { isMultiUserMode } from '../config/multiuser.js';
import { bootstrapInitialAdmin, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { bootstrapInitialAdmin, findUser, hasUsers, resolveClaudeModeForUsername } from '../user-store.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { installEmptyApiPostParser } from './request-body-parser.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
@@ -3021,11 +3021,33 @@ export class WebServer extends EventEmitter {
*/
private async refreshLiveWorktree(session: Session): Promise<void> {
const cwd = session.paneCwd;
const worktree = await findLiveWorktree(session.workingDir, cwd);
const worktree = await findLiveWorktree(session.workingDir, cwd, this.outsideWorktreeOptions(session));
if (session.paneCwd !== cwd) return;
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
}
/**
* The rules a worktree OUTSIDE the session's folder must pass to be followed (#765 follow-up):
* none in a Docker case workspace (the list is read only when a candidate needs it, and an
* unreadable list follows nothing), and in multi-user mode only inside the owner's space or
* `workingDir`. Undefined for a remote or Docker session, whose pane directory is not a path the
* agent works in on this host (it has no `paneCwd` either, so this is belt and braces).
*/
private outsideWorktreeOptions(session: Session): OutsideWorktreeOptions | undefined {
if (session.remote || session.docker) return undefined;
return {
dockerWorkspaces: defaultDockerWorkspaces,
...(isMultiUserMode() && {
allowRoot: async (root: string): Promise<boolean> => {
const owner = session.owner ? await findUser(session.owner) : undefined;
return folderAllowedForUser(root, owner ? { username: owner.username, role: owner.role } : undefined, {
workingDir: session.workingDir,
});
},
}),
};
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
+180
View File
@@ -0,0 +1,180 @@
/**
* @fileoverview The browser side of a worktree OUTSIDE the session folder (#765 follow-up).
*
* The server lists such a worktree with absolute paths and serves them as a second root. The
* browser has to route those paths to the workspace file routes (preview and edit), not to the
* attachment routes that serve an outside file read-only; the terminal has to open them in the
* preview, since the log viewer's `tail-file` stays confined to `workingDir`; and a root-relative
* markdown ref has to resolve from the top of the worktree. Real frontend code in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const WORKING_DIR = '/base/myapp';
const SIBLING = '/base/myapp-feature';
const outsideSession = () => ({
workingDir: WORKING_DIR,
liveWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
});
type Proto = {
_isExternalPreviewPath: (p: string, id: string) => boolean;
_isOutsideWorktreePath: (p: string, id: string) => boolean;
};
const loadRouting = (session: Record<string, unknown>) => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn(), getElementById: () => null },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (context as unknown as { __proto: Proto }).__proto;
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', session]]) }) as Proto;
return {
external: (p: string) => app._isExternalPreviewPath(p, 's1'),
outside: (p: string) => app._isOutsideWorktreePath(p, 's1'),
};
};
describe('preview routing for a worktree outside the session folder', () => {
it('keeps a path inside the outside worktree on the workspace routes', () => {
const app = loadRouting(outsideSession());
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(false);
expect(app.external(SIBLING)).toBe(false);
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(true);
});
it('still sends every other outside path through the attachment routes', () => {
const app = loadRouting(outsideSession());
expect(app.external('/base/myapp-feature-2/src/a.ts')).toBe(true);
expect(app.external('/tmp/shot.png')).toBe(true);
expect(app.outside('/base/myapp-feature-2/src/a.ts')).toBe(false);
expect(app.outside('relative/a.ts')).toBe(false);
});
it('leaves workspace paths and an inside worktree as they were', () => {
const app = loadRouting({
workingDir: WORKING_DIR,
liveWorktree: { name: 'probe', path: `${WORKING_DIR}/.claude/worktrees/probe` },
});
expect(app.external(`${WORKING_DIR}/src/a.ts`)).toBe(false);
expect(app.outside(`${WORKING_DIR}/.claude/worktrees/probe/a.ts`)).toBe(false);
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
});
it('treats a sibling path as external once the agent has left the worktree', () => {
const app = loadRouting({ workingDir: WORKING_DIR });
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(false);
});
});
describe('terminal links into a worktree outside the session folder', () => {
type Link = { text: string; activate: (ev: unknown, text: string) => void };
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
const makeApp = (line: string) => {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 's1';
app.sessions = new Map([['s1', outsideSession()]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
const providers: Provider[] = [];
app.terminal = {
cols: 80,
registerLinkProvider: (p: Provider) => providers.push(p),
buffer: {
active: {
length: 1,
getLine: (row: number) =>
row === 0
? { isWrapped: false, translateToString: (trim?: boolean) => (trim === false ? line.padEnd(80) : line) }
: undefined,
},
},
};
app.registerFilePathLinkProvider();
let links: Link[] = [];
providers[0].provideLinks(1, (found) => {
links = found || [];
});
return { app, link: links[0] };
};
it('opens a log in the outside worktree in the preview, since tail-file stays in the session folder', () => {
const { app, link } = makeApp(`tail -f ${SIBLING}/logs/server.log`);
link.activate({}, link.text);
expect(app.openFilePreview).toHaveBeenCalledWith(`${SIBLING}/logs/server.log`, 's1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('keeps a log in the session folder in the log viewer', () => {
const { app, link } = makeApp(`tail -f ${WORKING_DIR}/logs/server.log`);
link.activate({}, link.text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(`${WORKING_DIR}/logs/server.log`, 's1');
});
});
describe('markdown in a worktree outside the session folder', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
const { document } = dom.window;
it('resolves root-relative and relative refs from the worktree, not the session folder', () => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (context as unknown as { __proto: Record<string, any> }).__proto;
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', outsideSession()]]) });
const root = document.createElement('div');
root.innerHTML = '<img src="/assets/root.png" alt="root"><img src="img/a.png" alt="rel">';
app._rebaseFilePreviewMarkdownRefs(root, { sessionId: 's1', filePath: `${SIBLING}/docs/README.md` });
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
const src = (alt: string) => root.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
expect(src('root')).toBe(raw(`${SIBLING}/assets/root.png`));
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
});
});
+208
View File
@@ -0,0 +1,208 @@
/**
* @fileoverview `findLiveWorktree()` for a worktree OUTSIDE the session's folder (#765 follow-up).
*
* Such a worktree becomes a second root the file routes serve, so what matters is what must NOT be
* followed: a folder the session's repository does not list, a forged `.git` file with no registry
* entry naming it back, another repository, the home folder or anything above it, a folder holding
* the session's folder, a Docker case workspace, and a folder the multi-user rule refuses. Runs
* against worktrees real git made, since the registry files are the thing being read.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findLiveWorktree, findOutsideWorktree } from '../src/live-worktree.js';
const git = (cwd: string, ...args: string[]): void => {
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
};
describe('findLiveWorktree outside the session folder', () => {
let base: string;
let repo: string;
let sibling: string;
/** Not the user's real home: the base folder must not count as "home or above". */
let home: string;
beforeEach(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-')));
home = join(base, 'home');
mkdirSync(home);
repo = join(base, 'myapp');
mkdirSync(repo);
git(repo, 'init', '-q');
writeFileSync(join(repo, 'README.md'), 'main');
git(repo, 'add', 'README.md');
git(repo, 'commit', '-q', '-m', 'init');
sibling = join(base, 'myapp-feature');
git(repo, 'worktree', 'add', '-q', sibling);
mkdirSync(join(sibling, 'src', 'deep'), { recursive: true });
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
const expected = () => ({ name: 'myapp-feature', path: sibling, outside: true });
it('follows a sibling worktree of the session repository, from its root and from below it', async () => {
expect(await findLiveWorktree(repo, sibling, { home })).toEqual(expected());
expect(await findLiveWorktree(repo, join(sibling, 'src', 'deep'), { home })).toEqual(expected());
});
it('follows nothing outside the folder unless the caller passes the outside rules', async () => {
expect(await findLiveWorktree(repo, sibling)).toBeUndefined();
});
it('follows a worktree made with --relative-paths', async () => {
const relative = join(base, 'myapp-relative');
try {
git(repo, 'worktree', 'add', '-q', '--relative-paths', relative);
} catch {
return; // git older than 2.48 has no --relative-paths
}
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
name: 'myapp-relative',
path: relative,
outside: true,
});
});
it('follows the main worktree and other worktrees from a session started in a linked one', async () => {
const other = join(base, 'myapp-other');
git(repo, 'worktree', 'add', '-q', other);
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
expect(await findLiveWorktree(sibling, other, { home })).toEqual({
name: 'myapp-other',
path: other,
outside: true,
});
});
it('answers with the real path when the pane reached the worktree through a symlink', async () => {
const link = join(base, 'link-to-feature');
symlinkSync(sibling, link);
expect(await findLiveWorktree(repo, join(link, 'src'), { home })).toEqual(expected());
});
it('refuses a folder in another repository, and one in no repository at all', async () => {
const otherRepo = join(base, 'other');
mkdirSync(otherRepo);
git(otherRepo, 'init', '-q');
expect(await findLiveWorktree(repo, otherRepo, { home })).toBeUndefined();
const plain = join(base, 'plain');
mkdirSync(plain);
expect(await findLiveWorktree(repo, plain, { home })).toBeUndefined();
});
it('refuses a worktree of ANOTHER repository, even one laid out the same way', async () => {
const otherRepo = join(base, 'other');
mkdirSync(otherRepo);
git(otherRepo, 'init', '-q');
writeFileSync(join(otherRepo, 'a'), 'a');
git(otherRepo, 'add', 'a');
git(otherRepo, 'commit', '-q', '-m', 'a');
const otherWorktree = join(base, 'other-feature');
git(otherRepo, 'worktree', 'add', '-q', otherWorktree);
expect(await findLiveWorktree(repo, otherWorktree, { home })).toBeUndefined();
});
it('refuses a forged .git file that the registry does not name back', async () => {
const forged = join(base, 'forged');
mkdirSync(forged);
// Points at the real registry entry of the sibling, whose `gitdir` names the sibling.
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
writeFileSync(join(forged, '.git'), `gitdir: ${entry}\n`);
expect(await findLiveWorktree(repo, forged, { home })).toBeUndefined();
});
it('refuses a registry entry that names a .git symlink back', async () => {
const entry = execFileSync('git', ['rev-parse', '--git-dir'], { cwd: sibling, encoding: 'utf8' }).trim();
rmSync(join(entry, 'gitdir'));
symlinkSync(join(sibling, '.git'), join(entry, 'gitdir'));
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
});
it('refuses a .git file that is a symlink', async () => {
const text = execFileSync('cat', [join(sibling, '.git')], { encoding: 'utf8' });
const copy = join(base, 'dot-git-copy');
writeFileSync(copy, text);
rmSync(join(sibling, '.git'));
symlinkSync(copy, join(sibling, '.git'));
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
});
it('follows nothing from a session started in a subfolder of its repository', async () => {
const sub = join(repo, 'sub');
mkdirSync(sub);
expect(await findLiveWorktree(sub, sibling, { home })).toBeUndefined();
});
it('refuses a worktree at or above the home folder', async () => {
expect(await findLiveWorktree(repo, sibling, { home: join(sibling, 'src') })).toBeUndefined();
expect(await findLiveWorktree(repo, sibling, { home: sibling })).toBeUndefined();
});
it('refuses a worktree that holds the session folder', async () => {
// A registry entry forged around the session's own folder: `base` holds `repo`. Both halves
// of the link are in place, so only the containment rule can refuse it.
const entry = join(repo, '.git', 'worktrees', 'around');
mkdirSync(entry, { recursive: true });
writeFileSync(join(entry, 'gitdir'), `${join(base, '.git')}\n`);
writeFileSync(join(base, '.git'), `gitdir: ${entry}\n`);
const elsewhereHome = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-home-')));
try {
expect(await findOutsideWorktree(repo, base, { home: elsewhereHome })).toBeUndefined();
// The same forged link around a folder that does NOT hold the session folder is followed,
// which shows the refusal above came from the containment rule.
const loose = join(base, 'loose');
mkdirSync(loose);
const looseEntry = join(repo, '.git', 'worktrees', 'loose');
mkdirSync(looseEntry, { recursive: true });
writeFileSync(join(looseEntry, 'gitdir'), `${join(loose, '.git')}\n`);
writeFileSync(join(loose, '.git'), `gitdir: ${looseEntry}\n`);
expect(await findOutsideWorktree(repo, loose, { home: elsewhereHome })).toEqual({
name: 'loose',
path: loose,
outside: true,
});
} finally {
rmSync(elsewhereHome, { recursive: true, force: true });
}
});
it('refuses a worktree at, inside or around a Docker case workspace', async () => {
const at = async () => [sibling];
const around = async () => [join(sibling, 'src')];
const above = async () => [base];
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: at })).toBeUndefined();
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: around })).toBeUndefined();
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: above })).toBeUndefined();
const elsewhere = async () => [join(base, 'elsewhere')];
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: elsewhere })).toEqual(expected());
});
it('follows nothing while the Docker case list cannot be read', async () => {
const unreadable = async (): Promise<string[]> => {
throw new Error('docker-cases.json mid-write');
};
expect(await findLiveWorktree(repo, sibling, { home, dockerWorkspaces: unreadable })).toBeUndefined();
});
it('lets allowRoot refuse the worktree, and passes it the real path', async () => {
const seen: string[] = [];
const refuse = (root: string) => {
seen.push(root);
return false;
};
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: refuse })).toBeUndefined();
expect(seen).toEqual([sibling]);
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: async () => true })).toEqual(expected());
});
it('clears when the pane comes back to the session folder', async () => {
expect(await findLiveWorktree(repo, repo, { home })).toBeUndefined();
expect(await findLiveWorktree(repo, join(repo, 'src'), { home })).toBeUndefined();
});
});
+68
View File
@@ -29,6 +29,7 @@
*
* Port: ephemeral
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
@@ -349,6 +350,73 @@ describe('a pane read reaching the session record', () => {
});
});
describe('liveWorktree outside the session folder (#765 follow-up)', () => {
let base: string;
let repo: string;
let sibling: string;
const git = (cwd: string, ...args: string[]): void => {
execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd, stdio: 'ignore' });
};
beforeEach(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), 'pane-outside-worktree-')));
repo = join(base, 'myapp');
mkdirSync(repo);
git(repo, 'init', '-q');
writeFileSync(join(repo, 'README.md'), 'main');
git(repo, 'add', 'README.md');
git(repo, 'commit', '-q', '-m', 'init');
sibling = join(base, 'myapp-feature');
git(repo, 'worktree', 'add', '-q', sibling);
});
afterEach(() => {
rmSync(base, { recursive: true, force: true });
});
it('publishes a sibling worktree of the session repository as outside, and clears it on the way back', async () => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
sessions.set(session.id, session);
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
vi.fn();
mux.getPaneExit = () => undefined;
let dir = sibling;
mux.getPaneCurrentPath = () => dir;
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
// until it lands.
await vi.waitFor(() => {
mux.emit('paneExitsUpdated');
expect(session.liveWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
expect(session.toState().workingDir).toBe(repo);
dir = repo;
mux.emit('paneExitsUpdated');
await vi.waitFor(() => expect(session.liveWorktree).toBeUndefined());
});
it('follows nothing for a folder outside the session folder that the repository does not list', async () => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
sessions.set(session.id, session);
(web as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
vi.fn();
const plain = join(base, 'plain');
mkdirSync(plain);
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => plain;
const lookup = vi.spyOn(session, 'setLiveWorktree');
await vi.waitFor(() => {
mux.emit('paneExitsUpdated');
expect(lookup).toHaveBeenCalled();
});
expect(session.paneCwd).toBe(plain);
expect(session.liveWorktree).toBeUndefined();
});
});
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
const { mux, sessions } = build();
const session = localMuxSession();
@@ -0,0 +1,171 @@
/**
* @fileoverview The file routes for a worktree OUTSIDE the session's folder (#765 follow-up).
*
* `liveWorktree.outside` makes that worktree a second root: `GET .../files?root=live` lists it with
* ABSOLUTE paths, and the read, raw, edit and download routes confine an absolute path inside it to
* it. What must not change: a relative path still resolves against `workingDir` only, a path that
* escapes the worktree through a symlink is refused, and a worktree folder replaced by a symlink is
* neither listed nor served. Real filesystem on purpose, since realpath is the thing under test.
*/
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
describe('file routes for a worktree outside the session folder', () => {
let harness: RouteTestHarness;
let base: string;
let repo: string;
let sibling: string;
let secret: string;
const sessionUrl = (rest: string) => `/api/sessions/${harness.ctx._sessionId}/${rest}`;
const list = async (query = '') => {
const res = await harness.app.inject({ method: 'GET', url: sessionUrl(`files?depth=5${query}`) });
expect(res.statusCode).toBe(200);
return res.json().data;
};
const readContent = (path: string) =>
harness.app.inject({ method: 'GET', url: sessionUrl(`file-content?path=${encodeURIComponent(path)}`) });
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
base = realpathSync(mkdtempSync(join(tmpdir(), 'outside-worktree-route-')));
repo = join(base, 'myapp');
sibling = join(base, 'myapp-feature');
secret = join(base, 'secret');
mkdirSync(join(repo, 'src'), { recursive: true });
mkdirSync(join(sibling, 'src'), { recursive: true });
mkdirSync(secret);
writeFileSync(join(repo, 'src', 'main-only.ts'), 'main');
writeFileSync(join(sibling, 'src', 'feature-only.ts'), 'feature');
writeFileSync(join(secret, 'key.txt'), 'secret');
Object.assign(harness.ctx._session, {
workingDir: repo,
liveWorktree: { name: 'myapp-feature', path: sibling, outside: true },
});
});
afterEach(async () => {
await harness.app.close();
rmSync(base, { recursive: true, force: true });
});
describe('GET .../files', () => {
it('lists the worktree with absolute paths', async () => {
const data = await list('&root=live');
expect(data.root).toBe(sibling);
expect(data.worktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
expect(data.tree[0].path).toBe(join(sibling, 'src'));
expect(data.tree[0].children[0].path).toBe(join(sibling, 'src', 'feature-only.ts'));
});
it('searches the worktree, matching on paths relative to it and answering absolute ones', async () => {
const data = await list(`&root=live&q=${encodeURIComponent('src/feature*')}`);
expect(data.matches.map((m: { path: string }) => m.path)).toEqual([join(sibling, 'src', 'feature-only.ts')]);
});
it('keeps the session folder, with relative paths, without the parameter', async () => {
const data = await list();
expect(data.root).toBe(repo);
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
});
it('falls back to the session folder once a symlink has taken the worktree folder’s place', async () => {
renameSync(sibling, `${sibling}-moved`);
symlinkSync(secret, sibling);
const data = await list('&root=live');
expect(data.root).toBe(repo);
expect(JSON.stringify(data.tree)).not.toContain('key.txt');
});
});
describe('GET .../file-content', () => {
it('serves an absolute path inside the worktree', async () => {
const res = await readContent(join(sibling, 'src', 'feature-only.ts'));
expect(res.statusCode).toBe(200);
expect(JSON.stringify(res.json())).toContain('feature');
});
it('still resolves a relative path against the session folder only', async () => {
expect((await readContent('src/main-only.ts')).statusCode).toBe(200);
const escaped = await readContent('../myapp-feature/src/feature-only.ts');
expect(JSON.stringify(escaped.json())).not.toContain('"feature"');
expect(escaped.json().success).toBe(false);
});
it('refuses a symlink in the worktree that leads out of it', async () => {
symlinkSync(join(secret, 'key.txt'), join(sibling, 'leak.txt'));
const res = await readContent(join(sibling, 'leak.txt'));
expect(res.json().success).toBe(false);
expect(JSON.stringify(res.json())).not.toContain('"secret"');
});
it('serves a symlink in the worktree that leads into the session folder', async () => {
symlinkSync(join(repo, 'src', 'main-only.ts'), join(sibling, 'main-link.ts'));
const res = await readContent(join(sibling, 'main-link.ts'));
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
});
it('serves nothing from the folder once a symlink has taken its place', async () => {
renameSync(sibling, `${sibling}-moved`);
symlinkSync(secret, sibling);
const res = await readContent(join(sibling, 'key.txt'));
expect(res.json().success).toBe(false);
});
it('serves nothing outside the session folder once the worktree is not outside', async () => {
Object.assign(harness.ctx._session, { liveWorktree: { name: 'myapp-feature', path: sibling } });
const res = await readContent(join(sibling, 'src', 'feature-only.ts'));
expect(res.json().success).toBe(false);
});
});
describe('file-raw, download and edit', () => {
it('streams an absolute path inside the worktree from file-raw and download', async () => {
const target = join(sibling, 'src', 'feature-only.ts');
const raw = await harness.app.inject({
method: 'GET',
url: sessionUrl(`file-raw?path=${encodeURIComponent(target)}`),
});
expect(raw.statusCode).toBe(200);
expect(raw.body).toBe('feature');
const download = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(target)}`,
});
expect(download.statusCode).toBe(200);
expect(download.body).toBe('feature');
});
it('edits a file inside the worktree in place', async () => {
const target = join(sibling, 'notes.md');
writeFileSync(target, 'before\n');
const res = await harness.app.inject({
method: 'PUT',
url: sessionUrl('file-content'),
payload: { path: target, content: 'after\n', baseHash: sha256('before\n') },
});
expect(res.statusCode).toBe(200);
expect(readFileSync(target, 'utf8')).toBe('after\n');
});
it('refuses to edit through a symlink that leads out of the worktree', async () => {
symlinkSync(join(secret, 'key.txt'), join(sibling, 'leak.md'));
const res = await harness.app.inject({
method: 'PUT',
url: sessionUrl('file-content'),
payload: { path: join(sibling, 'leak.md'), content: 'pwned', baseHash: sha256('secret') },
});
expect(res.statusCode).toBe(404);
expect(readFileSync(join(secret, 'key.txt'), 'utf8')).toBe('secret');
});
});
});