Merge pull request #604: feat(preview): open ~/, relative and hard-wrapped paths; folder grid

This commit is contained in:
Codeman maintainer committed 2026-10-13 18:08:12 +02:00
commit 90034d07d7
25 files changed
+2949 -87

No files matched your search

+2 -2
View File
@@ -301,7 +301,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Prompt uploads** (`POST /api/sessions/:id/paste-image`; client wiring in `image-input.js` and `keyboard-accessory.js`): a pasted file 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. 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. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history) **Prompt uploads** (`POST /api/sessions/:id/paste-image`; client wiring in `image-input.js` and `keyboard-accessory.js`): a pasted file 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. 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. → [architecture-invariants#buffers-uploads-and-terminal-history](docs/architecture-invariants.md#buffers-uploads-and-terminal-history)
**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`). 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. ⚠️ `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`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--responseLine 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 adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` 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. ⚠️ `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`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entryLine truncated
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. 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`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. 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`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
@@ -397,7 +397,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog) **SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) **Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu + Tiles count menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, above the response viewer it opens from, below the file preview its tiles open), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
+6 -2
View File
@@ -369,7 +369,7 @@ The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings →
A file path an agent prints is a link on both surfaces it can appear on, and clicking it opens the file-preview overlay. Three things make that work and each has bitten: A file path an agent prints is a link on both surfaces it can appear on, and clicking it opens the file-preview overlay. Three things make that work and each has bitten:
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both build a fresh instance from it. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`. **One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
**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. `test/response-viewer-file-links.test.ts` pins both properties. **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. `test/response-viewer-file-links.test.ts` pins both properties.
@@ -377,6 +377,10 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
⚠️ **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); nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. ⚠️ **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); 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 returns the rooted matches first, 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 the session's `workingDir` (`.`/`..` 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`.
**Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`.
**Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so. **Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so.
**Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes. **Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes.
@@ -1065,7 +1069,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
### Z-index layers ### Z-index layers
**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it). **Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), Tiles count menu (1000, `.tile-count-menu`), the closing grid's still copy (`.tile-grid-ghosts`, z-index 3 inside `.main`, over the single view coming back; hidden under `.main.webview-active`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), folder grid (5090, `#folderGridOverlay` — above the response viewer a folder link opens it from, below the file preview its tiles open, so a tile's preview and its ←/→ paging sit on top of the grid), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
## Security layers ## Security layers
+2
View File
@@ -335,6 +335,8 @@ and it follows the same rule as the launch path: every ssh command line comes fr
| `GET /api/sessions/:id/file-thumbnail` | `400` for remote files | | `GET /api/sessions/:id/file-thumbnail` | `400` for remote files |
| `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there | | `POST /api/sessions/:id/attachments` | Registers an absolute path that lives on the **remote** host (a clicked link pointing outside the case directory) by probing it there |
| `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` | | `GET /api/sessions/:id/attachments/:attachmentId/raw` | Streams the registered remote file over ssh, same 200/206/416 contract; `preview` (office) and `thumbnail` answer `400` |
| `POST /api/sessions/:id/folders` | Lists a folder (absolute or `~/`) on the **remote** host in one ssh round trip (`remoteListFolder`): resolved with `cd -P`, missing → `404`, a file → `400 Not a folder` |
| `GET /api/sessions/:id/folders/:folderId/files/:name/raw` | Streams one file of a granted remote folder over ssh after the same re-probe + guard as an attachment; `preview` (office) and `thumbnail` answer `400` |
| `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry | | `GET /api/sessions/:id/attachments/:attachmentId`, `GET …/attachments` (history) | Size/mtime/existence resolved over ssh, so a remote entry is not reported `missing`; the history list resolves EVERY entry in one batched probe, never one connection per entry |
⚠️ The attachment route is the one a clicked path takes when it is **outside** the case ⚠️ The attachment route is the one a clicked path takes when it is **outside** the case
+17
View File
@@ -374,6 +374,23 @@ Two registration paths, with **different trust**:
file outside the session's own workspace. Cross‑workspace attach must go through file outside the session's own workspace. Cross‑workspace attach must go through
the explicit POST path above. the explicit POST path above.
**Folder grid grants** (`POST /api/sessions/:id/folders`, `src/folder-listing.ts`)
follow the explicit-POST trust level: a deliberate, Origin‑guarded request, allowed
cross‑workspace subject to the same guard (blocklist on the folder AND on every
listed file, `confineToWorkspace` when set, hidden entries and unsupported types
left out). The grant is an id for a resolved folder; files are served by
`folderId` + a bare file name only (no `/`, `..` or NUL, supported type), and each
request re‑runs `resolveServableAttachmentPath` on the joined path, so a file that
was swapped for a symlink to a blocked target after listing is still refused.
The file a symlink resolves to is held to the attachment gate as well (supported
extension, regular file), so a supported-looking name cannot serve an arbitrary
file, and a FIFO or device node is never opened.
In multi‑user mode a regular user is held to the filesystem path picker's rule
(`folderAllowedForUser`): the folder, and again each served file after symlink
resolution, must sit inside their own `userSpacePath` or the session's
`workingDir`, else 403. User spaces live inside `homedir()`, so without it one user
could list and read another's space.
### SSE log‑tail route — intentional extra read roots ### SSE log‑tail route — intentional extra read roots
The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log The live file‑tail SSE route (`FileStreamManager`, used to stream a growing log
+15 -3
View File
@@ -9,7 +9,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { realpathSync } from 'node:fs'; import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { basename, extname, isAbsolute } from 'node:path'; import { homedir } from 'node:os';
import { basename, extname, isAbsolute, join } from 'node:path';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js'; import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js'; import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js'; import { validateSessionFilePath } from './web/route-helpers.js';
@@ -148,6 +149,11 @@ class AttachmentRegistry {
export const attachmentRegistry = new AttachmentRegistry(); export const attachmentRegistry = new AttachmentRegistry();
/** Every extension {@link isSupportedAttachmentExtension} accepts (lowercase, no dot). */
export function supportedAttachmentExtensions(): string[] {
return [...SUPPORTED_ATTACHMENT_EXTENSIONS];
}
export function isSupportedAttachmentExtension(extension: string): boolean { export function isSupportedAttachmentExtension(extension: string): boolean {
return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, '')); return SUPPORTED_ATTACHMENT_EXTENSIONS.has(extension.toLowerCase().replace(/^\./, ''));
} }
@@ -330,13 +336,19 @@ export async function registerExternalAttachment(
requestedPath: string, requestedPath: string,
options: RegisterExternalAttachmentOptions = {} options: RegisterExternalAttachmentOptions = {}
): Promise<AttachmentRegistrationResult> { ): Promise<AttachmentRegistrationResult> {
if (!requestedPath || !isAbsolute(requestedPath)) { // `~/…` is the home of the session's HOST: expanded here for a local session,
// and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`),
// whose home this server does not know. A clicked terminal link sends it as is
// (Claude Code echoes attachments as `~/…`). Every guard below runs on the
// resolved absolute path either way.
const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/');
if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new AttachmentRegistrationError('Attachment path must be an absolute local path'); throw new AttachmentRegistrationError('Attachment path must be an absolute local path');
} }
const resolved = await (options.remote const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes) ? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
: resolveLocalAttachment(requestedPath)); : resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath));
// COD-53: enforce the active attachment-guard policy on the symlink-resolved // COD-53: enforce the active attachment-guard policy on the symlink-resolved
// path before doing anything else. // path before doing anything else.
+284
View File
@@ -0,0 +1,284 @@
/**
* @fileoverview Folder listings for the file-preview folder grid.
*
* A folder the user opens (a clicked folder path, or "Folder" in the file
* preview) is resolved, checked against the same attachment guard a single file
* goes through, and GRANTED an id. The grid then asks for files by
* `folderId + file name` only, so browser requests never carry arbitrary paths —
* the rule the attachment routes follow for single files.
*
* Grants live in their own small registry, not in the attachment registry: that one
* is capped at 200 records per session, and a folder of 300 renders would evict the
* session's real attachments.
*
* Listings contain non-hidden subfolders and files whose type the preview supports
* (`isSupportedAttachmentExtension`), newest first, capped at
* {@link MAX_FOLDER_ENTRIES}. Local folders are read with `fs`; a remote (SSH) case's
* folder is listed on its host in one ssh round trip (`remoteListFolder`).
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { extname, isAbsolute, join, posix } from 'node:path';
import {
getAttachmentType,
isSupportedAttachmentExtension,
supportedAttachmentExtensions,
} from './attachment-registry.js';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { isMultiUserMode, userSpacePath } from './config/multiuser.js';
import { remoteListFolder, RemoteFileAccessError, type RemoteFolderEntry } from './remote-files.js';
import type { AttachmentDetectedType, AuthUser } from './types.js';
import type { SessionRemote } from './types/session.js';
import { describeUnknownPath, probePathKind } from './utils/bounded-path-probe.js';
import { validateSessionFilePath } from './web/route-helpers.js';
/** Most entries (files + subfolders) one listing returns. */
export const MAX_FOLDER_ENTRIES = 1000;
/** Folder grants kept per session; the oldest is dropped beyond this. */
const MAX_FOLDER_GRANTS_PER_SESSION = 32;
export interface FolderFileEntry {
name: string;
extension: string;
attachmentType: AttachmentDetectedType;
size: number;
mtimeMs: number;
}
export interface FolderListing {
folderId: string;
/** The folder with symlinks resolved (on the session's host). */
path: string;
files: FolderFileEntry[];
/** Non-hidden subfolder names, sorted. */
folders: string[];
truncated: boolean;
}
export class FolderListingError extends Error {
constructor(
message: string,
readonly statusCode: number = 400
) {
super(message);
}
}
interface FolderGrant {
folderId: string;
dir: string;
}
class FolderGrants {
private bySession = new Map<string, Map<string, FolderGrant>>();
grant(sessionId: string, dir: string): string {
let grants = this.bySession.get(sessionId);
if (!grants) {
grants = new Map();
this.bySession.set(sessionId, grants);
}
for (const existing of grants.values()) {
if (existing.dir === dir) {
// Refresh its position so an often-used folder is the last to be dropped.
grants.delete(existing.folderId);
grants.set(existing.folderId, existing);
return existing.folderId;
}
}
const folderId = `fld_${randomUUID()}`;
grants.set(folderId, { folderId, dir });
while (grants.size > MAX_FOLDER_GRANTS_PER_SESSION) {
const oldest = grants.keys().next().value;
if (oldest === undefined) break;
grants.delete(oldest);
}
return folderId;
}
get(sessionId: string, folderId: string): FolderGrant | undefined {
return this.bySession.get(sessionId)?.get(folderId);
}
clearSession(sessionId: string): void {
this.bySession.delete(sessionId);
}
}
export const folderGrants = new FolderGrants();
/**
* A file name inside a granted folder, or null when it is not one: no path
* separator, not hidden (which also rules out `.`/`..`; the listing leaves hidden
* entries out, so the routes do too), no NUL, and a type the preview supports.
*/
export function validFolderFileName(name: unknown): string | null {
if (typeof name !== 'string' || !name || name.length > 255) return null;
if (name.startsWith('.') || name.includes('/') || name.includes('\\') || name.includes('\0')) return null;
const extension = extname(name).toLowerCase().replace(/^\./, '');
return isSupportedAttachmentExtension(extension) ? name : null;
}
/** Where a granted folder's file lives (on the session's host). */
export function folderFilePath(dir: string, name: string): string {
return posix.join(dir, name);
}
interface ListFolderOptions {
sessionWorkingDir?: string;
remote?: SessionRemote;
/** The requesting identity; in multi-user mode a regular user is held to {@link folderAllowedForUser}. */
user?: AuthUser;
}
/**
* Multi-user rule for folder grants, the same one the filesystem path picker follows:
* a regular user reaches only their own space (`userSpacePath`) or the session's
* `workingDir`. User spaces live inside `homedir()`, so without it a user could list
* and read another user's space. Always true in single-user mode and for admins; a
* remote session's folders are on its host, where only the `workingDir` applies.
*
* @param path Already resolved (realpath on the session's host).
*/
export function folderAllowedForUser(
path: string,
user: AuthUser | undefined,
scope: { workingDir?: string; remote?: SessionRemote }
): boolean {
if (!isMultiUserMode()) return true;
if (!user) return false;
if (user.role === 'admin') return true;
if (scope.remote) return !!scope.workingDir && isUnderTree(path, scope.workingDir);
return [userSpacePath(user.username), scope.workingDir].some(
(root) => !!root && !!validateSessionFilePath(root, path)
);
}
/**
* Resolve, guard, list and grant a folder for `sessionId`.
*
* A local folder is probed bounded first (`probePathKind`): one on a network mount
* that stopped answering must not freeze the server, and "did not answer" is never
* reported as "not found". The guard runs on the resolved folder before it is read.
*
* @param requestedPath Absolute, or `~/…` (the home of the session's host).
* @throws FolderListingError 400 not absolute / not a folder, 403 blocked or outside the
* user's space, 404 missing, 502 remote unreachable, 503 local folder not answering.
*/
export async function listFolder(
sessionId: string,
requestedPath: string,
options: ListFolderOptions = {}
): Promise<FolderListing> {
const homeRelative = typeof requestedPath === 'string' && (requestedPath === '~' || requestedPath.startsWith('~/'));
if (!requestedPath || typeof requestedPath !== 'string' || (!isAbsolute(requestedPath) && !homeRelative)) {
throw new FolderListingError('Folder path must be absolute');
}
const guard = await loadAttachmentGuardConfig();
const workingDir = options.sessionWorkingDir;
const assertAllowed = (dir: string): void => {
const confined = !guard.confineToWorkspace
? true
: options.remote
? !!workingDir && isUnderTree(dir, workingDir)
: !!workingDir && !!validateSessionFilePath(workingDir, dir);
if (!confined || isBlockedAttachmentPath(dir, guard.blockedTrees)) {
throw new FolderListingError('Access to this folder is blocked', 403);
}
if (!folderAllowedForUser(dir, options.user, { workingDir, remote: options.remote })) {
throw new FolderListingError('Folder is outside your space', 403);
}
};
let dir: string;
let entries: RemoteFolderEntry[];
let truncated = false;
if (options.remote) {
const remotePath = requestedPath === '~' ? '~/' : requestedPath;
let listing;
try {
listing = await remoteListFolder(options.remote, remotePath, supportedAttachmentExtensions(), MAX_FOLDER_ENTRIES);
} catch (err) {
throw new FolderListingError(err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable', 502);
}
if (!listing) throw new FolderListingError('Folder not found', 404);
if (listing === 'not-a-folder') throw new FolderListingError('Not a folder');
({ dir, entries, truncated } = listing);
// One ssh call lists and resolves together, so the guard can only run after it;
// nothing of a refused listing leaves this function.
assertAllowed(dir);
} else {
const expanded = homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath;
const kind = await probePathKind(expanded, { pastCap: true });
if (kind === 'unknown') {
throw new FolderListingError(describeUnknownPath('Folder', expanded, { pastCap: true }), 503);
}
if (kind === 'absent') throw new FolderListingError('Folder not found', 404);
if (kind !== 'directory') throw new FolderListingError('Not a folder');
try {
dir = await fs.realpath(expanded);
} catch {
throw new FolderListingError('Folder not found', 404);
}
assertAllowed(dir);
({ entries, truncated } = await listLocalFolder(dir));
}
const files: FolderFileEntry[] = [];
const folders: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
// A secret that happens to sit in an open folder stays out of the grid too.
if (isBlockedAttachmentPath(folderFilePath(dir, entry.name), guard.blockedTrees)) continue;
if (entry.kind === 'directory') {
folders.push(entry.name);
continue;
}
const extension = extname(entry.name).toLowerCase().replace(/^\./, '');
if (!isSupportedAttachmentExtension(extension)) continue;
files.push({
name: entry.name,
extension,
attachmentType: getAttachmentType(extension),
size: entry.size,
mtimeMs: entry.mtimeMs,
});
}
files.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
folders.sort((a, b) => a.localeCompare(b));
return { folderId: folderGrants.grant(sessionId, dir), path: dir, files, folders, truncated };
}
/** Local counterpart of the remote listing script: subfolders + supported files, stat'ed through symlinks. */
async function listLocalFolder(dir: string): Promise<{ entries: RemoteFolderEntry[]; truncated: boolean }> {
const dirents = await fs.readdir(dir, { withFileTypes: true });
const entries: RemoteFolderEntry[] = [];
let truncated = false;
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) continue;
if (entries.length >= MAX_FOLDER_ENTRIES) {
truncated = true;
break;
}
const extension = extname(dirent.name).toLowerCase().replace(/^\./, '');
const maybeFile = isSupportedAttachmentExtension(extension);
if (!dirent.isDirectory() && !maybeFile && !dirent.isSymbolicLink()) continue;
let stat;
try {
stat = await fs.stat(join(dir, dirent.name));
} catch {
continue; // dangling symlink, or gone since readdir
}
if (stat.isDirectory()) {
entries.push({ kind: 'directory', name: dirent.name, size: 0, mtimeMs: 0 });
} else if (stat.isFile() && maybeFile) {
entries.push({ kind: 'file', name: dirent.name, size: stat.size, mtimeMs: stat.mtimeMs });
}
}
return { entries, truncated };
}
+126
View File
@@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
* `echo` (which land before the script runs) from the records without any "last N * `echo` (which land before the script runs) from the records without any "last N
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses. * lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
* *
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
* server does not know that home, and a clicked `~/…` link means the host the
* session runs on. The record's `realPath` is absolute, as for any other path.
*
* Symlink resolution is portable AND fails closed. `readlink -f` where available * Symlink resolution is portable AND fails closed. `readlink -f` where available
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain * (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink` * with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
@@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string {
'probe() {', 'probe() {',
' i=$1', ' i=$1',
' p=$2', ' p=$2',
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`, ` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`, ` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`, ` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
@@ -269,6 +275,126 @@ export async function remoteProbePaths(
return results; return results;
} }
/** One entry of a remote folder listing: a file (with size/mtime) or a subfolder. */
export interface RemoteFolderEntry {
kind: 'file' | 'directory';
name: string;
size: number;
mtimeMs: number;
}
/** A remote folder listing; `dir` is the folder with symlinks resolved on the remote host. */
export interface RemoteFolderListing {
dir: string;
entries: RemoteFolderEntry[];
truncated: boolean;
}
/**
* List one remote folder in a SINGLE ssh round trip: its canonical path, then every
* non-hidden subfolder and every regular file (symlinks followed) whose name ends in
* one of `extensions` (any case), up to `maxEntries`.
*
* Same output discipline as {@link buildRemoteProbeCommand}: a lone NUL first (so rc
* chatter before it is discarded), then NUL-terminated records — `E|n` (no such
* folder), `E|x` (cannot be canonicalized), `D|<dir>`, `d|0|0|<name>`,
* `f|<size>|<mtime>|<name>`, and `T|` when the cap cut the listing. The name is the
* last field, so a `|` in it still parses, and NUL is the one byte a filename cannot
* hold. A leading `~/` is the remote user's `$HOME`, as in the probe.
*/
export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string {
// `[pP][nN][gG]`: any case, as the local listing compares lowercased (`shot.Png`).
const patterns = [...new Set(extensions.map((ext) => ext.toLowerCase()))]
.map((ext) => `*.${ext.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`)}`)
.join('|');
const script = [
`d=${shellescape(dir)}`,
"case $d in '~/'*) d=$HOME/${d#??} ;; esac",
"printf '\\0'",
'[ -d "$d" ] || { [ -e "$d" ] && printf \'E|f\\0\' || printf \'E|n\\0\'; exit 0; }',
'r=$(cd -P "$d" 2>/dev/null && pwd -P) || { printf \'E|x\\0\'; exit 0; }',
'printf \'D|%s\\0\' "$r"',
'n=0',
'for f in "$r"/*; do',
' [ -e "$f" ] || continue',
` [ "$n" -lt ${Math.max(1, Math.floor(maxEntries))} ] || { printf 'T|\\0'; break; }`,
' b=${f##*/}',
' if [ -d "$f" ]; then n=$((n + 1)); printf \'d|0|0|%s\\0\' "$b"; continue; fi',
' [ -f "$f" ] || continue',
` case $b in ${patterns || '*.__none__'}) ;; *) continue ;; esac`,
" sm=$(stat -L -c '%s %Y' \"$f\" 2>/dev/null || stat -L -f '%z %m' \"$f\" 2>/dev/null) || sm='0 0'",
' n=$((n + 1))',
' printf \'f|%s|%s|%s\\0\' "${sm% *}" "${sm#* }" "$b"',
'done',
].join('\n');
// ⚠️ sshd runs the command in the remote user's LOGIN shell, rc aliases included.
// On Hufflepuff `cd` is an alias that echoes the new directory (`$(cd -P "$d" &&
// pwd -P)` came back as two lines, then zsh aborted on the glob: "no matches found:
// /x\n/x/*"), so no folder listing worked over ssh. An explicit `sh` reads no rc
// file — the same wrapper the transcript read uses.
return `sh -c ${shellescape(script)}`;
}
/**
* Parse {@link buildRemoteFolderListCommand} output: null when nothing exists there
* (or it cannot be resolved), `'not-a-folder'` when it is a file.
*/
export function parseRemoteFolderListOutput(stdout: string): RemoteFolderListing | 'not-a-folder' | null {
const records = stdout.split('\0').slice(1);
let dir: string | null = null;
let truncated = false;
const entries: RemoteFolderEntry[] = [];
for (const record of records) {
if (record === 'E|f') return 'not-a-folder';
if (record.startsWith('E|')) return null;
if (record.startsWith('D|')) {
dir = record.slice(2);
continue;
}
if (record === 'T|') {
truncated = true;
continue;
}
const match = /^([fd])\|(\d*)\|(\d*)\|([\s\S]+)$/.exec(record);
if (!match) continue;
const size = Number.parseInt(match[2], 10);
const mtime = Number.parseInt(match[3], 10);
entries.push({
kind: match[1] === 'd' ? 'directory' : 'file',
name: match[4],
size: Number.isFinite(size) && size > 0 ? size : 0,
mtimeMs: Number.isFinite(mtime) && mtime > 0 ? mtime * 1000 : 0,
});
}
if (!dir) {
throw new RemoteFileAccessError('remote host returned no usable folder listing');
}
return { dir, entries, truncated };
}
/** List a remote folder (see {@link buildRemoteFolderListCommand}). */
export async function remoteListFolder(
remote: SessionRemote,
dir: string,
extensions: readonly string[],
maxEntries: number
): Promise<RemoteFolderListing | 'not-a-folder' | null> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, buildRemoteFolderListCommand(dir, extensions, maxEntries));
let stdout: string;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 })
);
stdout = result.stdout;
} catch (err) {
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
return parseRemoteFolderListOutput(stdout);
}
/** Read a whole remote file into memory, capped by `maxBytes`. */ /** Read a whole remote file into memory, capped by `maxBytes`. */
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> { export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
assertNotUnderTest(); assertNotUnderTest();
+18 -13
View File
@@ -2751,7 +2751,13 @@ class CodemanApp {
const filePath = pathLink.dataset.path; const filePath = pathLink.dataset.path;
// A rendered document's links name the session the preview was opened // A rendered document's links name the session the preview was opened
// for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab. // for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab.
if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId); // A path the linkifier found in prose may be relative: resolve it against
// that session's working directory. A markdown document's own links
// (rebased, no `linkified` mark) already are what the routes expect.
const sessionId = pathLink.dataset.sessionId || this.activeSessionId;
const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath;
if (filePath && pathLink.dataset.folder) this.openFolderGrid(target || filePath, sessionId);
else if (filePath) this.openFilePreview(target || filePath, sessionId);
return; return;
} }
@@ -2869,7 +2875,8 @@ class CodemanApp {
} }
/** /**
* Make absolute file paths in a rendered message clickable. * Make file paths in a rendered message clickable: absolute, `~/` and
* relative ones (`findFilePathLinks`, constants.js).
* *
* The terminal's link provider never sees these: the response viewer is * The terminal's link provider never sees these: the response viewer is
* markdown, and a path the agent wrote as prose or inline code renders as * markdown, and a path the agent wrote as prose or inline code renders as
@@ -2887,8 +2894,7 @@ class CodemanApp {
if (!root || typeof document === 'undefined') return; if (!root || typeof document === 'undefined') return;
// Guarded: a stale cached constants.js must degrade to plain text, not throw // Guarded: a stale cached constants.js must degrade to plain text, not throw
// out of the middle of rendering a message. // out of the middle of rendering a message.
if (typeof absoluteFilePathPattern !== 'function') return; if (typeof findFilePathLinks !== 'function') return;
const pattern = absoluteFilePathPattern();
// Collect first: replacing a node while the walker is positioned on it // Collect first: replacing a node while the walker is positioned on it
// invalidates the traversal. // invalidates the traversal.
@@ -2896,27 +2902,26 @@ class CodemanApp {
const targets = []; const targets = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) { for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue; if (node.parentElement?.closest('a')) continue;
pattern.lastIndex = 0; const found = findFilePathLinks(node.nodeValue || '');
if (pattern.test(node.nodeValue || '')) targets.push(node); if (found.length > 0) targets.push([node, found]);
} }
for (const node of targets) { for (const [node, found] of targets) {
const value = node.nodeValue; const value = node.nodeValue;
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
let cursor = 0; let cursor = 0;
let match; for (const { path, index, folder } of found) {
pattern.lastIndex = 0; if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index)));
while ((match = pattern.exec(value)) !== null) {
const path = match[1];
if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index)));
const link = document.createElement('a'); const link = document.createElement('a');
link.className = 'rv-path'; link.className = 'rv-path';
link.href = '#'; link.href = '#';
link.dataset.path = path; link.dataset.path = path;
link.dataset.linkified = '1';
if (folder) link.dataset.folder = '1';
link.title = path; link.title = path;
link.textContent = path; link.textContent = path;
frag.appendChild(link); frag.appendChild(link);
cursor = match.index + path.length; cursor = index + path.length;
} }
if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor))); if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor)));
node.parentNode?.replaceChild(frag, node); node.parentNode?.replaceChild(frag, node);
+268 -2
View File
@@ -2065,6 +2065,138 @@ function previewsInFileViewer(filePath) {
return FILE_PREVIEW_EXTENSIONS.has(ext); return FILE_PREVIEW_EXTENSIONS.has(ext);
} }
/**
* Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code
* echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`,
* or a bare `look_montage.png`. Same extension list and body as
* {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two
* equal).
*
* Without a known root to anchor on, the START is what has to be unambiguous:
* group 1 is the boundary in front of the path (line start, whitespace, a quote,
* an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of
* a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of
* a URL. The path itself is group 2. Its first character is never `/`: absolute
* paths belong to the rooted pattern, which deliberately leaves `/etc` out. A
* token containing `://` is a URL, never a path. Brackets, `=` and `,` end a
* relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link
* the file, not the punctuation around it.
*
* ⚠ Like its sibling, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const RELATIVE_FILE_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
/**
* Every linkable file path in `text`, in order: absolute (rooted) paths first,
* then `~/` and relative ones, then folder paths — each only where it overlaps
* neither an earlier one nor a URL.
*
* The one entry point for both consumers (terminal link provider, response
* viewer), so a path form is clickable in both or in neither.
*
* @param {string} text
* @returns {Array<{path: string, index: number, folder?: true}>} `index` is where `path` starts in
* `text`; `folder` marks a folder path (folder grid) rather than a file.
*/
function findFilePathLinks(text) {
const value = String(text || '');
const found = [];
const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length);
// Web URLs are never cut into a path: `https://x.io/a/home/b.png` and
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
// (`file:///home/x.png` is not one of them: its path is the file.)
const urls = [];
const urlPattern = /\bhttps?:\/\/\S+/gi;
let match;
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
const absolute = absoluteFilePathPattern();
while ((match = absolute.exec(value)) !== null) {
if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index });
}
const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g');
while ((match = relative.exec(value)) !== null) {
const path = match[2];
const index = match.index + match[1].length;
if (overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index });
}
const folders = new RegExp(FOLDER_PATH_LINK_PATTERN.source, 'g');
while ((match = folders.exec(value)) !== null) {
const path = folderLinkPath(match[2]);
const index = match.index + match[1].length;
if (!path || overlaps(index, path.length) || inUrl(index, path.length)) continue;
found.push({ path, index, folder: true });
}
return found.sort((a, b) => a.index - b.index);
}
/**
* Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN}
* (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a
* trailing slash (`builds/captures/`). Same boundary in front as the relative
* file pattern (group 1), the candidate itself is group 2 and goes through
* {@link folderLinkPath}, which decides whether it reads as a folder at all.
* File links are found first and win any overlap. A folder path ends at `:`,
* which is also a boundary in front: `PATH=/a/bin:/b/bin` is two candidates, and
* the `://` lookahead stops at the next colon instead of rescanning the token from
* every one (quadratic on `a:a:a:…//`).
*
* ⚠ Like its siblings, never share an instance (`lastIndex`): use
* {@link findFilePathLinks}.
*/
const FOLDER_PATH_LINK_PATTERN =
/(^|[\s"'`(\[{<>=,:])((?:\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/|~\/|(?![^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*:\/\/)[^\s"'`<>|;&()[\]{}=,:\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,:\n\x00-\x1f]*)/g;
/**
* A {@link FOLDER_PATH_LINK_PATTERN} candidate as a folder link, or null.
*
* Sentence punctuation after the path is not part of it. A path ending in `/` is
* a folder; otherwise only a rooted or `~/` path whose last segment has no
* extension (`.cache` and `styleframes` do, `a.rs` does not) — a relative word
* without a slash at the end is prose, not a folder.
*/
function folderLinkPath(candidate) {
const path = String(candidate || '').replace(/[.,:;!?'"]+$/, '');
if (!path || path === '~/' || /^\/[^/]+\/$/.test(path)) return null;
if (path.endsWith('/')) return /[^/.]/.test(path) ? path : null;
if (!path.startsWith('/') && !path.startsWith('~/')) return null;
const last = path.slice(path.lastIndexOf('/') + 1);
return /^\.?[^.]+$/.test(last) ? path : null;
}
/**
* Where a clicked path points, as the preview routes need it.
*
* Absolute and `~/` paths pass through unchanged — `~` is the home of the
* session's HOST, which only the server knows (a remote case's home is on the
* remote machine), so the server expands it. A relative path is resolved
* against the session's working directory, `.` and `..` collapsed, so a
* `../other/x.png` ends up outside the workspace and routes like any other
* external path. Without a working directory it is returned as is.
*
* @param {string} filePath
* @param {string} [workingDir]
* @returns {string}
*/
function resolveLinkedFilePath(filePath, workingDir) {
const path = String(filePath || '');
if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path;
const parts = [];
for (const part of `${workingDir}/${path}`.split('/')) {
if (!part || part === '.') continue;
if (part === '..') parts.pop();
else parts.push(part);
}
return `/${parts.join('/')}`;
}
/** /**
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one * The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
@@ -2169,6 +2301,134 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
return { startRow, endRow, text, offsetToCell, cellToOffset }; return { startRow, endRow, text, offsetToCell, cellToOffset };
} }
/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */
const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1];
const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`);
const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`);
const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`);
/** A relative head that names its own location (`./a`, `../a`) is an entry of its own. */
const PATH_TOKEN_STARTS_EXPLICIT_RELATIVE = /^\.{1,2}\//;
/** First directory of a path, a leading `/` or `./` ignored: `src` for `src/a.ts` and `/src/a.ts`. */
const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0];
/**
* Whether the line `nextText` carries on a file path `prevText` broke off.
*
* Answers `'join'` when the path ends (reaches a known extension) on the next
* line, `'chain'` when the next line is nothing but more of the path (a path
* spanning three or more lines; only worth keeping once a later line ends it),
* and `null` otherwise. The broken-off token must already look like a path in
* progress: it contains a `/`, ends on a path character and does not yet end
* on an extension. A next line that opens a NEW path is never glued on: a rooted
* one (`/home/…`, `~/…`), `./…`/`../…`, or a relative one that opens on the same
* top directory the broken token did (the next entry of a listing).
*/
function pathContinuation(prevText, nextText) {
const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || '';
if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null;
const next = String(nextText || '').replace(/^\s+/, '');
const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || '';
if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head) || PATH_TOKEN_STARTS_EXPLICIT_RELATIVE.test(head)) return null;
// A listing (`git ls-files`, `find`, `ls -R`) puts one path per row, so `src/components`
// followed by `src/index.ts` is two entries, not one path broken after `src/components`.
// A wrapped path continues INSIDE a name: its next row does not open on the same top directory.
if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null;
if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join';
return head === next.replace(/\s+$/, '') ? 'chain' : null;
}
/**
* {@link terminalLogicalLine}, extended across the line breaks a program puts
* INSIDE a file path. Used by the link provider only; touch selection keeps the
* plain logical line.
*
* Claude Code wraps its tool output itself, a few columns short of the terminal
* edge, so neither continuation signal of terminalLogicalLine fires (no
* `isWrapped`, the row does not reach the last column) and a long path was only
* linked up to the break:
*
* ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2
* 026-10-10/ghost1b_gameplay.png err=0 …
*
* A following logical line is glued on (its indent dropped, cell mapping kept
* exact) when {@link pathContinuation} says it carries the path on and the row it
* broke off is at least as long as the row it continues on. Bounded by the same
* `maxRows` as the logical line.
*
* @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist.
*/
function terminalPathLine(buffer, row, cols, maxRows) {
const base = terminalLogicalLine(buffer, row, cols, maxRows);
if (!base) return null;
const bound = Math.max(1, maxRows || 12);
const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound);
const rowsOf = (line) => line.endRow - line.startRow + 1;
// A program breaking a long token fills the row to its wrap width, and no
// later row of that block is wider. So the row a path broke off is at least as
// long as the row it continues on — which keeps `cd /mnt/foo` from being glued
// to a longer ` file.png written` below it.
const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length;
const continues = (prev, next, prevText) =>
rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text);
/** `first` and the lines a path carries on into, stopping at the last confirmed join. */
const extend = (first) => {
const parts = [first];
let text = first.text;
let rows = rowsOf(first);
let confirmed = 1;
for (;;) {
const next = lineAt(parts[parts.length - 1].endRow + 1);
if (!next || rows + rowsOf(next) > bound) break;
const kind = continues(parts[parts.length - 1], next, text);
if (!kind) break;
parts.push(next);
text += next.text.replace(/^\s+/, '');
rows += rowsOf(next);
if (kind === 'join') confirmed = parts.length;
}
return parts.slice(0, confirmed);
};
// The hovered row may be the second or third line of a broken path: walk back
// to where it could start, then let the forward pass decide what really joins.
let first = base;
let rows = rowsOf(base);
while (first.startRow > 0) {
const prev = lineAt(first.startRow - 1);
if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break;
first = prev;
rows += rowsOf(prev);
}
let parts = extend(first);
if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base);
if (parts.length === 1) return parts[0];
const segments = [];
let text = '';
for (const [i, part] of parts.entries()) {
const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length;
segments.push({ part, textStart: text.length, skip });
text += part.text.slice(skip);
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip);
}
return segments[0].part.offsetToCell(offset);
};
const cellToOffset = (targetRow, targetCol) => {
for (const seg of segments) {
if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue;
const offset = seg.part.cellToOffset(targetRow, targetCol);
return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip);
}
return -1;
};
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
}
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list) // Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -3081,8 +3341,14 @@ function describeSessionHarness(session, catalog) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS }; window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS };
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; window.CodemanFilePaths = {
window.CodemanTerminalLines = { terminalLogicalLine }; absoluteFilePathPattern,
findFilePathLinks,
resolveLinkedFilePath,
previewsInFileViewer,
FILE_PREVIEW_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanUrlSession = { sessionIdFromFragment }; window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = { window.CodemanSplitPane = {
clampDividerPercent, clampDividerPercent,
+19
View File
@@ -614,6 +614,9 @@
<div class="file-preview-header"> <div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span> <span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions"> <div class="file-preview-actions">
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>&lsaquo;</button>
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>&rsaquo;</button>
<button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button> <button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button> <button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button> <button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
@@ -634,6 +637,22 @@
</div> </div>
</div> </div>
<!-- Folder Grid: every previewable file of one folder (panels-ui.js openFolderGrid) -->
<div class="folder-grid-overlay" id="folderGridOverlay">
<div class="folder-grid-window">
<div class="file-preview-header">
<span class="file-preview-title" id="folderGridTitle" data-i18n-skip></span>
<div class="file-preview-actions">
<button class="btn-icon-sm" id="folderGridUpBtn" onclick="app.folderGridUp()" title="Parent folder" aria-label="Parent folder">&uarr;</button>
<button class="btn-icon-sm" onclick="app.closeFolderGrid()" title="Close" aria-label="Close">&times;</button>
</div>
</div>
<div class="folder-grid-folders" id="folderGridFolders" data-i18n-skip></div>
<div class="folder-grid-body" id="folderGridBody" data-i18n-skip></div>
<div class="file-preview-footer" id="folderGridFooter" data-i18n-skip></div>
</div>
</div>
<!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) --> <!-- Response Viewer (mobile: native-scroll overlay for reading full Claude responses) -->
<div class="response-viewer" id="responseViewer"> <div class="response-viewer" id="responseViewer">
<div class="response-viewer-header"> <div class="response-viewer-header">
+294 -8
View File
@@ -4037,6 +4037,9 @@ Object.assign(CodemanApp.prototype, {
* unknown workingDir answers false, leaving the historical path untouched. * unknown workingDir answers false, leaving the historical path untouched.
*/ */
_isExternalPreviewPath(filePath, sessionId) { _isExternalPreviewPath(filePath, sessionId) {
// `~/…` (a terminal or response-viewer link): only the server knows the home
// of the session's host, and only the attachment route expands it.
if (typeof filePath === 'string' && filePath.startsWith('~/')) return true;
if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false; if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false;
const workingDir = this.sessions.get(sessionId)?.workingDir; const workingDir = this.sessions.get(sessionId)?.workingDir;
if (!workingDir) return false; if (!workingDir) return false;
@@ -4083,8 +4086,19 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) { /**
* @param {object} [options]
* @param {{folderId: string, name: string}} [options.folderFile] A file of a
* granted folder (folder grid): rendered from the folder's by-name routes, so
* paging through 300 renders never touches the attachment registry.
*/
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) {
if (!sessionId || !filePath) return; if (!sessionId || !filePath) return;
const folderFile = options.folderFile || null;
// Prev/next only while browsing a folder grid; a preview opened from anywhere
// else ends that browsing.
if (!folderFile && this.folderGrid) this.folderGrid.index = null;
this._updateFolderPreviewNav(folderFile ? null : this._previewFolderPath(filePath, sessionId, attachmentId));
const overlay = this.$('filePreviewOverlay'); const overlay = this.$('filePreviewOverlay');
const titleEl = this.$('filePreviewTitle'); const titleEl = this.$('filePreviewTitle');
@@ -4093,6 +4107,12 @@ Object.assign(CodemanApp.prototype, {
if (!overlay || !bodyEl) return; if (!overlay || !bodyEl) return;
// Only the newest load may write: paging ←/→ through a folder starts the next
// load while the previous one still awaits its fetch, and its late answer would
// put the previous file's text under the next file's title.
const load = (this._filePreviewLoad = (this._filePreviewLoad || 0) + 1);
const stale = () => load !== this._filePreviewLoad;
// Edit mode: reset any prior editor state whenever a preview (re)loads. // Edit mode: reset any prior editor state whenever a preview (re)loads.
this._resetFilePreviewEdit(); this._resetFilePreviewEdit();
// Stop whatever the previous preview was playing. Overwriting innerHTML // Stop whatever the previous preview was playing. Overwriting innerHTML
@@ -4122,8 +4142,9 @@ Object.assign(CodemanApp.prototype, {
// for a file that is sitting right there on disk. // for a file that is sitting right there on disk.
let externalError = ''; let externalError = '';
let externalSize = 0; let externalSize = 0;
if (!attachmentId && this._isExternalPreviewPath(filePath, sessionId)) { if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) {
const external = await this._registerExternalPreview(filePath, sessionId); const external = await this._registerExternalPreview(filePath, sessionId);
if (stale()) return;
attachmentId = external.attachmentId || null; attachmentId = external.attachmentId || null;
externalError = external.error || ''; externalError = external.error || '';
externalSize = external.size || 0; externalSize = external.size || 0;
@@ -4140,9 +4161,14 @@ Object.assign(CodemanApp.prototype, {
// (html/htm arrive as a download there by design — file-raw serves them // (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.) // attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx'; const officeDoc = ext === 'docx' || ext === 'pptx';
const byIdBase = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`
: folderFile
? `/api/sessions/${sessionId}/folders/${encodeURIComponent(folderFile.folderId)}/files/${encodeURIComponent(folderFile.name)}`
: null;
this.filePreviewDetachUrl = CodemanBase.url( this.filePreviewDetachUrl = CodemanBase.url(
attachmentId byIdBase
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}` ? `${byIdBase}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc : officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}` ? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}` : `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
@@ -4152,8 +4178,9 @@ Object.assign(CodemanApp.prototype, {
// Registered attachment: render straight from its by-id routes — images and // Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched // PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.) // raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) { // A folder-grid file renders the same way, from its folder's by-name routes.
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`); if (byIdBase) {
const base = CodemanBase.url(byIdBase);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']); const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS // VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
// (src/attachment-registry.ts, the single source); the frontend cannot import // (src/attachment-registry.ts, the single source); the frontend cannot import
@@ -4163,6 +4190,7 @@ Object.assign(CodemanApp.prototype, {
// Size when we just registered the file ourselves, so a path opened from a // Size when we just registered the file ourselves, so a path opened from a
// link reads like a workspace preview instead of a bare "PNG". History // link reads like a workspace preview instead of a bare "PNG". History
// cards arrive with an id and no size and keep the short form. // cards arrive with an id and no size and keep the short form.
if (folderFile && !externalSize) externalSize = options.size || 0;
footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase(); footerEl.textContent = externalSize ? `${this.formatFileSize(externalSize)} • ${ext}` : ext.toUpperCase();
if (IMAGE_EXTS.has(ext)) { if (IMAGE_EXTS.has(ext)) {
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`; bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
@@ -4190,6 +4218,7 @@ Object.assign(CodemanApp.prototype, {
const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } }); const res = await fetch(`${base}/raw`, { headers: { Range: `bytes=0-${TEXT_PREVIEW_MAX_BYTES - 1}` } });
if (!res.ok) throw new Error('Failed to load attachment'); if (!res.ok) throw new Error('Failed to load attachment');
const text = await res.text(); const text = await res.text();
if (stale()) return;
const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES; const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES;
const lines = text.split('\n'); const lines = text.split('\n');
// Markdown keeps every line the Range read returned: the byte bound is // Markdown keeps every line the Range read returned: the byte bound is
@@ -4201,13 +4230,14 @@ Object.assign(CodemanApp.prototype, {
this.filePreviewContent = shown; this.filePreviewContent = shown;
// attachmentId: a card's filePath is the bare file name, so the // attachmentId: a card's filePath is the bare file name, so the
// rebase pass must know there is no directory to resolve against. // rebase pass must know there is no directory to resolve against.
this.filePreviewText = { ext, sessionId, filePath, attachmentId }; this.filePreviewText = { ext, sessionId, filePath, attachmentId: attachmentId || folderFile?.folderId };
this._renderFilePreviewText(); this._renderFilePreviewText();
if (clippedByLines || clippedByBytes) { if (clippedByLines || clippedByBytes) {
const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file'; const note = clippedByLines ? `showing first ${lineCap} lines` : 'showing the start of the file';
footerEl.textContent = `${footerEl.textContent} (${note})`; footerEl.textContent = `${footerEl.textContent} (${note})`;
} }
} catch (err) { } catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`; bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
} }
} }
@@ -4240,11 +4270,14 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`); const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
if (!res.ok) throw new Error('Failed to load image'); if (!res.ok) throw new Error('Failed to load image');
const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' })); const svgText = await res.text();
if (stale()) return;
const blobUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`; bodyEl.innerHTML = `<img src="${blobUrl}" alt="${escapeHtml(filePath)}">`;
const img = bodyEl.querySelector('img'); const img = bodyEl.querySelector('img');
if (img) img.onload = () => URL.revokeObjectURL(blobUrl); if (img) img.onload = () => URL.revokeObjectURL(blobUrl);
} catch (err) { } catch (err) {
if (stale()) return;
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`; bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
} }
return; return;
@@ -4260,6 +4293,7 @@ Object.assign(CodemanApp.prototype, {
if (!res.ok) throw new Error('Failed to load file'); if (!res.ok) throw new Error('Failed to load file');
const result = await res.json(); const result = await res.json();
if (stale()) return;
if (!result.success) throw new Error(result.error || 'Failed to load file'); if (!result.success) throw new Error(result.error || 'Failed to load file');
const data = result.data; const data = result.data;
@@ -4299,14 +4333,266 @@ Object.assign(CodemanApp.prototype, {
} }
} }
} catch (err) { } catch (err) {
if (stale()) return;
console.error('Failed to preview file:', err); console.error('Failed to preview file:', err);
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`; bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
} }
}, },
// ===== Folder grid =====
/**
* Show every previewable file of a folder as a grid (absolute or `~/` path on the
* session's host). The server grants the folder an id (`POST …/folders`), and the
* grid and the previews it opens fetch files by `folderId + name` only.
*
* Opened from a clicked folder path: when the server finds a FILE there after all,
* it falls back to the file preview, i.e. what a click did before folders were links.
*/
async openFolderGrid(folderPath, sessionId = this.activeSessionId) {
if (!sessionId || !folderPath) return;
const overlay = this.$('folderGridOverlay');
const body = this.$('folderGridBody');
if (!overlay || !body) return;
this._bindFolderGridKeys();
// Two quick opens (a chip, then its parent): only the newer one may render.
const load = (this._folderGridLoad = (this._folderGridLoad || 0) + 1);
let result = null;
let status = 0;
try {
const res = await fetch(`/api/sessions/${sessionId}/folders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: folderPath }),
});
status = res.status;
result = await res.json().catch(() => null);
} catch (err) {
if (load !== this._folderGridLoad) return;
this.showToast(`Cannot open folder: ${err.message || err}`, 'error');
return;
}
if (load !== this._folderGridLoad) return;
if (!result?.success || !result.data) {
const reason = result?.error || `HTTP ${status}`;
if (status === 400 && /not a folder/i.test(reason)) {
this.openFilePreview(folderPath, sessionId);
return;
}
this.showToast(`Cannot open folder ${folderPath}: ${reason}`, 'error');
return;
}
this.folderGrid = { sessionId, ...result.data, index: null };
this._renderFolderGrid();
overlay.classList.add('visible');
body.scrollTop = 0;
},
closeFolderGrid() {
// A listing still on its way must not reopen the grid that was just closed.
this._folderGridLoad = (this._folderGridLoad || 0) + 1;
this.$('folderGridOverlay')?.classList.remove('visible');
const body = this.$('folderGridBody');
// Drop the tiles: lazy <img>s still loading would keep fetching otherwise.
if (body) body.innerHTML = '';
this.folderGrid = null;
this._updateFolderPreviewNav(null);
},
folderGridUp() {
const grid = this.folderGrid;
if (!grid || grid.path === '/') return;
this.openFolderGrid(grid.path.slice(0, grid.path.lastIndexOf('/')) || '/', grid.sessionId);
},
_renderFolderGrid() {
const grid = this.folderGrid;
if (!grid) return;
const title = this.$('folderGridTitle');
const foldersEl = this.$('folderGridFolders');
const body = this.$('folderGridBody');
const footer = this.$('folderGridFooter');
title.textContent = grid.path;
title.title = grid.path;
const upBtn = this.$('folderGridUpBtn');
if (upBtn) upBtn.disabled = grid.path === '/';
foldersEl.innerHTML = '';
for (const name of grid.folders) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'folder-grid-chip';
chip.textContent = `${name}/`;
chip.onclick = () => this.openFolderGrid(`${grid.path === '/' ? '' : grid.path}/${name}`, grid.sessionId);
foldersEl.appendChild(chip);
}
body.innerHTML = '';
if (grid.files.length === 0) {
const empty = document.createElement('div');
empty.className = 'folder-grid-empty';
empty.textContent = 'No previewable files in this folder.';
body.appendChild(empty);
}
grid.files.forEach((file, index) => {
const tile = document.createElement('button');
tile.type = 'button';
tile.className = 'folder-grid-tile';
tile.title = `${file.name} • ${this.formatFileSize(file.size)}`;
tile.dataset.index = String(index);
const thumb = document.createElement('div');
thumb.className = 'folder-grid-thumb';
const label = file.extension.toUpperCase();
const route = this._folderFileUrl(grid, file.name, '');
// Images load themselves (lazily — a folder can hold hundreds of renders);
// PDF/Office show their first page where the server can render one (local
// folders), everything else a type label.
const src =
file.attachmentType === 'image'
? `${route}/raw`
: ['pdf', 'docx', 'pptx'].includes(file.extension)
? `${route}/thumbnail`
: null;
if (src) {
const img = document.createElement('img');
img.loading = 'lazy';
img.decoding = 'async';
img.alt = file.name;
img.src = src;
img.onerror = () => {
thumb.textContent = label;
};
thumb.appendChild(img);
} else {
thumb.textContent = label;
}
const name = document.createElement('span');
name.className = 'folder-grid-name';
name.textContent = file.name;
tile.append(thumb, name);
tile.onclick = () => this._openFolderGridFile(index);
body.appendChild(tile);
});
const count = `${grid.files.length} file${grid.files.length === 1 ? '' : 's'}`;
const subfolders = grid.folders.length ? ` • ${grid.folders.length} folder${grid.folders.length === 1 ? '' : 's'}` : '';
footer.textContent = `${count}${subfolders}${grid.truncated ? ' (folder too large — showing the first entries)' : ''}`;
},
_folderFileUrl(grid, name, suffix) {
return CodemanBase.url(
`/api/sessions/${grid.sessionId}/folders/${encodeURIComponent(grid.folderId)}/files/${encodeURIComponent(name)}${suffix}`
);
},
_openFolderGridFile(index) {
const grid = this.folderGrid;
const file = grid?.files[index];
if (!file) return;
const body = this.$('folderGridBody');
body?.querySelectorAll('.folder-grid-tile.current').forEach((t) => t.classList.remove('current'));
const tile = body?.querySelector(`.folder-grid-tile[data-index="${index}"]`);
tile?.classList.add('current');
tile?.scrollIntoView({ block: 'nearest' });
this.openFilePreview(`${grid.path === '/' ? '' : grid.path}/${file.name}`, grid.sessionId, null, {
folderFile: { folderId: grid.folderId, name: file.name },
size: file.size,
});
// After openFilePreview: it resets the index for previews from elsewhere.
grid.index = index;
grid.lastIndex = index;
this._updateFolderPreviewNav(null);
},
/** Page through the open folder from its preview (‹ › buttons, ←/→). */
stepFolderPreview(delta) {
const grid = this.folderGrid;
if (!grid || grid.index === null || grid.files.length === 0) return;
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
const next = (grid.index + delta + grid.files.length) % grid.files.length;
this._openFolderGridFile(next);
},
/** "Show folder" in the preview: the grid of the previewed file's folder. */
openFolderOfPreview() {
const target = this.filePreviewFolderTarget;
if (!target) return;
this.closeFilePreview();
this.openFolderGrid(target.dir, target.sessionId);
},
/**
* The folder a previewed file lives in, or null when that is unknown — an
* attachment card carries only a bare file name.
*/
_previewFolderPath(filePath, sessionId, attachmentId) {
if (typeof filePath !== 'string' || (attachmentId && !filePath.includes('/'))) return null;
const workingDir = this.sessions?.get?.(sessionId)?.workingDir;
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(filePath, workingDir) : filePath;
if (!resolved.startsWith('/') && !resolved.startsWith('~/')) return null;
const cut = resolved.lastIndexOf('/');
return { dir: resolved.slice(0, cut) || '/', sessionId };
},
/** Show prev/next while browsing a folder grid, else "Show folder" when the file's folder is known. */
_updateFolderPreviewNav(folderTarget) {
const browsing = !!this.folderGrid && this.folderGrid.index !== null && this.folderGrid.files.length > 1;
const prev = this.$('filePreviewPrevBtn');
const next = this.$('filePreviewNextBtn');
if (prev) prev.hidden = !browsing;
if (next) next.hidden = !browsing;
if (folderTarget !== undefined) this.filePreviewFolderTarget = folderTarget;
const inGrid = !!this.folderGrid && this.folderGrid.index !== null;
const folderBtn = this.$('filePreviewFolderBtn');
if (folderBtn) folderBtn.hidden = inGrid || !this.filePreviewFolderTarget;
},
/**
* ←/→ page the folder preview, Escape steps back (preview → grid → closed).
* Capture phase + stopPropagation: the terminal keeps keyboard focus under the
* overlays, and an arrow key that reached xterm would be typed into the session.
*/
_bindFolderGridKeys() {
if (this._folderGridKeysBound) return;
this._folderGridKeysBound = true;
document.addEventListener(
'keydown',
(ev) => {
const grid = this.folderGrid;
if (!grid || ev.altKey || ev.ctrlKey || ev.metaKey) return;
const previewOpen = this.$('filePreviewOverlay')?.classList.contains('visible');
const gridOpen = this.$('folderGridOverlay')?.classList.contains('visible');
if (!gridOpen) return;
const t = ev.target;
const typing =
t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')));
if (typing || this.filePreviewEdit) return;
let handled = true;
if (previewOpen && grid.index !== null && ev.key === 'ArrowLeft') this.stepFolderPreview(-1);
else if (previewOpen && grid.index !== null && ev.key === 'ArrowRight') this.stepFolderPreview(1);
// Grid alone: an arrow opens the last viewed tile (or the first), so it
// pages the folder instead of landing in the terminal behind the grid.
else if (!previewOpen && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight') && grid.files.length)
this._openFolderGridFile(grid.lastIndex ?? 0);
else if (ev.key === 'Escape') {
if (previewOpen) this.closeFilePreview();
else this.closeFolderGrid();
} else handled = false;
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
},
true
);
},
closeFilePreview() { closeFilePreview() {
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return; if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
this._resetFilePreviewEdit(); this._resetFilePreviewEdit();
// Back on the grid (if one is open underneath): its current tile stays marked.
if (this.folderGrid) this.folderGrid.index = null;
this._updateFolderPreviewNav(null);
const overlay = this.$('filePreviewOverlay'); const overlay = this.$('filePreviewOverlay');
if (overlay) { if (overlay) {
overlay.classList.remove('visible'); overlay.classList.remove('visible');
+128 -1
View File
@@ -11375,6 +11375,132 @@ kbd {
display: flex; display: flex;
} }
/* Folder grid (panels-ui.js openFolderGrid): one step BELOW the file preview, so a
tile opens its preview on top and closing that preview lands back on the grid. */
.folder-grid-overlay {
position: fixed;
inset: 0;
background: var(--modal-backdrop);
z-index: 5090;
display: none;
align-items: center;
justify-content: center;
}
.folder-grid-overlay.visible {
display: flex;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.folder-grid-window {
width: 92vw;
max-width: 1400px;
height: 88vh;
background: var(--floating-bg);
border: 1px solid var(--control-border);
border-radius: 12px;
box-shadow: var(--elevated-shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.folder-grid-folders {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--border);
max-height: 6.5rem;
overflow-y: auto;
flex-shrink: 0;
}
.folder-grid-folders:empty {
display: none;
}
.folder-grid-chip {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--control-border);
background: var(--bg-input);
color: var(--text);
cursor: pointer;
}
.folder-grid-chip:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}
.folder-grid-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
background: var(--bg-dark);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-auto-rows: min-content;
gap: 0.6rem;
}
.folder-grid-tile {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.3rem;
border: 1px solid transparent;
border-radius: 8px;
background: none;
color: var(--text);
cursor: pointer;
text-align: left;
min-width: 0;
}
.folder-grid-tile:hover,
.folder-grid-tile.current {
border-color: var(--accent, #4ea1ff);
}
.folder-grid-thumb {
aspect-ratio: 4 / 3;
border-radius: 6px;
background: var(--bg-input);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-muted, #888);
}
.folder-grid-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.folder-grid-name {
font-family: var(--font-mono);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-grid-empty {
grid-column: 1 / -1;
padding: 2rem;
text-align: center;
color: var(--text-muted, #888);
}
.file-preview-window { .file-preview-window {
width: 80vw; width: 80vw;
max-width: 900px; max-width: 900px;
@@ -19386,7 +19512,8 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
/* No gutter of their own. */ /* No gutter of their own. */
.modal, .modal,
.file-preview-overlay { .file-preview-overlay,
.folder-grid-overlay {
padding-right: var(--fold-inline-end); padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end); padding-bottom: var(--fold-block-end);
} }
+48 -15
View File
@@ -2017,6 +2017,17 @@ Object.assign(CodemanApp.prototype, {
}); });
}, },
/**
* A clicked path as the preview needs it: relative paths resolved against the
* session's working directory, absolute and `~/` paths unchanged
* (`resolveLinkedFilePath`, constants.js). Shared by the terminal link
* provider and the response viewer, so one path opens the same file in both.
*/
_resolveLinkedFilePath(filePath, sessionId) {
if (typeof resolveLinkedFilePath !== 'function') return filePath;
return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir);
},
/** /**
* Register a custom link provider for xterm.js that detects file paths * Register a custom link provider for xterm.js that detects file paths
* in terminal output and makes them clickable. * in terminal output and makes them clickable.
@@ -2069,10 +2080,14 @@ Object.assign(CodemanApp.prototype, {
// both continuation kinds, the indent a hard wrap leaves on its // both continuation kinds, the indent a hard wrap leaves on its
// continuation, and the offset↔cell mapping — because touch selection // continuation, and the offset↔cell mapping — because touch selection
// measures the SAME lines and the two must not disagree. // measures the SAME lines and the two must not disagree.
// `terminalPathLine` adds one thing on top, for links only: a path the
// program broke over lines itself (Claude Code wraps tool output short of
// the edge) is glued back together, so `…/captures/2` + `026-10-10/x.png`
// links as the one file it is.
// Bounded so a screenful of full-width output (wide tables, box drawing) // Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport. // cannot make every hover stitch and re-scan the entire viewport.
const MAX_STITCHED_ROWS = 12; const MAX_STITCHED_ROWS = 12;
const logical = window.CodemanTerminalLines?.terminalLogicalLine( const logical = window.CodemanTerminalLines?.terminalPathLine(
buffer, buffer,
bufferLineNumber - 1, bufferLineNumber - 1,
terminal.cols, terminal.cols,
@@ -2090,12 +2105,18 @@ Object.assign(CodemanApp.prototype, {
return { x: cell.col + 1, y: cell.row + 1 }; return { x: cell.col + 1, y: cell.row + 1 };
}; };
if (!lineText || !lineText.includes('/')) { // A path needs a `/` or an extension's `.`; a line with neither has no link.
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
callback(undefined); callback(undefined);
return; return;
} }
const links = []; const links = [];
// Text offsets of every link so far: a later pattern must not cut a
// second, overlapping link out of one already found (a relative `b.png`
// inside a URL's `?img=b.png`).
const taken = [];
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority // Pattern 0: URLs (https://, http://) — matched first so they take priority
// //
@@ -2115,6 +2136,7 @@ Object.assign(CodemanApp.prototype, {
const start = coordAt(startCol); const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length); const end = coordAt(startCol + cleaned.length);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
taken.push({ start: startCol, end: startCol + cleaned.length });
links.push({ links.push({
text: cleaned, text: cleaned,
@@ -2144,27 +2166,28 @@ Object.assign(CodemanApp.prototype, {
// the whole tab on hover. Non-empty token + bounded reps is O(n). // the whole tab on hover. Non-empty token + bounded reps is O(n).
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions. Image/PDF/media extensions are // Pattern 2: Paths with common extensions — absolute, `~/` and relative
// (`findFilePathLinks`, constants.js). Image/PDF/media extensions are
// included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and // included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and
// screenshots an agent just wrote are clickable; those open the file // screenshots an agent just wrote are clickable; those open the file
// preview rather than the log viewer (see addLink). // preview rather than the log viewer (see addLink).
// //
// The literal lives in constants.js because the response viewer linkifies // The patterns live in constants.js because the response viewer linkifies
// the SAME paths out of markdown — one definition, two consumers. A fresh // the SAME paths out of markdown — one definition, two consumers.
// instance per call: `lastIndex` is per-object state.
const extPattern = absoluteFilePathPattern();
// Pattern 3: Bash() tool output // Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
const addLink = (filePath, matchIndex) => { const addLink = (filePath, matchIndex, folder = false) => {
const startCol = lineText.indexOf(filePath, matchIndex); const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return; if (startCol === -1) return;
const start = coordAt(startCol); const start = coordAt(startCol);
const end = coordAt(startCol + filePath.length); const end = coordAt(startCol + filePath.length);
// Skip if already have link at this position // Skip if already have link at this position, or one overlapping it
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
if (isTaken(startCol, filePath.length)) return;
taken.push({ start: startCol, end: startCol + filePath.length });
links.push({ links.push({
text: filePath, text: filePath,
@@ -2187,12 +2210,23 @@ Object.assign(CodemanApp.prototype, {
// path clicked in the response viewer previewed fine. The preview // path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external // reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree. // paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId(); const sessionId = getSessionId();
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) { const target = self._resolveLinkedFilePath(text, sessionId);
self.openFilePreview(text, sessionId); // A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return; return;
} }
self.openLogViewerWindow(text, sessionId); if (previewsInFileViewer(target) || self._isExternalPreviewPath(target, sessionId)) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
}, },
hover() { hover() {
setHovered(true); setHovered(true);
@@ -2216,9 +2250,8 @@ Object.assign(CodemanApp.prototype, {
addLink(match[2], match.index); addLink(match[2], match.index);
} }
extPattern.lastIndex = 0; for (const found of findFilePathLinks(lineText)) {
while ((match = extPattern.exec(lineText)) !== null) { addLink(found.path, found.index, !!found.folder);
addLink(match[1], match.index);
} }
bashPattern.lastIndex = 0; bashPattern.lastIndex = 0;
+9
View File
@@ -17,6 +17,15 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
import { SseEvent } from './sse-events.js'; import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js'; import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js'; import type { EventPort } from './ports/event-port.js';
/**
* Longest path PARAMETER the router accepts (Fastify's default is 100). A file
* name goes up to 255 characters and arrives percent-encoded, so the folder
* grid's `…/files/:name/raw` answered 404 "Route not found" for a name the
* listing happily showed. Raw length, not decoded: 255 four-byte characters are
* 3060 characters of `%XX`.
*/
export const MAX_ROUTE_PARAM_LENGTH = 4096;
import type { AuthSessionRecord } from './ports/auth-port.js'; import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js'; import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js'; import { dataPath } from '../config/instance.js';
+242 -30
View File
@@ -23,6 +23,7 @@ import type {
} from '../../types.js'; } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { compileFileQuery } from '../../utils/file-query.js'; import { compileFileQuery } from '../../utils/file-query.js';
import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js';
import { fileStreamManager } from '../../file-stream-manager.js'; import { fileStreamManager } from '../../file-stream-manager.js';
import { import {
AUDIO_ATTACHMENT_EXTENSIONS, AUDIO_ATTACHMENT_EXTENSIONS,
@@ -30,12 +31,21 @@ import {
attachmentRecordToEvent, attachmentRecordToEvent,
attachmentRegistry, attachmentRegistry,
buildFileThumbnailRoute, buildFileThumbnailRoute,
getAttachmentType,
isSupportedAttachmentExtension, isSupportedAttachmentExtension,
registerExternalAttachment, registerExternalAttachment,
TEXT_ATTACHMENT_EXTENSIONS, TEXT_ATTACHMENT_EXTENSIONS,
VIDEO_ATTACHMENT_EXTENSIONS, VIDEO_ATTACHMENT_EXTENSIONS,
type AttachmentRecord, type AttachmentRecord,
} from '../../attachment-registry.js'; } from '../../attachment-registry.js';
import {
FolderListingError,
folderFilePath,
folderGrants,
folderAllowedForUser,
listFolder,
validFolderFileName,
} from '../../folder-listing.js';
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
@@ -426,6 +436,40 @@ async function resolveServableRemoteAttachment(
return { path: probe.realPath, probe }; return { path: probe.realPath, probe };
} }
/**
* Stream a guard-checked file ({@link resolveServableAttachmentPath}) — the body of
* the attachment raw route, shared with the folder-grid file route.
*
* A remote file streams over ssh exactly like file-raw, with the same 200/206/416
* contract, and its size comes from the guard's own re-probe — so serving a remote
* file needs no stat the local branch would not also need.
*/
async function serveServableRaw(
reply: FastifyReply,
servable: ServableAttachment,
remote: SessionRemote | undefined,
fileName: string,
extension: string,
query: { download?: string; preview?: string; range?: string }
): Promise<void> {
try {
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(extension, query, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(reply, target, size, fileName, extension, query.download === 'true', query.range);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
}
/** /**
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and * Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
* stream it inline. PDF/PNG and text formats don't need conversion — callers * stream it inline. PDF/PNG and text formats don't need conversion — callers
@@ -520,8 +564,20 @@ function getKnownSessionWorkingDir(
interface SessionFileScope { interface SessionFileScope {
workingDir: string; workingDir: string;
remote?: SessionRemote; remote?: SessionRemote;
/** The session runs inside a Docker case: a local session for these routes, but `~` is the container's. */
docker?: boolean;
} }
/**
* A Docker case is a LOCAL session for the file routes (the workspace is a
* bind mount, so its paths are host paths), but `~` inside the container is the
* container's own seeded home, not this host's. Expanding `~/x.png` against the
* host's home would open a different file than the one the agent printed, so
* the form is refused instead of guessed.
*/
const DOCKER_HOME_PATH_ERROR =
'A ~/ path inside a Docker case is the container home, which this server cannot read; use the absolute path';
/** /**
* The file-access scope of a session, live or persisted — the `workingDir`-only * The file-access scope of a session, live or persisted — the `workingDir`-only
* variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes * variant of {@link getKnownSessionWorkingDir}, plus the remote metadata the routes
@@ -539,12 +595,12 @@ function getKnownSessionFileScope(
const user = getAuthUser(req); const user = getAuthUser(req);
const liveSession = ctx.sessions.get(sessionId); const liveSession = ctx.sessions.get(sessionId);
if (liveSession && canAccessOwned(user, liveSession.owner)) { if (liveSession && canAccessOwned(user, liveSession.owner)) {
return { workingDir: liveSession.workingDir, remote: liveSession.remote }; return { workingDir: liveSession.workingDir, remote: liveSession.remote, docker: !!liveSession.docker };
} }
const stored = ctx.store.getSession(sessionId); const stored = ctx.store.getSession(sessionId);
if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) { if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) {
return { workingDir: stored.workingDir, remote: stored.remote }; return { workingDir: stored.workingDir, remote: stored.remote, docker: !!stored.docker };
} }
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`)); reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
@@ -2143,6 +2199,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path')); reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path'));
return; return;
} }
if (session.docker && body.path.startsWith('~/')) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try { try {
// A remote case registers a path that lives on the REMOTE host: the guard and // A remote case registers a path that lives on the REMOTE host: the guard and
@@ -2263,34 +2323,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}); });
if (!servable) return; if (!servable) return;
try { await serveServableRaw(reply, servable, session.remote, record.fileName, record.extension, {
// A remote record streams over ssh exactly like file-raw, with the same download,
// 200/206/416 contract, and its size comes from the guard's own re-probe — so preview,
// serving a remote attachment needs no stat the local branch would not also need. range: req.headers.range,
const remote = session.remote; });
const target: FileTarget =
servable.probe && remote
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(
reply,
target,
size,
record.fileName,
record.extension,
download === 'true',
req.headers.range
);
} catch (err) {
reply
.code(err instanceof RemoteFileAccessError ? 502 : 500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
}); });
// Serve a converted PDF preview of a registered attachment by id. Office docs // Serve a converted PDF preview of a registered attachment by id. Office docs
@@ -2354,6 +2391,181 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
await serveThumbnail(reply, servable.path, record.extension); await serveThumbnail(reply, servable.path, record.extension);
}); });
// ===== Folder grid =====
// Grant + list a folder (absolute or `~/` on the session's host) for the preview
// grid. Same guard as a single external attachment; the grid then fetches files by
// `folderId + name`, so no browser request carries a path. See folder-listing.ts.
app.post('/api/sessions/:id/folders', async (req, reply) => {
const { id } = req.params as { id: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return;
const body = (req.body || {}) as { path?: unknown };
if (!body.path || typeof body.path !== 'string') {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing folder path'));
return;
}
if (scope.docker && body.path.startsWith('~/')) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, DOCKER_HOME_PATH_ERROR));
return;
}
try {
const listing = await listFolder(id, body.path, {
sessionWorkingDir: scope.workingDir,
remote: scope.remote,
user: getAuthUser(req),
});
return { success: true, data: listing };
} catch (err) {
if (err instanceof FolderListingError) {
const code =
err.statusCode === 404
? ApiErrorCode.NOT_FOUND
: err.statusCode >= 500
? ApiErrorCode.OPERATION_FAILED
: ApiErrorCode.INVALID_INPUT;
reply.code(err.statusCode).send(createErrorResponse(code, err.message));
return;
}
return reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to list folder: ${getErrorMessage(err)}`));
}
});
/**
* One file of a granted folder, guard-checked like an attachment: the name is a
* bare file name of a supported type, and the joined path goes through the same
* {@link resolveServableAttachmentPath} (blocklist, workspace confinement, symlink
* re-resolution, remote re-probe) an attachment by id does. The multi-user rule
* ({@link folderAllowedForUser}) runs again on the resolved file: a symlink inside
* the granted folder must not lead into another user's space.
*/
async function resolveFolderFile(
req: FastifyRequest,
reply: FastifyReply
): Promise<{ scope: SessionFileScope; servable: ServableAttachment; name: string; extension: string } | null> {
const { id, folderId, name } = req.params as { id: string; folderId: string; name: string };
const scope = getKnownSessionFileScope(ctx, id, reply, req);
if (!scope) return null;
const grant = folderGrants.get(id, folderId);
if (!grant) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found'));
return null;
}
const fileName = validFolderFileName(name);
if (!fileName) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file name'));
return null;
}
const filePath = folderFilePath(grant.dir, fileName);
if (!scope.remote) {
// Bounded, like the folder itself: the grant may point at a mount that has stopped answering since.
const kind = await probePathKind(filePath, { pastCap: true });
if (kind === 'unknown') {
reply
.code(503)
.send(
createErrorResponse(ApiErrorCode.OPERATION_FAILED, describeUnknownPath('File', filePath, { pastCap: true }))
);
return null;
}
if (kind !== 'file') {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return null;
}
}
const extension = extname(fileName).toLowerCase().replace(/^\./, '');
const record: AttachmentRecord = {
attachmentId: '',
sessionId: id,
filePath,
fileName,
extension,
attachmentType: getAttachmentType(extension),
size: 0,
mtimeMs: 0,
timestamp: 0,
source: 'external',
};
const servable = await resolveServableAttachmentPath(reply, record, scope);
if (!servable) return null;
// The requested NAME only opens the door; registration judges the file a
// symlink resolves to (supported extension, regular file) and so does this.
// Without it `shot.png -> ../secret.bin` is served, and a FIFO or a device
// behind a supported name parks a worker on an open that never returns.
if (!isSupportedAttachmentExtension(extname(servable.path))) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Unsupported attachment type'));
return null;
}
const isRegularFile = scope.remote
? servable.probe?.kind === 'file'
: await fs.stat(servable.path).then(
(stat) => stat.isFile(),
() => false
);
if (!isRegularFile) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Attachment path is not a file'));
return null;
}
if (!folderAllowedForUser(servable.path, getAuthUser(req), scope)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File is outside your space'));
return null;
}
return { scope, servable, name: fileName, extension };
}
app.get('/api/sessions/:id/folders/:folderId/files/:name/raw', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
const { download, preview } = req.query as { download?: string; preview?: string };
await serveServableRaw(reply, file.servable, file.scope.remote, file.name, file.extension, {
download,
preview,
range: req.headers.range,
});
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/preview', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.extension !== 'docx' && file.extension !== 'pptx') {
const { id, folderId } = req.params as { id: string; folderId: string };
reply.redirect(
`/api/sessions/${encodeURIComponent(id)}/folders/${encodeURIComponent(folderId)}/files/${encodeURIComponent(file.name)}/raw`
);
return;
}
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Office document preview is not available for files in a remote (SSH) case'
)
);
return;
}
await serveConvertedPreview(reply, file.servable.path, file.name, file.extension);
});
app.get('/api/sessions/:id/folders/:folderId/files/:name/thumbnail', async (req, reply) => {
const file = await resolveFolderFile(req, reply);
if (!file) return;
if (file.servable.probe) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Thumbnails are not available for files in a remote (SSH) case'
)
);
return;
}
await serveThumbnail(reply, file.servable.path, file.extension);
});
// Serve converted document previews for a workspace-relative path. DOCX/PPTX // Serve converted document previews for a workspace-relative path. DOCX/PPTX
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw. // are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
app.get('/api/sessions/:id/file-preview', async (req, reply) => { app.get('/api/sessions/:id/file-preview', async (req, reply) => {
+11 -2
View File
@@ -75,6 +75,7 @@ import {
import { imageWatcher } from '../image-watcher.js'; import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js'; import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js'; import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { folderGrants } from '../folder-listing.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js'; import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js'; import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts } from '../custom-model-hosts.js'; import { readCustomModelHosts } from '../custom-model-hosts.js';
@@ -158,6 +159,7 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js'; import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js'; import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js'; import { SseEvent } from './sse-events.js';
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js'; import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js'; import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js'; import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
@@ -432,9 +434,14 @@ export class WebServer extends EventEmitter {
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(stripBasePath(this.basePath, req.url || '')); const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(stripBasePath(this.basePath, req.url || ''));
if (https) { if (https) {
const { key, cert } = getOrCreateSelfSignedCert(); const { key, cert } = getOrCreateSelfSignedCert();
this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl }); this.app = Fastify({
logger: false,
https: { key, cert },
rewriteUrl,
routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH },
});
} else { } else {
this.app = Fastify({ logger: false, rewriteUrl }); this.app = Fastify({ logger: false, rewriteUrl, routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH } });
} }
this.mux = createMultiplexer(); this.mux = createMultiplexer();
this.sse = new SseStreamManager( this.sse = new SseStreamManager(
@@ -1526,6 +1533,7 @@ export class WebServer extends EventEmitter {
fileStreamManager.closeSessionStreams(sessionId); fileStreamManager.closeSessionStreams(sessionId);
// Drop live external attachment registrations for this session // Drop live external attachment registrations for this session
attachmentRegistry.clearSession(sessionId); attachmentRegistry.clearSession(sessionId);
folderGrants.clearSession(sessionId);
// Stop watching for images in this session's directory // Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId); imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session. The dir belongs to the // Clean up pasted images directory for this session. The dir belongs to the
@@ -3436,6 +3444,7 @@ export class WebServer extends EventEmitter {
// otherwise meet the retry, which reuses the same session id by design. // otherwise meet the retry, which reuses the same session id by design.
this.stopTranscriptWatcher(sessionId); this.stopTranscriptWatcher(sessionId);
attachmentRegistry.clearSession(sessionId); attachmentRegistry.clearSession(sessionId);
folderGrants.clearSession(sessionId);
sessionWaits.notifySignal(sessionId, 'exit'); sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId); sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended'); approvalInbox.resolveForSession(sessionId, 'session_ended');
+322
View File
@@ -0,0 +1,322 @@
/**
* @fileoverview Clickable file paths beyond the rooted absolute form: `~/…`,
* relative paths, and paths a program broke over lines itself.
*
* - `findFilePathLinks` / `resolveLinkedFilePath` / `terminalPathLine`
* (constants.js) on the shapes real agent output has: Claude Code echoes an
* attached image as `› [image] ~/…/garage.png`, scripts print
* `builds/captures/…png`, and Claude Code wraps tool output a few columns
* short of the edge, so a long path arrives as `…/captures/2` + `026-10-10/x.png`.
* - The terminal link provider turns those into ONE link and opens the resolved
* path (relative against the session's working directory, `~/` as is).
* - The server expands `~/` (local: this host's home; remote: the probe on the
* remote host), so the attachment route can serve it.
*
* Real code under test: constants.js + terminal-ui.js in a `vm` context, and
* the shipped attachment registry / probe script.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { registerExternalAttachment } from '../src/attachment-registry.js';
import { buildRemoteProbeCommand, parseRemoteProbeOutput } from '../src/remote-files.js';
type Line = {
startRow: number;
endRow: number;
text: string;
offsetToCell: (o: number) => { row: number; col: number };
};
type Helpers = {
findFilePathLinks: (text: string) => Array<{ path: string; index: number }>;
resolveLinkedFilePath: (path: string, workingDir?: string) => string;
terminalPathLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => Line | null;
};
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
function loadHelpers(): Helpers {
const context = vm.createContext({});
vm.runInContext(
`${read('constants.js')}\nglobalThis.__h = { findFilePathLinks, resolveLinkedFilePath, terminalPathLine };`,
context,
{ filename: 'constants.js' }
);
return (context as { __h: Helpers }).__h;
}
const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpers();
const paths = (text: string) => findFilePathLinks(text).map((f) => f.path);
/** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */
function buffer(lines: string[], cols = 80) {
return {
length: lines.length,
getLine: (row: number) =>
row >= 0 && row < lines.length
? {
isWrapped: false,
translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)),
}
: undefined,
};
}
// Verbatim from a live pane (2026-10-10): the tool output wrapped at ~64 columns.
const BROKEN = [
' ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2',
' 026-10-10/ghost1b_gameplay.png err=0 kamera=(1108,13,811',
' ) blick=',
];
const BROKEN_PATH = '/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/ghost1b_gameplay.png';
describe('findFilePathLinks', () => {
it('links home-relative and relative paths', () => {
expect(paths(' › [image] ~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png (1.1MB)')).toEqual([
'~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png',
]);
expect(paths('2 Bericht: refs/shaderpatch_pixel_1010/BERICHT.md. Alle 12')).toEqual([
'refs/shaderpatch_pixel_1010/BERICHT.md',
]);
expect(paths('./a.md ../b.json look_montage.png')).toEqual(['./a.md', '../b.json', 'look_montage.png']);
});
it('keeps brackets, `=` and trailing punctuation out of a relative path', () => {
expect(paths('1. Garage-BG (garage_plate.png) — Bühne')).toEqual(['garage_plate.png']);
expect(paths('OUT=builds/captures/x.png, "shots/b.webp"')).toEqual(['builds/captures/x.png', 'shots/b.webp']);
expect(paths('see src/a.ts:120.')).toEqual(['src/a.ts']);
});
it('still finds rooted absolute paths, and never a relative path inside one', () => {
expect(paths('sign-off from /mnt/raid1/x/refs/look_montage.png.')).toEqual(['/mnt/raid1/x/refs/look_montage.png']);
// Unknown root: not linked at all (no `nix/store/…` cut out of it).
expect(paths('/nix/store/abc-x/share/icon.png')).toEqual([]);
// `/etc` stays out on purpose (the server refuses that tree).
expect(paths('/etc/app/config.json')).toEqual([]);
});
it('never cuts a path out of a web URL, but keeps file:// paths', () => {
expect(paths('https://x.io/a/home/b.png?img=c.png')).toEqual([]);
expect(paths('file:///home/joe/x.png')).toEqual(['/home/joe/x.png']);
});
it('reports where each path starts', () => {
const text = 'a ~/x.png b /tmp/y.pdf c z.md';
for (const { path, index } of findFilePathLinks(text)) expect(text.slice(index, index + path.length)).toBe(path);
});
});
describe('resolveLinkedFilePath', () => {
it('resolves relative paths against the working directory, collapsing . and ..', () => {
expect(resolveLinkedFilePath('builds/x.png', '/mnt/ws')).toBe('/mnt/ws/builds/x.png');
expect(resolveLinkedFilePath('./a/./b.md', '/mnt/ws/')).toBe('/mnt/ws/a/b.md');
expect(resolveLinkedFilePath('../other/x.png', '/mnt/ws')).toBe('/mnt/other/x.png');
});
it('leaves absolute and ~/ paths alone, and relative ones without a working directory', () => {
expect(resolveLinkedFilePath('/tmp/a.png', '/mnt/ws')).toBe('/tmp/a.png');
expect(resolveLinkedFilePath('~/a.png', '/mnt/ws')).toBe('~/a.png');
expect(resolveLinkedFilePath('a.png', undefined)).toBe('a.png');
});
});
describe('terminalPathLine', () => {
it('glues a path broken short of the edge back together, from either row', () => {
for (const row of [0, 1]) {
const line = terminalPathLine(buffer(BROKEN), row, 80)!;
expect([line.startRow, line.endRow]).toEqual([0, 1]);
const [found] = findFilePathLinks(line.text);
expect(found.path).toBe(BROKEN_PATH);
// The cells are exact on both rows: after ` ⎿ BILD ` and after the 5-column indent.
expect(line.offsetToCell(found.index)).toEqual({ row: 0, col: 10 });
expect(line.offsetToCell(found.index + found.path.length - 1)).toEqual({ row: 1, col: 34 });
}
expect(terminalPathLine(buffer(BROKEN), 2, 80)).toMatchObject({ startRow: 2, endRow: 2 });
});
it('follows a path over three rows', () => {
const rows = ['x /tmp/claude-1001/-home-joe-cases', ' -neon/de4576ce-aaaaaaaaaaaaa', ' -56b4/shot.png'];
const line = terminalPathLine(buffer(rows), 1, 80)!;
expect(paths(line.text)).toEqual(['/tmp/claude-1001/-home-joe-cases-neon/de4576ce-aaaaaaaaaaaaa-56b4/shot.png']);
});
it('does not glue lines that are not a broken path', () => {
// A shorter line ending on a directory, then a longer one: not a wrap.
expect(terminalPathLine(buffer(['cd /mnt/raid1/foo', ' file.png erstellt']), 0, 80)).toMatchObject({ endRow: 0 });
// A chain that never reaches a file is dropped.
const listing = terminalPathLine(buffer(['cd ~/repos/neon', 'ls', 'a.png']), 2, 80)!;
expect(paths(listing.text)).toEqual(['a.png']);
// A path that already ended, and a next line opening a new rooted path.
expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 });
});
// `git ls-files`, `find` and `ls -R` print one path per row. An extensionless
// entry (a directory, `Dockerfile`, `.gitignore`) followed by an entry that is
// equal or shorter looked exactly like a path broken short of the edge, and the
// two were glued: `src/components` + `src/index.ts` linked `src/componentssrc/index.ts`
// and the real `src/index.ts` link was gone (Ark0N, PR #604, round 2).
describe('a listing is never glued into one path', () => {
const LISTING = [
'src/components',
'src/index.ts',
'src/lib',
'./index.ts',
'../docs/README.md',
'packages/gesture-control/.gitignore',
'packages/gesture-control/README.md',
'packages/gesture-control/package.json',
'data/runs/a.jsonl',
'data/runs/b.jsonl',
'data/runs/c.jsonl',
'data/runs/d.json',
];
it('keeps every row its own line', () => {
const buf = buffer(LISTING, 120);
LISTING.forEach((entry, row) => {
const line = terminalPathLine(buf, row, 120)!;
expect([line.startRow, line.endRow], entry).toEqual([row, row]);
expect(line.text, entry).toBe(entry);
});
});
it('links the entry that used to be swallowed', () => {
const buf = buffer(LISTING, 120);
expect(paths(terminalPathLine(buf, 1, 120)!.text)).toEqual(['src/index.ts']);
});
it('still glues a path whose next row opens on a different directory (a real wrap)', () => {
// The control: `…/captures/2` + `026-10-10/…` has a `/` in its head too.
const line = terminalPathLine(buffer(BROKEN), 0, 80)!;
expect([line.startRow, line.endRow]).toEqual([0, 1]);
expect(paths(line.text)).toEqual([BROKEN_PATH]);
});
});
});
type Link = {
text: string;
range: { start: { x: number; y: number }; end: { x: number; y: number } };
activate: (ev: unknown, text: string) => void;
};
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
function loadApp() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
return app;
}
function linksOn(lines: string[], row: number) {
const app = loadApp();
app.terminal = { cols: 80, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines) } };
const provider = app.registerFilePathLinkProvider() as Provider;
let out: Link[] = [];
provider.provideLinks(row, (links) => {
out = links || [];
});
return { app, links: out };
}
describe('terminal link provider', () => {
it('links a broken path once, across both rows, and opens the whole path', () => {
const { app, links } = linksOn(BROKEN, 2);
expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]);
expect(links[0].range.start).toEqual({ x: 11, y: 1 });
expect(links[0].range.end.y).toBe(2);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(BROKEN_PATH, 's1');
});
it('opens a relative path resolved against the working directory', () => {
const { app, links } = linksOn([' ⎿ wrote builds/captures/2026-10-10/a.png'], 1);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(
'/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/a.png',
's1'
);
});
it('opens a ~/ path as is, in the preview (the server expands it), even for text', () => {
const { app, links } = linksOn([' › [image] ~/repos/x/garage.png', 'notes ~/logs/run.log'], 2);
links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith('~/logs/run.log', 's1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('opens a relative text file in the log viewer by its resolved path', () => {
const { app, links } = linksOn(['tail logs/run.log'], 1);
links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/build/neon_getaway-mgr-hebel/logs/run.log', 's1');
});
it('never adds a path link overlapping a URL link', () => {
const { links } = linksOn(['see https://x.io/view?img=shots/a.png now'], 1);
expect(links.map((l) => l.text)).toEqual(['https://x.io/view?img=shots/a.png']);
});
});
describe('server: ~/ expands on the session host', () => {
const root = mkdtempSync(join(tmpdir(), 'codeman-tilde-'));
afterAll(() => rmSync(root, { recursive: true, force: true }));
it('a local session registers ~/… against this host home', async () => {
// test/setup.ts points HOME at a temp dir, so this never touches the real home.
const dir = join(homedir(), 'tilde-link-test');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'shot.png'), 'PNG');
try {
const event = await registerExternalAttachment('tilde-session', '~/tilde-link-test/shot.png', {});
expect(event.attachmentId).toMatch(/^att_/);
expect(event.size).toBe(3);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('a relative path is still refused', async () => {
await expect(registerExternalAttachment('tilde-session', 'shots/a.png', {})).rejects.toThrow(/absolute/);
});
it('the remote probe expands ~/ to the remote $HOME, and reports the absolute path', () => {
writeFileSync(join(root, 'remote.png'), 'PNGPNG');
const requested = ['~/remote.png', '~/missing.png'];
const stdout = execFileSync('sh', ['-c', buildRemoteProbeCommand(requested)], {
env: { ...process.env, HOME: root },
}).toString();
const [found, missing] = parseRemoteProbeOutput(stdout, requested);
expect(found).toMatchObject({ kind: 'file', size: 6 });
expect(found?.realPath.endsWith('/remote.png')).toBe(true);
expect(found?.realPath.startsWith('/')).toBe(true);
expect(missing).toBeNull();
});
});
+1 -1
View File
@@ -298,7 +298,7 @@ describe('fold reserved region: every centred overlay is covered', () => {
it('finds the overlays it is meant to guard', () => { it('finds the overlays it is meant to guard', () => {
// A rename that empties this list would turn every assertion below into a // A rename that empties this list would turn every assertion below into a
// no-op, so the count is pinned. // no-op, so the count is pinned.
expect(CENTRED_OVERLAYS.length).toBe(7); expect(CENTRED_OVERLAYS.length).toBe(8);
}); });
it.each(CENTRED_OVERLAYS.map((o) => [o.selector, o] as const))('%s keeps its dialog out of the hinge', (_, o) => { it.each(CENTRED_OVERLAYS.map((o) => [o.selector, o] as const))('%s keeps its dialog out of the hinge', (_, o) => {
+416
View File
@@ -0,0 +1,416 @@
/**
* @fileoverview Folder grid, client side.
*
* - Folder paths are links (`findFilePathLinks` marks them `folder: true`): rooted
* and `~/` paths whose last segment has no extension, relative ones only with a
* trailing slash. File links win any overlap; prose and URLs stay plain text.
* - Both consumers route a folder link to `openFolderGrid`.
* - `openFolderGrid` grants the folder (`POST …/folders`), renders tiles from the
* folder's by-name routes, and falls back to the file preview when the server
* finds a file there.
* - A tile's preview renders from the folder routes (never the attachment
* registry), pages with ‹ › / ←/→ (wrapping), and the arrows are kept away from
* the terminal; "Show folder" opens the grid of a previewed file's folder.
*
* Real code under test: constants.js + panels-ui.js in a `vm` context against a
* stub app and a minimal fake DOM (no jsdom), as in file-preview-detach.test.ts.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
type Found = { path: string; index: number; folder?: true };
const constants = vm.createContext({});
vm.runInContext(`${read('constants.js')}\nglobalThis.__h = { findFilePathLinks, resolveLinkedFilePath };`, constants, {
filename: 'constants.js',
});
const { findFilePathLinks, resolveLinkedFilePath } = (
constants as unknown as {
__h: { findFilePathLinks: (t: string) => Found[]; resolveLinkedFilePath: (p: string, w?: string) => string };
}
).__h;
const folders = (text: string) =>
findFilePathLinks(text)
.filter((f) => f.folder)
.map((f) => f.path);
describe('folder links (findFilePathLinks)', () => {
it('links rooted, ~/ and slash-terminated relative folders', () => {
expect(folders('post mal /mnt/raid1/neon_getaway/styleframes')).toEqual(['/mnt/raid1/neon_getaway/styleframes']);
expect(folders('ls ~/repos/neon-menu/assets/')).toEqual(['~/repos/neon-menu/assets/']);
expect(folders('see builds/captures/ next')).toEqual(['builds/captures/']);
expect(folders('cd /home/joe/.codeman/app.')).toEqual(['/home/joe/.codeman/app']);
expect(folders('in (/mnt/raid1/foo), ok')).toEqual(['/mnt/raid1/foo']);
});
it('leaves files, prose, bare roots and URLs alone', () => {
expect(folders('/home/joe/a.png and /home/joe/a.rs')).toEqual([]);
expect(folders('Node.js and/or foo')).toEqual([]);
expect(folders('/home/ alone, ~/ alone')).toEqual([]);
expect(folders('https://x.io/home/joe/dir and http://host/a/')).toEqual([]);
expect(folders('/etc/nginx is not a link root')).toEqual([]);
});
it('ends a folder path at a colon (PATH lists), and stays linear on colon runs', () => {
expect(folders('export PATH=/home/u/bin:/usr/bin')).toEqual(['/home/u/bin']);
expect(folders('dirs /mnt/a/frames:/mnt/b/frames')).toEqual(['/mnt/a/frames', '/mnt/b/frames']);
const start = Date.now();
findFilePathLinks('a:'.repeat(16000) + '//');
expect(Date.now() - start).toBeLessThan(100);
});
it('keeps the file link where both could match, and orders by position', () => {
const found = findFilePathLinks('dir /mnt/a/frames then /mnt/a/frames/x.png');
expect(found.map((f) => [f.path, !!f.folder])).toEqual([
['/mnt/a/frames', true],
['/mnt/a/frames/x.png', false],
]);
});
});
describe('folder links are routed to the grid', () => {
it('terminal provider opens folder links in the grid', () => {
const src = read('terminal-ui.js');
expect(src).toMatch(/addLink\(found\.path, found\.index, !!found\.folder\)/);
expect(src).toMatch(/if \(folder\) \{\s*self\.openFolderGrid\(target, sessionId\);/);
});
it('response viewer marks folder links and opens them in the grid', () => {
const src = read('app.js');
expect(src).toMatch(/if \(folder\) link\.dataset\.folder = '1';/);
expect(src).toMatch(/pathLink\.dataset\.folder\) this\.openFolderGrid\(/);
});
it('ships the grid overlay and the preview buttons', () => {
const html = read('index.html');
for (const id of [
'folderGridOverlay',
'folderGridTitle',
'folderGridFolders',
'folderGridBody',
'folderGridFooter',
'folderGridUpBtn',
'filePreviewPrevBtn',
'filePreviewNextBtn',
'filePreviewFolderBtn',
]) {
expect(html, id).toContain(`id="${id}"`);
}
});
});
// ---------- panels-ui.js against a stub app ----------
type FakeEl = {
tagName: string;
children: FakeEl[];
textContent: string;
innerHTML: string;
className: string;
dataset: Record<string, string>;
hidden?: boolean;
disabled?: boolean;
title?: string;
src?: string;
onclick?: () => void;
onerror?: () => void;
classList: { add: (c: string) => void; remove: (c: string) => void; contains: (c: string) => boolean };
appendChild: (c: FakeEl) => void;
append: (...c: FakeEl[]) => void;
querySelector: (sel: string) => FakeEl | null;
querySelectorAll: (sel: string) => FakeEl[];
scrollIntoView: () => void;
};
function el(tagName = 'div'): FakeEl {
const classes = new Set<string>();
const node: FakeEl = {
tagName: tagName.toUpperCase(),
children: [],
textContent: '',
className: '',
dataset: {},
get innerHTML() {
return '';
},
set innerHTML(_v: string) {
node.children = [];
},
classList: {
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
contains: (c) => classes.has(c) || node.className.split(' ').includes(c),
},
appendChild: (c) => void node.children.push(c),
append: (...c) => void node.children.push(...c),
querySelector: () => null,
querySelectorAll: () => [],
scrollIntoView: () => {},
} as FakeEl;
return node;
}
function loadApp(fetchImpl: (url: string, init?: { body?: string }) => unknown) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const keyHandlers: Array<(ev: unknown) => void> = [];
const context = vm.createContext({
CodemanApp,
console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
CodemanBase: { base: '', url: (p: string) => p },
resolveLinkedFilePath,
document: {
getElementById: () => null,
createElement: (t: string) => el(t),
addEventListener: (type: string, fn: (ev: unknown) => void) => {
if (type === 'keydown') keyHandlers.push(fn);
},
},
window: { addEventListener: vi.fn(), open: vi.fn() },
setTimeout,
clearTimeout,
confirm: () => true,
fetch: vi.fn(async (url: string, init?: { body?: string }) => fetchImpl(url, init)),
});
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const previewOverlay = el();
const gridOverlay = el();
const previewBody = el();
const gridBody = el();
const elements: Record<string, FakeEl> = {
filePreviewOverlay: previewOverlay,
filePreviewBody: previewBody,
filePreviewTitle: el(),
filePreviewFooter: el(),
filePreviewDetachBtn: el(),
filePreviewPrevBtn: Object.assign(el(), { hidden: true }),
filePreviewNextBtn: Object.assign(el(), { hidden: true }),
filePreviewFolderBtn: Object.assign(el(), { hidden: true }),
folderGridOverlay: gridOverlay,
folderGridTitle: el(),
folderGridFolders: el(),
folderGridBody: gridBody,
folderGridFooter: el(),
folderGridUpBtn: el(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app._resetFilePreviewEdit = () => {};
app.showToast = vi.fn();
app.formatFileSize = (n: number) => `${n} B`;
app.sessions = new Map([['s1', { workingDir: '/home/joe/wiki' }]]);
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-x' }));
return { app, elements, keyHandlers, fetch: context.fetch as ReturnType<typeof vi.fn> };
}
const LISTING = {
folderId: 'fld_1',
path: '/mnt/raid1/styleframes',
files: [
{ name: 'b.png', extension: 'png', attachmentType: 'image', size: 10, mtimeMs: 2 },
{ name: 'deck.pdf', extension: 'pdf', attachmentType: 'pdf', size: 20, mtimeMs: 1 },
{ name: 'clip.mp4', extension: 'mp4', attachmentType: 'video', size: 30, mtimeMs: 0 },
],
folders: ['old'],
truncated: false,
};
function ok(data: unknown) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}
describe('openFolderGrid', () => {
it('grants the folder and renders a tile per file from the folder routes', async () => {
const { app, elements, fetch } = loadApp(() => ok(LISTING));
await app.openFolderGrid('~/styleframes', 's1');
expect(fetch).toHaveBeenCalledWith('/api/sessions/s1/folders', expect.objectContaining({ method: 'POST' }));
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ path: '~/styleframes' });
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true);
expect(elements.folderGridTitle.textContent).toBe('/mnt/raid1/styleframes');
const tiles = elements.folderGridBody.children;
expect(tiles).toHaveLength(3);
const thumb = (i: number) => tiles[i].children[0];
expect(thumb(0).children[0].src).toBe('/api/sessions/s1/folders/fld_1/files/b.png/raw');
expect(thumb(1).children[0].src).toBe('/api/sessions/s1/folders/fld_1/files/deck.pdf/thumbnail');
expect(thumb(2).children).toHaveLength(0);
expect(thumb(2).textContent).toBe('MP4');
expect(elements.folderGridFolders.children.map((c) => c.textContent)).toEqual(['old/']);
expect(elements.folderGridFooter.textContent).toBe('3 files • 1 folder');
});
it('falls back to the file preview when the path is a file', async () => {
const { app } = loadApp(() => ({ ok: false, status: 400, json: async () => ({ error: 'Not a folder' }) }));
app.openFilePreview = vi.fn();
await app.openFolderGrid('/mnt/raid1/notes', 's1');
expect(app.openFilePreview).toHaveBeenCalledWith('/mnt/raid1/notes', 's1');
});
it('toasts a missing or blocked folder', async () => {
const { app, elements } = loadApp(() => ({
ok: false,
status: 404,
json: async () => ({ error: 'Folder not found' }),
}));
await app.openFolderGrid('/mnt/raid1/gone', 's1');
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Folder not found'), 'error');
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
});
});
describe('folder preview paging', () => {
async function openGrid() {
const ctx = loadApp(() => ok(LISTING));
await ctx.app.openFolderGrid('/mnt/raid1/styleframes', 's1');
return ctx;
}
it('previews a tile from the folder routes, never registering an attachment', async () => {
const { app, elements } = await openGrid();
elements.folderGridBody.children[0].onclick?.();
await Promise.resolve();
expect(app._registerExternalPreview).not.toHaveBeenCalled();
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/folders/fld_1/files/b.png/raw');
expect(elements.filePreviewTitle.textContent).toBe('/mnt/raid1/styleframes/b.png');
expect(elements.filePreviewPrevBtn.hidden).toBe(false);
expect(elements.filePreviewNextBtn.hidden).toBe(false);
expect(elements.filePreviewFolderBtn.hidden).toBe(true);
});
it('pages with wrap-around and keeps arrow keys away from the terminal', async () => {
const { app, elements, keyHandlers } = await openGrid();
elements.folderGridBody.children[0].onclick?.();
elements.filePreviewOverlay.classList.add('visible');
app.stepFolderPreview(-1);
expect(app.folderGrid.index).toBe(2);
expect(app.filePreviewDetachUrl).toBe('/api/sessions/s1/folders/fld_1/files/clip.mp4/raw');
const ev = {
key: 'ArrowRight',
target: { tagName: 'TEXTAREA', classList: { contains: (c: string) => c === 'xterm-helper-textarea' } },
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
keyHandlers.forEach((h) => h(ev));
expect(app.folderGrid.index).toBe(0);
expect(ev.stopPropagation).toHaveBeenCalled();
const typing = {
...ev,
target: { tagName: 'INPUT', classList: { contains: () => false } },
stopPropagation: vi.fn(),
};
keyHandlers.forEach((h) => h(typing));
expect(app.folderGrid.index).toBe(0);
expect(typing.stopPropagation).not.toHaveBeenCalled();
});
it('Escape closes the preview first, then the grid', async () => {
const { app, elements, keyHandlers } = await openGrid();
elements.folderGridBody.children[1].onclick?.();
elements.filePreviewOverlay.classList.add('visible');
const esc = () => ({ key: 'Escape', target: null, preventDefault: vi.fn(), stopPropagation: vi.fn() });
keyHandlers.forEach((h) => h(esc()));
expect(elements.filePreviewOverlay.classList.contains('visible')).toBe(false);
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true);
expect(app.folderGrid.index).toBeNull();
keyHandlers.forEach((h) => h(esc()));
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
expect(app.folderGrid).toBeNull();
});
});
describe('stale loads', () => {
const TEXT_LISTING = {
...LISTING,
path: '/mnt/raid1/logs',
files: [
{ name: 'a.log', extension: 'log', attachmentType: 'text', size: 1, mtimeMs: 2 },
{ name: 'b.log', extension: 'log', attachmentType: 'text', size: 1, mtimeMs: 1 },
],
};
const text = (body: string) => ({ ok: true, status: 200, text: async () => body });
const flush = () => new Promise((r) => setTimeout(r, 0));
it("paging past a text file that is still loading never shows its text under the next file's title", async () => {
const pending: Record<string, (v: unknown) => void> = {};
const { app, elements } = loadApp((url) =>
url.endsWith('/folders') ? ok(TEXT_LISTING) : new Promise((r) => (pending[url] = r))
);
await app.openFolderGrid('/mnt/raid1/logs', 's1');
app._renderFilePreviewText = vi.fn();
elements.folderGridBody.children[0].onclick?.();
elements.filePreviewOverlay.classList.add('visible');
app.stepFolderPreview(1);
await flush();
pending['/api/sessions/s1/folders/fld_1/files/b.log/raw'](text('B'));
await flush();
pending['/api/sessions/s1/folders/fld_1/files/a.log/raw'](text('A'));
await flush();
expect(elements.filePreviewTitle.textContent).toBe('/mnt/raid1/logs/b.log');
expect(app.filePreviewContent).toBe('B');
expect(app.filePreviewText.filePath).toBe('/mnt/raid1/logs/b.log');
expect(app._renderFilePreviewText).toHaveBeenCalledTimes(1);
});
it('two quick folder opens render only the newer one, and a closed grid stays closed', async () => {
const pending: Record<string, (v: unknown) => void> = {};
const { app, elements } = loadApp((_url, init) => {
const path = JSON.parse(init?.body || '{}').path;
return new Promise((r) => (pending[path] = r));
});
const first = app.openFolderGrid('/mnt/raid1/styleframes', 's1');
const second = app.openFolderGrid('/mnt/raid1/logs', 's1');
pending['/mnt/raid1/logs'](ok(TEXT_LISTING));
await second;
pending['/mnt/raid1/styleframes'](ok(LISTING));
await first;
expect(app.folderGrid.path).toBe('/mnt/raid1/logs');
expect(elements.folderGridTitle.textContent).toBe('/mnt/raid1/logs');
const late = app.openFolderGrid('/mnt/raid1/styleframes', 's1');
app.closeFolderGrid();
pending['/mnt/raid1/styleframes'](ok(LISTING));
await late;
expect(app.folderGrid).toBeNull();
expect(elements.folderGridOverlay.classList.contains('visible')).toBe(false);
});
});
describe('"Show folder" in the preview', () => {
it('knows the folder of absolute, relative and ~/ paths, not of a bare attachment card', () => {
const { app } = loadApp(() => ok(LISTING));
expect(app._previewFolderPath('/mnt/raid1/x/a.png', 's1', null)).toEqual({ dir: '/mnt/raid1/x', sessionId: 's1' });
expect(app._previewFolderPath('builds/a.png', 's1', null)).toEqual({
dir: '/home/joe/wiki/builds',
sessionId: 's1',
});
expect(app._previewFolderPath('~/r/a.png', 's1', null)).toEqual({ dir: '~/r', sessionId: 's1' });
expect(app._previewFolderPath('a.png', 's1', 'att-1')).toBeNull();
});
it('shows the button for a previewed file and opens its folder', async () => {
const { app, elements, fetch } = loadApp(() => ok(LISTING));
await app.openFilePreview('/mnt/raid1/styleframes/b.png', 's1');
expect(elements.filePreviewFolderBtn.hidden).toBe(false);
expect(elements.filePreviewPrevBtn.hidden).toBe(true);
app.openFolderOfPreview();
await vi.waitFor(() => expect(elements.folderGridOverlay.classList.contains('visible')).toBe(true));
expect(JSON.parse(fetch.mock.calls.at(-1)[1].body)).toEqual({ path: '/mnt/raid1/styleframes' });
});
});
+18 -3
View File
@@ -36,7 +36,14 @@ function shippedPattern(name: string): RegExp {
return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1)); return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
} }
const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'FILE_PATH_LINK_PATTERN', 'bashPattern']; const PATTERN_NAMES = [
'urlPattern',
'cmdPattern',
'FILE_PATH_LINK_PATTERN',
'RELATIVE_FILE_PATH_LINK_PATTERN',
'FOLDER_PATH_LINK_PATTERN',
'bashPattern',
];
/** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */ /** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */
const KILLER_LINES = [ const KILLER_LINES = [
@@ -49,6 +56,8 @@ const KILLER_LINES = [
// long URL-ish and path-ish soup for the other patterns // long URL-ish and path-ish soup for the other patterns
'https://example.com/' + 'a/'.repeat(1500) + ' ' + '/home/x/'.repeat(400) + '.'.repeat(2000), 'https://example.com/' + 'a/'.repeat(1500) + ' ' + '/home/x/'.repeat(400) + '.'.repeat(2000),
'Bash(' + 'x'.repeat(4000), 'Bash(' + 'x'.repeat(4000),
// a colon run ending in `//`: a folder body that kept `:` rescanned it for `://` from every colon
'a:'.repeat(16000) + '//',
]; ];
describe('terminal link-provider regexes (shipped source)', () => { describe('terminal link-provider regexes (shipped source)', () => {
@@ -167,13 +176,19 @@ describe('terminal link-provider regexes (shipped source)', () => {
} }
}); });
it('terminal-ui builds its path pattern from the shared factory', () => { it('terminal-ui finds its paths through the shared helper', () => {
// Structural guard: a local literal here would drift from the response // Structural guard: a local literal here would drift from the response
// viewer's linkifier, which is the divergence the move exists to prevent. // viewer's linkifier, which is the divergence the move exists to prevent.
expect(SOURCE).toContain('absoluteFilePathPattern()'); expect(SOURCE).toContain('findFilePathLinks(lineText)');
expect(SOURCE).not.toMatch(/const extPattern =\s*\n?\s*\//); expect(SOURCE).not.toMatch(/const extPattern =\s*\n?\s*\//);
}); });
it('the rooted and the relative path pattern link the same extensions', () => {
const extensions = (name: string) => /\\\.\(\?:([^()]*)\)\)\\b$/.exec(shippedPattern(name).source)?.[1];
expect(extensions('FILE_PATH_LINK_PATTERN')).toBeTruthy();
expect(extensions('RELATIVE_FILE_PATH_LINK_PATTERN')).toBe(extensions('FILE_PATH_LINK_PATTERN'));
});
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => { it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
// structural guard: the dangerous construct is an empty-matchable token // structural guard: the dangerous construct is an empty-matchable token
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern // inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
+23 -3
View File
@@ -111,10 +111,24 @@ describe('response viewer file-path linkifier', () => {
}); });
it('leaves text with no path untouched', () => { it('leaves text with no path untouched', () => {
const root = linkify('<p>Ratio 3/4 on 2026/08/16, see src/app.ts</p>'); const root = linkify('<p>Ratio 3/4 on 2026/08/16, see the app</p>');
expect(paths(root)).toHaveLength(0); expect(paths(root)).toHaveLength(0);
expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts'); expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see the app');
});
it('links ~/ and relative paths too, the same forms the terminal links', () => {
const root = linkify(
'<p>Attached <code>~/repos/x/garage.png</code>, wrote builds/captures/a.png (see src/app.ts)</p>'
);
expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual([
'~/repos/x/garage.png',
'builds/captures/a.png',
'src/app.ts',
]);
expect(root.textContent).toBe('Attached ~/repos/x/garage.png, wrote builds/captures/a.png (see src/app.ts)');
expect(paths(root).every((a) => a.getAttribute('data-linkified') === '1')).toBe(true);
}); });
it('never linkifies /etc paths — the server blocks the whole tree, so the link could only 403', () => { it('never linkifies /etc paths — the server blocks the whole tree, so the link could only 403', () => {
@@ -145,6 +159,12 @@ describe('response viewer file-path linkifier', () => {
// either leaves inert paths (no linkify) or dead links (no handler). // either leaves inert paths (no linkify) or dead links (no handler).
expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)'); expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)');
expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/); expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/);
expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/); expect(APP_SOURCE).toMatch(/const sessionId = pathLink\.dataset\.sessionId \|\| this\.activeSessionId;/);
// Only the linkifier's own links are resolved; a markdown document's rebased
// links keep the path the rebase computed.
expect(APP_SOURCE).toMatch(
/pathLink\.dataset\.linkified \? this\._resolveLinkedFilePath\?\.\(filePath, sessionId\) : filePath/
);
expect(APP_SOURCE).toMatch(/openFilePreview\(target \|\| filePath, sessionId\)/);
}); });
}); });
+2 -1
View File
@@ -8,6 +8,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { MAX_ROUTE_PARAM_LENGTH } from '../../src/web/route-helpers.js';
export interface RouteTestHarness { export interface RouteTestHarness {
app: FastifyInstance; app: FastifyInstance;
@@ -30,7 +31,7 @@ export async function createRouteTestHarness(
registerFn: (app: FastifyInstance, ctx: any) => void, registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } } ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } }
): Promise<RouteTestHarness> { ): Promise<RouteTestHarness> {
const app = Fastify({ logger: false }); const app = Fastify({ logger: false, routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH } });
// Register cookie plugin — some routes access req.cookies // Register cookie plugin — some routes access req.cookies
await app.register(fastifyCookie); await app.register(fastifyCookie);
+81
View File
@@ -18,6 +18,7 @@ vi.mock('node:fs/promises', () => ({
readdir: vi.fn(async () => []), readdir: vi.fn(async () => []),
readFile: vi.fn(async () => 'file content'), readFile: vi.fn(async () => 'file content'),
stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })), stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })),
realpath: vi.fn(async (p: string) => p),
}, },
})); }));
@@ -442,6 +443,86 @@ describe('file-routes', () => {
} }
}); });
// The folder grid is a third file-serving surface (folder-listing.ts): the
// same rule as the picker, on the resolved folder AND on each served file.
describe('folder grid', () => {
const ALICE = `${SPACES}/alice/cases`;
const BOB = `${SPACES}/bob/renders`;
const owned = async (role: 'admin' | 'user', username: string) => {
const scoped = await harnessAs(role, username);
scoped.ctx._session.owner = username;
return scoped;
};
const grant = (scoped: RouteTestHarness, path: string) =>
scoped.app.inject({
method: 'POST',
url: `/api/sessions/${scoped.ctx._sessionId}/folders`,
payload: { path },
});
beforeEach(() => {
mockedStat.mockImplementation(async (p) => {
const file = /\.\w+$/.test(String(p));
return { size: 100, mtimeMs: 1, isFile: () => file, isDirectory: () => !file } as never;
});
});
it("refuses another user's space and never reads it", async () => {
const scoped = await owned('user', 'bob');
try {
mockedReaddir.mockClear();
const res = await grant(scoped, ALICE);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('Folder is outside your space');
expect(mockedReaddir).not.toHaveBeenCalled();
} finally {
await scoped.app.close();
}
});
it('lists a folder in their own space', async () => {
const scoped = await owned('user', 'bob');
try {
mockedReaddir.mockResolvedValueOnce([] as never);
const res = await grant(scoped, BOB);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.path).toBe(BOB);
} finally {
await scoped.app.close();
}
});
it("refuses a file of their own folder that resolves into another user's space", async () => {
const scoped = await owned('user', 'bob');
try {
mockedReaddir.mockResolvedValueOnce([] as never);
const { folderId } = JSON.parse((await grant(scoped, BOB)).body).data;
mockedRealpathSync.mockImplementation(((p: string) =>
p === `${BOB}/plan.png` ? `${ALICE}/secret-project/plan.png` : p) as never);
const res = await scoped.app.inject({
method: 'GET',
url: `/api/sessions/${scoped.ctx._sessionId}/folders/${folderId}/files/plan.png/raw`,
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('File is outside your space');
expect(mockedCreateReadStream).not.toHaveBeenCalled();
} finally {
await scoped.app.close();
}
});
it('lets a multi-user admin list any folder', async () => {
const scoped = await owned('admin', 'root');
try {
mockedReaddir.mockResolvedValueOnce([] as never);
expect((await grant(scoped, ALICE)).statusCode).toBe(200);
} finally {
await scoped.app.close();
}
});
});
it('keeps the host-wide roots for a multi-user admin', async () => { it('keeps the host-wide roots for a multi-user admin', async () => {
const scoped = await harnessAs('admin', 'root'); const scoped = await harnessAs('admin', 'root');
try { try {
+596
View File
@@ -0,0 +1,596 @@
/**
* @fileoverview Folder grid — `POST /api/sessions/:id/folders` grants + lists a
* folder, `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)` serves one
* of its files by name. Local folders are real temp dirs; a remote case mocks only
* the ssh IO, and the remote listing script itself runs on a real `sh`.
*
* Port: N/A (app.inject doesn't open ports)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Readable } from 'node:stream';
import { execFileSync } from 'node:child_process';
import {
closeSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { folderGrants, validFolderFileName } from '../../src/folder-listing.js';
import type { SessionRemote } from '../../src/types/session.js';
vi.mock('../../src/remote-files.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
return {
...actual,
remoteListFolder: vi.fn(),
remoteProbePaths: vi.fn(),
remoteCreateReadStream: vi.fn(),
};
});
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn(actual.probePathKind) };
});
import { probePathKind } from '../../src/utils/bounded-path-probe.js';
import {
buildRemoteFolderListCommand,
parseRemoteFolderListOutput,
remoteCreateReadStream,
remoteListFolder,
remoteProbePaths,
} from '../../src/remote-files.js';
const mockedProbePathKind = vi.mocked(probePathKind);
const mockedListFolder = vi.mocked(remoteListFolder);
const mockedProbePaths = vi.mocked(remoteProbePaths);
const mockedCreateReadStream = vi.mocked(remoteCreateReadStream);
function touch(path: string, body: string, mtimeSec: number) {
writeFileSync(path, body);
utimesSync(path, mtimeSec, mtimeSec);
}
describe('folder grid routes — local folder', () => {
let harness: RouteTestHarness;
let sessionId: string;
let root: string;
let dir: string;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
sessionId = harness.ctx._sessionId;
root = mkdtempSync(join(tmpdir(), 'codeman-folder-grid-'));
dir = join(root, 'renders');
mkdirSync(dir);
touch(join(dir, 'old.png'), 'old-png', 1_700_000_000);
touch(join(dir, 'new.PNG'), 'new-png', 1_700_000_500);
touch(join(dir, 'clip.mp4'), 'mp4', 1_700_000_100);
touch(join(dir, 'notes.xyz'), 'unsupported', 1_700_000_900);
touch(join(dir, '.hidden.png'), 'hidden', 1_700_000_900);
mkdirSync(join(dir, 'shots'));
mkdirSync(join(dir, '.cache'));
harness.ctx._session.workingDir = root;
});
afterEach(async () => {
await harness.app.close();
folderGrants.clearSession(sessionId);
rmSync(root, { recursive: true, force: true });
delete process.env.CODEMAN_ATTACHMENT_CONFINE;
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
});
async function grant(path: string) {
return harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/folders`, payload: { path } });
}
it('lists supported files newest first plus subfolders, skipping hidden + unsupported', async () => {
const res = await grant(dir);
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body).data;
expect(data.folderId).toMatch(/^fld_/);
expect(data.path).toBe(dir);
expect(data.files.map((f: { name: string }) => f.name)).toEqual(['new.PNG', 'clip.mp4', 'old.png']);
expect(data.files[0]).toMatchObject({ extension: 'png', attachmentType: 'image', size: 7 });
expect(data.folders).toEqual(['shots']);
expect(data.truncated).toBe(false);
});
it('grants the same folder the same id', async () => {
const a = JSON.parse((await grant(dir)).body).data.folderId;
const b = JSON.parse((await grant(`${dir}/`)).body).data.folderId;
expect(b).toBe(a);
});
it('rejects a relative path, a file, and a missing folder', async () => {
expect((await grant('renders')).statusCode).toBe(400);
expect((await grant(join(dir, 'old.png'))).statusCode).toBe(400);
expect((await grant(join(root, 'nope'))).statusCode).toBe(404);
});
it('refuses a blocked folder and a folder outside a confined workspace', async () => {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = dir;
expect((await grant(dir)).statusCode).toBe(403);
delete process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS;
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
harness.ctx._session.workingDir = join(dir, 'shots');
expect((await grant(dir)).statusCode).toBe(403);
harness.ctx._session.workingDir = root;
expect((await grant(dir)).statusCode).toBe(200);
});
it('never reads a refused folder: the guard runs on the resolved folder first', async () => {
const readdir = vi.spyOn(fs, 'readdir');
try {
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = dir;
expect((await grant(dir)).statusCode).toBe(403);
expect(readdir).not.toHaveBeenCalled();
} finally {
readdir.mockRestore();
}
});
it('answers a folder or file that does not respond with 503, never 404, and does not read it', async () => {
const readdir = vi.spyOn(fs, 'readdir');
try {
mockedProbePathKind.mockResolvedValueOnce('unknown');
const res = await grant(dir);
expect(res.statusCode).toBe(503);
expect(JSON.parse(res.body).error).toContain(`Folder is not responding or not readable: ${dir}`);
expect(readdir).not.toHaveBeenCalled();
} finally {
readdir.mockRestore();
}
const { folderId } = JSON.parse((await grant(dir)).body).data;
mockedProbePathKind.mockResolvedValueOnce('unknown');
const file = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
});
expect(file.statusCode).toBe(503);
expect(JSON.parse(file.body).error).toContain('File is not responding or not readable');
});
it('does not serve a hidden file by name, as the listing hides it', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/.hidden.png/raw`,
});
expect(res.statusCode).toBe(400);
});
it('serves a granted file by name, with ranges', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
});
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
expect(res.body).toBe('old-png');
const ranged = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/old.png/raw`,
headers: { range: 'bytes=0-2' },
});
expect(ranged.statusCode).toBe(206);
expect(ranged.body).toBe('old');
});
it('redirects a non-office preview to raw', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/preview`,
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe(`/api/sessions/${sessionId}/folders/${folderId}/files/clip.mp4/raw`);
});
it('never serves a name that leaves the folder or has an unsupported type', async () => {
const { folderId } = JSON.parse((await grant(dir)).body).data;
writeFileSync(join(root, 'outside.png'), 'outside');
for (const name of ['..%2Foutside.png', '..', '.', 'notes.xyz', 'shots%2Fx.png']) {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${name}/raw`,
});
expect(res.statusCode, name).not.toBe(200);
}
const missing = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/gone.png/raw`,
});
expect(missing.statusCode).toBe(404);
});
it('refuses a file in the folder that symlinks to a blocked target', async () => {
const secret = join(root, 'secret');
mkdirSync(secret);
writeFileSync(join(secret, 'key.png'), 'secret');
symlinkSync(join(secret, 'key.png'), join(dir, 'innocent.png'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = secret;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/innocent.png/raw`,
});
expect(res.statusCode).toBe(403);
});
// The attachment gate (`registerExternalAttachment`) judges the file a symlink
// RESOLVES to: its extension must be a supported one and it must be a regular
// file. The folder routes only looked at the requested name, so a name that
// passed (`shot.png`) could serve anything it pointed at.
describe('the resolved target is held to the attachment gate', () => {
const files = (folderId: string, name: string, leaf: 'raw' | 'preview' | 'thumbnail' = 'raw') =>
harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${encodeURIComponent(name)}/${leaf}`,
});
it('refuses a supported-looking name whose target has an unsupported extension', async () => {
writeFileSync(join(root, 'secret.bin'), 'secret');
symlinkSync(join(root, 'secret.bin'), join(dir, 'shot.png'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
for (const leaf of ['raw', 'preview', 'thumbnail'] as const) {
const res = await files(folderId, 'shot.png', leaf);
expect(res.statusCode, leaf).toBe(400);
expect(res.body, leaf).not.toContain('secret');
}
});
it('refuses a name whose target has no extension at all', async () => {
writeFileSync(join(root, 'history'), 'private history');
symlinkSync(join(root, 'history'), join(dir, 'notes.txt'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await files(folderId, 'notes.txt');
expect(res.statusCode).toBe(400);
expect(res.body).not.toContain('private history');
});
it('refuses a device node behind a supported name', async () => {
// /dev/null, not /dev/zero: the same class (character device, no
// extension), but it ends the stream instead of filling the test's memory
// should the guard ever be missing.
symlinkSync('/dev/null', join(dir, 'zero.txt'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await files(folderId, 'zero.txt');
expect(res.statusCode).toBe(400);
});
it('refuses a FIFO without opening it', async () => {
const fifo = join(dir, 'pipe.txt');
execFileSync('mkfifo', [fifo]);
// Hold both ends open from here: if the route ever did open it, the open
// returns at once and the read blocks, which the race below reports as a
// hang instead of leaving a libuv worker parked on a writer that never comes.
const fd = openSync(fifo, 'r+');
try {
const { folderId } = JSON.parse((await grant(dir)).body).data;
const outcome = await Promise.race([
files(folderId, 'pipe.txt').then((res) => res.statusCode),
new Promise<string>((resolve) => setTimeout(() => resolve('hung'), 2000)),
]);
expect(outcome).toBe(400);
} finally {
closeSync(fd);
}
});
it('still serves a symlink whose target is a supported regular file', async () => {
writeFileSync(join(root, 'real.png'), 'real-png');
symlinkSync(join(root, 'real.png'), join(dir, 'alias.png'));
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await files(folderId, 'alias.png');
expect(res.statusCode).toBe(200);
expect(res.body).toBe('real-png');
});
});
it('serves a file whose name is longer than the 100-character default of a route parameter', async () => {
// The listing shows it (names go up to 255), so the raw/preview/thumbnail
// routes have to be able to take it back.
const longName = `${'n'.repeat(146)}.png`;
expect(longName.length).toBe(150);
touch(join(dir, longName), 'long-png', 1_700_000_300);
const { folderId } = JSON.parse((await grant(dir)).body).data;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${longName}/raw`,
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe('long-png');
});
// A Docker case is a LOCAL session for the file routes, but its `~` is the
// container's home: a separate, seeded directory. `~/x.png` printed inside the
// container used to open the HOST's `~/x.png`.
describe('~/ in a Docker case', () => {
beforeEach(() => {
(harness.ctx._session as { docker?: unknown }).docker = {
hostId: 'local',
label: 'local',
engine: 'docker',
image: 'codeman-sandbox',
containerName: 'codeman-case-test',
hostWorkspacePath: root,
containerWorkdir: root,
network: 'bridge',
};
});
it('refuses a ~/ folder, and still lists an absolute one', async () => {
const home = await grant('~/renders');
expect(home.statusCode).toBe(400);
expect(JSON.parse(home.body).error).toMatch(/docker/i);
expect((await grant(dir)).statusCode).toBe(200);
});
it('refuses a ~/ attachment, and still registers an absolute one', async () => {
const post = (path: string) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/attachments`, payload: { path } });
const home = await post('~/renders/old.png');
expect(home.statusCode).toBe(400);
expect(JSON.parse(home.body).error).toMatch(/docker/i);
expect((await post(join(dir, 'old.png'))).statusCode).toBe(200);
});
});
it('keeps resolving ~/ for a session that is not in a Docker case', async () => {
// The contrast: only the Docker case is turned away, `~/` itself still works.
const res = await grant('~/this-folder-does-not-exist-codeman-test');
expect(res.statusCode).toBe(404);
});
it('404s an unknown folder id and another session’s grant', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/fld_nope/files/old.png/raw`,
});
expect(res.statusCode).toBe(404);
const foreign = folderGrants.grant('another-session', dir);
const res2 = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${foreign}/files/old.png/raw`,
});
expect(res2.statusCode).toBe(404);
folderGrants.clearSession('another-session');
});
});
describe('folder grid routes — remote (SSH) case', () => {
const REMOTE_DIR = '/srv/remote/case';
const remote: SessionRemote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: REMOTE_DIR,
};
let harness: RouteTestHarness;
let sessionId: string;
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
sessionId = harness.ctx._sessionId;
harness.ctx._session.workingDir = REMOTE_DIR;
harness.ctx._session.remote = { ...remote };
mockedListFolder.mockResolvedValue({
dir: '/mnt/raid/styleframes',
entries: [
{ kind: 'file', name: 'a.png', size: 3, mtimeMs: 1000 },
{ kind: 'file', name: 'b.jpg', size: 4, mtimeMs: 2000 },
{ kind: 'directory', name: 'old', size: 0, mtimeMs: 0 },
],
truncated: true,
});
mockedProbePaths.mockResolvedValue([
{ realPath: '/mnt/raid/styleframes/a.png', kind: 'file', size: 3, mtimeMs: 1000 },
{ realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 },
]);
mockedCreateReadStream.mockReturnValue({ stream: Readable.from([Buffer.from('abc')]), close: vi.fn() } as never);
});
afterEach(async () => {
await harness.app.close();
folderGrants.clearSession(sessionId);
vi.clearAllMocks();
});
it('lists the folder on the remote host and serves its files over ssh', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '~/styleframes' },
});
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body).data;
expect(mockedListFolder).toHaveBeenCalledWith(
expect.objectContaining({ host: '192.0.2.10' }),
'~/styleframes',
expect.arrayContaining(['png', 'pdf']),
expect.any(Number)
);
expect(data.path).toBe('/mnt/raid/styleframes');
expect(data.files.map((f: { name: string }) => f.name)).toEqual(['b.jpg', 'a.png']);
expect(data.folders).toEqual(['old']);
expect(data.truncated).toBe(true);
const raw = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/raw`,
});
expect(raw.statusCode).toBe(200);
expect(raw.body).toBe('abc');
expect(mockedProbePaths).toHaveBeenCalledWith(expect.anything(), ['/mnt/raid/styleframes/a.png', REMOTE_DIR]);
const thumb = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${data.folderId}/files/a.png/thumbnail`,
});
expect(thumb.statusCode).toBe(400);
});
describe('the resolved remote target is held to the attachment gate', () => {
async function rawOf(name: string) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '~/styleframes' },
});
const { folderId } = JSON.parse(res.body).data;
return harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/folders/${folderId}/files/${name}/raw`,
});
}
it('refuses a name whose remote symlink resolves to an unsupported extension', async () => {
mockedProbePaths.mockResolvedValue([
{ realPath: '/mnt/raid/secret.bin', kind: 'file', size: 6, mtimeMs: 1000 },
{ realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 },
]);
const res = await rawOf('a.png');
expect(res.statusCode).toBe(400);
expect(mockedCreateReadStream).not.toHaveBeenCalled();
});
it('refuses a name that resolves to something that is not a regular file', async () => {
mockedProbePaths.mockResolvedValue([
{ realPath: '/mnt/raid/styleframes/a.png', kind: 'other', size: 0, mtimeMs: 0 },
{ realPath: REMOTE_DIR, kind: 'directory', size: 0, mtimeMs: 0 },
]);
const res = await rawOf('a.png');
expect(res.statusCode).toBe(400);
expect(mockedCreateReadStream).not.toHaveBeenCalled();
});
});
it('answers 400 "Not a folder" for a remote file, so the client falls back to the preview', async () => {
mockedListFolder.mockResolvedValue('not-a-folder');
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '/srv/remote/case/Makefile' },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/not a folder/i);
});
it('answers 404 for a missing remote folder', async () => {
mockedListFolder.mockResolvedValue(null);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${sessionId}/folders`,
payload: { path: '/nope' },
});
expect(res.statusCode).toBe(404);
});
});
describe('remote folder listing script (real sh)', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-folder-sh-'));
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
function run(dir: string, max = 100, home = root) {
const stdout = execFileSync('sh', ['-c', buildRemoteFolderListCommand(dir, ['png', 'pdf'], max)], {
encoding: 'utf8',
env: { ...process.env, HOME: home },
});
return parseRemoteFolderListOutput(stdout);
}
it('lists supported files with size/mtime, subfolders, odd names; resolves ~ and symlinks', () => {
const dir = join(root, 'frames');
mkdirSync(dir);
touch(join(dir, "it's a | frame.PNG"), '12345', 1_700_000_000);
touch(join(dir, 'doc.pdf'), 'pdf', 1_700_000_100);
touch(join(dir, 'shot.Png'), 'x', 1_700_000_100);
touch(join(dir, 'skip.txt'), 'x', 1_700_000_100);
touch(join(dir, '.hidden.png'), 'x', 1_700_000_100);
mkdirSync(join(dir, 'sub dir'));
symlinkSync(dir, join(root, 'link'));
const listing = run('~/link');
expect(listing?.dir).toBe(dir);
expect(listing?.truncated).toBe(false);
const byName = Object.fromEntries((listing?.entries ?? []).map((e) => [e.name, e]));
// Any case, as the local listing: `shot.Png` too.
expect(Object.keys(byName).sort()).toEqual(['doc.pdf', "it's a | frame.PNG", 'shot.Png', 'sub dir']);
expect(byName["it's a | frame.PNG"]).toMatchObject({ kind: 'file', size: 5, mtimeMs: 1_700_000_000_000 });
expect(byName['sub dir']).toMatchObject({ kind: 'directory' });
});
it('is not thrown off by a login shell whose `cd` prints the directory (Hufflepuff: alias cd=folderprompt)', () => {
// sshd hands the command to the remote user's LOGIN shell. There `cd` is an
// alias that echoes the new directory, so `r=$(cd -P "$d" && pwd -P)` captured
// the path twice and the listing failed ("zsh: no matches found: /x\n/x/*").
// The script has to run under an explicit `sh`, which reads no rc file.
const dir = join(root, 'frames');
mkdirSync(dir);
touch(join(dir, 'a.png'), 'x', 1_700_000_000);
const stdout = execFileSync(
'bash',
['-c', `cd() { builtin cd "$@" && pwd; }; ${buildRemoteFolderListCommand(dir, ['png'], 100)}`],
{ encoding: 'utf8', env: { ...process.env, HOME: root } }
);
const listing = parseRemoteFolderListOutput(stdout);
expect(listing).not.toBeNull();
expect(listing).not.toBe('not-a-folder');
expect((listing as { dir: string }).dir).toBe(dir);
expect((listing as { entries: { name: string }[] }).entries.map((e) => e.name)).toEqual(['a.png']);
});
it('caps entries; tells a missing folder (null) from a file', () => {
for (let i = 0; i < 5; i++) touch(join(root, `f${i}.png`), 'x', 1_700_000_000);
const capped = run(root, 3);
expect(capped?.entries).toHaveLength(3);
expect(capped?.truncated).toBe(true);
expect(run(join(root, 'missing'))).toBeNull();
expect(run(join(root, 'f0.png'))).toBe('not-a-folder');
});
});
describe('validFolderFileName', () => {
it('accepts bare supported names only', () => {
expect(validFolderFileName('a b.png')).toBe('a b.png');
for (const bad of ['', '.', '..', '.hidden.png', 'a/b.png', 'a\\b.png', 'x.xyz', 'a\0.png', 42]) {
expect(validFolderFileName(bad)).toBeNull();
}
});
});
describe('server wiring', () => {
// The route harness builds its own Fastify instance, so a test through it
// cannot notice the real server losing the option. Same style as the other
// wiring guards: read the source, not a comment.
it('gives the real server the same route-parameter limit the folder routes are tested with', () => {
const source = readFileSync(join(__dirname, '../../src/web/server.ts'), 'utf-8');
const instances = source.match(/Fastify\(\{[^}]*\}[^)]*\)/g) ?? [];
expect(instances.length).toBeGreaterThanOrEqual(2);
for (const instance of instances) {
expect(instance).toContain('maxParamLength: MAX_ROUTE_PARAM_LENGTH');
}
});
});
+1 -1
View File
@@ -308,7 +308,7 @@ describe('callers consult the hook first', () => {
it('response viewer links route through the hook and keep the file-path handler first', () => { it('response viewer links route through the hook and keep the file-path handler first', () => {
const bind = APP_JS.indexOf('_bindResponseViewerInteractions(body) {'); const bind = APP_JS.indexOf('_bindResponseViewerInteractions(body) {');
const section = APP_JS.slice(bind, bind + 2500); const section = APP_JS.slice(bind, bind + 3500);
const pathHandler = section.indexOf("closest('a.rv-path')"); const pathHandler = section.indexOf("closest('a.rv-path')");
const urlHandler = section.indexOf("closest('a[href]')"); const urlHandler = section.indexOf("closest('a[href]')");
expect(pathHandler).toBeGreaterThan(-1); expect(pathHandler).toBeGreaterThan(-1);