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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/files-panel-lazy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---

Files panel loads one folder at a time (expanding a folder fetches just that folder), sorts by name or modified time, and filters loaded entries locally while typing; Enter runs the whole-workspace search.
5 changes: 5 additions & 0 deletions .changeset/picker-file-transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---

Path picker downloads and uploads files: a download link on every file row and an Upload button for the folder on screen, opened from the mobile Path key or the new quick-browser button in the Files panel header, so desktop browsers get the same fast browser.
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph

**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`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)

**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 + the Files panel's quick browser): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file, and (not in Link Existing) `GET /api/filesystem/download` + `POST /api/filesystem/upload`, which must resolve through the same `resolveFilesystemPickerPath()`; upload creates with `O_EXCL|O_NOFOLLOW`, never overwrites. 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)

**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`

**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel lazy tree + search** (`?lazy=1&path=` lists ONE folder; typing filters loaded nodes locally, the server search below runs on Enter only; → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)). **Server search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)

**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).

Expand Down
6 changes: 5 additions & 1 deletion docs/architecture-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,10 +394,12 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli

### Filesystem path picker

**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key + the Files panel's quick-browser button): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. With `fileTransfer` (Path key and Files panel, never Link Existing) it adds `GET /api/filesystem/download` (any regular file, always `attachment`) and `POST /api/filesystem/upload` (multipart field `file`, into the folder named by `path`). It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.

⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.

⚠️ **Download and upload resolve through the SAME `resolveFilesystemPickerPath()` as browse** (roots, realpath containment, hidden opt-in, `isBlockedPickerPath`), so they can never reach a path a listing could not show; never give them their own resolution. The upload is the picker's only WRITE: same-origin multipart check (`isSameOriginMultipartRequest`, shared with paste-image), a per-IP bucket, a bounded probe of the folder (#516), a sanitized basename (`sanitizePickerUploadName`: no separators, control characters or leading dots), the blocked-path check on the FINAL name, and `O_CREAT|O_EXCL|O_NOFOLLOW` creation with ` (n)` suffixes, so it never overwrites and never follows a planted symlink; a truncated or failed stream is unlinked. Tests: `test/routes/filesystem-transfer.test.ts`.

⚠️ **Ownership scoping is also not inherited, and both endpoints must do it themselves.** Two separate holes shipped in the original version and are now regression-guarded in `test/routes/file-routes.test.ts`:

1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check has to be written out by hand. Without it a multi-user caller pins **another user's** working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists.
Expand Down Expand Up @@ -659,6 +661,8 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`

### Files panel search

**The tree is LAZY** (`?lazy=1[&path=<workspace-relative dir>]` on `GET /api/sessions/:id/files`): one directory per request, folders WITHOUT `children` (= not loaded; the client fetches on expand, `_loadFileBrowserChildren`, epoch-guarded like the root load), every entry with `mtimeMs` for the per-device sort (`codeman:fileBrowserSort`). The depth-5 walk took 14 s for a 5,000-node workspace on WSL `/mnt/d` (9P, a stat per entry); one folder takes well under a second. `path` resolves through `validateSessionFilePath()` (realpath, workspace-confined), a hidden segment needs `showHidden`, and the generated-directory excludes do not apply since nothing is walked. ⚠️ Typing in the search box is a LOCAL filter over already-loaded nodes (`filterFileBrowserLoaded`, ordered-substring `*` match, never a RegExp); the server search below runs only on Enter (`onFileBrowserSearchKeydown` → `filterFileBrowser`), and a folder hit the lazy tree has not loaded is opened by loading each ancestor (`_revealLazyFileBrowserDirectory`). Expand All opens only loaded folders, so it never fans out into a request per folder. The non-lazy tree response is unchanged. Tests: `test/routes/file-tree-lazy.test.ts`, the lazy block of `test/file-browser-search.test.ts`.

**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure, no IO, so it unit-tests directly) compiles the query into a reusable predicate, which is what lets the server-side walk prune instead of streaming the whole tree.

⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested.
Expand Down
22 changes: 21 additions & 1 deletion docs/wiki/Working-With-Files.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ until you get home.
A panel that browses the active session's working directory. Its header button is on by
default; if it is missing, re-enable it in **App Settings → Header & Panels**.

The tree loads one folder at a time: opening the panel lists the workspace's top level, and
expanding a folder fetches just that folder, so a large workspace on a slow mount (WSL
`/mnt/c`, a network share) opens in well under a second instead of waiting for a walk of
the whole tree. The sort menu beside the search box orders each folder by name or by
modified time (newest first finds the file an agent just wrote), folders always first, and
is remembered per device. Typing in the search box filters what is already loaded, in your
browser, with no request; press **Enter** to search the whole workspace on the server.

It renders what it can:

| Kind | Behaviour |
Expand Down Expand Up @@ -148,10 +156,12 @@ their workspace is bind-mounted at the same path.

## The path picker

For choosing a path rather than typing one. It appears in two places:
For choosing a path rather than typing one. It appears in three places:

- **Browse** in **Add Case → Link Existing**.
- The **📁 Path** key on the mobile keyboard bar.
- The **📂** quick-browser button in the Files panel header, so a desktop browser gets the
same fast browser as a phone.

It browses one directory at a time and can show hidden entries on request. The current
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
Expand All @@ -162,6 +172,16 @@ choice is remembered per device. The picker inserts the path into your prompt
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
key clears the unsent prompt, and never sends the agent's `/clear` command.

Opened from the Path key or the Files panel, it also transfers files: every file row has a
**⬇** download link (any file type, sent as a download, never rendered), and **⬆ Upload**
sends one or more files from your device into the folder on screen. An upload never
overwrites: a name already taken is saved as `name (1).ext`, and the uploaded file is
selected afterwards, so **Insert Path** puts it on the prompt in one tap. Uploads are capped
at 1GB per file (`CODEMAN_MAX_PICKER_UPLOAD_BYTES`) and 60 per minute; downloads follow the
global download cap. Both reach exactly what the picker can list, under the same roots and
blocks, and a sensitive name (`credentials.json`, a key file) is refused for upload as it is
for download. Link Existing's picker stays browse-only.

This is a separate file-serving surface from the viewer, with its own rules: it allowlists
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a
Expand Down
9 changes: 9 additions & 0 deletions src/config/buffer-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAG
*/
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB

/**
* Maximum size (bytes) of one file uploaded through the filesystem path picker
* (`POST /api/filesystem/upload`, the picker's Upload button). Like the video
* cap it streams to disk, so it bounds disk use, not memory. 0 is not accepted
* here: an upload always has a ceiling. Override: CODEMAN_MAX_PICKER_UPLOAD_BYTES (bytes)
*/
export const MAX_PICKER_UPLOAD_BYTES =
parseInt(process.env.CODEMAN_MAX_PICKER_UPLOAD_BYTES || '') || 1024 * 1024 * 1024; // 1GB

// ============================================================================
// File Download Limits
// ============================================================================
Expand Down
Loading
Loading