Compare commits

..
Author SHA1 Message Date
Codeman maintainer 078bd74bd1 fix(files): the review's fixes for the Files panel delete
The fixes #529's review asked for, each with a test.

- No inline handler. The button quoted the path inside
  onclick="...decodeURIComponent('<path>')", and encodeURIComponent leaves
  ' alone, so a file named x'-alert(1)-'.txt ran script in Codeman's
  origin and it's.txt was a syntax error. It is now bound in the existing
  .file-tree-item loops from the row's escaped data-path.
- Symbolic links are refused, never followed. validateSessionFilePath()
  returns the realpath, so deleting AGENTS.md -> CLAUDE.md deleted
  CLAUDE.md and left the link dangling. The path as named is lstat'ed,
  a link answers 400, and the inode is compared again before the unlink.
- The edit route's confinement whole: validateLocalSessionFilePath()
  behind withGuardedOutsideWorktree(), so an absolute path in a followed
  outside worktree obeys the same rules, and the sensitive, blocked and
  .git checks run on both the realpath and the path as named.
- Adopted sessions are refused like remote ones (the /tmp exception of
  the older file surfaces is not copied to a new one); Docker workspaces
  are allowed, as on the edit route. The workspace is probed bounded
  first (#516). Errors are thrown with their status.
- Deleting from search results re-runs the search, the row buttons stay
  visible on touch screens, the confirm and toasts have zh-CN text, and
  no button is drawn for remote or adopted sessions.
- Docs: CLAUDE.md, architecture-invariants, api-reference and the
  Working With Files wiki page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 03:09:46 +01:00
shenlvkang-collab dfe1af2e5f feat(files): delete a file from the Files panel
A delete button on each file row of the Files panel (tree and search
results) and a DELETE /api/sessions/:id/files?path= route that removes one
regular file from the session workspace, refused for remote (SSH)
sessions and behind the same sensitive-path, attachment-guard and .git
checks as the edit route.

Salvaged from #529.
2026-11-09 03:09:46 +01:00
20 changed files with 1259 additions and 1445 deletions

No files matched your search

+2
View File
@@ -243,6 +243,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership, where an absolute path inside a followed outside worktree (`SessionState.outsideWorktree`) counts as a second workspace root) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
**Files panel 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)
**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. Tree and search share one walk shape: a per-directory round-robin (breadth-first, so fair per directory, not per subtree) over one 5,000-node budget, where a folder's own files count as one share beside the subfolders it just found (`walkTurnEnd()`). ⚠️ That budget caps what is LISTED, never what is read: a read keeps only what the listing can use (`readWalkFolder()`) and `MAX_WALK_ENTRIES_READ` (250,000) stops opening folders, or one GET reads the whole workspace into memory. ⚠️ A folder below the walk's root is probed (#516) before its `readdir`, and anything but `directory` is skipped unflagged. ⚠️ A directory has its node before it is read, so every cursor still queued or unread when the walk ends must get `incomplete: true` or it reads as an empty folder. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)
**Files panel in a worktree** (#765, `src/live-worktree.ts`, `root=live` and `root=live-any` on `GET /api/sessions/:id/files`): Claude's EnterWorktree moves the process, and `paneCwd` follows it (tmux reads the process cwd, measured on 2.1.318, back out on ExitWorktree too), while `workingDir` stays the session's identity. `refreshLiveWorktree()` (server.ts) turns a changed `paneCwd` into `SessionState.liveWorktree` by reading the `.git` FILE (never spawning git), walking up and stopping at `workingDir`, a repository or submodule of its own, or anything unreadable. That is the lookup for a pane inside `workingDir`; a pane outside it goes to `findOutsideWorktree()`, described below. ⚠️ The security argument has two halves. For a worktree inside `workingDir` the lookup only answers a folder strictly below it, but that is a LEXICAL judgement made when the pane moves, and a symlink can replace the folder or a parent later without moving the pane, so `root=live` re-checks on every request (`canWalkLiveWorktree()`, file-routes.ts: walk the worktree only when it resolves strictly inside the resolved `workingDir`, else walk `workingDir`). The walk starts there, returned `path`s stay relative to `workingDir`, and the routes keep confining to `workingDir`, so never make an INSIDE `liveWorktree` a confinement root. ⚠️ Only that check uses resolved paths: the walk keeps the session's spelling, or a workspace reached through a symlink stops producing paths relative to `workingDir`. ⚠️ A plain subfolder is deliberately no answer, or a `cd` in a shell would re-root the panel. ⚠️ A listing reports `worktree` whichever folder it walked, even when the worktree has gone or the check refused it: `_followFileBrowserWorktree()` compares that with the session's state, and an absent report would reload forever. The one exception is a listing without `root=live-any`, which omits an outside worktree; while the session folder is pinned, the panel compares with the worktree it pinned away from (`pinnedWorktree`, panels-ui.js). ⚠️ **A worktree OUTSIDE `workingDir` (`git worktree add ../feature`, or an EnterWorktree placed beside the repo) IS a second root**, answered only through `findOutsideWorktree()` with `outside: true` and published on the wire as `SessionState.outsideWorktree`, never in `liveWorktree`, which stays inside-only: the session's own repository must vouch for it by FILE READS (`workingDir` must be the top of its repository or worktree, so a session in a subfolder follows nothing outside it; the candidate's `.git` FILE names `<common dir>/worktrees/<id>` and that entry's `gitdir` names the candidate back, or its `.git` FOLDER is the session's common dir itself, which is the main worktree for a session started in a linked one, and a symlinked `.git` is refused; never spawn git from the pane watcher). It is refused at `/`, home or above, around `workingDir` and at/inside/around a Docker case workspace (an unreadable Docker list follows nothing). It is refused while attachment workspace confinement is on. It is also refused at, inside or around an attachment blocked tree, and at a root that is itself a sensitive folder such as `~/.ssh` (a child probe, since the sensitive patterns describe file locations). These rules are judged when the pane moves like the Docker rule, and multi-user mode applies `folderAllowedForUser`. The routes also re-check confinement and the blocked trees on every request (`isOutsideWorktreeRootRefused()`), so turning confinement on or blocking the folder stops serving it at once. ⚠️ `validateLocalSessionFilePath()` also refuses any file there that `isSensitivePath()` blocklists, so a root such as a credential folder can never serve its keys. Walking it is opt-in: only `root=live-any` (which the panel sends) lists it, with ABSOLUTE `path`s, while `root=live` never walks an outside worktree: it walks `workingDir` with relative paths, and its listing omits `worktree` for an outside one. An absolute path inside it is confined to it, falling back to `workingDir` when the worktree root refuses it (`validateLocalSessionFilePath()`, route-helpers.ts), while a relative path still resolves against `workingDir` alone. ⚠️ It is recorded as a REAL path, and every request re-checks that the folder still resolves to exactly that path (`outsideWorktreeRootFor()`, `canWalkOutsideWorktree()`), or a symlink swapped in would re-root the routes anywhere. Tests: `test/live-worktree.test.ts`, `test/live-worktree-outside.test.ts`, `test/routes/file-routes-live-worktree.test.ts`, `test/routes/file-routes-outside-worktree.test.ts`, `test/file-browser-worktree.test.ts`, `test/file-browser-outside-worktree.test.ts`. → [architecture-invariants#files-panel-in-a-worktree](docs/architecture-invariants.md#files-panel-in-a-worktree)
+1 -1
View File
@@ -108,7 +108,7 @@ The first run asks three questions (data folder, port, password; Enter takes the
**[Download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · one universal app for Apple Silicon and Intel · macOS 11.3 or later
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while they work, blinks when one waits for you and flashes when one needs permission, and each state can take a style of your own. It stays off until you turn it on. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
A small native app that opens Codeman in its own window. On a Mac without Codeman it installs it for you, with no Terminal and no administrator password, shows the install as a checklist, and offers to install a coding agent if the Mac has none yet. On a Mac that already runs Codeman it finds the server and opens it, or starts it when it is stopped. Under **Another machine** it connects to Codeman on your Mac mini, a home server or your tailnet, with the password kept in the Keychain, and can start an install over there through SSH. On a MacBook Pro with a PROLED XDR display, whose lid logo lights up, **View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of your own for working, for waiting for you and for needing permission; all three stay off until you choose. The app is Rust around macOS's own WebKit, not a bundled browser, so the download is under 5 MB.
Open `Codeman.dmg`, drag Codeman into Applications and open it from there. macOS asks once whether to open an app downloaded from the internet: click **Open**.
+1 -1
View File
@@ -110,7 +110,7 @@ bash docker/Start-Codeman.sh
**[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg)** · 同一个通用应用,适用于 Apple 芯片和 Intel · macOS 11.3 或更高版本
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志显示智能体在做的事:工作时缓慢呼吸,有智能体等你回复时闪烁,需要权限时快闪三下;每种状态也可以换成你自己选的样式。在你打开之前,它保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
一个小巧的原生应用,在它自己的窗口里打开 Codeman。在还没有 Codeman 的 Mac 上,它会替你安装,不用终端,也不需要管理员密码,用一张清单显示安装进度;如果这台 Mac 上还没有编程智能体,它还会提出帮你装一个。在已经运行 Codeman 的 Mac 上,它会找到服务器并打开;服务器停着时则替你启动。在 **Another machine** 下,它可以连接你的 Mac mini、家用服务器或 tailnet 上的 Codeman,密码保存在钥匙串里,还能通过 SSH 在那台机器上开始安装。在配备 PROLED XDR 显示屏、机盖 Apple 标志会发光的 MacBook Pro 上,**View > Blink Apple Logo** 会让标志按智能体在做的事闪烁:工作中、等你回复、需要权限各用一种你自己选的样式;在你选定之前,三种都保持关闭。这个应用用 Rust 写成,套在 macOS 自带的 WebKit 外面,不捆绑浏览器,所以下载不到 5 MB。
打开 `Codeman.dmg`,把 Codeman 拖进「应用程序」文件夹,再从那里打开。macOS 会问一次是否打开这个从互联网下载的应用:点**打开**。
+21 -2
View File
@@ -901,6 +901,25 @@ already serves non-media text inert (`text/plain`, `nosniff`), so this just
keeps the server from storing what it would serve or an agent could run, and
html and svg are the ones deliberately kept out.
## Deleting a file (`DELETE /api/v1/sessions/:id/files?path=`)
The Files panel's delete button. `path` is what the listing gave the row: relative to the
session's `workingDir`, or absolute inside the session's outside worktree (see
[Live worktree](#live-worktree-liveworktree-outsideworktree)). It removes that one regular file
and answers `{ path }` as sent. It never deletes a folder and never follows a symbolic link:
the file routes resolve a link to its target, so deleting through one would remove the file it
points to.
| Status | When |
| ------ | ---- |
| `200` | The file is gone. |
| `400` | A remote (SSH) session or an adopted session (both refused before any path is looked at), a missing or repeated `path`, a symbolic link, or anything that is not a regular file. |
| `403` | A sensitive file (`.env`, credentials and the like), a blocked attachment tree, or anything under `.git`. |
| `404` | No such file, a path outside the workspace (`../`, an absolute path elsewhere, a link leading out), a dangling link, or a session the caller does not own in multi-user mode. |
| `409` | The file was replaced between the checks and the delete; reload the listing. |
| `422` | The workspace's mount did not answer. |
## New folder in the path picker
## New folder in the path picker
`POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one
@@ -1021,8 +1040,8 @@ of `liveWorktree` and `outsideWorktree`:
Like `liveWorktree`, it is never restored after a restart.
Such a worktree is a second root for the session's file routes. `file-content` (read and edit),
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download` and
`POST /api/v1/sessions/:id/html-view` accept an ABSOLUTE path inside it, while a relative path
`file-raw`, `file-preview`, `file-thumbnail`, `GET /api/v1/download`,
`POST /api/v1/sessions/:id/html-view` and `DELETE /api/v1/sessions/:id/files` accept an ABSOLUTE path inside it, while a relative path
still resolves against `workingDir` only. A file there that the sensitive-path blocklist names
(`.env`, `.aws/credentials`, `users.json` and the like) is refused on every one of those routes.
Codeman reports the worktree only when the session's own repository lists it, and only when `workingDir` is the top of that repository or of a linked worktree, so a session started in a subfolder of its repository reports none. It never reports:
+13 -1
View File
@@ -468,7 +468,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
### File Viewer edit mode
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes**, so it carries every rule the read surfaces have plus its own:
**File Viewer edit mode** (issue #212, design in `docs/file-viewer-edit-plan.md`): the file-preview overlay can edit workspace text files in place — `GET /api/sessions/:id/file-content?edit=1` (read-for-edit) + `PUT /api/sessions/:id/file-content` (save), policy in `src/config/file-editing.ts`, UI in `panels-ui.js`. This is the **only file surface that writes file contents** (the Files panel's delete, [below](#files-panel-delete), removes whole files and borrows these confinement rules), so it carries every rule the read surfaces have plus its own:
- **Confinement is the read path's, plus write-only gates.** `findSessionOrFail` (ownership) → `validateSessionFilePath` (realpath + workspace boundary; escapes report as 404, same as reads) → sensitive-path + attachment-guard blocklists (403) → `.git/` subtree deny (403 — `.git/hooks/*` is code execution) → extension **allowlist** (400; `svg` and `env` deliberately excluded). ⚠️ **There is no `O_CREAT` anywhere in the handler** — that absence is what makes "edit-in-place only, never create" a structural property instead of a convention. Do not add a create path without treating it as a new security surface.
- **A truncated buffer must never become an edit buffer.** The plain preview truncates to `lines` (default 500); saving such a buffer would silently delete everything past the cut, and the hash check cannot catch it (the loaded prefix hashes differently from the full file, which reads as an ordinary conflict at best). `edit=1` therefore never truncates — it 413s over `MAX_EDITABLE_BYTES` (512KB) instead — and the frontend always re-fetches with `edit=1` before swapping in the textarea, even though the preview already holds content.
@@ -479,6 +479,18 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
Tests: `test/file-editing-policy.test.ts` (pure policy), `test/routes/file-write-routes.test.ts` (deliberately **unmocked fs** against a real temp workspace — symlink/TOCTOU/mode behavior must be exercised for real).
### Files panel delete
**Files panel delete** (salvaged from contributor PR #529, which was closed; its upload half was dropped because `.codeman-uploads/` and the Attach button already cover it): a × on each file row of the Files panel, tree and search results alike, backed by `DELETE /api/sessions/:id/files?path=` in `file-routes.ts`. It removes ONE regular file and is the only file surface that deletes, so it takes the edit route's confinement whole and adds a rule of its own.
- **Order of checks**: `findSessionOrFail` (ownership; a foreign session is a 404 in multi-user mode) → remote refused (400, before any path check, for the same reason as the edit route: `validateSessionFilePath` resolves against the LOCAL filesystem, so a same-named local folder would take the delete) → adopted refused (400) → a missing or repeated `path` (400) → a bounded probe of `workingDir`, and of the outside worktree root for an absolute path (#516; `422` on a mount that does not answer) → `validateLocalSessionFilePath(await withGuardedOutsideWorktree(...))` (realpath + boundary, an absolute path inside a followed outside worktree confined to it, the attachment guard re-read per request; any escape, a dangling link and a missing file are all 404) → sensitive / blocked trees / `.git` (403), each tested against BOTH the realpath and the path as named, so a `.git` reached through a linked folder and a linked `.git` are both caught → the link rule below → not a regular file (a folder: 400).
- **A symbolic link is refused, never followed.** The confinement helper returns the REALPATH, which for a link is its target. #529 unlinked that path, so `DELETE ?path=AGENTS.md` with `AGENTS.md -> CLAUDE.md` deleted `CLAUDE.md` and left `AGENTS.md` dangling. The route therefore `lstat`s the path as the client named it (`resolve(workingDir, path)`, which keeps an absolute outside-worktree path as it is) and answers 400 for a link. The listing shows a link to a folder as a file row, so it gets a button, and the same 400 answers it. Deleting the link itself was the other option (confine the PARENT's realpath, unlink the link); it was not taken because refusing has no way to touch the target at all.
- **The file checked is the file deleted.** A path whose last part is not a link has a realpath naming the same inode, so the route `lstat`s the realpath again and refuses with 409 if the inode, the device or the file type changed in between. `unlink` never follows a final symlink, so a link swapped in after that still removes only the link. A parent folder swapped for a link between the realpath and the `unlink` is the same check-then-use window every file route accepts (an agent running as the same user can delete the file anyway).
- **Adopted and Docker.** An adopted session's folder is the neutral `/tmp`. The older file surfaces keep serving it as a standing exception (root `CLAUDE.md`, Adopted tmux sessions), which is not copied to a new surface, least of all one that deletes; the prompt-upload route refuses adopted sessions the same way. A Docker case workspace is bind-mounted at the same path and an `unlink` runs no hooks or filters (the reason git is kept away from those folders), so the route treats it like the edit route does: allowed.
- **Frontend.** The button carries no inline handler: `encodeURIComponent` leaves `'` alone, so #529's `onclick="...decodeURIComponent('<path>')"` let a file named `x'-alert(1)-'.txt` run script in Codeman's origin, and `it's.txt` was a syntax error. It is bound in the existing `.file-tree-item` loops (`renderFileBrowserTree`, `_renderFileBrowserSearchResults`) from the row's escaped `data-path`, with `stopPropagation` so the row does not also open the preview. `deleteFileFromBrowser()` asks with `confirm()` (the panel's other destructive actions use the same native dialog, translated by i18n.js's wrapper) and reloads through `_reloadFileBrowserKeepingQuery()`, which re-runs a search in the box, so a row deleted from search results leaves them. The row buttons stay visible under `@media (hover: none)`, where the old hover-only rule meant the first tap opened the preview instead. `_fileBrowserCanDelete()` draws no button for a remote or an adopted session (`session.remote` is undefined on an adopted one, so `session.adopt` is tested too).
Tests: `test/routes/file-delete-routes.test.ts` (unmocked fs again: the symlink defect only shows against a real link), `test/file-delete-ui.test.ts` (jsdom with `runScripts: 'dangerously'`, so the old inline handler would really run there, and the real `escapeHtml` from constants.js).
### File Viewer text view: rendered markdown and text toggles
**Rendered markdown + Lines/Wrap** (`_renderFilePreviewText()` and its helpers in `panels-ui.js`, buttons in the `.file-preview-actions` row): a `.md`/`.markdown` opened in the File Viewer renders as a document by default, with an `MD` pill back to source; the plain-text view has `Lines` (a CSS-counter gutter) and `Wrap` toggles. Codeman already had `marked` + DOMPurify behind `_renderMarkdown()` for the Response Viewer, so the viewer reuses that and the codebase keeps ONE markdown pipeline.
+3 -4
View File
@@ -248,10 +248,9 @@ A small native app that opens Codeman in its own window. Its first screen has tw
runs the installer on that machine over SSH.
On a MacBook Pro with a PROLED XDR display, whose lid logo lights up,
**View > Blink Apple Logo** makes the logo show what your agents are doing: it breathes while
they work, blinks when one waits for you and flashes three times when one needs permission.
Each state can take another style under **View > Apple Logo Blink Styles**. It is off until
you turn it on, and the menu only exists on such a Mac.
**View > Blink Apple Logo** blinks the logo by what your agents are doing, with a style of
your own for working, for waiting for you and for needing permission. All three are off
until you choose, and the menu only exists on such a Mac.
### First launch
+7
View File
@@ -19,6 +19,13 @@ not finish shows **Not fully scanned** rather than looking empty. A folder that
such as a network mount that went away, is skipped rather than waited on and is marked the same way,
so a search can miss what is inside it. The panel's Refresh button tries it again.
Each file row has a download arrow and a × that deletes the file after you confirm. Both show
when you point at the row, and always on a touch screen. Delete removes one file at a time,
never a folder, and refuses a symbolic link (deleting one from here would remove the file it
points to), anything under `.git`, and the sensitive files the viewer will not serve. It is not
offered for remote (SSH) sessions or for tmux sessions opened from outside Codeman. Deleting
from search results takes the row out of the results.
It renders what it can:
| Kind | Behaviour |
+35 -47
View File
@@ -40,7 +40,7 @@ cannot do.
| **Links** | On Codeman's own pages (the dashboard and session windows), a link to another site opens in your default browser, also on `⌘`-click or a middle click. Pages can send at most 3 links to the browser at once, then 1 per second. Inside a rendered HTML preview or a web tab, a plain link to another site does nothing, while one that asks for a new window (`target="_blank"`, `window.open`) opens in your default browser within the same budget (see Known limits). |
| **Downloads** | Codeman's Download links, and downloads Codeman opens in a new window, save the file to `~/Downloads` whatever its type, and a toast in Codeman names the file. Files the web view cannot display are saved the same way. |
| **A page that stops** | When the web content process of a window ends (a WebKit crash, or macOS taking the process back under memory pressure), the window loads its page again instead of staying blank. A second time within 30 seconds, the main window goes back to the connect screen and a pop-out closes, so a page that cannot stay up never becomes a reload loop. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing. **View > Blink Apple Logo** is the one switch, off until you turn it on. The more the logo moves, the more it needs you: it breathes (a slow fade out and back in) while agents work, blinks every two seconds for a yellow tab and flashes three times, then rests, for a red one. Each state can take another style under **View > Apple Logo Blink Styles**: Off, Breathe, Calm, Slow, Wink, Heartbeat, Fast, or Alarm. Picking a style turns the switch on, and turning the switch on with every state off brings back the recommended three (Breathe, Calm, Alarm). When several states hold, the most urgent one with a style shows: red, then yellow, then work. Styles chosen in 1.50.0 are kept, with the switch on if any of them was. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. The logo moves on its own hardware fades, the ones [LogoLight](https://github.com/Ark0N/LogoLight) uses, so a blink writes no setting, and a logo you switched off yourself stays off. While LogoLight runs, Codeman leaves the logo to it. While the app is closed nothing blinks, and while the display sleeps or the lid is closed the blink pauses and writes nothing. |
| **Apple logo** | On a MacBook Pro with a PROLED XDR display, the lit Apple logo on the lid says what the agents are doing, with a blink style of your own for each state: **View > Blink Apple Logo > While Working**, **Waiting for You (Yellow)** and **Needs Permission (Red)**. Off, Calm, Slow, Wink, Heartbeat, Fast, or Alarm, three sharp flashes for a state that needs you. All three are off until you choose. When several states hold, the most urgent one with a style shows: red, then yellow, then work. A style chosen in an earlier version stays as the one for work, and yellow and red start as Wink and Alarm. The menu only exists on a Mac with that logo. No password is needed. Shell tabs do not count. The app asks the server every two seconds, and a change has to hold for three seconds before the logo follows it, so an agent between two turns does not flicker it. It keeps working while the window is hidden, and with any Codeman server. A logo you switched off yourself stays off, and if the setting changes while a blink runs, the blink ends and leaves it as it found it. While the app is closed nothing blinks, and macOS keeps the logo dark while the display sleeps or the lid is closed, so the blink pauses then and writes nothing. |
| **Window** | Size, position and full-screen state are remembered. A saved window that hangs off the screen it opens on (it was last on a larger display that is gone, say) is pulled back on and shrunk to fit; one whose title bar is on no screen at all opens at the default size instead. Clicking the Dock icon brings back a minimized window. |
## Build (on a Mac)
@@ -225,43 +225,31 @@ If that probe is refused or rate limited, the connect screen comes back; the
app does not send the refused login again. Keeping no copy of the login in
WebKit has a cost here: after a server restart, each request that arrives
without a cookie gets a 401 before the
login goes out, and those count until on**The logo moves on its own hardware fades, from a child process, through CoreBrightness.**
Apple documents one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`, an on/off setting that
`corebrightnessd` keeps in root's preferences. A process of the logged-in user can reach the
daemon without an administrator through `BrightnessSystemClient` in the private CoreBrightness
framework, and 1.50.0 blinked by switching that setting there. The blink now uses two other
properties of the same client, which [LogoLight](https://github.com/Ark0N/LogoLight) (MIT, its
`docs/corebrightnessd.md`) measured on the real logo and `src/logo.rs` reimplements:
`LEPWMFactorFadeDuration` sets how long the next fade takes, and `DisplayLogicalState` 1
(Blank) fades the logo to dark and 2 (Unblank) back to lit, on the LED's own spring curve.
Nothing of that is saved, so a blink writes no setting and leaves nothing behind a restart;
`LEBacklightMode` is only read.
login goes out, and those count until one succeeds. Many windows reconnecting
at once can push a request past 10, and that request then fails with a 429
instead of being answered. A refusal that lands while you are in another app
does not bring Codeman forward: the connect screen loads where the window is,
and the Dock icon bounces.
The daemon decides the rest. Writing the direction the logo already heads in is ignored, and
the state cannot be read back, so the child keeps track of it and, when it does not know,
first turns the logo round with a fade too slow to see. The keyboard backlight follows
`DisplayLogicalState` too, so after every Blank the keyboards alone are told Unblank
(`setProperty:withKey:keyboardID:`), only while the built-in display is awake
(`CGDisplayIsAsleep`); when it sleeps they are told Blank, and Unblank when it wakes. A rise
from exactly dark waits for the display to show a frame, up to half a second with nothing
changing on screen. So the short dark beats (Alarm's, Heartbeat's, Wink's, Fast's) turn round
while their fall is still under way and never quite reach dark, and a long one (Calm's,
Slow's, the breath's rest) times its light from when the rise starts.
**The logo blinks from a child process, through CoreBrightness.** Apple documents
one control for the logo ([support.apple.com/en-us/149248](https://support.apple.com/en-us/149248)):
`sudo defaults write com.apple.CoreBrightness LEBacklightMode -int 0`. Doing that
for every blink would need a root helper and an administrator password. The same
setting is also a property of `BrightnessSystemClient` in the private
CoreBrightness framework, and a process of the logged-in user can set it there,
which is what `src/logo.rs` does. Measured on a Mac17,13 with macOS 27.2 from an
ad-hoc signed binary: `corebrightnessd` logs the logo dark about a millisecond
after the write, with no fade, and the NVRAM variable `bklb-backlight-enabled`
keeps its value while the logo is dark.
The app must not end with the logo dark, and the release profile has `panic = "abort"`, so no
cleanup code of the app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as the switch is on and a server is open, tied to the app by
nothing but a pipe: when the pipe closes, however the app ended, it puts the logo back (lit,
with the daemon's automatic fade duration) and exits. Switching it off closes the same pipe,
so the path a crash takes is the one used every day. A marker file (`logo-fade`, next to
`desktop.json`) covers what the child cannot survive itself: at the next launch the app puts
back a logo a blink left dark, and with 1.50.0's marker (`logo-blink`) it switches back on a
logo that version left off. The framework is loaded at run time and never linked, and
`LEBacklightMode` reading nil (a Mac mini answers nil) is what hides the option, so a macOS
that drops it loses this feature and nothing else.
e child cannot survive itself:
The setting is system-wide and survives a reboot, so the app must not end with the
logo dark, and the release profile has `panic = "abort"`, so no cleanup code of the
app can be relied on. So all of it runs as this same binary with
`--logo-blink`, for as long as any style is chosen and a server is open, tied to the app
by nothing but a pipe: when the pipe closes, however the app ended, it puts the logo
back and exits. Switching it off closes the same pipe, so the path a crash
takes is the one used every day. A marker file
(`logo-blink`, next to `desktop.json`) covers what the child cannot survive itself:
at the next launch the app puts back a logo a blink left dark. The framework is
loaded at run time and never linked, and `LEBacklightMode` reading nil (a Mac mini
answers nil) is what hides the option, so a macOS that drops it loses this feature
@@ -320,17 +308,17 @@ crosses, so the login is never on a command line.
look as a failed login, like any look at a server the app has no login for.
*Check again* asks again.
- **The logo blink rests on a private macOS interface.** Apple documents the
on/off setting, not the fades the app uses, so a macOS update can take them
away. The menu disappears if `LEBacklightMode` goes; if only the fade keys
change, the logo stops moving. A long dark beat (*Calm*, *Slow*, the rest in
*Breathe*) can be up to half a second longer than its style says, while the
display waits for a frame before the logo rises. Every Blank dims the
keyboard backlight a little before it is told to stay lit. A child killed
outright (SIGKILL) leaves the logo where it was, perhaps dark, until the
display next sleeps and wakes or the app next starts. Another app that drives
the logo the same way and is not LogoLight is not recognised, and the two
fight. `api/sessions` does not say which turns are Codeman's own, so with
prompt-cache keep-warm on, a keep-alive turn blinks too.
setting, not the way the app reaches it, so a macOS update can take it away;
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
from about once every two seconds (*Slow*, *Wink*) to about three times a
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
setting is on or off, so these styles are rhythms of hard changes. After a
blink the key stays there rather than being absent as on a fresh Mac. That
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
LEBacklightMode` removes it. After a power cut in a
dark phase the logo stays dark until the app next starts. `api/sessions` does
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
keep-alive turn blinks too.
- **Self-signed HTTPS is refused.** A server started with `codeman web --https`
uses a self-signed certificate, and WKWebView rejects it (wry has no trust
override). Use `http://127.0.0.1:3000` for a server on this Mac. For another
+5 -10
View File
@@ -44,10 +44,9 @@ pub struct Config {
pub auto_connect: bool,
/// Always open in full screen (otherwise the last state is restored).
pub start_fullscreen: bool,
/// Whether the lit Apple logo shows what the agents are doing, and its style
/// for each state (logo.rs). The menu only shows on a Mac that has one. A
/// value this version does not know reads as off: a bad style must not make
/// the whole file unreadable.
/// What the lit Apple logo does for each state of the agents (logo.rs). The
/// menu only shows on a Mac that has one. A value this version does not know
/// reads as off: a bad style must not make the whole file unreadable.
#[serde(deserialize_with = "lenient_blink")]
pub logo_blink: BlinkStyles,
/// Anything that is not a whole frame reads as None: a bad frame must not
@@ -416,12 +415,9 @@ mod tests {
use crate::logo::BlinkStyle;
let mut c = Config::default();
assert_eq!(c.logo_blink, BlinkStyles::default());
assert!(!c.logo_blink.enabled, "off until the user turns it on");
c.logo_blink.enabled = true;
c.logo_blink.needs_you = BlinkStyle::Wink;
c.logo_blink.needs_you = BlinkStyle::Alarm;
let text = serde_json::to_string(&c).unwrap();
assert!(text.contains(r#""needsYou":"wink""#), "{text}");
assert!(text.contains(r#""enabled":true"#), "{text}");
assert!(text.contains(r#""needsYou":"alarm""#), "{text}");
assert_eq!(serde_json::from_str::<Config>(&text).unwrap().logo_blink, c.logo_blink);
for bad in [
r#"{"logoBlink":3}"#,
@@ -438,7 +434,6 @@ mod tests {
use crate::logo::BlinkStyle;
let c: Config = serde_json::from_str(r#"{"logoBlink":"heartbeat"}"#).unwrap();
assert_eq!(c.logo_blink.working, BlinkStyle::Heartbeat);
assert!(c.logo_blink.enabled);
}
#[test]
+363 -1309
View File
File diff suppressed because it is too large. Load diff
+17 -49
View File
@@ -110,8 +110,6 @@ enter it again (a single-user Codeman accepts the right password at once), or wa
const KEYCHAIN_UNREADABLE: &str = "The saved password could not be read from the Keychain. Enter it again.";
const PAGE_KEEPS_STOPPING: &str = "Codeman's page stopped twice in a row, so it was closed. Connect again.";
const LOGO_SWITCHED_OFF: &str = "The Apple logo is switched off on this Mac, so it cannot blink.";
const LOGO_DRIVEN_ELSEWHERE: &str =
"LogoLight is driving the Apple logo, so Codeman leaves it alone. Quit LogoLight to see your agents on it.";
/// What a download's toast calls a file whose name is not known.
const UNNAMED_FILE: &str = "the file";
const START_REFUSED: &str = "Codeman can only be started here for an http://127.0.0.1 or http://localhost address.";
@@ -1032,63 +1030,34 @@ impl App {
}
}
/// The logo is watched while the switch is on, a state has a style and a
/// server is open.
/// The logo is watched while any style is chosen and a server is open.
fn sync_logo_watch(&mut self) {
let styles = self.config.logo_blink;
let phase = self.phase.borrow();
let server = phase.loaded().filter(|_| styles.active() && logo::available());
let server = phase.loaded().filter(|_| styles.any() && logo::available());
self.logo_watch
.sync(server.map(|url| (url.as_str(), self.auth.as_deref(), styles)));
}
fn choose_logo_blink(&mut self, signal: Signal, style: BlinkStyle) {
let was_active = self.config.logo_blink.active();
// A click flips the item it landed on, the chosen one included, so all
// of that state's are set.
for (item_signal, item_style, item) in &self.menu.logo_styles {
if *item_signal == signal {
item.set_checked(*item_style == style);
}
}
let was_off = !self.config.logo_blink.any();
self.config.logo_blink.set(signal, style);
// Picking a style is asking for the logo to show it.
if style != BlinkStyle::Off {
self.config.logo_blink.enabled = true;
}
self.logo_blink_changed(was_active);
}
/// The one switch. Turned on with every state off, it brings the recommended
/// styles, so that on always shows something.
fn toggle_logo_blink(&mut self) {
let was_active = self.config.logo_blink.active();
let on = !self.config.logo_blink.enabled;
self.config.logo_blink.enabled = on;
if on && !self.config.logo_blink.any_style() {
self.config.logo_blink = logo::BlinkStyles::RECOMMENDED;
}
self.logo_blink_changed(was_active);
}
fn logo_blink_changed(&mut self, was_active: bool) {
// A click flips the item it landed on, so every item is set from the
// settings, the clicked one included.
let styles = self.config.logo_blink;
if let Some(switch) = &self.menu.logo_switch {
switch.set_checked(styles.enabled);
}
for (signal, style, item) in &self.menu.logo_styles {
item.set_checked(styles.get(*signal) == *style);
}
self.config_dirty = true;
self.sync_logo_watch();
// Only from off: with a blink running, a dark logo may be our own.
if was_active || !styles.active() {
return;
}
let warning = if logo::switched_off() {
LOGO_SWITCHED_OFF
} else if logo::driven_elsewhere() {
LOGO_DRIVEN_ELSEWHERE
} else {
return;
};
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main.webview.evaluate_script(&assets::toast_script(warning, "warning"));
// Only from off: with a style running, a dark logo may be our own.
if was_off && style != BlinkStyle::Off && logo::switched_off() {
if let Some(main) = self.windows.get(&self.main_id) {
let _ = main
.webview
.evaluate_script(&assets::toast_script(LOGO_SWITCHED_OFF, "warning"));
}
}
}
@@ -1101,7 +1070,6 @@ impl App {
menu_id::SWITCH => self.show_connect_page(None, true),
menu_id::ADD_AGENT => self.add_agent(),
menu_id::RELOAD => self.reload_focused(),
menu_id::LOGO_BLINK => self.toggle_logo_blink(),
menu_id::START_FULLSCREEN => {
let value = self.menu.start_fullscreen.is_checked();
self.config.start_fullscreen = value;
+4 -19
View File
@@ -16,7 +16,6 @@ pub mod menu_id {
pub const ADD_AGENT: &str = "add-agent";
pub const RELOAD: &str = "reload";
pub const START_FULLSCREEN: &str = "start-fullscreen";
pub const LOGO_BLINK: &str = "logo-blink";
pub const DEVTOOLS: &str = "devtools";
pub const HELP_DOCS: &str = "help-docs";
pub const HELP_GITHUB: &str = "help-github";
@@ -27,10 +26,8 @@ pub struct AppMenu {
pub window_menu: Submenu,
pub help_menu: Submenu,
pub start_fullscreen: CheckMenuItem,
/// The one switch for the logo. None on a Mac without a lit logo, which has no
/// One item per state and style. Empty on a Mac without a lit logo, which has no
/// such menu.
pub logo_switch: Option<CheckMenuItem>,
/// One item per state and style, empty without the switch.
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
}
@@ -113,19 +110,10 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
),
],
)?;
// Only a Mac with a lit Apple logo has these items at all: the switch, and the
// style for each state behind it.
// Only a Mac with a lit Apple logo has this menu at all.
let mut logo_styles = Vec::new();
let mut logo_switch = None;
if logo::available() {
let switch = CheckMenuItem::with_id(
menu_id::LOGO_BLINK,
"Blink Apple Logo",
true,
config.logo_blink.enabled,
None,
);
let submenu = Submenu::new("Apple Logo Blink Styles", true);
let submenu = Submenu::new("Blink Apple Logo", true);
for signal in Signal::ALL {
let group = Submenu::new(signal.label(), true);
for style in BlinkStyle::ALL {
@@ -140,9 +128,7 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
submenu.append(&group)?;
}
// Right under "Always Open in Full Screen", before the separator.
view.insert(&switch, 4)?;
view.insert(&submenu, 5)?;
logo_switch = Some(switch);
view.insert(&submenu, 4)?;
}
let window_menu = Submenu::with_items(
"Window",
@@ -170,7 +156,6 @@ pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
window_menu,
help_menu,
start_fullscreen,
logo_switch,
logo_styles,
})
}
+6
View File
@@ -115,6 +115,12 @@ export interface FileWriteData {
eol: 'lf' | 'crlf';
}
/** Response payload for `DELETE /api/sessions/:id/files?path=` (the Files panel's delete button). */
export interface FileDeleteData {
/** The path as submitted (workspace-relative, or absolute inside the session's outside worktree) */
path: string;
}
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
/**
+5
View File
@@ -1688,6 +1688,11 @@
// The file name is user text: it passes through untranslated.
[/^Downloaded (.+) to Downloads$/, (_m, name) => `已将 ${name} 下载到“下载”文件夹`],
[/^Download of (.+) failed$/, (_m, name) => `下载 ${name} 失败`],
// The Files panel's delete button (panels-ui.js): the path is user text, and the server's
// reason passes through untranslated.
[/^Delete the file ([\s\S]+)\? This cannot be undone\.$/, (_m, path) => `删除文件 ${path}?此操作无法撤销。`],
[/^File deleted: ([\s\S]+)$/, (_m, path) => `已删除文件:${path}`],
[/^Delete failed: ([\s\S]+)$/, (_m, why) => `删除失败:${why}`],
// Group names are user text: they pass through untranslated.
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
// Session names are user text too.
+58
View File
@@ -3777,6 +3777,7 @@ Object.assign(CodemanApp.prototype, {
}
const html = [];
const canDelete = this._fileBrowserCanDelete(owner);
const renderNode = (node, depth) => {
const isDir = node.type === 'directory';
@@ -3799,6 +3800,13 @@ Object.assign(CodemanApp.prototype, {
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
// No inline handler: the path is a workspace file name, which an agent or a cloned repo
// chose, and `encodeURIComponent` leaves `'` alone, so a quoted string in an onclick ran
// whatever a file name like `x'-alert(1)-'.txt` held (#529's review). The click is bound
// below from the row's already-escaped `data-path`.
const deleteBtn = !isDir && canDelete
? `<button type="button" class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(node.name)}">&times;</button>`
: '';
html.push(`
<div class="file-tree-item" data-path="${escapeHtml(node.path)}" data-type="${escapeHtml(node.type)}" data-depth="${depth}">
@@ -3807,6 +3815,7 @@ Object.assign(CodemanApp.prototype, {
<span class="${nameClass}">${escapeHtml(node.name)}</span>
${sizeStr}
${downloadBtn}
${deleteBtn}
</div>
`);
@@ -3842,9 +3851,21 @@ Object.assign(CodemanApp.prototype, {
this.openFilePreview(path, owner);
}
});
item.querySelector('.file-tree-delete')?.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteFileFromBrowser(owner, item.dataset.path);
});
});
},
// The delete button is offered only where the route can act: never for a remote (SSH) session,
// whose files live on another host, nor for an adopted one, whose folder is not a workspace of
// ours (`session.remote` is undefined on an adopted session, so both are tested).
_fileBrowserCanDelete(sessionId) {
const session = this.sessions?.get?.(sessionId);
return !session?.remote && !session?.adopt;
},
toggleFileBrowserFolder(path) {
if (this.fileBrowserExpandedDirs.has(path)) {
this.fileBrowserExpandedDirs.delete(path);
@@ -3970,6 +3991,7 @@ Object.assign(CodemanApp.prototype, {
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
} else {
const ownerPath = encodeURIComponent(ownerSessionId);
const canDelete = this._fileBrowserCanDelete(ownerSessionId);
treeEl.innerHTML = matches
.map(match => {
const isDir = match.type === 'directory';
@@ -3981,6 +4003,10 @@ Object.assign(CodemanApp.prototype, {
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
// Bound below from `data-path`, never an inline handler (see renderFileBrowserTree).
const deleteBtn = !isDir && canDelete
? `<button type="button" class="file-tree-delete" title="Delete" aria-label="Delete ${escapeHtml(match.name)}">&times;</button>`
: '';
return `
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
<span class="file-tree-expand"></span>
@@ -3988,6 +4014,7 @@ Object.assign(CodemanApp.prototype, {
<span class="${nameClass}">${escapeHtml(match.name)}</span>
${sizeStr}
${downloadBtn}
${deleteBtn}
</div>
`;
})
@@ -4003,6 +4030,10 @@ Object.assign(CodemanApp.prototype, {
this.openFilePreview(path, ownerSessionId);
}
});
item.querySelector('.file-tree-delete')?.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteFileFromBrowser(ownerSessionId, item.dataset.path);
});
});
const statusEl = this.$('fileBrowserStatus');
@@ -4012,6 +4043,33 @@ Object.assign(CodemanApp.prototype, {
}
},
// The Files panel's delete button (DELETE .../files?path=): asks first, in the same native
// dialog the panel's other destructive actions use, then reloads the listing. With a search in
// the box the reload re-runs it, so the deleted row leaves the results too; a plain reload
// would refresh the tree behind them and leave the row on screen.
async deleteFileFromBrowser(sessionId, filePath) {
if (!sessionId || !filePath) return;
if (!confirm(`Delete the file ${filePath}? This cannot be undone.`)) return;
let result = null;
try {
const response = await fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/files?path=${encodeURIComponent(filePath)}`,
{ method: 'DELETE' },
);
result = await response.json().catch(() => null);
if (!response.ok || result?.success !== true) {
throw new Error(result?.error || `HTTP ${response.status}`);
}
} catch (error) {
console.error('File browser delete failed:', error);
this.showToast(`Delete failed: ${error?.message || 'network error'}`, 'error');
return;
}
this.showToast(`File deleted: ${filePath}`, 'success');
const state = this._ensureFileBrowserState();
if (state.ownerSessionId === sessionId) await this._reloadFileBrowserKeepingQuery();
},
_findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
if (!Array.isArray(nodes)) return null;
for (const node of nodes) {
+26
View File
@@ -12386,6 +12386,32 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.file-tree-download:hover {
color: #4fc3f7;
}
.file-tree-delete {
display: none;
margin-left: 2px;
padding: 0 4px;
border: 0;
background: transparent;
color: #888;
font-size: 14px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.file-tree-item:hover .file-tree-delete {
display: inline;
}
.file-tree-delete:hover {
color: #ef5350;
}
/* A touch screen has no hover, so the row's buttons stay visible there: otherwise the first tap
on a row opens the preview and the buttons never show. */
@media (hover: none) {
.file-tree-download,
.file-tree-delete {
display: inline;
}
}
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
+102 -2
View File
@@ -1,9 +1,11 @@
/**
* @fileoverview File browser and streaming routes.
* Provides directory listing, file content preview, raw file serving, tail
* streaming, and the File Viewer edit-mode write path (edit=1 read +
* streaming, the File Viewer edit-mode write path (edit=1 read +
* PUT /api/sessions/:id/file-content; policy in src/config/file-editing.ts,
* design in docs/file-viewer-edit-plan.md).
* design in docs/file-viewer-edit-plan.md), and the Files panel's delete
* (DELETE /api/sessions/:id/files?path=: one regular file, the edit route's
* confinement and guards, symbolic links refused).
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
@@ -19,6 +21,7 @@ import type {
FilesystemBrowseEntry,
FilesystemBrowseRoot,
FilesystemPreviewKind,
FileDeleteData,
FileWriteData,
} from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
@@ -2000,6 +2003,103 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
});
// The Files panel's delete button: remove ONE regular file from the session's workspace (or from
// its outside worktree, for an absolute path inside it). A write surface like the edit route
// above, so it takes that route's confinement and guards whole (ownership, realpath + workspace boundary, sensitive and blocked trees, `.git`), and adds
// its own rule for links: a symbolic link is REFUSED, never followed. The confinement check
// returns the link's realpath, which is its TARGET, so an `unlink` of that path deletes the file
// the link points at and leaves the link dangling (#529's review: `AGENTS.md -> CLAUDE.md`
// deleted CLAUDE.md). The final component is therefore read with `lstat` on the path as the
// client named it, and only a path whose last part is a plain file goes on; `unlink` never
// follows a final symlink, so a link swapped in after that check is removed rather than its
// target, and the inode is compared again right before the unlink.
app.delete('/api/sessions/:id/files', async (req): Promise<ApiResponse<FileDeleteData>> => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const session = findSessionOrFail(ctx, id, req);
// Ahead of any path check, as on the edit route: the validation below resolves against the
// LOCAL filesystem, so a same-named local directory would take the delete.
if (session.remote) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Deleting is not supported for remote (SSH) sessions');
}
// An adopted session has no workspace of ours: its folder is the neutral `/tmp`. The older
// file surfaces keep serving it as a standing exception (root CLAUDE.md, Adopted tmux
// sessions), which is not copied to a new surface, least of all one that deletes.
if (session.isAdopted) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Deleting is not supported for adopted sessions');
}
// A repeated `?path=` arrives as an array.
if (typeof filePath !== 'string' || !filePath) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
}
// #516: the confinement check below runs realpathSync, which freezes the server on a mount
// that stopped answering. Probe the folders it will touch first, bounded.
const scope = await withGuardedOutsideWorktree({
workingDir: session.workingDir,
liveWorktree: session.liveWorktree,
});
const worktreeRoot = scope.liveWorktree?.outside && isAbsolute(filePath) ? scope.liveWorktree.path : null;
for (const folder of worktreeRoot ? [session.workingDir, worktreeRoot] : [session.workingDir]) {
if ((await probePathKind(folder, { pastCap: true })) === 'unknown') {
throwRouteError(
422,
ApiErrorCode.OPERATION_FAILED,
describeUnknownPath('Workspace', folder, { pastCap: true })
);
}
}
const validated = validateLocalSessionFilePath(scope, filePath);
if (!validated) {
// Missing files, traversal and symlink escapes alike, as on every other file route.
throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
}
const { resolvedPath, relativePath } = validated;
// The path as the client named it: `resolve` keeps an absolute (outside worktree) path as is.
const namedPath = resolve(session.workingDir, filePath);
const guard = await loadAttachmentGuardConfig();
if (
isSensitivePath(resolvedPath) ||
isSensitivePath(namedPath) ||
isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) ||
isBlockedAttachmentPath(namedPath, guard.blockedTrees)
) {
throwRouteError(403, ApiErrorCode.FORBIDDEN, 'Deleting this file is blocked');
}
// Both spellings: a `.git` reached through a linked folder, and a linked `.git`.
if (isDeniedEditRelativePath(relativePath) || isDeniedEditRelativePath(relative(session.workingDir, namedPath))) {
throwRouteError(403, ApiErrorCode.FORBIDDEN, 'Files under .git cannot be deleted');
}
const named = await fs.lstat(namedPath).catch(() => undefined);
if (!named) {
throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
}
if (named.isSymbolicLink()) {
throwRouteError(
400,
ApiErrorCode.INVALID_INPUT,
'Symbolic links are not deleted from the Files panel (deleting one here would remove the file it points to)'
);
}
if (!named.isFile()) {
throwRouteError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be deleted');
}
// The same file the checks were made on: realpath of a path whose last part is no link names
// the same inode. A change in between (the file replaced) refuses rather than guesses.
const real = await fs.lstat(resolvedPath).catch(() => undefined);
if (!real || !real.isFile() || real.ino !== named.ino || real.dev !== named.dev) {
throwRouteError(409, ApiErrorCode.CONFLICT, 'The file changed while it was being deleted; reload and try again');
}
try {
await fs.unlink(resolvedPath);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') throwRouteError(404, ApiErrorCode.NOT_FOUND, 'File not found');
// The errno text names the resolved path, so only its code goes back.
throwRouteError(500, ApiErrorCode.OPERATION_FAILED, `Failed to delete the file (${code ?? 'error'})`);
}
return { success: true, data: { path: filePath } };
});
// Get file content for preview (File Browser)
app.get('/api/sessions/:id/file-content', async (req) => {
const { id } = req.params as { id: string };
+4
View File
@@ -30,6 +30,9 @@ interface FakeRow {
dataset: Record<string, string>;
addEventListener: (type: string, handler: ClickHandler) => void;
click: () => void;
// The row's delete button is looked up per row; the fake rows carry none (file-delete-ui.test.ts
// drives it over a real DOM).
querySelector: (selector: string) => null;
}
interface FakeElement {
@@ -105,6 +108,7 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
click() {
clickHandler?.();
},
querySelector: () => null,
});
}
},
+264
View File
@@ -0,0 +1,264 @@
/**
* @fileoverview The Files panel's delete button (salvaged from #529), in the browser module.
*
* #529 first rendered the button with an inline `onclick` that quoted the path inside
* `decodeURIComponent('...')`. `encodeURIComponent` leaves `'` alone, so a file named
* `x'-alert(1)-'.txt` ran script in Codeman's origin, and `it's.txt` was a syntax error whose
* button did nothing. Workspace names come from agents and cloned repos, so the click is now
* bound from the row's escaped `data-path`.
*
* The DOM is jsdom with `runScripts: 'dangerously'` on purpose: jsdom then compiles inline event
* attributes the way a browser does, with `window.app` and `window.alert` present, so the old
* markup would really call `alert` here and this test would fail on it. The escaper is the real
* one from constants.js, not a stub. panels-ui.js loads via `vm` as in
* test/file-browser-incomplete.test.ts. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const CONSTANTS = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
// The shipped escaper: its map, its pattern and the function, cut from constants.js.
const ESCAPE_START = CONSTANTS.indexOf('const _htmlEscapeMap');
const ESCAPE_END = CONSTANTS.indexOf('\n}\n', CONSTANTS.indexOf('function escapeHtml(')) + 3;
const ESCAPE_SRC = CONSTANTS.slice(ESCAPE_START, ESCAPE_END);
const HOSTILE = ["x'-alert(1)-'.txt", "it's.txt", 'a "b" <i>c</i> & d.txt', "');alert(2);('.txt"];
const SESSION = 'sess-1';
const dom = new JSDOM(
`<div id="fileBrowserPanel" class="visible"></div>
<input id="fileBrowserSearch">
<div id="fileBrowserStatus"></div>
<div id="fileBrowserTree"></div>`,
{ runScripts: 'dangerously' }
);
afterAll(() => dom.window.close());
const document = dom.window.document;
const win = dom.window as unknown as Record<string, unknown>;
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
interface FakeResponse {
ok: boolean;
status: number;
json: () => Promise<unknown>;
}
const reply = (body: unknown, status = 200): FakeResponse => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
function loadPanel(
options: {
confirm?: (message: string) => boolean;
fetch?: (url: string, init?: { method?: string }) => Promise<FakeResponse>;
session?: Record<string, unknown>;
} = {}
) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
// The search debounce, collapsed so the re-run lands on the next turn.
setTimeout: (fn: () => void) => setTimeout(fn, 0),
clearTimeout,
CodemanBase: { url: (p: string) => p },
confirm: vi.fn(options.confirm ?? (() => true)),
fetch: vi.fn(options.fetch ?? (async () => reply({ success: true, data: {} }))),
});
vm.runInContext(ESCAPE_SRC, context, { filename: 'constants.js (escapeHtml)' });
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.activeSessionId = SESSION;
app.sessions = new Map([[SESSION, options.session ?? { id: SESSION }]]);
app.fileBrowserExpandedDirs = new Set(['src']);
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
app.openFilePreview = vi.fn();
app.showToast = vi.fn();
// The sentinel the old inline handler reached: `app.deleteFileFromBrowser(...)` ran in the page.
win.app = app;
win.alert = vi.fn();
return { app, context: context as Record<string, any> };
}
const fileNode = (name: string) => ({ name, path: `src/${name}`, type: 'file', size: 1, extension: 'txt' });
const treeEnvelope = (names: string[]) => ({
success: true,
data: {
root: '/repo',
tree: [{ name: 'src', path: 'src', type: 'directory', children: names.map(fileNode) }],
totalFiles: names.length,
totalDirectories: 1,
truncated: false,
},
});
const searchEnvelope = (names: string[]) => ({
success: true,
data: { mode: 'search', matches: names.map(fileNode), matchCount: names.length, truncated: false },
});
const rowFor = (path: string) =>
[...treeEl().querySelectorAll<HTMLElement>('.file-tree-item')].find((row) => row.dataset.path === path);
const deleteButtonFor = (path: string) => rowFor(path)?.querySelector<HTMLButtonElement>('.file-tree-delete');
function renderTree(app: Record<string, any>, names: string[]) {
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(treeEnvelope(names));
app.renderFileBrowserTree(SESSION);
}
function renderSearch(app: Record<string, any>, names: string[]) {
app._renderFileBrowserSearchResults(names.map(fileNode), SESSION, { matchCount: names.length, truncated: false });
}
describe('Files panel delete button: no inline handler, the exact path', () => {
it.each([
['tree', renderTree],
['search results', renderSearch],
])('%s: a click deletes exactly the hostile path and runs nothing', (_label, render) => {
const { app } = loadPanel();
app.deleteFileFromBrowser = vi.fn();
render(app, HOSTILE);
expect(treeEl().innerHTML).not.toContain('decodeURIComponent');
for (const name of HOSTILE) {
const button = deleteButtonFor(`src/${name}`);
expect(button, name).toBeTruthy();
expect(button!.getAttribute('onclick')).toBeNull();
expect(button!.type).toBe('button');
button!.click();
expect(app.deleteFileFromBrowser).toHaveBeenLastCalledWith(SESSION, `src/${name}`);
}
expect(app.deleteFileFromBrowser).toHaveBeenCalledTimes(HOSTILE.length);
expect(win.alert).not.toHaveBeenCalled();
// The click stays on the button: the row behind it does not open the file.
expect(app.openFilePreview).not.toHaveBeenCalled();
// The row itself still opens it.
rowFor(`src/${HOSTILE[0]}`)!.click();
expect(app.openFilePreview).toHaveBeenCalledWith(`src/${HOSTILE[0]}`, SESSION);
});
it('labels the button with the escaped name, as text', () => {
const { app } = loadPanel();
renderTree(app, ['a "b" <i>c</i> & d.txt']);
const button = deleteButtonFor('src/a "b" <i>c</i> & d.txt')!;
expect(button.getAttribute('aria-label')).toBe('Delete a "b" <i>c</i> & d.txt');
expect(rowFor('src/a "b" <i>c</i> & d.txt')!.querySelector('i')).toBeNull();
});
it('offers no delete button on a folder row', () => {
const { app } = loadPanel();
renderTree(app, ['a.txt']);
expect(rowFor('src')!.querySelector('.file-tree-delete')).toBeNull();
expect(deleteButtonFor('src/a.txt')).toBeTruthy();
});
it.each([
['a remote (SSH) session', { id: SESSION, remote: { host: 'h' } }],
['an adopted session', { id: SESSION, adopt: { kind: 'local' } }],
])('offers none for %s, which the route refuses', (_label, session) => {
const { app } = loadPanel({ session });
renderTree(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
renderSearch(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
});
});
describe('deleteFileFromBrowser', () => {
it('asks first and sends nothing when the answer is no', async () => {
const { app, context } = loadPanel({ confirm: () => false });
await app.deleteFileFromBrowser(SESSION, "src/it's.txt");
expect(context.confirm).toHaveBeenCalledWith("Delete the file src/it's.txt? This cannot be undone.");
expect(context.fetch).not.toHaveBeenCalled();
});
it('sends one DELETE with the encoded path, then reloads keeping the query', async () => {
const { app, context } = loadPanel({ fetch: async () => reply({ success: true, data: { path: 'x' } }) });
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
const path = "src/x'-alert(1)-' & #?.txt";
await app.deleteFileFromBrowser(SESSION, path);
expect(context.fetch).toHaveBeenCalledTimes(1);
expect(context.fetch).toHaveBeenCalledWith(`/api/sessions/${SESSION}/files?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
expect(app.showToast).toHaveBeenCalledWith(`File deleted: ${path}`, 'success');
expect(app._reloadFileBrowserKeepingQuery).toHaveBeenCalledTimes(1);
});
it('shows the server reason and reloads nothing when the delete is refused', async () => {
const { app } = loadPanel({
fetch: async () => reply({ success: false, error: 'Symbolic links are not deleted from the Files panel' }, 400),
});
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
await app.deleteFileFromBrowser(SESSION, 'AGENTS.md');
expect(app.showToast).toHaveBeenCalledWith(
'Delete failed: Symbolic links are not deleted from the Files panel',
'error'
);
expect(app._reloadFileBrowserKeepingQuery).not.toHaveBeenCalled();
});
it('removes the row from the search results it was deleted from', async () => {
let files = ['delete-me.txt', 'keep-me.txt', 'other.md'];
const { app, context } = loadPanel({
fetch: async (url, init) => {
const query = new URL(url, 'http://localhost');
if (init?.method === 'DELETE') {
const gone = query.searchParams.get('path')!.replace(/^src\//, '');
files = files.filter((name) => name !== gone);
return reply({ success: true, data: { path: query.searchParams.get('path') } });
}
const q = query.searchParams.get('q');
return reply(q ? searchEnvelope(files.filter((name) => name.includes(q))) : treeEnvelope(files));
},
});
await app.loadFileBrowser(SESSION);
(document.getElementById('fileBrowserSearch') as HTMLInputElement).value = 'me';
app.filterFileBrowser('me');
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeTruthy());
expect(rowFor('src/other.md')).toBeUndefined();
deleteButtonFor('src/delete-me.txt')!.click();
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeUndefined());
expect(rowFor('src/keep-me.txt')).toBeTruthy();
// Still the search view, not the tree behind it.
expect(rowFor('src/other.md')).toBeUndefined();
expect(document.getElementById('fileBrowserStatus')!.textContent).toBe('1 match');
const methods = context.fetch.mock.calls.map(([url, init]: [string, { method?: string }?]) =>
init?.method === 'DELETE' ? 'DELETE' : url.includes('&q=') ? 'search' : 'tree'
);
expect(methods).toEqual(['tree', 'search', 'DELETE', 'tree', 'search']);
});
});
describe('Files panel delete button: touch screens', () => {
it('keeps the row buttons visible where there is no hover', () => {
const blocks = [...STYLES.matchAll(/@media \(hover: none\) \{([^{}]*\{[^{}]*\})\s*\}/g)].map((m) => m[1]);
expect(
blocks.some((block) => /\.file-tree-download,\s*\.file-tree-delete\s*\{\s*display: inline;/.test(block))
).toBe(true);
});
});
+322
View File
@@ -0,0 +1,322 @@
/**
* @fileoverview `DELETE /api/sessions/:id/files?path=`, the Files panel's delete button (salvaged
* from #529).
*
* Real temp workspace, no fs mock, like test/routes/file-write-routes.test.ts: the thing under
* test is what the route does to real files, and the defect it fixes (a delete of
* `AGENTS.md -> CLAUDE.md` removed CLAUDE.md, because the confinement check hands back the
* realpath) only shows against a real symlink. Each refusal also checks that nothing on disk moved.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
describe('DELETE /api/sessions/:id/files (real fs)', () => {
let harness: RouteTestHarness;
let base: string;
let workDir: string;
let outsideDir: string;
const del = (path: string, h: RouteTestHarness = harness) =>
h.app.inject({
method: 'DELETE',
url: `/api/sessions/${h.ctx._sessionId}/files?path=${encodeURIComponent(path)}`,
});
beforeEach(async () => {
harness = await createRouteTestHarness(registerFileRoutes);
// realpath: tmpdir() can hold a symlinked component (macOS /tmp), and the fixtures compare paths.
base = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-delete-')));
workDir = join(base, 'ws');
outsideDir = join(base, 'outside');
mkdirSync(workDir);
mkdirSync(outsideDir);
harness.ctx._session.workingDir = workDir;
});
afterEach(async () => {
await harness.app.close();
rmSync(base, { recursive: true, force: true });
rmSync(SETTINGS_PATH, { force: true });
});
it('deletes a regular file and answers the path it was given', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'old.ts'), 'x');
writeFileSync(join(workDir, 'keep.ts'), 'y');
const res = await del('src/old.ts');
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { path: 'src/old.ts' } });
expect(existsSync(join(workDir, 'src', 'old.ts'))).toBe(false);
expect(existsSync(join(workDir, 'src'))).toBe(true);
expect(readFileSync(join(workDir, 'keep.ts'), 'utf8')).toBe('y');
});
it('deletes a file whose name carries quotes and other characters a shell or a URL cares about', async () => {
const name = "x'-alert(1)-' & #?.txt";
writeFileSync(join(workDir, name), 'x');
const res = await del(name);
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, name))).toBe(false);
});
describe('symbolic links are refused, never followed', () => {
it('leaves both the link and its target when asked to delete AGENTS.md -> CLAUDE.md', async () => {
writeFileSync(join(workDir, 'CLAUDE.md'), '# rules');
symlinkSync('CLAUDE.md', join(workDir, 'AGENTS.md'));
const res = await del('AGENTS.md');
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
expect(res.json().error).toMatch(/symbolic link/i);
expect(readFileSync(join(workDir, 'CLAUDE.md'), 'utf8')).toBe('# rules');
expect(lstatSync(join(workDir, 'AGENTS.md')).isSymbolicLink()).toBe(true);
});
it('refuses a link to a folder, which the listing shows as a file row', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'a.txt'), 'a');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'real', 'a.txt'), 'utf8')).toBe('a');
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
it('answers 404 for a link that leads out of the workspace, and leaves its target', async () => {
writeFileSync(join(outsideDir, 'secret.txt'), 'secret');
symlinkSync(join(outsideDir, 'secret.txt'), join(workDir, 'leak.txt'));
const res = await del('leak.txt');
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'secret.txt'), 'utf8')).toBe('secret');
});
it('deletes a plain file reached through a linked folder inside the workspace', async () => {
mkdirSync(join(workDir, 'real'));
writeFileSync(join(workDir, 'real', 'b.txt'), 'b');
symlinkSync('real', join(workDir, 'alias'));
const res = await del('alias/b.txt');
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, 'real', 'b.txt'))).toBe(false);
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
});
});
it('refuses a file under .git with 403, also when .git is reached through a linked folder', async () => {
mkdirSync(join(workDir, '.git'));
writeFileSync(join(workDir, '.git', 'config'), '[core]');
symlinkSync('.git', join(workDir, 'gitdir'));
for (const path of ['.git/config', 'gitdir/config']) {
const res = await del(path);
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
}
expect(readFileSync(join(workDir, '.git', 'config'), 'utf8')).toBe('[core]');
});
it('refuses a sensitive file with 403', async () => {
writeFileSync(join(workDir, '.env'), 'TOKEN=abc');
const res = await del('.env');
expect(res.statusCode).toBe(403);
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('TOKEN=abc');
});
it('answers 404 for a path that climbs out of the workspace', async () => {
writeFileSync(join(outsideDir, 'victim.txt'), 'v');
for (const path of ['../outside/victim.txt', join(outsideDir, 'victim.txt')]) {
const res = await del(path);
expect(res.statusCode).toBe(404);
}
expect(readFileSync(join(outsideDir, 'victim.txt'), 'utf8')).toBe('v');
});
it('answers 404 for a file that does not exist', async () => {
expect((await del('nope.txt')).statusCode).toBe(404);
});
it('refuses a directory with 400', async () => {
mkdirSync(join(workDir, 'src'));
writeFileSync(join(workDir, 'src', 'a.ts'), 'a');
const res = await del('src');
expect(res.statusCode).toBe(400);
expect(readFileSync(join(workDir, 'src', 'a.ts'), 'utf8')).toBe('a');
});
it('refuses a missing or repeated path parameter with 400', async () => {
const missing = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files`,
});
expect(missing.statusCode).toBe(400);
writeFileSync(join(workDir, 'a.txt'), 'a');
const repeated = await harness.app.inject({
method: 'DELETE',
url: `/api/sessions/${harness.ctx._sessionId}/files?path=a.txt&path=a.txt`,
});
expect(repeated.statusCode).toBe(400);
expect(existsSync(join(workDir, 'a.txt'))).toBe(true);
});
it('refuses a remote (SSH) session before looking at the local twin of its folder', async () => {
// The local folder of the same absolute name is exactly the hazard: validation would resolve
// against it and the delete would land here while the user meant the remote file.
writeFileSync(join(workDir, 'twin.txt'), 'local');
harness.ctx._session.remote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: workDir,
};
const res = await del('twin.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/remote/i);
expect(readFileSync(join(workDir, 'twin.txt'), 'utf8')).toBe('local');
});
it('refuses an adopted session, whose folder is not a workspace of ours', async () => {
writeFileSync(join(workDir, 'shared.txt'), 'tmp');
harness.ctx._session.isAdopted = true;
const res = await del('shared.txt');
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/adopted/i);
expect(readFileSync(join(workDir, 'shared.txt'), 'utf8')).toBe('tmp');
});
describe('a worktree outside the session folder', () => {
let sibling: string;
beforeEach(() => {
sibling = join(base, 'ws-feature');
mkdirSync(join(sibling, 'src'), { recursive: true });
writeFileSync(join(sibling, 'src', 'feature.ts'), 'feature');
Object.assign(harness.ctx._session, {
liveWorktree: { name: 'ws-feature', path: sibling, outside: true },
});
});
it('deletes an absolute path inside it, as the other file routes serve it', async () => {
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(200);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(false);
});
it('does not reach it with a relative path, which resolves against the session folder only', async () => {
const res = await del('../ws-feature/src/feature.ts');
expect(res.statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it('refuses a symlink in it that leads out of it, and leaves the target', async () => {
writeFileSync(join(outsideDir, 'key.txt'), 'secret');
symlinkSync(join(outsideDir, 'key.txt'), join(sibling, 'leak.txt'));
const res = await del(join(sibling, 'leak.txt'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(outsideDir, 'key.txt'), 'utf8')).toBe('secret');
});
it('reaches nothing there once it is no longer an outside worktree', async () => {
Object.assign(harness.ctx._session, { liveWorktree: { name: 'ws-feature', path: sibling } });
expect((await del(join(sibling, 'src', 'feature.ts'))).statusCode).toBe(404);
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
});
it.each([
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
['its parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
])('deletes nothing there once %s', async (_label, settings) => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings()));
const res = await del(join(sibling, 'src', 'feature.ts'));
expect(res.statusCode).toBe(404);
expect(readFileSync(join(sibling, 'src', 'feature.ts'), 'utf8')).toBe('feature');
});
});
describe('multi-user mode', () => {
let prevMultiUser: string | undefined;
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
});
it("answers 404 for another user's session and deletes nothing", async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'alice';
writeFileSync(join(workDir, 'alice.txt'), 'mine');
const res = await del('alice.txt', bob);
expect(res.statusCode).toBe(404);
expect(readFileSync(join(workDir, 'alice.txt'), 'utf8')).toBe('mine');
} finally {
await bob.app.close();
}
});
it('lets the owner delete in their own session', async () => {
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
try {
bob.ctx._session.workingDir = workDir;
(bob.ctx._session as unknown as { owner?: string }).owner = 'bob';
writeFileSync(join(workDir, 'bob.txt'), 'mine');
expect((await del('bob.txt', bob)).statusCode).toBe(200);
expect(existsSync(join(workDir, 'bob.txt'))).toBe(false);
} finally {
await bob.app.close();
}
});
});
});