fix(files): harden the outside-worktree root after review

Keep the 1.49.0 API meaning: liveWorktree and root=live stay inside the
session folder, and an outside worktree is published as outsideWorktree
and walked only with root=live-any.

Refuse a symlinked .git folder as the main worktree, sensitive files
inside a followed worktree, and any outside worktree while attachment
confinement is on or around a blocked tree. The file routes re-check
the guard settings on every request, and a throwing rule check refuses.
The panel keeps its Back to worktree button while pinned to the session
folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael GrundbergandClaude Opus 5.5 committed 2026-11-06 20:33:14 +01:00
1 parent c9b79097b0
commit 8d33f53fd0
28 files changed
+856 -198

No files matched your search

+3 -3
View File
@@ -230,17 +230,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which reserves spaced-path spans first (an unlinkable one only while its last word holds a `/`; any label, before an absolute or relative path, must be an explicit path label, prose after that label stays out, and an earlier connective or verb such as `and` or `has` (`SPACED_PATH_PROSE_WORD`) makes any candidate prose; rules and trade-offs in the architecture-invariants paragraph linked below), then adds rooted, `~/`, relative and folder paths without overlapping a reservation, earlier match or URL; a relative path resolves against the pane's live directory `SessionState.paneCwd` when known, else `workingDir` (`relativeResolutionBase()` + `resolveLinkedFilePath`; `paneCwd` is only a BASE, never a confinement root: routing and the file routes still judge against `workingDir`, or against a followed worktree for an absolute path inside it, and remote/docker/direct-PTY/rediscovered sessions never get one, scoped by `paneExitApplies`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. The one exception is an absolute path inside a followed worktree outside `workingDir` (`SessionState.outsideWorktree`), which the file routes serve as a second root (see Files panel in a worktree). An ambiguous parenthesised hard wrap is checked only on activation, original first: only a 404 permits a spaced-path lookup, and only a registered alternative is opened; every other original result uses the normal opener, never an attachment-policy gate on workspace files, and hover performs no lookup. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs.
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**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 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)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` 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. That is the lookup for a pane inside `workingDir`; a pane outside it goes to `findOutsideWorktree()`, described below. ⚠️ The security argument has two halves. For a worktree inside `workingDir` the lookup only answers a folder strictly below it, 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. The one exception is a listing without `root=live-any`, which omits an outside worktree; while the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `outside: true` and published on the wire as `SessionState.outsideWorktree`, never in `liveWorktree`, which stays inside-only: the session's own repository must vouch for it by FILE READS (`workingDir` must be the top of its repository or worktree, so a session in a subfolder follows nothing outside it; the candidate's `.git` FILE names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back, or its `.git` FOLDER is the session's common dir itself, which is the main worktree for a session started in a linked one, and a symlinked `.git` is refused; 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). It is refused while attachment workspace confinement is on. It is also refused at, inside or around an attachment blocked tree, and at a root that is itself a sensitive folder such as `~/.ssh` (a child probe, since the sensitive patterns describe file locations). These rules are judged when the pane moves like the Docker rule, and multi-user mode applies `folderAllowedForUser`. The routes also re-check confinement and the blocked trees on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. ⚠️ `validateLocalSessionFilePath()` also refuses any file there that `isSensitivePath()` blocklists, so a root such as a credential folder can never serve its keys. Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses 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).
+41 -19
View File
@@ -988,11 +988,12 @@ the web UI uses as the base for a relative path printed in the terminal. It is
never restored after a restart (it is written to `state.json` with the rest of the
session state but not read back), absent for remote,
Docker, adopted, direct-PTY and rediscovered sessions and for a session split into several panes,
and never a confinement root: file routes stay confined to `workingDir`.
and never a confinement root. File routes stay confined to `workingDir`, except for an
absolute path inside an `outsideWorktree`, described in the next section.
tmux reports the resolved directory, so for a workspace reached through a symlink a
directory at or under the resolved workspace is reported in `workingDir`'s spelling.
## Live worktree (`liveWorktree`)
## Live worktree (`liveWorktree`, `outsideWorktree`)
Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `liveWorktree`,
the linked git worktree the session's pane is in now, for example after Claude Code's
@@ -1002,34 +1003,55 @@ 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. 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 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 in it. `path` is in
`workingDir`'s spelling. Like `paneCwd` it is never restored after a restart and never a
confinement root.
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:
Session state may also carry the optional `outsideWorktree`: a
worktree of the session's repository OUTSIDE `workingDir`, such as `git worktree add ../feature`.
It can be a linked worktree, or the repository's main worktree for a session started in a linked
one. It carries `"outside": true`, and `path` is the folder's real path. A session has at most one
of `liveWorktree` and `outsideWorktree`:
```json
"liveWorktree": { "name": "repo-feature", "path": "/home/me/repo-feature", "outside": true }
"outsideWorktree": { "name": "repo-feature", "path": "/home/me/repo-feature", "outside": true }
```
Like `liveWorktree`, it is never restored after a restart.
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.
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download` and
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
- `/`, the home folder or above, or a folder holding `workingDir`;
- a folder at, inside or around a Docker case workspace;
- in multi-user mode, a worktree outside the owner's folder rule (their own space or the session
folder; an admin owner is not limited);
- any outside worktree while attachment workspace confinement is on;
- a folder at, inside or around a blocked attachment tree, or a folder that is itself a
sensitive folder such as `.ssh`.
The file routes also apply confinement and the blocked trees on every request, so a setting changed later takes effect at once, and a listing then walks `workingDir`. `html-view` also applies the attachment guard to such a path on every request.
`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
`q` search, at a worktree inside `workingDir` instead of at `workingDir`. `root=live` never walks
an outside worktree. `root=live-any` does the same and also walks an outside worktree, whose
listing carries ABSOLUTE `path`s. 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` (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`.
`workingDir` either way, except in a `root=live-any` listing of an outside worktree, where every `path` is
absolute. A relative `path` and an absolute `path` inside an outside worktree both work on
`file-content`, `file-raw`, `file-preview`, `file-thumbnail`, `/api/v1/download` and `html-view`.
A `q` containing `/` is matched against paths relative to the folder walked. The response
also carries `worktree`, the session's `liveWorktree` object (`{ name, path }`) whenever the
session has one, whichever folder was walked and with or without `root=live` or `root=live-any`.
An `outsideWorktree` (`{ name, path, outside: true }`) is reported in `worktree` only for
`root=live-any`.
The walk lists at most 5,000 files and folders and opens no more folders once it has read 250,000
directory entries (the folder that crosses that line is read whole). It sets `truncated` when it
+13 -11
View File
@@ -425,13 +425,13 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. The anchors the walk builds carry `dir="ltr"`, so a path at the head of a right-to-left sentence does not decide that sentence's direction; a rewritten link keeps no `dir`, since its label is prose (#715). ⚠️ `_applyBlockDirection()` runs after the linkifier in both consumers, never before it: a block already carrying `dir="auto"` re-resolves its direction on every text node replaced under it. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite, `test/response-viewer-rtl.test.ts` the direction and the order.
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, except an absolute path inside a followed worktree outside `workingDir` (see Files panel in a worktree), which they serve as a second root. For every other path that is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against a base (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`.
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
**The relative base is the pane's directory.** The base is `relativeResolutionBase(session)`: the pane's live directory `SessionState.paneCwd` when known, else `workingDir`, which is fixed at create time and never follows a `cd`. `paneCwd` is `#{pane_current_path}` from the pane-exit watcher's existing batched `list-panes -a` read (`derivePaneCurrentPaths()`, single-pane sessions and absolute paths only; no new tmux call and no new SSE event: the server applies it on the `paneExitsUpdated` tick and broadcasts `session:updated` only on a change). tmux reports the cwd of the pane's FOREGROUND process-group leader, so a shell's `cd` moves it, while an agent CLI's tool subprocesses (Claude's Bash tool, codex's exec) do not: for those sessions it stays the CLI's own directory. ⚠️ `paneCwd` is a resolution BASE only, never a confinement root: `_isExternalPreviewPath`, `file-content`/`file-raw` and the log viewer's `tail -f` allowlist all still judge against `workingDir`, except that `_isExternalPreviewPath` and `file-content`/`file-raw` also admit an absolute path inside a followed outside worktree (`outsideWorktree` in session state), so a pane that `cd`s outside the workspace sends its relative links through the attachment routes like any other external path, and what can be opened is unchanged (an absolute path to the same file already opened). ⚠️ `Session.setPaneCwd()` uses the `paneExitApplies` scoping rule and fails closed the same way: a remote session's local pane runs the ssh client and a docker case's runs `docker exec`, so their pane directory is a host directory the agent was never in; direct-PTY and rediscovered sessions get none either, and the browser re-checks `remote`/`docker`. ⚠️ tmux reads the directory from `/proc/<pgrp>/cwd`, always the RESOLVED path, while `workingDir` keeps the spelling the case was created with (a symlinked `~/codeman-cases`, a case linked through a symlink, macOS `/tmp`), and `_isExternalPreviewPath` compares lexically, so `setPaneCwd()` writes a reading at or under the workspace's resolved path back in `workingDir`'s spelling. The resolution runs once per session in the background, bounded (`probePathKind()` first, then a `realpath()` raced against the same timeout, never on the watcher tick and never `realpathSync`); an `unknown` probe or a failed or slow `realpath()` leaves the reading untranslated and retries after `WORKSPACE_REALPATH_RETRY_MS`. Until it settles, only a reading lexically inside `workingDir` is taken, so the first tick cannot mis-route links. ⚠️ Never restored after a restart: written to `state.json` by `toState()` like the rest of the session state but never read back, and a directory change alone never persists. A failed or empty read keeps every last directory, and a blank `#{pane_current_path}` on a single-pane session keeps that session's (`derivePaneCurrentPaths()` carries it forward); an authoritative read that lists the session as split into several panes, or not at all, clears it, so a hand-split session falls back to `workingDir`. The server applies the manager's answer as given, `undefined` included, and `setPaneCwd()` reports a clear once. Trade-off: the base is the pane's directory at CLICK time, so a relative path printed before a `cd` resolves against the new directory. Tests: `test/pane-cwd-tracking.test.ts`.
Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `<strong>` label in a paragraph or list item, or one starting a later line of a paragraph (after a `<br>`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap.
@@ -768,35 +768,37 @@ 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, 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.
Matching semantics: a query containing `/` matches the path relative to the folder being walked (`workingDir`, or the worktree of a `root=live` or `root=live-any` listing; the returned `path`s stay relative to `workingDir`, except in a `root=live-any` listing of 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
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` on `GET /api/sessions/:id/files`): Claude Code's EnterWorktree moves the running process into `<repo>/.claude/worktrees/<name>`, while `Session.workingDir` is the session's identity and stays on the main checkout, so the panel kept listing a tree the agent had left. `paneCwd` does follow, in both directions, because tmux reads the process cwd (measured on 2.1.318). Hook payloads are the wrong signal: their `cwd` follows every `cd` in a Bash call too.
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude Code's EnterWorktree moves the running process into `<repo>/.claude/worktrees/<name>`, while `Session.workingDir` is the session's identity and stays on the main checkout, so the panel kept listing a tree the agent had left. `paneCwd` does follow, in both directions, because tmux reads the process cwd (measured on 2.1.318). Hook payloads are the wrong signal: their `cwd` follows every `cd` in a Bash call too.
When `paneCwd` changes, `refreshLiveWorktree()` (server.ts) runs `findLiveWorktree()`, which walks up from the pane and recognises a linked worktree by its `.git` FILE (`gitdir: .../worktrees/<id>`). It never runs git, since this fires from the pane watcher and a file read starts no process, config or filter. The walk stops at `workingDir`, at a repository or submodule of its own and at anything it cannot read; probes are bounded (#516), and a `.git` that is a symlink or a FIFO is refused before anything opens it. The answer is published as `SessionState.liveWorktree`. A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel.
⚠️ **That answer is lexical and goes stale.** It is judged only when the pane moves, and a symlink can take the place of the folder, or of a parent, afterwards without moving the pane, while `probePathKind()` and `readdir` both follow one. So `root=live` checks again on every request: `canWalkLiveWorktree()` (file-routes.ts) probes the folder, resolves it and `workingDir`, and the walk starts at the worktree only when that resolves strictly inside the resolved `workingDir`; otherwise it starts at `workingDir`, as it does for a worktree that has gone. The 1.49.0 release review reproduced the escape end to end on tmux 3.4: without this check a symlink swapped in after the lookup listed the names and sizes of a folder outside the workspace, though `file-content` and `file-raw` refused those paths. The root is the only place this can happen, because the walk does not follow a symlink below it (`Dirent.isDirectory()` is false for one). A swap between the check and the `readdir` is the same check-then-use race any path route accepts.
⚠️ **That answer is lexical and goes stale.** It is judged only when the pane moves, and a symlink can take the place of the folder, or of a parent, afterwards without moving the pane, while `probePathKind()` and `readdir` both follow one. So `root=live` and `root=live-any` check again on every request: `canWalkLiveWorktree()` (file-routes.ts) probes the folder, resolves it and `workingDir`, and the walk starts at the worktree only when that resolves strictly inside the resolved `workingDir`; otherwise it starts at `workingDir`, as it does for a worktree that has gone. The 1.49.0 release review reproduced the escape end to end on tmux 3.4: without this check a symlink swapped in after the lookup listed the names and sizes of a folder outside the workspace, though `file-content` and `file-raw` refused those paths. The root is the only place this can happen, because the walk does not follow a symlink below it (`Dirent.isDirectory()` is false for one). A swap between the check and the `readdir` is the same check-then-use race any path route accepts.
⚠️ **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.
⚠️ 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:
**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 is published as `SessionState.outsideWorktree` (carrying `outside: true`), never in `liveWorktree`, which stays inside-only on the wire. `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.
- Attachment workspace confinement (`attachmentConfineToWorkspace` / `CODEMAN_ATTACHMENT_CONFINE`) is off.
- It is not at, inside or around an attachment blocked tree (`attachmentBlockedPaths` / `CODEMAN_ATTACHMENT_BLOCKED_PATHS`, plus the defaults), and not a sensitive folder: the sensitive patterns describe file locations, so `outsideWorktreeOptions()` also probes a child of the root, which refuses a root such as `~/.ssh`. The routes judge both guard rules again on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. The Docker and multi-user rules are judged only when the pane moves.
- In multi-user mode, the owner's `folderAllowedForUser` rule admits it (an admin owner is not limited by 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`.
Every comparison uses real paths, except that a home folder or Docker workspace that cannot be resolved is compared as written, 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. ⚠️ Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it or when `isSensitivePath()` names the file, so `.env`, `.aws/credentials` and `users.json` are never served from it (`validateLocalSessionFilePath()`, used by `file-content`, the edit route, `file-raw`, `file-preview`, `file-thumbnail`, `/api/download` and `html-view`). `html-view` also runs the attachment guard for such a path and mints its capability as outside the workspace, so the serve route's per-request confinement re-check covers it. A relative path still resolves against `workingDir` alone, so `../feature/x` is refused. 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 worktree of a session started below the repository's top is 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.
⚠️ 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. A listing without `root=live-any` is the exception: it omits an outside worktree. While the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js).
Two surfaces do not move with it. The Git panel keeps working from `workingDir`, so it shows the main checkout's branch while the strip names the worktree. And `POST /api/sessions/:id/html-view` applies its dot-segment rule below `workingDir`, so while the session's folder is the main checkout a page under `.claude/worktrees/<name>` is refused and opens as source (a session started in the worktree renders it). Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`.
Two surfaces do not move with a worktree inside `workingDir`. The Git panel keeps working from `workingDir`, so it shows the main checkout's branch while the strip names the worktree. And `POST /api/sessions/:id/html-view` applies its dot-segment rule below `workingDir`, so while the session's folder is the main checkout a page under `.claude/worktrees/<name>` is refused and opens as source (a session started in the worktree renders it). For a worktree outside `workingDir` the Git panel also stays on `workingDir`, while `html-view` serves its pages as described above. Tests: `test/live-worktree.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`.
### Raw file bodies: streamed and range-aware
+15 -5
View File
@@ -343,13 +343,23 @@ resolve outside). The realpath‑before‑check ordering closes the validation
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
same repository OUTSIDE its folder (published as `outsideWorktree`). 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 folder still `realpath`s to the path recorded for it. The edit route
(`PUT /api/sessions/:id/file-content`) uses the same check, so a file in that worktree can be
written as well as read. 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.
home folder or above, a folder holding the working directory, or a folder at, inside or
around a Docker case workspace (`src/live-worktree.ts`). It also refuses a folder that is
itself a sensitive folder such as `.ssh`, and `validateLocalSessionFilePath()` never serves a
file from the worktree that `isSensitivePath()` blocklists. In multi-user mode a regular
owner's session also needs the owner's folder rule (their own space or the working directory);
an admin's does not. It follows nothing while attachment workspace confinement is on, and
nothing at, inside or around a blocked attachment tree. Codeman judges these rules when the pane
changes directory, and the routes judge confinement and the blocked trees again on every request,
so turning confinement on or blocking the folder stops serving it at once. The multi-user rule is
judged only when the pane changes directory. On every request the routes also check that the
folder still resolves to its recorded path. A relative path is still confined to the working directory alone.
| Route | Cap | Notes |
|-------|-----|-------|
+7 -2
View File
@@ -57,8 +57,13 @@ A worktree beside the repository (`git worktree add ../feature`, or a setup that
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.
session's own repository lists it. Codeman never follows one of these:
- your home folder or above it, or a folder that holds the session's folder;
- a folder at, inside or around a Docker case's workspace;
- in multi-user mode, unless the session's owner is an admin, a folder outside the owner's own space and the session's folder;
- any worktree beside the repository while the server confines attachments to the workspace;
- a folder at, inside or around a tree the server blocks for attachments, or a folder that is itself a credential folder such as `.ssh`.
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
+3 -1
View File
@@ -24,7 +24,7 @@ It renders what it can:
| Kind | Behaviour |
| ------------------------ | ------------------------------------------------------------------------- |
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. Persian, Arabic and Hebrew read right to left, each block following its own first letter, while code stays left to right. The MD pill in the header flips to source. |
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub; for a file in a worktree beside the workspace that the agent is in, they resolve from the top of that worktree). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. Persian, Arabic and Hebrew read right to left, each block following its own first letter, while code stays left to right. The MD pill in the header flips to source. |
| Images | Inline. |
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
@@ -117,6 +117,8 @@ another checkout. Those are served through the attachment routes rather than the
ones, so the same rules apply as to any other attachment: secret trees are blocked, the
extension allowlist decides what can be opened, and symlinks are resolved before either check.
One exception: while the agent works in a worktree of the session's repository beside it (see [Git Worktrees](Git-Worktrees)), a file in that worktree opens through the workspace routes like a workspace file, and you can edit it there. Two things differ from a workspace file: a text file there that you click in the terminal opens in the preview, not the live tail viewer, and secret files such as `.env` there are not served.
Outside the workspace the allowlist is images, video, audio, PDF, Office documents, and text
files, where "text" is the same list the viewer will let you edit: code, config, logs, csv,
markdown. The reasoning is that a session can already `cat` any of those, so the file suffix
+23 -1
View File
@@ -27,7 +27,7 @@
* @module config/attachment-guard
*/
import { sep } from 'node:path';
import { join, sep } from 'node:path';
import { isSensitivePath } from '../web/sensitive-path.js';
import { readJsonConfig, SETTINGS_PATH } from '../web/route-helpers.js';
@@ -131,3 +131,25 @@ export function isBlockedAttachmentPath(absPath: string, blockedTrees: readonly
if (isSensitivePath(absPath)) return true;
return blockedTrees.some((tree) => isUnderTree(absPath, tree));
}
/**
* Like `isBlockedAttachmentPath`, for a directory. The sensitive patterns describe file locations
* such as ~/.ssh/<key>, so a child probe is what refuses a folder like ~/.ssh itself.
*/
export function isBlockedAttachmentDirectory(dir: string, blockedTrees: readonly string[]): boolean {
return (
isBlockedAttachmentPath(dir, blockedTrees) ||
isBlockedAttachmentPath(join(dir, '__codeman_blocked_dir_probe__'), blockedTrees)
);
}
/**
* True when the attachment guard refuses `root` as the root of a worktree outside the session
* folder: workspace confinement is on, the folder is blocked or sensitive, or it surrounds a
* blocked tree. `root` must be absolute and symlink-resolved.
*/
export function isOutsideWorktreeRootRefused(root: string, guard: AttachmentGuardConfig): boolean {
if (guard.confineToWorkspace) return true;
if (isBlockedAttachmentDirectory(root, guard.blockedTrees)) return true;
return guard.blockedTrees.some((tree) => isUnderTree(tree, root));
}
+63 -44
View File
@@ -23,11 +23,17 @@
* 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.
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep
* judging every path against `workingDir`.
* For a worktree inside `workingDir` the 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 there is a confinement decision. `GET /api/sessions/:id/files` resolves the folder on
* every request and walks it only while it really lies inside `workingDir`
* (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep judging every path against
* `workingDir`.
*
* For an outside worktree {@link findOutsideWorktree} IS the confinement decision for the second
* root. Its vouching, home, Docker and `allowRoot` rules run only when the pane moves. The routes
* re-check on each request only that the folder still realpaths to the recorded path
* (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`).
*
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
* `unknown` answer stops the walk rather than being read as "no `.git` here".
@@ -63,12 +69,19 @@ export interface OutsideWorktreeOptions {
/** 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.
* The caller's last word on which worktree may be followed. The server passes its attachment
* guard rules (workspace confinement, blocked trees, sensitive folders) in every mode, plus the
* owner's folder rule (`folderAllowedForUser`) in multi-user mode, so a regular user's session
* cannot list another user's checkout. A rejection counts as a refusal.
*/
allowRoot?: (root: string) => boolean | Promise<boolean>;
}
/** The text after `gitdir:` in a `.git` file, as written (possibly relative). */
function rawGitDir(text: string): string | undefined {
return /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
}
/** More than any real `.git` file. It is read only to recognise the first line. */
const MAX_DOT_GIT_FILE_BYTES = 4096;
@@ -78,12 +91,33 @@ const MAX_DOT_GIT_FILE_BYTES = 4096;
* The path may be relative: `git worktree add --relative-paths` writes it that way.
*/
export function isLinkedWorktreeGitFile(text: string): boolean {
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
const gitDir = rawGitDir(text);
if (!gitDir) return false;
const segments = gitDir.split('/').filter(Boolean);
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
}
/**
* The text of a small file, opened so that nothing blocks or escapes. The probe a caller ran
* follows symlinks and the file can change after it, so the open itself refuses: O_NOFOLLOW a
* symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and hold a threadpool
* worker for good. Whatever it did open is then judged on the handle. Null for anything but a
* regular file it could read.
*/
async function readOpenedSmallFile(path: string): Promise<string | 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(() => {});
}
}
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
const dotGit = join(dir, '.git');
@@ -91,20 +125,9 @@ async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'
if (kind === 'absent') return 'none';
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
if (kind !== 'file') return 'stop';
let handle: fs.FileHandle | undefined;
try {
// The probe follows symlinks and the file can change after it, so the open itself refuses:
// O_NOFOLLOW a symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and
// hold a threadpool worker for good. Whatever it did open is then judged on the handle.
handle = await fs.open(dotGit, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return 'stop';
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
} catch {
return 'stop';
} finally {
await handle?.close().catch(() => {});
}
// `null` is a symlink, a FIFO, a non-regular file or an unreadable one: all stop the walk.
const text = await readOpenedSmallFile(dotGit);
return text !== null && isLinkedWorktreeGitFile(text) ? 'worktree' : 'stop';
}
/**
@@ -147,29 +170,14 @@ async function realPathBounded(p: string): Promise<string | 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.
*/
/** The text of a small regular file that a bounded probe found, via {@link readOpenedSmallFile}. */
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(() => {});
}
return (await probePathKind(path)) === 'file' ? readOpenedSmallFile(path) : null;
}
/** 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];
const raw = rawGitDir(text);
return raw ? resolve(dir, raw) : null;
}
@@ -206,7 +214,9 @@ async function sessionCommonDir(workingDir: string): Promise<string | null> {
* 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).
* - `allowRoot`, when given, has the last word (the server's attachment-guard and multi-user rules).
*
* Never rejects: an `allowRoot` that rejects refuses the worktree.
*
* 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
@@ -239,8 +249,9 @@ export async function findOutsideWorktree(
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;
// The main worktree: its `.git` folder is the session's common dir itself. The folder must
// really hold it: a `.git` symlink resolves elsewhere, so it fails this comparison.
if (join(real, '.git') !== commonDir) return undefined;
} else {
const text = await readSmallFile(join(dir, '.git'));
const gitDir = text && isLinkedWorktreeGitFile(text) ? gitDirOf(text, dir) : null;
@@ -265,6 +276,14 @@ export async function findOutsideWorktree(
if (isSameOrUnder(real, realWorkspace) || isSameOrUnder(realWorkspace, real)) return undefined;
}
}
if (opts.allowRoot && !(await opts.allowRoot(real))) return undefined;
if (opts.allowRoot) {
let allowed = false;
try {
allowed = await opts.allowRoot(real);
} catch {
// A callback that throws or rejects refuses the worktree.
}
if (!allowed) return undefined;
}
return { name: basename(real), path: real, outside: true };
}
+6 -2
View File
@@ -1639,7 +1639,9 @@ export class Session extends EventEmitter {
}
/**
* The linked git worktree that {@link paneCwd} is in, when there is one (`live-worktree.ts`).
* The git worktree that {@link paneCwd} is in, when there is one (`live-worktree.ts`): a linked
* worktree inside `workingDir`, or, for an outside answer, a linked worktree or the repository's
* main worktree (from a session started in a linked one).
* 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
@@ -2419,7 +2421,9 @@ export class Session extends EventEmitter {
status: this._status,
workingDir: this.workingDir,
paneCwd: this._paneCwd,
liveWorktree: this._liveWorktree,
// The wire keeps `liveWorktree` inside `workingDir` only; an outside answer rides its own field.
liveWorktree: this._liveWorktree?.outside ? undefined : this._liveWorktree,
outsideWorktree: this._liveWorktree?.outside ? this._liveWorktree : undefined,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
+20 -13
View File
@@ -934,24 +934,31 @@ export interface SessionState {
* from disk, so a restart starts blank. Absent for remote and docker sessions
* (their local pane runs the ssh / docker-exec client) and for direct-PTY or
* rediscovered sessions. NOT a confinement root: the file routes stay confined
* to `workingDir`, and a path resolved outside it goes through the attachment
* routes like any other out-of-workspace path.
* to `workingDir`, except for an absolute path inside an
* {@link outsideWorktree}. Any other path resolved outside `workingDir` goes
* through the attachment routes like any other out-of-workspace path.
*/
paneCwd?: string;
/**
* 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.
* 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 always a folder strictly below
* `workingDir`: a worktree elsewhere is reported in {@link outsideWorktree}, never here. 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.
*/
liveWorktree?: LiveWorktree;
/**
* The worktree of the session's repository outside `workingDir` that the pane is in NOW, when
* there is one: a linked worktree, or the repository's main worktree for a session started in a
* linked one. It carries `outside: true`, and its `path` is a real path that the repository
* itself vouched for. The file routes serve absolute paths inside it as a second root while it
* still resolves to that same path. Runtime-only, like {@link liveWorktree}; at most one of
* the two fields is set.
*/
outsideWorktree?: LiveWorktree;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
+34 -19
View File
@@ -3284,6 +3284,8 @@ Object.assign(CodemanApp.prototype, {
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
// The person asked for the session folder while the agent is in a worktree (#765).
sessionRootPinned: false,
// The worktree on screen when the pin was set: a pinned listing omits an outside worktree.
pinnedWorktree: null,
};
}
return this._fileBrowserState;
@@ -3307,6 +3309,7 @@ Object.assign(CodemanApp.prototype, {
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
state.pinnedWorktree = null;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3351,6 +3354,7 @@ Object.assign(CodemanApp.prototype, {
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
state.pinnedWorktree = null;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3464,49 +3468,58 @@ Object.assign(CodemanApp.prototype, {
_renderFileBrowserRoot(data) {
const strip = this.$?.('fileBrowserRoot');
if (!strip) return;
const worktree = data?.worktree;
const worktree =
data?.worktree ?? (this._fileBrowserState?.sessionRootPinned ? this._fileBrowserState.pinnedWorktree : null);
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
strip.hidden = true;
return;
}
const inWorktree = data.root === worktree.path;
const inWorktree = data?.root === worktree.path;
const label = this.$?.('fileBrowserRootLabel');
const name = this.$?.('fileBrowserRootName');
const button = this.$?.('fileBrowserRootSwitch');
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
if (name) name.textContent = inWorktree ? worktree.name : '';
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
strip.title = typeof data.root === 'string' ? data.root : '';
strip.title = typeof data?.root === 'string' ? data.root : '';
strip.hidden = false;
},
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
// a worktree, so the panel always asks unless the person chose the session folder.
_fileBrowserRootParam() {
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live-any';
},
toggleFileBrowserRoot() {
const state = this._ensureFileBrowserState();
state.sessionRootPinned = !state.sessionRootPinned;
const shown = this.fileBrowserData?.worktree;
state.pinnedWorktree =
state.sessionRootPinned && typeof shown?.path === 'string' && typeof shown?.name === 'string'
? { name: shown.name, path: shown.path }
: null;
return this._reloadFileBrowserKeepingQuery();
},
// Called on every session update of the active session. Compares with what the listing on
// screen reported rather than with the previous update, so a load already in flight when the
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
// pane moved is corrected by the next update, and an answer that matches ends it. A listing
// reports the session's worktree whichever folder it walked, except that a listing without
// root=live-any omits an outside worktree. While the person has pinned the session folder, the
// panel compares with the worktree it pinned away from, so this cannot loop.
_followFileBrowserWorktree(session) {
const state = this._fileBrowserState;
if (!state || state.ownerSessionId !== session.id) return;
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
const normal = state.normalState;
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
const live = session.liveWorktree?.path ?? null;
const listed = normal.data?.worktree?.path ?? null;
const live = (session.liveWorktree ?? session.outsideWorktree)?.path ?? null;
const listed = (normal.data?.worktree ?? (state.sessionRootPinned ? state.pinnedWorktree : null))?.path ?? null;
if (live === listed) return;
// The pin was about the worktree the agent has just left.
state.sessionRootPinned = false;
state.pinnedWorktree = null;
this._reloadFileBrowserKeepingQuery();
},
@@ -4247,10 +4260,12 @@ Object.assign(CodemanApp.prototype, {
/**
* Whether a path is absolute and provably OUTSIDE this session's workspace.
*
* `file-content` / `file-raw` resolve every path against `workingDir` and
* refuse anything that escapes it, so an absolute path elsewhere on the host
* (an agent's `/tmp` scratchpad capture, a screenshot, another checkout) can
* only ever 404 there — it has to go through the attachment routes instead.
* `file-content` / `file-raw` serve paths inside `workingDir` and, as a second
* root, an absolute path inside a followed worktree outside it
* (`outsideWorktree`). They refuse every other path that escapes
* `workingDir`, so an absolute path elsewhere on the host (an agent's `/tmp`
* scratchpad capture, a screenshot, another checkout) can only ever 404 there
* — it has to go through the attachment routes instead.
*
* A string compare is enough for ROUTING; the real containment decision stays
* server-side (realpath + guard) on whichever route the request lands on. An
@@ -4267,18 +4282,18 @@ Object.assign(CodemanApp.prototype, {
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;
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
* is in (`outsideWorktree`, #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;
const worktree = this.sessions.get(sessionId)?.outsideWorktree;
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);
@@ -5214,11 +5229,11 @@ 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;
// A root-relative ref (`/docs/x.png`) resolves from the workspace root, as on GitHub, or from
// the top of the worktree outside the workspace that the agent is in (#765).
const worktree = this.sessions.get(sessionId)?.outsideWorktree;
const rootPrefix =
worktree?.outside === true && this._isOutsideWorktreePath?.(filePath, sessionId)
worktree?.outside === true && this._isOutsideWorktreePath(filePath, sessionId)
? `${worktree.path.replace(/\/+$/, '')}/`
: '';
// Workspace ref = no scheme, not protocol-relative (//host), not a fragment.
+1 -1
View File
@@ -2372,7 +2372,7 @@ Object.assign(CodemanApp.prototype, {
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId) ||
self._isOutsideWorktreePath?.(target, sessionId)
self._isOutsideWorktreePath(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
+8 -3
View File
@@ -16,6 +16,7 @@ import type { WaitCapacityError } from './session-wait-registry.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
import { isSensitivePath } from './sensitive-path.js';
import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js';
@@ -125,16 +126,20 @@ export function outsideWorktreeRootFor(
/**
* {@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
* (the session's outside worktree, `outside: true`): 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.
* `workingDir`, which is served anyway. A file there that `isSensitivePath()` blocklists (`.env`,
* `.aws/credentials`, `users.json`, ...) is never served from the worktree root. 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);
const viaRoot = root ? validateSessionFilePath(root, filePath) : null;
if (viaRoot && !isSensitivePath(viaRoot.resolvedPath)) return viaRoot;
return validateSessionFilePath(scope.workingDir, filePath);
}
/**
+49 -19
View File
@@ -49,7 +49,13 @@ import {
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import {
isBlockedAttachmentDirectory,
isBlockedAttachmentPath,
isOutsideWorktreeRootRefused,
isUnderTree,
loadAttachmentGuardConfig,
} from '../../config/attachment-guard.js';
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
import {
CASES_DIR,
@@ -660,6 +666,19 @@ type FileTargetResolution =
message: string;
};
/**
* The scope with an outside worktree (#765) dropped when the attachment guard refuses its root
* right now. `findOutsideWorktree()` judged the guard when the pane moved, and an admin can turn
* on workspace confinement or block the folder afterwards, so every request judges it again. A
* scope without an outside worktree comes back unchanged and reads no settings.
*/
async function withGuardedOutsideWorktree<T extends { liveWorktree?: LiveWorktree }>(scope: T): Promise<T> {
const worktree = scope.liveWorktree;
if (!worktree?.outside) return scope;
if (!isOutsideWorktreeRootRefused(worktree.path, await loadAttachmentGuardConfig())) return scope;
return { ...scope, liveWorktree: undefined };
}
/**
* Resolve a request's `?path=` against the session's file scope.
*
@@ -669,7 +688,7 @@ type FileTargetResolution =
*/
async function resolveFileTarget(scope: SessionFileScope, filePath: string): Promise<FileTargetResolution> {
if (!scope.remote) {
const validated = validateLocalSessionFilePath(scope, filePath);
const validated = validateLocalSessionFilePath(await withGuardedOutsideWorktree(scope), filePath);
if (!validated) {
return {
ok: false,
@@ -889,7 +908,8 @@ async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Pr
}
/**
* Whether a `root=live` listing may start at a worktree OUTSIDE `workingDir` (`liveWorktree.outside`).
* Whether a `root=live-any` listing may start at a worktree OUTSIDE `workingDir` (`outside: true`,
* published as `outsideWorktree`).
* `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).
@@ -971,11 +991,7 @@ function resolveCandidateRootPaths(candidates: ReadonlyArray<{ path: string }>):
}
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
// The shared sensitive-path matcher describes file locations such as
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
// opened and used to enumerate those filenames.
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
return directory ? isBlockedAttachmentDirectory(path, blockedTrees) : isBlockedAttachmentPath(path, blockedTrees);
}
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
@@ -1735,19 +1751,27 @@ 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;
// A worktree OUTSIDE `workingDir` (the session's `outsideWorktree` on the wire) is a second
// root, and walking it is opt-in: only `root=live-any` does. `root=live` never walks it (every
// `path` stays relative to `workingDir`), so with an outside worktree it walks `workingDir`. The outside
// walk happens only while the folder still resolves to the real path recorded for it, and it
// lists 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 = (await withGuardedOutsideWorktree({ liveWorktree: session.liveWorktree })).liveWorktree;
const walksOutside =
root === 'live' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
root === 'live-any' && !!liveWorktree?.outside && (await canWalkOutsideWorktree(liveWorktree.path));
const walksInside =
root === 'live' &&
(root === 'live' || root === 'live-any') &&
!!liveWorktree &&
!liveWorktree.outside &&
(await canWalkLiveWorktree(workingDir, liveWorktree.path));
const walkRoot = liveWorktree && (walksOutside || walksInside) ? liveWorktree.path : workingDir;
// Only `root=live-any` reports an outside worktree: it is the client that opted in to it.
// A listing keeps reporting a worktree the guard refuses to walk, or the panel would reload on
// every session update.
const sessionWorktree = session.liveWorktree;
const reportedWorktree =
sessionWorktree && (!sessionWorktree.outside || root === 'live-any') ? sessionWorktree : undefined;
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.
@@ -1876,7 +1900,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
success: true,
data: {
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
...(reportedWorktree && { worktree: reportedWorktree }),
tree: [],
matches,
totalFiles,
@@ -1967,7 +1991,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
success: true,
data: {
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
...(reportedWorktree && { worktree: reportedWorktree }),
tree,
totalFiles,
totalDirectories,
@@ -2269,7 +2293,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
}
const validated = validateLocalSessionFilePath(session, body.path);
const validated = validateLocalSessionFilePath(
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
body.path
);
if (!validated) {
// Covers missing files, traversal, and symlink escapes alike — a write
// target that fails confinement is reported identically to a missing
@@ -3014,7 +3041,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const session = findSessionOrFail(ctx, sessionId, req);
const validated = validateLocalSessionFilePath(session, filePath);
const validated = validateLocalSessionFilePath(
await withGuardedOutsideWorktree({ workingDir: session.workingDir, liveWorktree: session.liveWorktree }),
filePath
);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
+18 -2
View File
@@ -33,7 +33,14 @@ 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, validateLocalSessionFilePath } from '../route-helpers.js';
import {
CASES_DIR,
findSessionOrFail,
ownerFor,
parseBody,
validateLocalSessionFilePath,
validateSessionFilePath,
} 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';
@@ -176,8 +183,14 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
// attachment guard, exactly as an external file preview is admitted.
let resolvedPath: string;
const inWorkspace = validateLocalSessionFilePath(session, requestedPath);
// A path the live-worktree root admitted (an outside worktree, a second served root) is
// inside that root but not inside `workingDir`: it still answers to the attachment guard.
let viaOutsideWorktree = false;
if (inWorkspace) {
resolvedPath = inWorkspace.resolvedPath;
if (!validateSessionFilePath(session.workingDir, requestedPath)) {
viaOutsideWorktree = true;
}
} else {
if (!isAbsolute(requestedPath)) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
@@ -189,6 +202,9 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
}
// One gate for every page outside `workingDir`: an external file and one in an outside worktree.
if (!inWorkspace || viaOutsideWorktree) {
const guard = await loadAttachmentGuardConfig();
if (guard.confineToWorkspace || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
@@ -233,7 +249,7 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
return;
}
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace);
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req), !inWorkspace || viaOutsideWorktree);
return {
success: true,
data: { url: `${HTML_VIEW_PREFIX}/${cap}/${encodeURIComponent(basename(resolvedPath))}` },
+23 -12
View File
@@ -81,6 +81,7 @@ import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { folderAllowedForUser, folderGrants } from '../folder-listing.js';
import { isOutsideWorktreeRootRefused, loadAttachmentGuardConfig } from '../config/attachment-guard.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';
@@ -3028,23 +3029,33 @@ export class WebServer extends EventEmitter {
/**
* 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).
*
* - none in a Docker case workspace (the list is read only when a candidate needs it, and an
* unreadable list follows nothing);
* - none while attachment workspace confinement is on (`attachmentConfineToWorkspace` /
* `CODEMAN_ATTACHMENT_CONFINE`), since an outside worktree is a second served root;
* - none at, inside or around an attachment blocked tree (`attachmentBlockedPaths` /
* `CODEMAN_ATTACHMENT_BLOCKED_PATHS`, plus the defaults), or at a sensitive path;
* - in multi-user mode, only inside the owner's space or `workingDir`.
*
* Codeman judges these rules when the pane moves. The file routes judge the two guard rules
* again on every request (`isOutsideWorktreeRootRefused()`), so a setting changed later applies
* at once. The Docker and multi-user rules are judged only when the pane moves. 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,
});
},
}),
allowRoot: async (root: string): Promise<boolean> => {
if (isOutsideWorktreeRootRefused(root, await loadAttachmentGuardConfig())) return false;
if (!isMultiUserMode()) return true;
const owner = session.owner ? await findUser(session.owner) : undefined;
return folderAllowedForUser(root, owner ? { username: owner.username, role: owner.role } : undefined, {
workingDir: session.workingDir,
});
},
};
}
+133 -1
View File
@@ -19,7 +19,7 @@ const WORKING_DIR = '/base/myapp';
const SIBLING = '/base/myapp-feature';
const outsideSession = () => ({
workingDir: WORKING_DIR,
liveWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
outsideWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
});
type Proto = {
@@ -178,3 +178,135 @@ describe('markdown in a worktree outside the session folder', () => {
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
});
});
describe('the Files panel following an agent in a worktree outside the session folder', () => {
const OUTSIDE = { name: 'myapp-feature', path: SIBLING };
const update = (extra: Record<string, unknown>) => ({ id: 'sess-1', ...extra });
const panelElement = () => {
const classes = new Set<string>();
return {
innerHTML: '',
textContent: '',
title: '',
hidden: true,
value: '',
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
contains: (name: string) => classes.has(name),
},
setAttribute: () => {},
querySelectorAll: () => [],
};
};
const loadPanel = () => {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (x: string) => String(x),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: undefined as unknown,
CodemanBase: { url: (path: string) => path },
});
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const ids = [
'fileBrowserPanel',
'fileBrowserTree',
'fileBrowserStatus',
'fileBrowserHiddenBtn',
'fileBrowserSearch',
'fileBrowserRoot',
'fileBrowserRootLabel',
'fileBrowserRootName',
'fileBrowserRootSwitch',
];
const elements = Object.fromEntries(ids.map((id) => [id, panelElement()]));
elements.fileBrowserPanel.classList.toggle('visible', true);
const app = new CodemanApp();
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
// A stand-in for the route. The agent is in the outside worktree when `outside` is set, and
// a listing then reports it only when the request asks for root=live-any.
const server = { outside: null as typeof OUTSIDE | null, requests: [] as string[] };
context.fetch = async (url: string) => {
server.requests.push(url);
const live = url.includes('root=live') && server.outside;
const data = {
root: live ? server.outside!.path : WORKING_DIR,
...(live && { worktree: server.outside }),
tree: [],
totalFiles: 0,
totalDirectories: 0,
truncated: false,
};
return { ok: true, json: async () => ({ success: true, data }) };
};
return { app, elements, server };
};
it('reloads when an update carries only outsideWorktree and the listing reports none', async () => {
const { app, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests).toHaveLength(1);
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
server.outside = OUTSIDE;
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
expect(reload).toHaveBeenCalledTimes(1);
});
it('does not reload when the listing already reports that outside worktree', async () => {
const { app, server } = loadPanel();
server.outside = OUTSIDE;
await app.loadFileBrowser('sess-1');
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
expect(reload).not.toHaveBeenCalled();
});
it('keeps the session folder pinned while the agent stays in the outside worktree', async () => {
const { app, elements, server } = loadPanel();
server.outside = OUTSIDE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
expect(app._fileBrowserState.normalState.data.worktree).toBeUndefined();
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
expect(reload).not.toHaveBeenCalled();
expect(app._fileBrowserState.sessionRootPinned).toBe(true);
expect(elements.fileBrowserRoot.hidden).toBe(false);
expect(elements.fileBrowserRootLabel.textContent).toBe('Session folder');
expect(elements.fileBrowserRootSwitch.textContent).toBe('Back to worktree');
});
it('drops the pin and reloads once the agent leaves the outside worktree', async () => {
const { app, server } = loadPanel();
server.outside = OUTSIDE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
server.outside = null;
app._followFileBrowserWorktree(update({}));
expect(reload).toHaveBeenCalledTimes(1);
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
expect(app._fileBrowserState.pinnedWorktree).toBeNull();
});
});
+24 -16
View File
@@ -387,7 +387,9 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(1);
expect(pending).toHaveLength(1);
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live');
expect(pending[0].url).toBe(
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live-any'
);
});
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
@@ -399,7 +401,9 @@ describe('File Viewer server search', () => {
app.activeSessionId = 'session/B';
app.filterFileBrowser('after-session');
await vi.advanceTimersByTimeAsync(250);
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live');
expect(pending[0].url).toBe(
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live-any'
);
pending[0].reply.resolve(response(successfulData()));
await vi.advanceTimersByTimeAsync(0);
@@ -450,7 +454,7 @@ describe('File Viewer server search', () => {
expect(pending).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live-any',
]);
});
@@ -659,7 +663,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live`)).toBe(true);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live-any`)).toBe(true);
});
it('rejects 257 trimmed characters without a request', async () => {
@@ -786,7 +790,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any');
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
await toggle;
@@ -794,7 +798,7 @@ describe('File Viewer server search', () => {
expect(app._fileBrowserState.view).toBe('search-pending');
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
await vi.advanceTimersByTimeAsync(250);
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live');
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live-any');
pending[2].reply.resolve(
response(
successfulData({
@@ -877,8 +881,8 @@ describe('File Viewer server search', () => {
const firstToggle = app.toggleFileBrowserHidden();
const secondToggle = app.toggleFileBrowserHidden();
expect(pending.slice(1).map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
]);
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
@@ -891,7 +895,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(4);
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live');
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live-any');
expect(app.fileBrowserShowHidden).toBe(false);
});
@@ -1674,7 +1678,9 @@ describe('File Viewer server search', () => {
expect(immediateState.activeSessionId).toBe('session/B');
expect(immediateState.ownerSessionId).toBe('session/B');
expect(immediateState.resizeCalls).toBe(1);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
expect(immediateState.fileUrls).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
]);
expect(filePending).toHaveLength(1);
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
@@ -1755,7 +1761,9 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live']);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
]);
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
@@ -1797,8 +1805,8 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live-any',
]);
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
});
@@ -1815,9 +1823,9 @@ describe('File Viewer server search', () => {
expect(secondA).not.toBe(firstA);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
]);
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
+1
View File
@@ -244,6 +244,7 @@ function loadApp() {
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' }));
app.showToast = vi.fn();
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
app._isOutsideWorktreePath = () => false;
return app;
}
+2
View File
@@ -146,6 +146,8 @@ function loadApp(prefs: Record<string, string> = {}) {
app.$ = (id: string) => document.getElementById(id);
app._resetFilePreviewEdit = () => {};
app._isExternalPreviewPath = () => false;
app._isOutsideWorktreePath = () => false;
app.sessions = new Map();
app.formatFileSize = (n: number) => `${n} B`;
app.showToast = vi.fn();
app.filePreviewContent = '';
+43 -6
View File
@@ -8,9 +8,9 @@
* 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 { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, relative as posixRelative } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { findLiveWorktree, findOutsideWorktree } from '../src/live-worktree.js';
@@ -55,15 +55,31 @@ describe('findLiveWorktree outside the session folder', () => {
expect(await findLiveWorktree(repo, sibling)).toBeUndefined();
});
it('follows a worktree made with --relative-paths', async () => {
it('follows a worktree whose gitdir links are relative paths, whatever the git version', async () => {
// Hand-written, since git older than 2.48 cannot make this layout itself.
const relative = join(base, 'myapp-relative');
const entry = join(repo, '.git', 'worktrees', 'myapp-relative');
mkdirSync(relative);
mkdirSync(entry, { recursive: true });
writeFileSync(join(entry, 'gitdir'), `${posixRelative(entry, join(relative, '.git'))}\n`);
writeFileSync(join(relative, '.git'), `gitdir: ${posixRelative(relative, entry)}\n`);
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
name: 'myapp-relative',
path: relative,
outside: true,
});
});
it('follows a worktree made with --relative-paths', async (ctx) => {
const relative = join(base, 'myapp-relative-git');
try {
git(repo, 'worktree', 'add', '-q', '--relative-paths', relative);
} catch {
return; // git older than 2.48 has no --relative-paths
ctx.skip(); // git older than 2.48 has no --relative-paths
return;
}
expect(await findLiveWorktree(repo, relative, { home })).toEqual({
name: 'myapp-relative',
name: 'myapp-relative-git',
path: relative,
outside: true,
});
@@ -119,8 +135,12 @@ describe('findLiveWorktree outside the session folder', () => {
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();
// The symlink leads to a copy of the original `gitdir` file, whose text is `<sibling>/.git`,
// so only the refusal of a symlink can stop it: followed, it would name the sibling back.
const copy = join(base, 'gitdir-copy');
writeFileSync(copy, readFileSync(join(entry, 'gitdir')));
rmSync(join(entry, 'gitdir'));
symlinkSync(join(sibling, '.git'), join(entry, 'gitdir'));
symlinkSync(copy, join(entry, 'gitdir'));
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
});
@@ -133,6 +153,16 @@ describe('findLiveWorktree outside the session folder', () => {
expect(await findLiveWorktree(repo, sibling, { home })).toBeUndefined();
});
it('refuses a folder whose .git is a symlink to the repository’s .git folder', async () => {
const fake = join(base, 'fake');
mkdirSync(fake);
symlinkSync(join(repo, '.git'), join(fake, '.git'));
// The session is in the linked worktree, so the main-worktree branch is the one exercised.
expect(await findLiveWorktree(sibling, fake, { home })).toBeUndefined();
// The real main worktree is still followed from the same session.
expect(await findLiveWorktree(sibling, repo, { home })).toEqual({ name: 'myapp', path: repo, outside: true });
});
it('follows nothing from a session started in a subfolder of its repository', async () => {
const sub = join(repo, 'sub');
mkdirSync(sub);
@@ -201,6 +231,13 @@ describe('findLiveWorktree outside the session folder', () => {
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: async () => true })).toEqual(expected());
});
it('treats an allowRoot that rejects as a refusal', async () => {
const reject = async () => {
throw new Error('settings unreadable');
};
expect(await findLiveWorktree(repo, sibling, { home, allowRoot: reject })).toBeUndefined();
});
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();
+111 -11
View File
@@ -32,11 +32,13 @@
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';
import { dirname, join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Session, rebasePath } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import { dataPath } from '../src/config/instance.js';
import { createUser } from '../src/user-store.js';
import type { SessionDocker, SessionRemote } from '../src/types.js';
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js';
@@ -315,9 +317,12 @@ describe('a pane read reaching the session record', () => {
await vi.waitFor(() => expect(session.liveWorktree).toEqual({ name: 'probe', path: worktree }));
// What the browser receives, and what the file routes read: never the confinement root.
const state = (
web as unknown as { getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown } }
web as unknown as {
getSessionStateWithRespawn: (s: Session) => { liveWorktree?: unknown; outsideWorktree?: unknown };
}
).getSessionStateWithRespawn(session);
expect(state.liveWorktree).toEqual({ name: 'probe', path: worktree });
expect(state.outsideWorktree).toBeUndefined();
expect(session.toState().workingDir).toBe(repo);
const broadcasts = broadcast.mock.calls.length;
@@ -391,29 +396,116 @@ describe('a pane read reaching the session record', () => {
expect(session.liveWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
expect(session.toState().workingDir).toBe(repo);
// On the wire an outside answer rides `outsideWorktree`; `liveWorktree` stays inside-only.
const state = session.toState();
expect(state.outsideWorktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
expect(state.liveWorktree).toBeUndefined();
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 () => {
/**
* A session in `repo` whose pane sits in the sibling worktree, ticked until the lookup has
* answered. Returns the answer, so a test can tell "followed" from "refused".
*/
const paneInSibling = async (sessionExtra: Record<string, unknown> = {}) => {
const { mux, sessions, web } = build();
const session = localMuxSession({ workingDir: repo });
const session = localMuxSession({ workingDir: repo, ...sessionExtra });
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;
mux.getPaneCurrentPath = () => sibling;
const lookup = vi.spyOn(session, 'setLiveWorktree');
// A reading outside `workingDir` waits until the workspace's real path is known, so tick
// until a lookup has run.
await vi.waitFor(() => {
mux.emit('paneExitsUpdated');
expect(lookup).toHaveBeenCalled();
});
expect(session.paneCwd).toBe(plain);
expect(session.liveWorktree).toBeUndefined();
// The lookup answers asynchronously; let the answer land before it is read.
await new Promise((resolve) => setTimeout(resolve, 50));
return session.liveWorktree;
};
/** Sets env vars for one test and restores them afterwards. */
const withEnv = async (env: Record<string, string>, run: () => Promise<void>): Promise<void> => {
const before = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
Object.assign(process.env, env);
try {
await run();
} finally {
for (const [key, value] of Object.entries(before)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
};
it('follows nothing while attachment workspace confinement is on', async () => {
await withEnv({ CODEMAN_ATTACHMENT_CONFINE: '1' }, async () => {
expect(await paneInSibling()).toBeUndefined();
});
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
it('follows nothing at a worktree that is itself a sensitive folder such as .ssh', async () => {
const keys = join(base, 'keys');
mkdirSync(keys);
const ssh = join(keys, '.ssh');
git(repo, 'worktree', 'add', '-q', '-b', 'keys-branch', ssh);
sibling = ssh;
expect(await paneInSibling()).toBeUndefined();
});
it('follows nothing at, inside or around an attachment blocked tree', async () => {
// Around the worktree: its parent folder is blocked.
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: base }, async () => {
expect(await paneInSibling()).toBeUndefined();
});
// Inside the worktree: a blocked tree below its root.
mkdirSync(join(sibling, 'private'));
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(sibling, 'private') }, async () => {
expect(await paneInSibling()).toBeUndefined();
});
// A blocked tree elsewhere changes nothing.
await withEnv({ CODEMAN_ATTACHMENT_BLOCKED_PATHS: join(base, 'elsewhere') }, async () => {
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
});
it('follows nothing in a Docker case workspace, around or inside it', async () => {
const casesFile = dataPath('docker-cases.json');
mkdirSync(dirname(casesFile), { recursive: true });
try {
for (const hostWorkspacePath of [sibling, base, join(sibling, 'src')]) {
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath }]));
expect(await paneInSibling()).toBeUndefined();
}
writeFileSync(casesFile, JSON.stringify([{ hostWorkspacePath: join(base, 'elsewhere') }]));
expect(await paneInSibling()).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
} finally {
rmSync(casesFile, { force: true });
}
});
it('applies the owner folder rule in multi-user mode', async () => {
const spaces = join(base, 'spaces');
mkdirSync(spaces);
await createUser({ username: 'pane-admin', role: 'admin', password: 'correct horse battery' });
await createUser({ username: 'pane-user', role: 'user', password: 'correct horse battery' });
await withEnv({ CODEMAN_MULTIUSER: '1', CODEMAN_USER_SPACES_DIR: spaces }, async () => {
// A regular user's space does not hold the sibling, and neither does the session folder.
expect(await paneInSibling({ owner: 'pane-user' })).toBeUndefined();
// An admin may follow any worktree the repository lists.
expect(await paneInSibling({ owner: 'pane-admin' })).toEqual({
name: 'myapp-feature',
path: sibling,
outside: true,
});
});
});
});
@@ -620,10 +712,14 @@ describe('confinement is unchanged by the live cwd', () => {
__proto: {
_resolveLinkedFilePath: (p: string, id: string) => string;
_isExternalPreviewPath: (p: string, id: string) => boolean;
_isOutsideWorktreePath: (p: string, id: string) => boolean;
};
}
).__proto;
const app = { sessions: new Map([['s1', session]]) };
const app = {
sessions: new Map([['s1', session]]),
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
};
return {
target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'),
external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'),
@@ -668,10 +764,14 @@ describe('a workspace reached through a symlink', () => {
__proto: {
_resolveLinkedFilePath: (p: string, id: string) => string;
_isExternalPreviewPath: (p: string, id: string) => boolean;
_isOutsideWorktreePath: (p: string, id: string) => boolean;
};
}
).__proto;
const app = { sessions: new Map([['s1', session]]) };
const app = {
sessions: new Map([['s1', session]]),
_isOutsideWorktreePath: proto._isOutsideWorktreePath,
};
return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1');
};
@@ -81,6 +81,21 @@ describe('Files panel listing of a live worktree', () => {
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
});
it('lists an inside worktree for root=live-any, the root the Files panel sends', async () => {
const data = await list('&root=live-any');
expect(data.root).toBe(worktree);
expect(data.worktree).toEqual({ name: 'probe', path: worktree });
expect(names(data.tree)).toEqual(['src']);
expect(data.tree[0].path).toBe(`${PREFIX}/src`);
expect(data.tree[0].children[0].path).toBe(`${PREFIX}/src/worktree-only.ts`);
});
it('walks the session folder for root=live-any when a symlink replaced the worktree', async () => {
rmSync(worktree, { recursive: true });
symlinkSync(makeOutsideFolder(), worktree);
expectSessionFolderListing(await list('&root=live-any&showHidden=true'));
});
it('does not need hidden files on, though the worktree lives under a dot folder', async () => {
const data = await list('&root=live&showHidden=false');
expect(data.totalFiles).toBe(1);
@@ -1,9 +1,9 @@
/**
* @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
* A session's outside worktree is a second root: `GET .../files?root=live-any` lists it with
* ABSOLUTE paths (`root=live` keeps walking `workingDir`), 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.
*/
@@ -11,10 +11,18 @@ 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 { dirname } from 'node:path';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
// The converter would shell out to pdftoppm/LibreOffice; the route's own path check is under test.
vi.mock('../../src/document-thumbnailer.js', () => ({
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
}));
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
@@ -55,11 +63,12 @@ describe('file routes for a worktree outside the session folder', () => {
afterEach(async () => {
await harness.app.close();
rmSync(base, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
describe('GET .../files', () => {
it('lists the worktree with absolute paths', async () => {
const data = await list('&root=live');
const data = await list('&root=live-any');
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'));
@@ -67,20 +76,29 @@ describe('file routes for a worktree outside the session folder', () => {
});
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*')}`);
const data = await list(`&root=live-any&q=${encodeURIComponent('src/feature*')}`);
expect(data.matches.map((m: { path: string }) => m.path)).toEqual([join(sibling, 'src', 'feature-only.ts')]);
});
it('keeps walking the session folder, with relative paths, for root=live', async () => {
const data = await list('&root=live');
expect(data.root).toBe(repo);
expect(data.worktree).toBeUndefined();
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
expect(JSON.stringify(data.tree)).not.toContain('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.worktree).toBeUndefined();
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');
const data = await list('&root=live-any');
expect(data.root).toBe(repo);
expect(JSON.stringify(data.tree)).not.toContain('key.txt');
});
@@ -93,6 +111,32 @@ describe('file routes for a worktree outside the session folder', () => {
expect(JSON.stringify(res.json())).toContain('feature');
});
it('refuses a sensitive file in the worktree on file-content and file-raw, and serves its neighbours', async () => {
writeFileSync(join(sibling, '.env'), 'TOKEN=abc');
mkdirSync(join(sibling, '.aws'));
writeFileSync(join(sibling, '.aws', 'credentials'), 'aws_secret=abc');
writeFileSync(join(sibling, 'plain.ts'), 'plain');
for (const blocked of [join(sibling, '.env'), join(sibling, '.aws', 'credentials')]) {
const content = await readContent(blocked);
expect(content.json().success).toBe(false);
expect(content.body).not.toContain('abc');
const raw = await harness.app.inject({
method: 'GET',
url: sessionUrl(`file-raw?path=${encodeURIComponent(blocked)}`),
});
expect(raw.statusCode).not.toBe(200);
expect(raw.body).not.toContain('abc');
}
const plain = await readContent(join(sibling, 'plain.ts'));
expect(plain.json().success).toBe(true);
const plainRaw = await harness.app.inject({
method: 'GET',
url: sessionUrl(`file-raw?path=${encodeURIComponent(join(sibling, 'plain.ts'))}`),
});
expect(plainRaw.statusCode).toBe(200);
expect(plainRaw.body).toBe('plain');
});
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');
@@ -168,4 +212,88 @@ describe('file routes for a worktree outside the session folder', () => {
expect(readFileSync(join(secret, 'key.txt'), 'utf8')).toBe('secret');
});
});
describe('file-preview and file-thumbnail', () => {
const preview = (path: string) =>
harness.app.inject({ method: 'GET', url: sessionUrl(`file-preview?path=${encodeURIComponent(path)}`) });
const thumbnail = (path: string) =>
harness.app.inject({ method: 'GET', url: sessionUrl(`file-thumbnail?path=${encodeURIComponent(path)}`) });
beforeEach(() => {
vi.mocked(generateFirstPageThumbnail).mockClear();
writeFileSync(join(sibling, 'doc.pdf'), '%PDF-1.4 fake');
writeFileSync(join(sibling, 'pic.png'), 'png');
});
it('answers file-preview for an absolute path inside the worktree', async () => {
const res = await preview(join(sibling, 'doc.pdf'));
expect(res.statusCode).toBeGreaterThanOrEqual(300);
expect(res.statusCode).toBeLessThan(400);
expect(res.headers.location).toContain('/file-raw?path=');
});
it('serves file-thumbnail for an absolute path inside the worktree', async () => {
const res = await thumbnail(join(sibling, 'pic.png'));
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('image/png');
expect(generateFirstPageThumbnail).toHaveBeenCalled();
});
it('refuses both once a symlink has taken the worktree folder’s place', async () => {
writeFileSync(join(secret, 'doc.pdf'), '%PDF-1.4 fake');
writeFileSync(join(secret, 'pic.png'), 'png');
renameSync(sibling, `${sibling}-moved`);
symlinkSync(secret, sibling);
expect((await preview(join(sibling, 'doc.pdf'))).statusCode).toBe(404);
expect((await thumbnail(join(sibling, 'pic.png'))).statusCode).toBe(404);
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
});
});
describe('the attachment guard, judged again on every request', () => {
const target = () => join(sibling, 'src', 'feature-only.ts');
const writeSettings = (settings: Record<string, unknown>) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
};
const get = (route: string) =>
harness.app.inject({ method: 'GET', url: sessionUrl(`${route}?path=${encodeURIComponent(target())}`) });
it('serves the worktree while no guard rule applies', async () => {
writeSettings({});
expect((await get('file-raw')).statusCode).toBe(200);
});
it.each([
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
['the worktree’s parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
])('stops serving it once %s', async (_label, settings) => {
writeSettings(settings());
const content = await get('file-content');
expect(content.json().success).toBe(false);
expect(content.body).not.toContain('feature');
expect((await get('file-raw')).statusCode).toBe(404);
const before = readFileSync(target(), 'utf8');
const put = await harness.app.inject({
method: 'PUT',
url: sessionUrl('file-content'),
payload: { path: target(), content: 'changed', baseHash: sha256(before) },
});
expect(put.statusCode).toBe(404);
expect(readFileSync(target(), 'utf8')).toBe(before);
const download = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(target())}`,
});
expect(download.statusCode).toBe(404);
// The listing walks the session folder, and keeps reporting the worktree so the panel settles.
const data = await list('&root=live-any');
expect(data.root).toBe(repo);
expect(data.tree[0].children[0].path).toBe('src/main-only.ts');
expect(data.worktree).toEqual({ name: 'myapp-feature', path: sibling, outside: true });
});
});
});
+61
View File
@@ -34,6 +34,9 @@ describe('html-view routes', () => {
writeFileSync(join(root, 'outside.txt'), 'outside');
symlinkSync(join(root, 'outside.txt'), join(workspace, 'escape.txt'));
mkdirSync(join(root, 'sibling-feature'));
writeFileSync(join(root, 'sibling-feature', 'report.html'), '<p>feature</p>');
harness = await createRouteTestHarness(registerHtmlViewRoutes);
sid = harness.ctx._sessionId as string;
(harness.ctx.sessions.get(sid) as { workingDir: string }).workingDir = workspace;
@@ -270,6 +273,64 @@ describe('html-view routes', () => {
});
});
describe('in a worktree outside the workspace (#765 follow-up)', () => {
let sibling: string;
const saved = {
confine: process.env.CODEMAN_ATTACHMENT_CONFINE,
blocked: process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS,
};
beforeEach(() => {
sibling = realpathSync(join(root, 'sibling-feature'));
Object.assign(harness.ctx.sessions.get(sid) as object, {
liveWorktree: { name: 'sibling-feature', path: sibling, outside: true },
});
});
afterEach(() => {
for (const [key, value] of [
['CODEMAN_ATTACHMENT_CONFINE', saved.confine],
['CODEMAN_ATTACHMENT_BLOCKED_PATHS', saved.blocked],
] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it('renders a page from the worktree, minted as outside the workspace', async () => {
const res = await open(join(sibling, 'report.html'));
expect(res.statusCode).toBe(200);
const url = JSON.parse(res.body).data.url as string;
const page = await harness.app.inject({ method: 'GET', url });
expect(page.statusCode).toBe(200);
expect(page.body).toBe('<p>feature</p>');
// Minted as outside the workspace, so the serve route's per-request confinement check covers it.
expect(htmlViewCapabilities.resolve(capabilityFromHtmlViewPath(url) as string)?.outsideWorkspace).toBe(true);
});
it('answers to the attachment guard: confinement on, or a blocked tree', async () => {
const res = await open(join(sibling, 'report.html'));
const url = JSON.parse(res.body).data.url as string;
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403);
// A page minted before confinement was switched on stops serving too.
expect((await harness.app.inject({ method: 'GET', url })).statusCode).toBe(404);
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = sibling;
expect((await open(join(sibling, 'report.html'))).statusCode).toBe(403);
});
it('leaves a path outside both roots as it was', async () => {
mkdirSync(join(root, 'reports'));
writeFileSync(join(root, 'reports', 'report.html'), '<p>report</p>');
expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(200);
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
expect((await open(join(root, 'reports', 'report.html'))).statusCode).toBe(403);
});
});
it('refuses an HTML file inside a hidden directory, in or out of the workspace', async () => {
mkdirSync(join(workspace, '.cache'));
writeFileSync(join(workspace, '.cache', 'r.html'), '<p>r</p>');
+1
View File
@@ -251,6 +251,7 @@ describe('terminal link provider with cell-wrapped paths', () => {
_openTerminalUrl: proto._openTerminalUrl,
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
_isExternalPreviewPath: () => false,
_isOutsideWorktreePath: () => false,
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
};
app.registerFilePathLinkProvider.call(app);
@@ -83,6 +83,7 @@ function makeApp() {
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = () => false;
app._isOutsideWorktreePath = () => false;
return app;
}
+2
View File
@@ -964,6 +964,7 @@ describe('terminal link tap', () => {
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = () => false;
app._isOutsideWorktreePath = () => false;
windowRef.open = vi.fn();
app.registerFilePathLinkProvider();
return { app, windowRef, sent };
@@ -1421,6 +1422,7 @@ describe('terminal wrapped-line handling', () => {
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = () => false;
app._isOutsideWorktreePath = () => false;
windowRef.open = vi.fn();
app.registerFilePathLinkProvider();
return { app, windowRef, select: app.terminal.select as ReturnType<typeof vi.fn> };