diff --git a/CLAUDE.md b/CLAUDE.md index 88e2e751..a698230e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -395,7 +395,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) -**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). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 0b0cb216..cfcedd4a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -375,7 +375,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **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 rooted or `~/` path is never joined, 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 `realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`), 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), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard) 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. ⚠️ 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. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.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 `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. 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. @@ -1063,7 +1063,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### 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 diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 8a9f39ed..d0992447 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -382,6 +382,11 @@ 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. +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 diff --git a/src/folder-listing.ts b/src/folder-listing.ts index a9cd0b20..36d10d12 100644 --- a/src/folder-listing.ts +++ b/src/folder-listing.ts @@ -18,7 +18,6 @@ */ import { randomUUID } from 'node:crypto'; -import { realpathSync } from 'node:fs'; import fs from 'node:fs/promises'; import { homedir } from 'node:os'; import { extname, isAbsolute, join, posix } from 'node:path'; @@ -28,9 +27,11 @@ import { 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 } from './types.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. */ @@ -111,11 +112,12 @@ export const folderGrants = new FolderGrants(); /** * A file name inside a granted folder, or null when it is not one: no path - * separator, not `.`/`..`, no NUL, and a type the preview supports. + * 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 === '.' || name === '..' || name.includes('/') || name.includes('\\') || name.includes('\0')) 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; } @@ -128,13 +130,43 @@ export function folderFilePath(dir: string, name: string): string { 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, 404 missing, 502 remote unreachable. + * @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, @@ -146,6 +178,22 @@ export async function listFolder( 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; @@ -160,29 +208,26 @@ export async function listFolder( 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 = realpathSync(expanded); + dir = await fs.realpath(expanded); } catch { throw new FolderListingError('Folder not found', 404); } - const stat = await fs.stat(dir); - if (!stat.isDirectory()) throw new FolderListingError('Not a folder'); + assertAllowed(dir); ({ entries, truncated } = await listLocalFolder(dir)); } - const guard = await loadAttachmentGuardConfig(); - const workingDir = options.sessionWorkingDir; - 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); - } - const files: FolderFileEntry[] = []; const folders: string[] = []; for (const entry of entries) { diff --git a/src/remote-files.ts b/src/remote-files.ts index ba5e20e6..1188e7d2 100644 --- a/src/remote-files.ts +++ b/src/remote-files.ts @@ -293,7 +293,7 @@ export interface RemoteFolderListing { /** * 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` (either case), up to `maxEntries`. + * 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 @@ -303,9 +303,9 @@ export interface RemoteFolderListing { * hold. A leading `~/` is the remote user's `$HOME`, as in the probe. */ export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string { - const patterns = extensions - .flatMap((ext) => [ext.toLowerCase(), ext.toUpperCase()]) - .map((ext) => `*.${ext}`) + // `[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('|'); return [ `d=${shellescape(dir)}`, diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 3a8f6750..837dd75b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2054,13 +2054,16 @@ function findFilePathLinks(text) { * 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. + * 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; + /(^|[\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. diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 5033affd..f2f8c709 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4107,6 +4107,12 @@ Object.assign(CodemanApp.prototype, { 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. this._resetFilePreviewEdit(); // Stop whatever the previous preview was playing. Overwriting innerHTML @@ -4138,6 +4144,7 @@ Object.assign(CodemanApp.prototype, { let externalSize = 0; if (!attachmentId && !folderFile && this._isExternalPreviewPath(filePath, sessionId)) { const external = await this._registerExternalPreview(filePath, sessionId); + if (stale()) return; attachmentId = external.attachmentId || null; externalError = external.error || ''; externalSize = external.size || 0; @@ -4211,6 +4218,7 @@ Object.assign(CodemanApp.prototype, { 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'); const text = await res.text(); + if (stale()) return; const clippedByBytes = res.status === 206 && text.length >= TEXT_PREVIEW_MAX_BYTES; const lines = text.split('\n'); // Markdown keeps every line the Range read returned: the byte bound is @@ -4229,6 +4237,7 @@ Object.assign(CodemanApp.prototype, { footerEl.textContent = `${footerEl.textContent} (${note})`; } } catch (err) { + if (stale()) return; bodyEl.innerHTML = `
`; } } @@ -4261,11 +4270,14 @@ Object.assign(CodemanApp.prototype, { try { const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`); 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 = `