Merge #797: the File Viewer edits workspace dotenv files

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:23:25 +01:00
commit 18b309fc02
13 files changed
+417 -34

No files matched your search

+1 -1
View File
@@ -241,7 +241,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing, Create New's custom folder and Clone Repo's parent folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; dotenv is the one sensitive family it opens (`allowDotenv`, because the plain preview already serves it), so every other credential pattern, a dotenv file inside a blocked tree and the data dir's own `.env` still refuse; 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 delete** (salvaged from #529; `DELETE /api/sessions/:id/files?path=`, the × on a file row of the Files panel, tree and search results): removes ONE regular file and is the **only file surface that deletes**. It takes the edit route's confinement and guards whole (ownership via `findSessionOrFail`, `validateLocalSessionFilePath` behind `withGuardedOutsideWorktree`, so an absolute path in a followed outside worktree obeys the same rules, sensitive/blocked/`.git` 403), and both spellings are checked, the realpath and the path as named. ⚠️ **A symbolic link is refused (400), never followed**: the confinement check returns the REALPATH, i.e. the link's target, so an `unlink` of it deleted `CLAUDE.md` for `AGENTS.md -> CLAUDE.md` and left the link dangling. The final component is `lstat`ed on the path AS NAMED, only a plain file goes on, the inode is compared again before `unlink`, and `unlink` never follows a final link. A dangling link fails realpath and reads as 404. ⚠️ Remote sessions are refused before any path check (a same-named local folder would take the delete), and so are adopted ones (the `/tmp` file-surface exception is not copied to a new surface); Docker workspaces are allowed like the edit route. The workspace (and the outside worktree root) is probed bounded first (#516, `422` on a dead mount). ⚠️ The button is bound from the row's escaped `data-path` in the `.file-tree-item` loops, NEVER an inline `onclick` (`encodeURIComponent` leaves `'` alone, so `x'-alert(1)-'.txt` ran script); it asks with `confirm()`, reloads through `_reloadFileBrowserKeepingQuery()` so a search re-runs, stays visible under `@media (hover: none)`, and is not drawn for remote or adopted sessions. Tests: `test/routes/file-delete-routes.test.ts`, `test/file-delete-ui.test.ts`. → [architecture-invariants#files-panel-delete](docs/architecture-invariants.md#files-panel-delete)