mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(file-viewer): edit mode for text files (edit + save in the viewer)
Closes #212. The file-preview overlay can now edit workspace text files in place, phone-first: agent writes a file, you review it in the viewer, tweak two lines, save, tell the agent to continue. Backend (file-routes.ts, policy in src/config/file-editing.ts): - GET file-content?edit=1: read-for-edit that never truncates (a truncated buffer must never become an edit buffer), 512KB cap (413 over it), and returns the sha256 hash + detected EOL the client echoes back on save. - PUT /api/sessions/:id/file-content: edit-in-place only, with no O_CREAT anywhere in the handler. Confinement matches the read path (realpath + workspace boundary + ownership via findSessionOrFail), plus sensitive-path and attachment-guard blocklists, a .git subtree deny, and an extension allowlist (svg and env deliberately excluded). Optimistic concurrency via baseHash: mismatch is a 409 unless force. Writes are wx-temp + fchmod + fsync + rename, closing the validate-then-write TOCTOU window. - Corruption guards: NUL sniff + UTF-8 round-trip compare (refuses binary and latin-1), and server-side EOL re-application so a textarea's LF normalization cannot rewrite every line of a CRLF file. - Plain reads gain an additive editable flag the UI keys the button off. Frontend (panels-ui.js + overlay markup/styles): - Edit button on editable text previews; textarea editor with Save/Cancel, dirty indicator, discard-confirm on cancel/close, and a conflict dialog that offers overwrite (force) when the file changed on disk mid-edit. - Phone: full-bleed window sized by --app-height so the editor and Save bar track the OS keyboard; 16px editor font (iOS zoom guard); no autofocus. - zh-CN strings for the new chrome. Tests: pure policy unit tests plus a route suite that deliberately does NOT mock node:fs. It runs against a real temp workspace so symlink escapes, write-through of in-workspace symlinks, mode preservation, CRLF round-trip, 409/force, and the no-create property are exercised for real. Also verified end to end on an isolated beta instance: 39-check curl matrix, Playwright desktop flow (real clicks and typing, bytes asserted on disk, live conflict with an external rewrite), and a 393px phone profile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -214,6 +214,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||||
|
|
||||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||||
|
|||||||
@@ -87,6 +87,19 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`.
|
The general rule: **any new endpoint that turns a caller-supplied `sessionId` into a filesystem path is an ownership boundary**, whether or not it goes through `findSessionOrFail`.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Concurrency is optimistic by content hash, not mtime.** The client echoes the sha256 it loaded (`baseHash`); mismatch → 409 CONFLICT (plain envelope — the error arm carries no data; the client re-fetches `edit=1` for fresh state) unless `force:true`. mtime alone is wrong: agents rewrite files within one timestamp tick.
|
||||||
|
- **Writes are `wx` temp + `fchmod` + `fsync` + `rename` in the target's directory.** `wx` cannot follow a pre-existing symlink and `rename()` replaces (not follows) a symlink final component, which closes the validate-then-write TOCTOU window; `fchmod` because `open()`'s mode argument is masked by the umask; a symlink whose target is *inside* the workspace is deliberately written through (validation returns the realpath). Trade-off (same as vim): the inode changes, so hardlinks keep old content.
|
||||||
|
- **Corruption guards**: NUL-sniff + UTF-8 **round-trip compare** (`Buffer.from(buf.toString('utf8'), 'utf8').equals(buf)`) refuse binary and non-UTF-8 files — decoding latin-1 yields U+FFFD replacements and writing those back destroys the original bytes. EOL is detected server-side and re-applied on save because a `<textarea>` normalizes to LF (a two-line edit of a CRLF file must not become a whole-file diff).
|
||||||
|
- **Two size caps on the wire**: the Zod `.max()` counts UTF-16 code units (coarse pre-filter, 400) while the handler's `Buffer.byteLength` check enforces the real byte cap (413); the route sets `bodyLimit: 4MB` because JSON escaping can expand 512KB of content past Fastify's 1MB default. Error paths **throw** structured `{statusCode, body}` errors (`throwFileEditError`) rather than returning envelopes — the central preSerialization status-mapping hook is absent from the route-test harness, and 413 has no errorCode mapping at all.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
### Ultracode and workflow-run visualization
|
### Ultracode and workflow-run visualization
|
||||||
|
|
||||||
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
**Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`.
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# File Viewer edit mode (issue #212)
|
||||||
|
|
||||||
|
Plan only. No implementation yet.
|
||||||
|
|
||||||
|
Goal: close the loop "agent writes a file, you review it in the viewer, tweak two lines, save, tell the
|
||||||
|
agent to continue" without hopping into the terminal, with the phone as the primary target.
|
||||||
|
|
||||||
|
Scope from the issue: an Edit toggle on text previews, a write endpoint that inherits the read path's
|
||||||
|
confinement, text-only, edit-in-place (no create, no delete, no rename), no editing through the
|
||||||
|
Docker/remote overlays.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What exists today
|
||||||
|
|
||||||
|
**Read path (backend), all in `src/web/routes/file-routes.ts`:**
|
||||||
|
|
||||||
|
| Route | Line | Notes |
|
||||||
|
| ------------------------------------ | ------ | ------------------------------------------------------------------ |
|
||||||
|
| `GET /api/sessions/:id/files` | `741` | Tree scan of `session.workingDir`, hidden files off by default |
|
||||||
|
| `GET /api/sessions/:id/file-content` | `865` | The text/preview classifier. `findSessionOrFail` + `validateSessionFilePath` |
|
||||||
|
| `GET /api/sessions/:id/file-raw` | `1018` | Bytes, 50MB cap |
|
||||||
|
| `GET /api/sessions/:id/file-preview` | `1254` | DOCX/PPTX to PDF, everything else redirects to `file-raw` |
|
||||||
|
| `GET /api/download` | `1384` | The only read route that also runs `isSensitivePath()` |
|
||||||
|
|
||||||
|
`file-content` classification order (`file-routes.ts:881-1011`): extension buckets (image / video / audio /
|
||||||
|
known-binary) return metadata only; otherwise the bytes are read, sniffed for a NUL in the first 8KB, and
|
||||||
|
either reported as `type:'binary'` or decoded as UTF-8 and **truncated to `lines` (default 500, hard cap
|
||||||
|
10000)**. Caps: `MAX_TEXT_FILE_SIZE` 10MB.
|
||||||
|
|
||||||
|
Confinement is `validateSessionFilePath()` (`src/web/route-helpers.ts:67`): `resolve()` then `realpathSync()`
|
||||||
|
then reject if the result is not under `workingDir`. Because it realpaths the *full* path, a symlink whose
|
||||||
|
target escapes the workspace is already rejected. Ownership is `findSessionOrFail()` which runs
|
||||||
|
`canAccessOwned()` (`route-helpers.ts:102`), a no-op outside multi-user mode.
|
||||||
|
|
||||||
|
**Read path (frontend), `src/web/public/panels-ui.js`:**
|
||||||
|
|
||||||
|
- `loadFileBrowser()` `2947`, `renderFileBrowserTree()` `2978`, click to `openFilePreview()` `3056`.
|
||||||
|
- `openFilePreview(filePath, sessionId, attachmentId)` `3193`: attachment-id branch, then docx/pptx, pdf,
|
||||||
|
svg branches, then the generic `file-content` fetch at `3274` with **`&lines=500` hardcoded**, rendering
|
||||||
|
text as `<pre><code>${escapeHtml(...)}</code></pre>` at `3298` and stashing `this.filePreviewContent`.
|
||||||
|
- `closeFilePreview()` `3308`, `copyFilePreviewContent()` `3751`.
|
||||||
|
- Markup: `src/web/public/index.html:420-432` (`filePreviewOverlay` / `-Title` / `-Body` / `-Footer`, two
|
||||||
|
header buttons: copy and close).
|
||||||
|
- CSS: `src/web/public/styles.css:9320-9430`. Overlay `z-index: 2000`, window `80vw/80vh`, capped
|
||||||
|
`900x700`. There are **no `.file-preview-*` rules in `mobile.css` at all**.
|
||||||
|
|
||||||
|
**Reachability on phones.** The header File Viewer button is hidden below 430px
|
||||||
|
(`mobile.css:482`, locked by `KNOWN_PHONE_HIDDEN` in `test/mobile-header-buttons-policy.test.ts`), so on a
|
||||||
|
phone the preview overlay is reached through:
|
||||||
|
|
||||||
|
1. an attachment card's **Preview** button (`panels-ui.js:3451`), which is exactly the "agent just wrote a
|
||||||
|
file" path the issue describes,
|
||||||
|
2. the attachment-history drawer (`panels-ui.js:3709`),
|
||||||
|
3. App Settings to Panels to **File Browser** (`showFileBrowser`, applied in `settings-ui.js:2202`; the
|
||||||
|
panel is mobile-styled at `mobile.css:1868`).
|
||||||
|
|
||||||
|
So edit mode is reachable on a phone today via (1) and (2) without touching the header policy. Improving
|
||||||
|
the entry point is listed as an open decision in section 10, not assumed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Threat model, stated honestly
|
||||||
|
|
||||||
|
Anyone who can call this API can already reach `POST /api/sessions/:id/input` and type an arbitrary prompt
|
||||||
|
into an agent running with `--dangerously-skip-permissions`. A workspace-confined write endpoint therefore
|
||||||
|
does not create a new privilege tier for an authenticated caller.
|
||||||
|
|
||||||
|
What it *would* create if built carelessly is a **new host-write primitive reachable by path**, so the
|
||||||
|
things this plan actually defends against are:
|
||||||
|
|
||||||
|
1. **Path traversal / symlink escape** writing outside the workspace.
|
||||||
|
2. **TOCTOU**: a path component that becomes a symlink between validation and write.
|
||||||
|
3. **Cross-user writes** in multi-user mode (`canAccessOwned`).
|
||||||
|
4. **Silent data loss**, which is the highest-probability real-world failure here and gets its own section.
|
||||||
|
|
||||||
|
CSRF is already covered: `registerHostGuard()` (`src/web/middleware/auth.ts:555-578`) rejects any
|
||||||
|
non-safe-method request whose `Origin` is cross-site. The webview-capability exemption at that gate is
|
||||||
|
fenced to `GET`/`HEAD` for the Referer form (`auth.ts:161`) and to `/webview/:cap/*` paths for the path
|
||||||
|
form, so a proxied dashboard cannot reach a new `PUT /api/...`. Using `PUT` + `application/json` also
|
||||||
|
forces a preflight for any cross-origin attempt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Backend design
|
||||||
|
|
||||||
|
### 3.1 New policy module: `src/config/file-editing.ts`
|
||||||
|
|
||||||
|
Pure, unit-testable, no IO (config lives in `src/config/`, no barrel, import the file directly).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const MAX_EDITABLE_BYTES = 512 * 1024; // content cap, both directions
|
||||||
|
export const EDITABLE_EXTENSIONS: ReadonlySet<string>; // ts,tsx,js,jsx,mjs,cjs,json,jsonc,md,mdx,txt,
|
||||||
|
// css,scss,less,html,htm,xml,svg?,yml,yaml,toml,
|
||||||
|
// ini,cfg,conf,env?,sh,bash,zsh,fish,py,rb,go,rs,
|
||||||
|
// java,kt,swift,c,h,cpp,hpp,cs,php,sql,graphql,
|
||||||
|
// proto,lua,pl,r,jl,tf,gradle,csv,tsv,log,diff,patch
|
||||||
|
export const EDITABLE_BASENAMES: ReadonlySet<string>; // Dockerfile, Makefile, LICENSE, .gitignore,
|
||||||
|
// .prettierignore, .editorconfig, .nvmrc, ...
|
||||||
|
export function isEditableFileName(fileName: string): boolean;
|
||||||
|
export function isDeniedEditRelativePath(rel: string): boolean; // `.git/` subtree
|
||||||
|
export function detectEol(text: string): 'lf' | 'crlf';
|
||||||
|
export function applyEol(text: string, eol: 'lf' | 'crlf'): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
Decisions baked in:
|
||||||
|
|
||||||
|
- **Allowlist, not blocklist**, per the issue and per the existing attachment-guard precedent.
|
||||||
|
- `svg` and `env` are deliberately marked with `?` above: `svg` is served as an untrusted octet-stream on
|
||||||
|
the read side (`file-routes.ts:118`) so allowing an edit is defensible, but I recommend **excluding
|
||||||
|
both** in v1. `.env` files are matched by `isSensitivePath()` anyway and would be rejected downstream;
|
||||||
|
excluding them at the allowlist keeps a single obvious refusal.
|
||||||
|
- `isDeniedEditRelativePath` blocks the `.git/` subtree: `.git/hooks/*` is code execution and a corrupt
|
||||||
|
index is unrecoverable-looking to a user who only wanted to fix a typo. Other dotfiles stay allowed but
|
||||||
|
are not reachable from the tree UI anyway (`showHidden=false`).
|
||||||
|
|
||||||
|
### 3.2 Read-for-edit: extend the existing GET
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/file-content?path=<rel>&edit=1`
|
||||||
|
|
||||||
|
When `edit=1`:
|
||||||
|
|
||||||
|
- skip line truncation entirely (a truncated buffer must never become an edit buffer, see section 4.1),
|
||||||
|
- enforce `MAX_EDITABLE_BYTES` instead of `MAX_TEXT_FILE_SIZE` and answer 413 over it (as a structured
|
||||||
|
throw with `statusCode: 413`, the `throwFilesystemPickerError` pattern, since the central errorCode-to-
|
||||||
|
status map has no 413 entry; see the error-mechanics note in 3.3),
|
||||||
|
- run the editability gate (`isEditableFileName`, `isDeniedEditRelativePath`, `isSensitivePath`,
|
||||||
|
`isBlockedAttachmentPath`) and the content gate (NUL sniff plus UTF-8 round-trip, see 4.3),
|
||||||
|
- return `{ content, size, mtimeMs, totalLines, truncated: false, extension, editable: true, hash, eol }`.
|
||||||
|
`hash` is `sha256` hex of the exact on-disk bytes.
|
||||||
|
|
||||||
|
Non-`edit` responses gain **only** `editable: boolean` (additive, no shape change for existing consumers),
|
||||||
|
which is all the UI needs to decide whether to show the Edit button. No `hash` on plain reads: the Edit
|
||||||
|
action re-fetches with `edit=1` anyway (section 4.1), which is where the hash comes from, and hashing every
|
||||||
|
casual 10MB preview would be pure waste.
|
||||||
|
|
||||||
|
### 3.3 Write: `PUT /api/sessions/:id/file-content`
|
||||||
|
|
||||||
|
Body (new `FileWriteSchema` in `src/web/schemas.ts`, Zod v4):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{ path: string, content: string, baseHash: string, eol?: 'lf'|'crlf', force?: boolean }
|
||||||
|
```
|
||||||
|
|
||||||
|
Registered with an explicit route option `{ bodyLimit: 4 * 1024 * 1024 }`. **Fastify's default `bodyLimit`
|
||||||
|
is 1MB and this repo configures none**, and JSON escaping expands content: 2x for a file full of quotes or
|
||||||
|
backslashes, up to 6x for control characters (each serialized as a `\uXXXX` escape), so 512KB of content
|
||||||
|
can legitimately exceed 1MB on the wire; blowing the limit produces a raw `FST_ERR_CTP_BODY_TOO_LARGE`, not an `ApiResponse` envelope. Two
|
||||||
|
related sizing notes: `z.string().max()` counts **UTF-16 code units, not bytes**, so the schema's `.max()`
|
||||||
|
is only a coarse pre-filter and the real cap is an explicit `Buffer.byteLength(content, 'utf8')` check in
|
||||||
|
the handler (step 7a below); and 4MB comfortably bounds the worst-case expansion of a 512KB file without
|
||||||
|
inviting multi-MB bodies elsewhere.
|
||||||
|
|
||||||
|
**Error mechanics** (matters for both prod behavior and testability): a handler that *returns* a
|
||||||
|
`{success:false, errorCode}` envelope gets its HTTP status assigned centrally by the preSerialization hook
|
||||||
|
in `server.ts` (`httpStatusForErrorCode()`, `src/types/api.ts`), but the route-test harness
|
||||||
|
(`test/routes/_route-test-utils.ts`) installs only `installRouteErrorHandler`, **not** that hook, so
|
||||||
|
returned envelopes surface as HTTP 200 in tests. The PUT handler should therefore use the same
|
||||||
|
structured-**throw** pattern as the filesystem picker (`throwFilesystemPickerError`, `file-routes.ts:411`):
|
||||||
|
thrown `{statusCode, body}` errors are rendered identically in prod and in the harness, and they allow the
|
||||||
|
one status the code map cannot express (413). The error envelope itself is strictly
|
||||||
|
`{success:false, error, errorCode}`, **it has no data arm**, so no error response may carry extra payload.
|
||||||
|
|
||||||
|
Handler order (each step is a test case):
|
||||||
|
|
||||||
|
1. `findSessionOrFail(ctx, id, req)` (live sessions only, matching the read route, and it carries the
|
||||||
|
multi-user ownership check).
|
||||||
|
2. `parseBody(FileWriteSchema, req.body)`, then `Buffer.byteLength(content, 'utf8') <= MAX_EDITABLE_BYTES`
|
||||||
|
or 413 (the schema `.max()` alone cannot enforce a byte cap, see the sizing note above).
|
||||||
|
3. `validateSessionFilePath(session.workingDir, path)` or 404 (do not distinguish "outside workspace" from
|
||||||
|
"missing", matching the read route).
|
||||||
|
4. `isSensitivePath(resolvedPath) || isBlockedAttachmentPath(resolvedPath, guard.blockedTrees)` or 403.
|
||||||
|
5. `isDeniedEditRelativePath(relativePath)` or 403.
|
||||||
|
6. `isEditableFileName(basename(resolvedPath))` or 400.
|
||||||
|
7. `stat`: must be `isFile()`, size within `MAX_EDITABLE_BYTES`, else 400/413. **No `O_CREAT` anywhere in
|
||||||
|
this handler**, which is what enforces edit-in-place.
|
||||||
|
8. Read current bytes, compute `hash`, run the NUL sniff and the UTF-8 round-trip check, else 400.
|
||||||
|
9. `hash !== baseHash && !force` gives **409 CONFLICT** (`ApiErrorCode.CONFLICT`, plain envelope; the error
|
||||||
|
arm carries no data, see the error-mechanics note). The client's conflict dialog gets fresh state by
|
||||||
|
re-fetching `edit=1`, which it needs for its Reload action anyway.
|
||||||
|
10. Build the output buffer: `applyEol(content, eol ?? detected-from-original)`; re-check
|
||||||
|
`Buffer.byteLength` against the cap.
|
||||||
|
11. Write atomically in the resolved parent directory:
|
||||||
|
`fs.open(<dir>/.<name>.codeman-tmp-<rand>, 'wx', stat.mode & 0o777)`, then `fchmod(stat.mode & 0o777)`
|
||||||
|
(open's mode argument is masked by the process umask, so the chmod is what actually preserves an
|
||||||
|
unusual mode), write, `fsync`, close, `fs.rename(tmp, resolvedPath)`, unlink the temp on any failure.
|
||||||
|
12. Re-stat, return `{ success: true, data: { path, size, mtimeMs, hash, totalLines } }`.
|
||||||
|
|
||||||
|
Why `O_EXCL` temp plus rename rather than truncate-in-place:
|
||||||
|
|
||||||
|
- `wx` cannot follow a pre-existing symlink, which closes the TOCTOU window from step 3 to step 11 without
|
||||||
|
needing `O_NOFOLLOW` gymnastics.
|
||||||
|
- `rename()` does not follow a symlink in the final component, so even if `resolvedPath` were swapped for a
|
||||||
|
symlink after validation, the symlink itself is replaced and the swap target is untouched.
|
||||||
|
- A crash mid-write leaves the original intact.
|
||||||
|
|
||||||
|
Caveat to document in the code comment: rename replaces the inode, so hardlinks to the file keep the old
|
||||||
|
content. That is the same trade-off vim makes by default and is preferable to a truncate window here.
|
||||||
|
|
||||||
|
No SSE event in v1. Nothing else in the app needs to know: `image-watcher.ts` only reacts to
|
||||||
|
`.png/.jpg/.jpeg/.gif/.webp/.bmp/.svg/.pdf/.docx/.pptx` adds (`image-watcher.ts:23-25`), none of which are
|
||||||
|
editable text, and the temp filename does not match either.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The five traps
|
||||||
|
|
||||||
|
These are the parts that turn a "small write endpoint" into a bug report.
|
||||||
|
|
||||||
|
### 4.1 Truncation (the data-loss trap)
|
||||||
|
|
||||||
|
The frontend fetches `&lines=500` (`panels-ui.js:3274`). Saving that buffer back would **delete every line
|
||||||
|
past 500**. Worse, the content hash of the full file would still match, so an optimistic-concurrency check
|
||||||
|
cannot catch it.
|
||||||
|
|
||||||
|
Mitigations, all three:
|
||||||
|
|
||||||
|
- The Edit affordance is only offered when the loaded payload came from `edit=1` (which never truncates).
|
||||||
|
Tapping Edit on an already-rendered preview **re-fetches** with `edit=1` before swapping in the editor.
|
||||||
|
- The read-for-edit path 413s above `MAX_EDITABLE_BYTES` rather than truncating, so "too big to edit here"
|
||||||
|
is an explicit refusal with a message, never a silent partial buffer.
|
||||||
|
- A test asserts `edit=1` never returns `truncated: true`.
|
||||||
|
|
||||||
|
### 4.2 Line endings
|
||||||
|
|
||||||
|
A `<textarea>`'s `.value` normalizes to LF. Saving a CRLF file naively rewrites every line, producing a
|
||||||
|
whole-file diff for a two-line change. So: the read returns the detected `eol`, the client echoes it back
|
||||||
|
unchanged, and the server re-applies it. Mixed-EOL files use the dominant style, which is lossy for the
|
||||||
|
minority lines; call that out in the response and accept it in v1.
|
||||||
|
|
||||||
|
### 4.3 Encoding
|
||||||
|
|
||||||
|
`buf.toString('utf-8')` on a latin-1 or otherwise non-UTF-8 file yields U+FFFD replacement characters, and
|
||||||
|
writing that back **corrupts the file**. The check is a round-trip:
|
||||||
|
`Buffer.from(decoded, 'utf8').equals(buf)`. If it fails, `editable: false` and the write is refused. This
|
||||||
|
also catches binary content that the NUL sniff misses. A UTF-8 BOM survives because it round-trips as a
|
||||||
|
leading U+FEFF; do not strip it.
|
||||||
|
|
||||||
|
### 4.4 Concurrency with the agent
|
||||||
|
|
||||||
|
The whole use case is editing a file the agent just wrote and may write again. `baseHash` plus 409 is the
|
||||||
|
guard. Do not use mtime alone: agents rewrite files within a single filesystem timestamp tick, and an
|
||||||
|
identical rewrite should not be reported as a conflict.
|
||||||
|
|
||||||
|
### 4.5 Symlinks and TOCTOU
|
||||||
|
|
||||||
|
Covered by `validateSessionFilePath` (escape) plus `wx` temp and `rename` (post-validation swap). One
|
||||||
|
intentional allowance: a symlink whose target is *inside* the workspace is edited through to its target,
|
||||||
|
because `validateSessionFilePath` returns the realpath. That matches what a user tapping the file expects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend design
|
||||||
|
|
||||||
|
All in `panels-ui.js` (prettier-exempt, hand-formatted; match the surrounding style), `index.html`,
|
||||||
|
`styles.css`, `mobile.css`.
|
||||||
|
|
||||||
|
### 5.1 State
|
||||||
|
|
||||||
|
```js
|
||||||
|
filePreviewEdit = { active, sessionId, path, baseHash, eol, original, dirty }
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset in `closeFilePreview()` and on every `openFilePreview()` entry.
|
||||||
|
|
||||||
|
### 5.2 Markup (`index.html:420-432`)
|
||||||
|
|
||||||
|
Add one header button (pencil, `btn-icon-sm`, `id="filePreviewEditBtn"`, hidden by default) next to the
|
||||||
|
copy button, and an edit bar inside the footer region holding Save / Cancel / a dirty dot. Keep the
|
||||||
|
existing footer text element; the edit bar is a sibling toggled by class so the read-mode footer is
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
### 5.3 Behavior
|
||||||
|
|
||||||
|
- `openFilePreview()` shows the Edit button only when the response has `editable: true` and the render took
|
||||||
|
the text branch. Attachment-id previews, media, binary, pdf, docx/pptx and svg all leave it hidden.
|
||||||
|
- **Enter edit**: re-fetch with `edit=1`; on 413 or `editable:false`, toast the reason and stay in read
|
||||||
|
mode. This fetch must **parse the error envelope on non-ok responses**: the existing generic
|
||||||
|
`if (!res.ok) throw new Error('Failed to load file')` pattern (`panels-ui.js:3275`) would swallow the
|
||||||
|
specific "too large to edit here" message, since error envelopes arrive with real 4xx statuses in prod. On success replace the body with `<textarea class="file-preview-editor" spellcheck="false"
|
||||||
|
autocapitalize="off" autocorrect="off" autocomplete="off" wrap="off">` and assign `.value = content`
|
||||||
|
(never `innerHTML`, so no escaping question arises). Do **not** autofocus: on a phone that opens the
|
||||||
|
keyboard before the user has picked a line.
|
||||||
|
- `input` sets `dirty` and enables Save.
|
||||||
|
- **Save**: `PUT` with `baseHash`, `eol`, and `content`. On success update `baseHash`/`original` from the
|
||||||
|
response, leave edit mode, re-render the read view from the local editor value (the response carries
|
||||||
|
metadata only, not content), toast "Saved". On **409** offer `Reload (discard mine)` / `Overwrite`:
|
||||||
|
Reload re-fetches `edit=1` and replaces the buffer; Overwrite re-sends with `force: true`. The 409 body
|
||||||
|
itself carries no state (section 3.3, step 9).
|
||||||
|
- **Cancel / close / Escape while dirty**: `confirm('Discard unsaved changes?')`, consistent with the
|
||||||
|
existing `window.confirm` usage in this codebase (`panels-ui.js:4323`, `app.js:4176`). Note the global
|
||||||
|
Escape handler (`app.js:999-1007`) closes other panels via `closeAllPanels()` but does not touch this
|
||||||
|
overlay today; if Escape-to-close is wired up as part of this work it must go through the same dirty
|
||||||
|
guard.
|
||||||
|
- `copyFilePreviewContent()` copies the live editor value while editing.
|
||||||
|
|
||||||
|
⚠️ Repo gotcha to respect at the fetch call: **Zod `.optional()` rejects `null`**. Build the body with
|
||||||
|
`eol: eol ?? undefined` (or declare `.nullish()`), or the PUT fails `INVALID_INPUT`. This has shipped as a
|
||||||
|
real bug twice.
|
||||||
|
|
||||||
|
### 5.4 Mobile
|
||||||
|
|
||||||
|
- **Sizing.** The window is `80vw/80vh` centered with no mobile override, so when the keyboard opens on iOS
|
||||||
|
the lower half sits behind it. Add a `@media (max-width: 430px)` block using
|
||||||
|
`height: var(--app-height, 100vh)`, full width, no border radius. `--app-height` is already maintained
|
||||||
|
against `visualViewport` by `KeyboardHandler.handleViewportResize()` (`mobile-handlers.js:283-317`), so
|
||||||
|
the editor tracks the keyboard for free.
|
||||||
|
- **iOS zoom.** The editor font must be >= 16px on phones; there is an existing zoom-prevention block at
|
||||||
|
`mobile.css` under `@media (max-width: 768px)`. Verify it covers `textarea` and do not override it with a
|
||||||
|
smaller `rem` value.
|
||||||
|
- **Accessory bar.** Focusing any input fires `KeyboardHandler.onKeyboardShow()`, which calls
|
||||||
|
`KeyboardAccessoryBar.show()` and refits/resizes the terminal (`mobile-handlers.js:407+`). The bar's keys
|
||||||
|
target the **terminal**, not the editor, so an Esc or clear-input tap while editing goes to the agent.
|
||||||
|
The overlay's `z-index: 2000` covers the bar's `51`, so it is not visible, but confirm it is not
|
||||||
|
interactive underneath and consider an explicit `KeyboardAccessoryBar.hide()` while the editor holds
|
||||||
|
focus. This is the item most likely to look "fine on desktop, wrong on the phone".
|
||||||
|
- No header-policy change is needed (section 1), so
|
||||||
|
`test/mobile-header-buttons-policy.test.ts` stays untouched.
|
||||||
|
|
||||||
|
### 5.5 i18n
|
||||||
|
|
||||||
|
`i18n.js` already skips `textarea`, `pre`, `code` and `.file-preview-content` in its `SKIP_SELECTOR`
|
||||||
|
(`i18n.js:20-38`), so file content is never translated. Add zh-CN entries for the new chrome: Edit, Save,
|
||||||
|
Cancel, Unsaved changes, Discard unsaved changes?, File changed on disk, Reload, Overwrite, Saved,
|
||||||
|
Too large to edit here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Docker and remote cases
|
||||||
|
|
||||||
|
Out of scope per the issue, and the current behavior already degrades correctly:
|
||||||
|
|
||||||
|
- **Docker cases**: the workspace is a host directory bind-mounted at the same absolute path, so a host-side
|
||||||
|
write is visible in the container immediately. Edit mode works and needs nothing special. Worth one line
|
||||||
|
in the docs.
|
||||||
|
- **Remote SSH cases**: `workingDir` is a path on the remote host. `validateSessionFilePath` realpaths it
|
||||||
|
locally, which fails, so the write returns 404 exactly like the read routes do today. Confirm the viewer
|
||||||
|
shows a clean empty/error state rather than an unexplained failure, and do not attempt an SFTP path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tests
|
||||||
|
|
||||||
|
| File | Kind | Covers |
|
||||||
|
| ------------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| `test/file-editing-policy.test.ts` | pure unit | `isEditableFileName` (allow + deny + basenames), `isDeniedEditRelativePath`, `detectEol`/`applyEol` round-trip incl. mixed EOL, BOM preservation |
|
||||||
|
| `test/routes/file-write-routes.test.ts` | `app.inject` | The handler order in 3.3, against a **real temp dir** (do not `vi.mock('node:fs')` in this file; set `MockSession.workingDir`, `test/mocks/mock-session.ts:14`) |
|
||||||
|
| extend `test/routes/file-routes.test.ts` | `app.inject` | `edit=1` never truncates; `editable` present on the plain read |
|
||||||
|
|
||||||
|
Status-code caveat for all of these: the route-test harness does not install the server's preSerialization
|
||||||
|
envelope hook, so a handler that *returns* an error envelope answers 200 in tests. The statuses below are
|
||||||
|
only assertable because the plan has the handler **throw** structured errors (section 3.3, error
|
||||||
|
mechanics), which `installRouteErrorHandler` renders identically in prod and in the harness.
|
||||||
|
|
||||||
|
Route cases to assert explicitly:
|
||||||
|
|
||||||
|
1. happy path writes the bytes and returns a new hash
|
||||||
|
2. `../` and absolute paths give 404
|
||||||
|
3. symlink pointing outside the workspace gives 404
|
||||||
|
4. symlink pointing inside is written through to the target
|
||||||
|
5. non-allowlisted extension gives 400
|
||||||
|
6. `.git/config` gives 403
|
||||||
|
7. a `.env` in the workspace gives 403 (sensitive-path)
|
||||||
|
8. a file with a NUL byte gives 400
|
||||||
|
9. a latin-1 file that fails the UTF-8 round-trip gives 400
|
||||||
|
10. stale `baseHash` gives 409 (`CONFLICT` envelope, no data); `force:true` then succeeds
|
||||||
|
11. over `MAX_EDITABLE_BYTES` gives 413
|
||||||
|
12. a path that does not exist gives 404 and creates nothing (no `O_CREAT`)
|
||||||
|
13. multi-user: `authUser: {role:'user'}` against another user's session gives 404 (pass `authUser` to
|
||||||
|
`createRouteTestHarness`, otherwise the synthetic admin makes the test pass vacuously)
|
||||||
|
14. CRLF file edited and saved stays CRLF
|
||||||
|
15. file mode is preserved across the temp-plus-rename
|
||||||
|
|
||||||
|
Run with `npm test -- test/routes/file-write-routes.test.ts`, never bare `npm test`.
|
||||||
|
|
||||||
|
**End-to-end verification before any deploy** (unit tests passing is not sufficient here):
|
||||||
|
|
||||||
|
- `curl -sk https://localhost:3000/...` against a **throwaway** session created for the purpose, never
|
||||||
|
`w1`/`w2`/`w3`; delete it by exact id afterwards.
|
||||||
|
- Playwright on a phone profile: open a preview, tap Edit, type with `page.keyboard.type()`, Save, then
|
||||||
|
assert the bytes on disk changed. Assert real state, not HTTP 200.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Docs and release
|
||||||
|
|
||||||
|
- This plan lives at `docs/file-viewer-edit-plan.md`.
|
||||||
|
- `docs/architecture-invariants.md`: new anchor `#file-viewer-edit-mode` covering the write confinement
|
||||||
|
chain, the truncation invariant, and why temp-plus-rename.
|
||||||
|
- `CLAUDE.md`: one line under the **Filesystem path picker** neighborhood noting that the File Viewer now
|
||||||
|
has a **third** file surface and that it is the only one that writes, plus its confinement rules.
|
||||||
|
Remember `CLAUDE.md` is prettier-ignored on purpose.
|
||||||
|
- `docs/api-reference.md`: the new `PUT` and the `edit=1` query.
|
||||||
|
- Release: a normal COM applies (the 1.10.0 batch hold is over). This is a new user-facing feature plus an
|
||||||
|
additive API surface, so **COM minor** when it ships.
|
||||||
|
|
||||||
|
Formatting note: `panels-ui.js`, `styles.css`, `mobile.css`, `index.html` are all in `.prettierignore` and
|
||||||
|
are hand-formatted; new TypeScript (`src/config/file-editing.ts`, route + schema edits) is prettier-enforced
|
||||||
|
and must pass `npm run format:check`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation order
|
||||||
|
|
||||||
|
Each phase is independently reviewable and leaves the tree working.
|
||||||
|
|
||||||
|
1. **Policy module + tests.** `src/config/file-editing.ts` and `test/file-editing-policy.test.ts`. Pure, no
|
||||||
|
route wiring. (Small.)
|
||||||
|
2. **Read-for-edit.** `edit=1` (returning `hash`/`eol`) plus the additive `editable` flag on plain reads,
|
||||||
|
tests. Nothing consumes it yet. (Small.)
|
||||||
|
3. **Write endpoint.** `FileWriteSchema`, `PUT` handler, `test/routes/file-write-routes.test.ts`. Fully
|
||||||
|
testable by curl before any UI exists. (Medium, the security-relevant part.)
|
||||||
|
4. **Desktop UI.** Edit button, textarea swap, Save/Cancel, dirty guard, 409 flow. (Medium.)
|
||||||
|
5. **Mobile pass.** `mobile.css` sizing against `--app-height`, font size, accessory-bar interaction,
|
||||||
|
real-device check. (Small but the part that decides whether the feature is actually usable.)
|
||||||
|
6. **Docs, i18n strings, changeset.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open decisions
|
||||||
|
|
||||||
|
1. **Editor widget.** Recommend a plain `<textarea>` for v1: zero dependencies, no CSP question, no bundle
|
||||||
|
growth, and it is the only thing guaranteed to behave with the iOS keyboard. CodeMirror-light with
|
||||||
|
syntax highlighting is a clean follow-up once the write path is proven. The issue allows either.
|
||||||
|
2. **Phone entry point.** Edit mode is reachable on a phone through attachment cards and the history
|
||||||
|
drawer without changing anything. A dedicated toolbar or overview affordance for "browse this session's
|
||||||
|
files" would make it discoverable, but it is a separate UX change and would need a decision against the
|
||||||
|
deliberately minimal phone header policy. Recommend deferring it and revisiting after the feature ships.
|
||||||
|
3. **`svg` editability.** Recommend excluded in v1 (it is deliberately treated as untrusted on the read
|
||||||
|
side). Easy to add later.
|
||||||
|
4. **Create / delete / rename.** Explicitly out of scope per the issue. Note that keeping `O_CREAT` out of
|
||||||
|
the handler is what makes that a structural property rather than a convention.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview File Viewer edit-mode policy (issue #212).
|
||||||
|
*
|
||||||
|
* Pure, IO-free policy for which workspace files the in-viewer editor may read
|
||||||
|
* for editing and write back. Consumed by the `edit=1` branch of
|
||||||
|
* `GET /api/sessions/:id/file-content` and by `PUT /api/sessions/:id/file-content`
|
||||||
|
* in `src/web/routes/file-routes.ts`.
|
||||||
|
*
|
||||||
|
* Design (docs/file-viewer-edit-plan.md):
|
||||||
|
* - ALLOWLIST of text extensions/basenames, not a blocklist — matching the
|
||||||
|
* attachment-guard precedent. `svg` and `env` are deliberately absent: svg is
|
||||||
|
* treated as untrusted on the read side, and `.env` is sensitive-path blocked
|
||||||
|
* anyway; excluding them here keeps a single obvious refusal.
|
||||||
|
* - The `.git/` subtree is denied outright: `.git/hooks/*` is code execution and
|
||||||
|
* a corrupted index looks unrecoverable to a user who wanted to fix a typo.
|
||||||
|
* - EOL helpers exist because a browser <textarea> normalizes to LF; the server
|
||||||
|
* re-applies the file's original ending so a two-line edit of a CRLF file does
|
||||||
|
* not become a whole-file diff. Mixed-EOL files normalize to the dominant
|
||||||
|
* style (documented lossy edge).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hard cap for edit-mode reads AND writes (bytes of file content). */
|
||||||
|
export const MAX_EDITABLE_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
/** Lowercase extensions (no dot) the editor will open and save. */
|
||||||
|
export const EDITABLE_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||||
|
// JS/TS ecosystem
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'js',
|
||||||
|
'jsx',
|
||||||
|
'mjs',
|
||||||
|
'cjs',
|
||||||
|
'json',
|
||||||
|
'jsonc',
|
||||||
|
// Docs / plain text
|
||||||
|
'md',
|
||||||
|
'mdx',
|
||||||
|
'txt',
|
||||||
|
'rst',
|
||||||
|
'adoc',
|
||||||
|
// Web
|
||||||
|
'css',
|
||||||
|
'scss',
|
||||||
|
'less',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'xml',
|
||||||
|
// Config
|
||||||
|
'yml',
|
||||||
|
'yaml',
|
||||||
|
'toml',
|
||||||
|
'ini',
|
||||||
|
'cfg',
|
||||||
|
'conf',
|
||||||
|
'properties',
|
||||||
|
// Shell
|
||||||
|
'sh',
|
||||||
|
'bash',
|
||||||
|
'zsh',
|
||||||
|
'fish',
|
||||||
|
// Languages
|
||||||
|
'py',
|
||||||
|
'rb',
|
||||||
|
'go',
|
||||||
|
'rs',
|
||||||
|
'java',
|
||||||
|
'kt',
|
||||||
|
'swift',
|
||||||
|
'c',
|
||||||
|
'h',
|
||||||
|
'cpp',
|
||||||
|
'hpp',
|
||||||
|
'cc',
|
||||||
|
'cs',
|
||||||
|
'php',
|
||||||
|
'sql',
|
||||||
|
'graphql',
|
||||||
|
'proto',
|
||||||
|
'lua',
|
||||||
|
'pl',
|
||||||
|
'r',
|
||||||
|
'jl',
|
||||||
|
'tf',
|
||||||
|
'gradle',
|
||||||
|
// Data / misc text
|
||||||
|
'csv',
|
||||||
|
'tsv',
|
||||||
|
'log',
|
||||||
|
'diff',
|
||||||
|
'patch',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Extensionless (or dot-led) file names that are still editable text. */
|
||||||
|
export const EDITABLE_BASENAMES: ReadonlySet<string> = new Set([
|
||||||
|
'dockerfile',
|
||||||
|
'makefile',
|
||||||
|
'license',
|
||||||
|
'readme',
|
||||||
|
'changelog',
|
||||||
|
'authors',
|
||||||
|
'codeowners',
|
||||||
|
'procfile',
|
||||||
|
'.gitignore',
|
||||||
|
'.gitattributes',
|
||||||
|
'.dockerignore',
|
||||||
|
'.prettierignore',
|
||||||
|
'.prettierrc',
|
||||||
|
'.editorconfig',
|
||||||
|
'.nvmrc',
|
||||||
|
'.npmrc',
|
||||||
|
'.eslintignore',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Whether a file name (basename only) is eligible for in-viewer editing. */
|
||||||
|
export function isEditableFileName(fileName: string): boolean {
|
||||||
|
const lower = fileName.toLowerCase();
|
||||||
|
if (EDITABLE_BASENAMES.has(lower)) return true;
|
||||||
|
const dot = lower.lastIndexOf('.');
|
||||||
|
// No extension (or a bare dotfile like `.bashrc`): only the basename list applies.
|
||||||
|
if (dot <= 0) return false;
|
||||||
|
return EDITABLE_EXTENSIONS.has(lower.slice(dot + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a workspace-relative path is denied for editing regardless of its
|
||||||
|
* extension. Currently: anything inside a `.git` directory at any depth.
|
||||||
|
*/
|
||||||
|
export function isDeniedEditRelativePath(relativePath: string): boolean {
|
||||||
|
return relativePath.split('/').some((segment) => segment === '.git');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileEol = 'lf' | 'crlf';
|
||||||
|
|
||||||
|
/** Dominant line-ending style of a text buffer (LF when tied or single-line). */
|
||||||
|
export function detectEol(text: string): FileEol {
|
||||||
|
let crlf = 0;
|
||||||
|
let lf = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text.charCodeAt(i) === 10) {
|
||||||
|
if (i > 0 && text.charCodeAt(i - 1) === 13) crlf++;
|
||||||
|
else lf++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crlf > lf ? 'crlf' : 'lf';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize every line ending in `text` to the requested style. */
|
||||||
|
export function applyEol(text: string, eol: FileEol): string {
|
||||||
|
const normalized = text.replace(/\r\n/g, '\n');
|
||||||
|
return eol === 'crlf' ? normalized.replace(/\n/g, '\r\n') : normalized;
|
||||||
|
}
|
||||||
@@ -97,6 +97,22 @@ export interface FilesystemBrowseData {
|
|||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Response payload for `PUT /api/sessions/:id/file-content` (File Viewer edit mode). */
|
||||||
|
export interface FileWriteData {
|
||||||
|
/** Workspace-relative path as submitted */
|
||||||
|
path: string;
|
||||||
|
/** Size of the written content in bytes */
|
||||||
|
size: number;
|
||||||
|
/** mtime of the file after the write */
|
||||||
|
mtimeMs: number;
|
||||||
|
/** sha256 hex of the written bytes — the client's next baseHash */
|
||||||
|
hash: string;
|
||||||
|
/** Line count of the written content */
|
||||||
|
totalLines: number;
|
||||||
|
/** Line-ending style that was applied */
|
||||||
|
eol: 'lf' | 'crlf';
|
||||||
|
}
|
||||||
|
|
||||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -600,6 +600,9 @@
|
|||||||
'Select a run to view its agents': '选择一次运行以查看其智能体',
|
'Select a run to view its agents': '选择一次运行以查看其智能体',
|
||||||
'Source type filter': '来源类型筛选',
|
'Source type filter': '来源类型筛选',
|
||||||
'Copy content': '复制内容',
|
'Copy content': '复制内容',
|
||||||
|
'Edit file': '编辑文件',
|
||||||
|
'Unsaved changes': '未保存的更改',
|
||||||
|
Saved: '已保存',
|
||||||
'Export as JSON': '导出为 JSON',
|
'Export as JSON': '导出为 JSON',
|
||||||
'Export as Markdown': '导出为 Markdown',
|
'Export as Markdown': '导出为 Markdown',
|
||||||
'Mark all read': '全部标为已读',
|
'Mark all read': '全部标为已读',
|
||||||
|
|||||||
@@ -422,11 +422,18 @@
|
|||||||
<div class="file-preview-header">
|
<div class="file-preview-header">
|
||||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||||
<div class="file-preview-actions">
|
<div class="file-preview-actions">
|
||||||
|
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||||
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button>
|
||||||
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">×</button>
|
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-preview-body" id="filePreviewBody"></div>
|
<div class="file-preview-body" id="filePreviewBody"></div>
|
||||||
|
<div class="file-preview-editbar" id="filePreviewEditBar" hidden>
|
||||||
|
<span class="file-preview-dirty" id="filePreviewDirty" hidden>Unsaved changes</span>
|
||||||
|
<span class="file-preview-editbar-spacer"></span>
|
||||||
|
<button class="file-preview-editbar-btn" onclick="app.cancelFilePreviewEdit()">Cancel</button>
|
||||||
|
<button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button>
|
||||||
|
</div>
|
||||||
<div class="file-preview-footer" id="filePreviewFooter"></div>
|
<div class="file-preview-footer" id="filePreviewFooter"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1871,6 +1871,33 @@ html.mobile-init .file-browser-panel {
|
|||||||
bottom: calc(44px + 2rem + var(--safe-area-bottom));
|
bottom: calc(44px + 2rem + var(--safe-area-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* File preview window: full screen on phones. --app-height tracks the visual
|
||||||
|
viewport (KeyboardHandler), so the edit textarea + Save bar stay above the
|
||||||
|
OS keyboard instead of hiding behind it. Footer/edit bar pad for the home
|
||||||
|
indicator. */
|
||||||
|
.file-preview-window {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: var(--app-height, 100vh);
|
||||||
|
max-height: var(--app-height, 100vh);
|
||||||
|
border-radius: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar {
|
||||||
|
padding-bottom: calc(0.4rem + var(--safe-area-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-overlay .file-preview-footer {
|
||||||
|
padding-bottom: calc(0.35rem + var(--safe-area-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* >=16px or iOS Safari auto-zooms the page on focus */
|
||||||
|
.file-preview-body textarea.file-preview-editor {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Notification drawer - full width on mobile, includes safe area padding */
|
/* Notification drawer - full width on mobile, includes safe area padding */
|
||||||
.notification-drawer {
|
.notification-drawer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+185
-2
@@ -3200,6 +3200,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
if (!overlay || !bodyEl) return;
|
if (!overlay || !bodyEl) return;
|
||||||
|
|
||||||
|
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
||||||
|
this._resetFilePreviewEdit();
|
||||||
|
|
||||||
// Show overlay with loading state
|
// Show overlay with loading state
|
||||||
overlay.classList.add('visible');
|
overlay.classList.add('visible');
|
||||||
titleEl.textContent = filePath;
|
titleEl.textContent = filePath;
|
||||||
@@ -3298,6 +3301,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
bodyEl.innerHTML = `<pre><code>${escapeHtml(data.content)}</code></pre>`;
|
||||||
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
||||||
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
||||||
|
// Edit affordance only when the server says an edit=1 re-fetch would
|
||||||
|
// succeed (workspace text file inside the allowlist and size cap).
|
||||||
|
if (data.editable) {
|
||||||
|
this.filePreviewEditTarget = { sessionId, filePath };
|
||||||
|
const editBtn = this.$('filePreviewEditBtn');
|
||||||
|
if (editBtn) editBtn.hidden = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to preview file:', err);
|
console.error('Failed to preview file:', err);
|
||||||
@@ -3306,6 +3316,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
closeFilePreview() {
|
closeFilePreview() {
|
||||||
|
if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return;
|
||||||
|
this._resetFilePreviewEdit();
|
||||||
const overlay = this.$('filePreviewOverlay');
|
const overlay = this.$('filePreviewOverlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
overlay.classList.remove('visible');
|
overlay.classList.remove('visible');
|
||||||
@@ -3313,6 +3325,172 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.filePreviewContent = '';
|
this.filePreviewContent = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_resetFilePreviewEdit() {
|
||||||
|
this.filePreviewEdit = null;
|
||||||
|
this.filePreviewEditTarget = null;
|
||||||
|
const editBtn = this.$('filePreviewEditBtn');
|
||||||
|
if (editBtn) editBtn.hidden = true;
|
||||||
|
const editBar = this.$('filePreviewEditBar');
|
||||||
|
if (editBar) editBar.hidden = true;
|
||||||
|
const dirtyEl = this.$('filePreviewDirty');
|
||||||
|
if (dirtyEl) dirtyEl.hidden = true;
|
||||||
|
const saveBtn = this.$('filePreviewSaveBtn');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Save';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async enterFilePreviewEdit() {
|
||||||
|
const target = this.filePreviewEditTarget;
|
||||||
|
if (!target || this.filePreviewEdit) return;
|
||||||
|
const bodyEl = this.$('filePreviewBody');
|
||||||
|
const footerEl = this.$('filePreviewFooter');
|
||||||
|
if (!bodyEl) return;
|
||||||
|
|
||||||
|
// Always re-fetch with edit=1: the preview buffer may be line-truncated and
|
||||||
|
// a truncated buffer must never become an edit buffer. Parse the envelope
|
||||||
|
// even on non-ok responses so the specific refusal ("too large to edit
|
||||||
|
// here") reaches the toast instead of a generic failure.
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/sessions/${target.sessionId}/file-content?path=${encodeURIComponent(target.filePath)}&edit=1`
|
||||||
|
);
|
||||||
|
const result = await res.json().catch(() => null);
|
||||||
|
if (!result || result.success !== true) {
|
||||||
|
throw new Error(result?.error || `Failed to load file for editing (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
|
data = result.data;
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast(err.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filePreviewEdit = {
|
||||||
|
sessionId: target.sessionId,
|
||||||
|
filePath: target.filePath,
|
||||||
|
baseHash: data.hash,
|
||||||
|
eol: data.eol,
|
||||||
|
original: data.content,
|
||||||
|
dirty: false,
|
||||||
|
saving: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.className = 'file-preview-editor';
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
textarea.setAttribute('autocapitalize', 'off');
|
||||||
|
textarea.setAttribute('autocorrect', 'off');
|
||||||
|
textarea.setAttribute('autocomplete', 'off');
|
||||||
|
textarea.wrap = 'off';
|
||||||
|
textarea.value = data.content;
|
||||||
|
textarea.addEventListener('input', () => this._onFilePreviewEditInput());
|
||||||
|
bodyEl.innerHTML = '';
|
||||||
|
bodyEl.appendChild(textarea);
|
||||||
|
// Deliberately no autofocus: on phones that would pop the OS keyboard
|
||||||
|
// before the user has scrolled to the line they want to change.
|
||||||
|
|
||||||
|
const editBtn = this.$('filePreviewEditBtn');
|
||||||
|
if (editBtn) editBtn.hidden = true;
|
||||||
|
const editBar = this.$('filePreviewEditBar');
|
||||||
|
if (editBar) editBar.hidden = false;
|
||||||
|
if (footerEl) {
|
||||||
|
const eolNote = data.eol === 'crlf' ? ' • CRLF' : '';
|
||||||
|
footerEl.textContent = `Editing • ${data.totalLines} lines • ${this.formatFileSize(data.size)}${eolNote}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_onFilePreviewEditInput() {
|
||||||
|
const edit = this.filePreviewEdit;
|
||||||
|
if (!edit) return;
|
||||||
|
const textarea = this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor');
|
||||||
|
if (!textarea) return;
|
||||||
|
edit.dirty = textarea.value !== edit.original;
|
||||||
|
const dirtyEl = this.$('filePreviewDirty');
|
||||||
|
if (dirtyEl) dirtyEl.hidden = !edit.dirty;
|
||||||
|
const saveBtn = this.$('filePreviewSaveBtn');
|
||||||
|
if (saveBtn) saveBtn.disabled = !edit.dirty || edit.saving;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelFilePreviewEdit() {
|
||||||
|
const edit = this.filePreviewEdit;
|
||||||
|
if (!edit) return;
|
||||||
|
if (edit.dirty && !confirm('Discard unsaved changes?')) return;
|
||||||
|
const { sessionId, filePath } = edit;
|
||||||
|
this._resetFilePreviewEdit();
|
||||||
|
this.openFilePreview(filePath, sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveFilePreviewEdit(force = false) {
|
||||||
|
const edit = this.filePreviewEdit;
|
||||||
|
if (!edit || edit.saving) return;
|
||||||
|
const textarea = this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor');
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
edit.saving = true;
|
||||||
|
const saveBtn = this.$('filePreviewSaveBtn');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving…';
|
||||||
|
}
|
||||||
|
const restoreSaveState = () => {
|
||||||
|
edit.saving = false;
|
||||||
|
if (saveBtn) saveBtn.textContent = 'Save';
|
||||||
|
this._onFilePreviewEditInput();
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = null;
|
||||||
|
let status = 0;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sessions/${edit.sessionId}/file-content`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: edit.filePath,
|
||||||
|
content: textarea.value,
|
||||||
|
baseHash: edit.baseHash,
|
||||||
|
eol: edit.eol ?? undefined, // Zod .optional() rejects null
|
||||||
|
force: force || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
status = res.status;
|
||||||
|
result = await res.json().catch(() => null);
|
||||||
|
} catch (err) {
|
||||||
|
restoreSaveState();
|
||||||
|
this.showToast(`Save failed: ${err.message}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 409 || result?.errorCode === 'CONFLICT') {
|
||||||
|
restoreSaveState();
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
'File changed on disk since you loaded it.\nOK overwrites it with your version; Cancel keeps your draft open.'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.saveFilePreviewEdit(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result || result.success !== true) {
|
||||||
|
restoreSaveState();
|
||||||
|
this.showToast(`Save failed: ${result?.error || `HTTP ${status}`}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId, filePath } = edit;
|
||||||
|
this._resetFilePreviewEdit();
|
||||||
|
this.showToast('Saved', 'success');
|
||||||
|
// Re-open in read mode — re-fetching shows the truth on disk (including the
|
||||||
|
// server-side EOL normalization) rather than trusting the local buffer.
|
||||||
|
this.openFilePreview(filePath, sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Attachment Cards (detected documents/images)
|
// Attachment Cards (detected documents/images)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -3749,8 +3927,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
copyFilePreviewContent() {
|
copyFilePreviewContent() {
|
||||||
if (this.filePreviewContent) {
|
// While editing, copy the live editor buffer (not the stale preview text).
|
||||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
const editTextarea = this.filePreviewEdit
|
||||||
|
? this.$('filePreviewBody')?.querySelector('textarea.file-preview-editor')
|
||||||
|
: null;
|
||||||
|
const content = editTextarea ? editTextarea.value : this.filePreviewContent;
|
||||||
|
if (content) {
|
||||||
|
navigator.clipboard.writeText(content).then(() => {
|
||||||
this.showToast('Copied to clipboard', 'success');
|
this.showToast('Copied to clipboard', 'success');
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
this.showToast('Failed to copy', 'error');
|
this.showToast('Failed to copy', 'error');
|
||||||
|
|||||||
@@ -9430,6 +9430,84 @@ kbd {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- File Viewer edit mode (issue #212) ---- */
|
||||||
|
|
||||||
|
.file-preview-body textarea.file-preview-editor {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
overflow: auto;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-input);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-dirty {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--warning, #e5c07b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-dirty::before {
|
||||||
|
content: '\25CF ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar-btn {
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar-btn:hover {
|
||||||
|
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar-btn--save {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar-btn--save:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-editbar-btn--save:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== Log Viewer Windows (Floating) ========== */
|
/* ========== Log Viewer Windows (Floating) ========== */
|
||||||
|
|
||||||
.log-viewer-window {
|
.log-viewer-window {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview File browser and streaming routes.
|
* @fileoverview File browser and streaming routes.
|
||||||
* Provides directory listing, file content preview, raw file serving, and tail streaming.
|
* Provides directory listing, file content preview, raw file serving, tail
|
||||||
|
* streaming, and 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).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -14,6 +18,7 @@ import type {
|
|||||||
FilesystemBrowseEntry,
|
FilesystemBrowseEntry,
|
||||||
FilesystemBrowseRoot,
|
FilesystemBrowseRoot,
|
||||||
FilesystemPreviewKind,
|
FilesystemPreviewKind,
|
||||||
|
FileWriteData,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||||
@@ -44,7 +49,14 @@ import type { SessionAttachmentHistoryItem, SessionState } from '../../types/ses
|
|||||||
import { isSensitivePath } from '../sensitive-path.js';
|
import { isSensitivePath } from '../sensitive-path.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js';
|
||||||
|
import {
|
||||||
|
MAX_EDITABLE_BYTES,
|
||||||
|
applyEol,
|
||||||
|
detectEol,
|
||||||
|
isDeniedEditRelativePath,
|
||||||
|
isEditableFileName,
|
||||||
|
} from '../../config/file-editing.js';
|
||||||
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
@@ -453,6 +465,71 @@ function appendDownloadFlag(url: string): string {
|
|||||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== File Viewer edit mode (issue #212) =====
|
||||||
|
// Policy lives in src/config/file-editing.ts; design in docs/file-viewer-edit-plan.md.
|
||||||
|
|
||||||
|
function sha256Hex(buf: Buffer): string {
|
||||||
|
return createHash('sha256').update(buf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NUL byte in the first 8KB — same binary signal the plain read path uses. */
|
||||||
|
function sniffsBinary(buf: Buffer): boolean {
|
||||||
|
const sniffLength = Math.min(buf.length, 8192);
|
||||||
|
for (let i = 0; i < sniffLength; i++) {
|
||||||
|
if (buf[i] === 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured-throw variant for the edit read/write paths. Identical mechanics to
|
||||||
|
* throwFilesystemPickerError (rendered by the central route error handler both
|
||||||
|
* in prod and in the app.inject() test harness); a separate name only so edit
|
||||||
|
* failures grep distinctly.
|
||||||
|
*/
|
||||||
|
function throwFileEditError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||||
|
throw Object.assign(new Error(message), {
|
||||||
|
statusCode,
|
||||||
|
body: createErrorResponse(code, message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate a resolved workspace file for edit-mode read/write. Throws a structured
|
||||||
|
* error when the file may not be edited; returns void when it may. Order
|
||||||
|
* matters for the message a user sees: confinement (the caller's 404) →
|
||||||
|
* sensitive/blocked (403) → .git (403) → extension allowlist (400).
|
||||||
|
*/
|
||||||
|
function assertEditableTarget(resolvedPath: string, relativePath: string, blockedTrees: readonly string[]): void {
|
||||||
|
if (isSensitivePath(resolvedPath) || isBlockedAttachmentPath(resolvedPath, blockedTrees)) {
|
||||||
|
throwFileEditError(403, ApiErrorCode.FORBIDDEN, 'Editing this file is blocked');
|
||||||
|
}
|
||||||
|
if (isDeniedEditRelativePath(relativePath)) {
|
||||||
|
throwFileEditError(403, ApiErrorCode.FORBIDDEN, 'Files under .git cannot be edited');
|
||||||
|
}
|
||||||
|
if (!isEditableFileName(pathBasename(resolvedPath))) {
|
||||||
|
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'This file type is not editable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a candidate edit buffer, refusing binary and non-UTF-8 content. The
|
||||||
|
* round-trip compare is what protects against silent corruption: decoding
|
||||||
|
* latin-1 (or any non-UTF-8) bytes yields U+FFFD replacements, and writing
|
||||||
|
* those back would destroy the original bytes. A UTF-8 BOM round-trips and is
|
||||||
|
* deliberately preserved.
|
||||||
|
*/
|
||||||
|
function decodeEditableText(buf: Buffer): string {
|
||||||
|
if (sniffsBinary(buf)) {
|
||||||
|
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Binary files cannot be edited');
|
||||||
|
}
|
||||||
|
const text = buf.toString('utf8');
|
||||||
|
if (!Buffer.from(text, 'utf8').equals(buf)) {
|
||||||
|
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only UTF-8 text files can be edited');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionAttachmentHistory(
|
function getSessionAttachmentHistory(
|
||||||
ctx: SessionPort & ConfigPort,
|
ctx: SessionPort & ConfigPort,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -864,7 +941,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
// Get file content for preview (File Browser)
|
// Get file content for preview (File Browser)
|
||||||
app.get('/api/sessions/:id/file-content', async (req) => {
|
app.get('/api/sessions/:id/file-content', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
|
const {
|
||||||
|
path: filePath,
|
||||||
|
lines,
|
||||||
|
raw,
|
||||||
|
edit,
|
||||||
|
} = req.query as { path?: string; lines?: string; raw?: string; edit?: string };
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
@@ -876,7 +958,52 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
if (!validated) {
|
if (!validated) {
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found');
|
||||||
}
|
}
|
||||||
const { resolvedPath } = validated;
|
const { resolvedPath, relativePath } = validated;
|
||||||
|
|
||||||
|
// Read-for-edit: never truncated (a truncated buffer must never become an
|
||||||
|
// edit buffer), tighter size cap, full editability gate, and the hash/eol
|
||||||
|
// the client must echo back on PUT. Outside the shared try/catch below so
|
||||||
|
// its structured errors keep their status codes instead of collapsing into
|
||||||
|
// OPERATION_FAILED.
|
||||||
|
if (edit === '1' || edit === 'true') {
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
assertEditableTarget(resolvedPath, relativePath, guard.blockedTrees);
|
||||||
|
|
||||||
|
let editStat;
|
||||||
|
try {
|
||||||
|
editStat = await fs.stat(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||||
|
}
|
||||||
|
if (!editStat.isFile()) {
|
||||||
|
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be edited');
|
||||||
|
}
|
||||||
|
if (editStat.size > MAX_EDITABLE_BYTES) {
|
||||||
|
throwFileEditError(
|
||||||
|
413,
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`File too large to edit here (${Math.ceil(editStat.size / 1024)}KB > ${MAX_EDITABLE_BYTES / 1024}KB limit)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const editBuf = await fs.readFile(resolvedPath);
|
||||||
|
const editText = decodeEditableText(editBuf);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path: filePath,
|
||||||
|
content: editText,
|
||||||
|
size: editBuf.length,
|
||||||
|
mtimeMs: editStat.mtimeMs,
|
||||||
|
totalLines: editText.split('\n').length,
|
||||||
|
truncated: false,
|
||||||
|
extension: filePath.split('.').pop()?.toLowerCase() || '',
|
||||||
|
editable: true,
|
||||||
|
hash: sha256Hex(editBuf),
|
||||||
|
eol: detectEol(editText),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(resolvedPath);
|
const stat = await fs.stat(resolvedPath);
|
||||||
@@ -998,6 +1125,19 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
const truncatedContent = allLines.length > maxLines;
|
const truncatedContent = allLines.length > maxLines;
|
||||||
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
|
||||||
|
|
||||||
|
// Additive edit-mode advertisement: whether an edit=1 re-fetch would
|
||||||
|
// succeed. The UTF-8 round-trip compare is a cheap memcmp and mirrors
|
||||||
|
// decodeEditableText; no hash here — the Edit action re-fetches with
|
||||||
|
// edit=1, which is where the baseHash comes from.
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
const editable =
|
||||||
|
isEditableFileName(pathBasename(resolvedPath)) &&
|
||||||
|
!isDeniedEditRelativePath(relativePath) &&
|
||||||
|
!isSensitivePath(resolvedPath) &&
|
||||||
|
!isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) &&
|
||||||
|
stat.size <= MAX_EDITABLE_BYTES &&
|
||||||
|
Buffer.from(content, 'utf8').equals(buf);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -1007,6 +1147,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
totalLines: allLines.length,
|
totalLines: allLines.length,
|
||||||
truncated: truncatedContent,
|
truncated: truncatedContent,
|
||||||
extension: ext,
|
extension: ext,
|
||||||
|
editable,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1014,6 +1155,121 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// File Viewer edit mode: save a text file back into the session workspace.
|
||||||
|
// Edit-in-place ONLY — there is deliberately no O_CREAT path in this handler,
|
||||||
|
// so it can never create, and it never deletes. Confinement is identical to
|
||||||
|
// the read path (realpath + workspace boundary + ownership via
|
||||||
|
// findSessionOrFail), plus the sensitive-path/attachment-guard blocklists and
|
||||||
|
// the extension allowlist. Concurrency is optimistic: the client echoes the
|
||||||
|
// sha256 it loaded (baseHash) and a mismatch is a 409 unless force is set.
|
||||||
|
// bodyLimit: JSON escaping can expand content up to ~6x (each control char
|
||||||
|
// becomes \uXXXX), so the 512KB content cap needs headroom over Fastify's
|
||||||
|
// 1MB default.
|
||||||
|
app.put(
|
||||||
|
'/api/sessions/:id/file-content',
|
||||||
|
{ bodyLimit: 4 * 1024 * 1024 },
|
||||||
|
async (req): Promise<ApiResponse<FileWriteData>> => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
const body = parseBody(FileWriteSchema, req.body);
|
||||||
|
|
||||||
|
// Exact byte cap — the schema's .max() counts UTF-16 code units and is
|
||||||
|
// only a coarse pre-filter.
|
||||||
|
if (Buffer.byteLength(body.content, 'utf8') > MAX_EDITABLE_BYTES) {
|
||||||
|
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateSessionFilePath(session.workingDir, body.path);
|
||||||
|
if (!validated) {
|
||||||
|
// Covers missing files, traversal, and symlink escapes alike — a write
|
||||||
|
// target that fails confinement is reported identically to a missing
|
||||||
|
// one, matching the read route.
|
||||||
|
throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||||
|
}
|
||||||
|
const { resolvedPath, relativePath } = validated;
|
||||||
|
|
||||||
|
const guard = await loadAttachmentGuardConfig();
|
||||||
|
assertEditableTarget(resolvedPath, relativePath, guard.blockedTrees);
|
||||||
|
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = await fs.stat(resolvedPath);
|
||||||
|
} catch {
|
||||||
|
throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found');
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be edited');
|
||||||
|
}
|
||||||
|
if (stat.size > MAX_EDITABLE_BYTES) {
|
||||||
|
throwFileEditError(
|
||||||
|
413,
|
||||||
|
ApiErrorCode.INVALID_INPUT,
|
||||||
|
`File too large to edit here (${MAX_EDITABLE_BYTES / 1024}KB limit)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBuf = await fs.readFile(resolvedPath);
|
||||||
|
const currentText = decodeEditableText(currentBuf);
|
||||||
|
const currentHash = sha256Hex(currentBuf);
|
||||||
|
if (currentHash !== body.baseHash && !body.force) {
|
||||||
|
throwFileEditError(
|
||||||
|
409,
|
||||||
|
ApiErrorCode.CONFLICT,
|
||||||
|
'File changed on disk since it was loaded — reload it or overwrite'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply the file's original line endings (a <textarea> normalizes to
|
||||||
|
// LF; without this a two-line edit of a CRLF file rewrites every line).
|
||||||
|
const eol = body.eol ?? detectEol(currentText);
|
||||||
|
const outText = applyEol(body.content, eol);
|
||||||
|
const outBuf = Buffer.from(outText, 'utf8');
|
||||||
|
if (outBuf.length > MAX_EDITABLE_BYTES) {
|
||||||
|
throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic replace: O_EXCL temp in the same directory, then rename.
|
||||||
|
// 'wx' cannot follow a pre-existing symlink and rename() replaces (not
|
||||||
|
// follows) a symlink in the final component, which closes the
|
||||||
|
// validate-then-write TOCTOU window. fchmod because open()'s mode is
|
||||||
|
// masked by the process umask; fsync so the rename never publishes a
|
||||||
|
// partially-durable file. Trade-off (same as vim's default): the inode
|
||||||
|
// changes, so hardlinks keep the old content.
|
||||||
|
const fileMode = stat.mode & 0o777;
|
||||||
|
const tmpPath = join(
|
||||||
|
dirname(resolvedPath),
|
||||||
|
`.${pathBasename(resolvedPath)}.codeman-tmp-${randomBytes(6).toString('hex')}`
|
||||||
|
);
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await fs.open(tmpPath, 'wx', fileMode);
|
||||||
|
await handle.chmod(fileMode);
|
||||||
|
await handle.writeFile(outBuf);
|
||||||
|
await handle.sync();
|
||||||
|
await handle.close();
|
||||||
|
handle = undefined;
|
||||||
|
await fs.rename(tmpPath, resolvedPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (handle) await handle.close().catch(() => {});
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
throwFileEditError(500, ApiErrorCode.OPERATION_FAILED, `Failed to save file: ${getErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStat = await fs.stat(resolvedPath).catch(() => undefined);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path: body.path,
|
||||||
|
size: outBuf.length,
|
||||||
|
mtimeMs: newStat?.mtimeMs ?? Date.now(),
|
||||||
|
hash: sha256Hex(outBuf),
|
||||||
|
totalLines: outText.split('\n').length,
|
||||||
|
eol,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Serve raw file content (for images/binary files)
|
// Serve raw file content (for images/binary files)
|
||||||
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
MIN_TERMINAL_BUFFER_BYTES,
|
MIN_TERMINAL_BUFFER_BYTES,
|
||||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
} from '../config/terminal-history.js';
|
} from '../config/terminal-history.js';
|
||||||
|
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||||
|
|
||||||
// ========== Path Validation ==========
|
// ========== Path Validation ==========
|
||||||
|
|
||||||
@@ -83,6 +84,30 @@ export const FilesystemPreviewQuerySchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body validation for `PUT /api/sessions/:id/file-content` (File Viewer edit
|
||||||
|
* mode). `content.max()` counts UTF-16 code units, which for UTF-8 output is
|
||||||
|
* always <= the byte length, so it is a coarse pre-filter that never rejects
|
||||||
|
* valid content; the handler enforces the exact MAX_EDITABLE_BYTES byte cap.
|
||||||
|
* Workspace containment and symlink resolution are enforced by the route via
|
||||||
|
* validateSessionFilePath after parsing.
|
||||||
|
*/
|
||||||
|
export const FileWriteSchema = z
|
||||||
|
.object({
|
||||||
|
path: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(4096)
|
||||||
|
.refine((p) => !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||||
|
message: 'Invalid path',
|
||||||
|
}),
|
||||||
|
content: z.string().max(MAX_EDITABLE_BYTES),
|
||||||
|
baseHash: z.string().regex(/^[a-f0-9]{64}$/, 'baseHash must be a sha256 hex digest'),
|
||||||
|
eol: z.enum(['lf', 'crlf']).optional(),
|
||||||
|
force: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
// ========== Env Var Allowlist ==========
|
// ========== Env Var Allowlist ==========
|
||||||
|
|
||||||
/** Allowlisted env var key prefixes */
|
/** Allowlisted env var key prefixes */
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Unit tests for the File Viewer edit-mode policy module.
|
||||||
|
*
|
||||||
|
* Pure functions only — no IO, no server.
|
||||||
|
* Port: N/A (no server)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
MAX_EDITABLE_BYTES,
|
||||||
|
applyEol,
|
||||||
|
detectEol,
|
||||||
|
isDeniedEditRelativePath,
|
||||||
|
isEditableFileName,
|
||||||
|
} from '../src/config/file-editing.js';
|
||||||
|
|
||||||
|
describe('file-editing policy', () => {
|
||||||
|
describe('isEditableFileName', () => {
|
||||||
|
it('allows common text extensions', () => {
|
||||||
|
for (const name of ['a.ts', 'b.md', 'c.json', 'd.py', 'style.css', 'notes.txt', 'x.yml', 'Q.SQL']) {
|
||||||
|
expect(isEditableFileName(name), name).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows well-known basenames regardless of case', () => {
|
||||||
|
for (const name of ['Dockerfile', 'Makefile', 'LICENSE', '.gitignore', '.editorconfig', '.nvmrc']) {
|
||||||
|
expect(isEditableFileName(name), name).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects binary/media/document extensions', () => {
|
||||||
|
for (const name of ['a.png', 'b.pdf', 'c.docx', 'd.zip', 'e.woff2', 'f.mp4', 'g.exe']) {
|
||||||
|
expect(isEditableFileName(name), name).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects svg and env (deliberate v1 exclusions)', () => {
|
||||||
|
expect(isEditableFileName('image.svg')).toBe(false);
|
||||||
|
expect(isEditableFileName('config.env')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects extensionless and unknown-dotfile names not on the basename list', () => {
|
||||||
|
expect(isEditableFileName('somebinary')).toBe(false);
|
||||||
|
expect(isEditableFileName('.bashrc')).toBe(false);
|
||||||
|
expect(isEditableFileName('archive.xyz')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isDeniedEditRelativePath', () => {
|
||||||
|
it('denies anything inside a .git directory at any depth', () => {
|
||||||
|
expect(isDeniedEditRelativePath('.git/config')).toBe(true);
|
||||||
|
expect(isDeniedEditRelativePath('.git/hooks/pre-commit')).toBe(true);
|
||||||
|
expect(isDeniedEditRelativePath('sub/module/.git/HEAD')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows non-.git paths, including names merely containing "git"', () => {
|
||||||
|
expect(isDeniedEditRelativePath('src/index.ts')).toBe(false);
|
||||||
|
expect(isDeniedEditRelativePath('.github/workflows/ci.yml')).toBe(false);
|
||||||
|
expect(isDeniedEditRelativePath('digits/file.md')).toBe(false);
|
||||||
|
expect(isDeniedEditRelativePath('.gitignore')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectEol / applyEol', () => {
|
||||||
|
it('detects LF, CRLF, and defaults to LF for single-line text', () => {
|
||||||
|
expect(detectEol('a\nb\nc')).toBe('lf');
|
||||||
|
expect(detectEol('a\r\nb\r\nc')).toBe('crlf');
|
||||||
|
expect(detectEol('no newline at all')).toBe('lf');
|
||||||
|
expect(detectEol('')).toBe('lf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the dominant style for mixed-EOL text', () => {
|
||||||
|
expect(detectEol('a\r\nb\r\nc\nd')).toBe('crlf');
|
||||||
|
expect(detectEol('a\nb\nc\r\nd')).toBe('lf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyEol round-trips a textarea-normalized (LF) buffer back to CRLF', () => {
|
||||||
|
const original = 'line1\r\nline2\r\nline3';
|
||||||
|
const textareaValue = original.replace(/\r\n/g, '\n');
|
||||||
|
expect(applyEol(textareaValue, detectEol(original))).toBe(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyEol is idempotent and never doubles CR', () => {
|
||||||
|
expect(applyEol('a\r\nb', 'crlf')).toBe('a\r\nb');
|
||||||
|
expect(applyEol('a\r\nb', 'lf')).toBe('a\nb');
|
||||||
|
expect(applyEol('a\nb', 'lf')).toBe('a\nb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a UTF-8 BOM through the EOL rewrite', () => {
|
||||||
|
const withBom = 'hello\nworld';
|
||||||
|
expect(applyEol(withBom, 'crlf')).toBe('hello\r\nworld');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a sane editable-bytes cap', () => {
|
||||||
|
expect(MAX_EDITABLE_BYTES).toBe(512 * 1024);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview File Viewer edit mode — read-for-edit (`edit=1`) and
|
||||||
|
* `PUT /api/sessions/:id/file-content` (issue #212).
|
||||||
|
*
|
||||||
|
* Deliberately does NOT mock node:fs — every case runs against a real temp
|
||||||
|
* workspace so the confinement (realpath + workspace boundary), the symlink
|
||||||
|
* behavior, the atomic temp+rename write, and mode preservation are exercised
|
||||||
|
* for real, not against a mock's assumptions.
|
||||||
|
*
|
||||||
|
* Uses app.inject() — no real HTTP ports needed.
|
||||||
|
* Port: N/A (app.inject doesn't open ports)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
mkdtempSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
symlinkSync,
|
||||||
|
chmodSync,
|
||||||
|
statSync,
|
||||||
|
realpathSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
existsSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
|
import { MAX_EDITABLE_BYTES } from '../../src/config/file-editing.js';
|
||||||
|
|
||||||
|
function sha256(data: string | Buffer): string {
|
||||||
|
return createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('file viewer edit mode (real fs)', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
let workDir: string;
|
||||||
|
let outsideDir: string;
|
||||||
|
const sessionId = 'test-session-1';
|
||||||
|
|
||||||
|
const putFile = (path: string, body: Record<string, unknown>) =>
|
||||||
|
harness.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content`,
|
||||||
|
payload: { path, ...body },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEdit = (path: string) =>
|
||||||
|
harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(path)}&edit=1`,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createRouteTestHarness(registerFileRoutes);
|
||||||
|
// realpath: on some hosts tmpdir() contains a symlinked component, which
|
||||||
|
// would make validateSessionFilePath's relative() check misfire.
|
||||||
|
workDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-edit-ws-')));
|
||||||
|
outsideDir = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-edit-out-')));
|
||||||
|
harness.ctx._session.workingDir = workDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await harness.app.close();
|
||||||
|
rmSync(workDir, { recursive: true, force: true });
|
||||||
|
rmSync(outsideDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== GET ?edit=1 ==========
|
||||||
|
|
||||||
|
describe('GET /api/sessions/:id/file-content?edit=1', () => {
|
||||||
|
it('returns the FULL content (never truncated) with hash and eol', async () => {
|
||||||
|
const content = Array.from({ length: 800 }, (_, i) => `line ${i + 1}`).join('\n');
|
||||||
|
writeFileSync(join(workDir, 'long.md'), content);
|
||||||
|
|
||||||
|
const res = await getEdit('long.md');
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.content).toBe(content);
|
||||||
|
expect(body.data.truncated).toBe(false);
|
||||||
|
expect(body.data.totalLines).toBe(800);
|
||||||
|
expect(body.data.editable).toBe(true);
|
||||||
|
expect(body.data.hash).toBe(sha256(content));
|
||||||
|
expect(body.data.eol).toBe('lf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports crlf for a CRLF file', async () => {
|
||||||
|
writeFileSync(join(workDir, 'dos.txt'), 'a\r\nb\r\nc');
|
||||||
|
const res = await getEdit('dos.txt');
|
||||||
|
expect(res.json().data.eol).toBe('crlf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('413s above MAX_EDITABLE_BYTES instead of truncating', async () => {
|
||||||
|
writeFileSync(join(workDir, 'big.log'), 'x'.repeat(MAX_EDITABLE_BYTES + 1));
|
||||||
|
const res = await getEdit('big.log');
|
||||||
|
expect(res.statusCode).toBe(413);
|
||||||
|
expect(res.json().success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s for a non-allowlisted extension', async () => {
|
||||||
|
writeFileSync(join(workDir, 'data.xyz'), 'text');
|
||||||
|
const res = await getEdit('data.xyz');
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s for binary content even with a text extension', async () => {
|
||||||
|
writeFileSync(join(workDir, 'fake.txt'), Buffer.from([0x68, 0x00, 0x69]));
|
||||||
|
const res = await getEdit('fake.txt');
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plain read editable flag', () => {
|
||||||
|
it('advertises editable:true for an editable text file', async () => {
|
||||||
|
writeFileSync(join(workDir, 'notes.md'), 'hello');
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=notes.md`,
|
||||||
|
});
|
||||||
|
expect(res.json().data.editable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises editable:false for a non-allowlisted extension', async () => {
|
||||||
|
writeFileSync(join(workDir, 'schema.xsd'), '<xml/>');
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content?path=schema.xsd`,
|
||||||
|
});
|
||||||
|
const data = res.json().data;
|
||||||
|
expect(data.content).toBeDefined();
|
||||||
|
expect(data.editable).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== PUT ==========
|
||||||
|
|
||||||
|
describe('PUT /api/sessions/:id/file-content', () => {
|
||||||
|
it('happy path: writes the bytes, returns new hash, leaves no temp files', async () => {
|
||||||
|
const original = 'line one\nline two\n';
|
||||||
|
writeFileSync(join(workDir, 'notes.md'), original);
|
||||||
|
|
||||||
|
const updated = 'line one EDITED\nline two\n';
|
||||||
|
const res = await putFile('notes.md', { content: updated, baseHash: sha256(original) });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.hash).toBe(sha256(updated));
|
||||||
|
expect(body.data.eol).toBe('lf');
|
||||||
|
expect(body.data.size).toBe(Buffer.byteLength(updated));
|
||||||
|
|
||||||
|
expect(readFileSync(join(workDir, 'notes.md'), 'utf8')).toBe(updated);
|
||||||
|
const leftovers = readdirSync(workDir).filter((n) => n.includes('codeman-tmp'));
|
||||||
|
expect(leftovers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s on ../ traversal without touching the outside file', async () => {
|
||||||
|
const target = join(outsideDir, 'victim.md');
|
||||||
|
writeFileSync(target, 'safe');
|
||||||
|
// Build a relative path that resolves outside the workspace.
|
||||||
|
const traversal = `..${target.startsWith('/') ? target : `/${target}`}`;
|
||||||
|
const res = await putFile(traversal, { content: 'pwned', baseHash: sha256('safe') });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(readFileSync(target, 'utf8')).toBe('safe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s on an absolute path outside the workspace', async () => {
|
||||||
|
const target = join(outsideDir, 'victim2.md');
|
||||||
|
writeFileSync(target, 'safe');
|
||||||
|
const res = await putFile(target, { content: 'pwned', baseHash: sha256('safe') });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(readFileSync(target, 'utf8')).toBe('safe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s a symlink pointing outside the workspace and never follows it', async () => {
|
||||||
|
const target = join(outsideDir, 'secret.md');
|
||||||
|
writeFileSync(target, 'outside');
|
||||||
|
symlinkSync(target, join(workDir, 'sneaky.md'));
|
||||||
|
|
||||||
|
const res = await putFile('sneaky.md', { content: 'pwned', baseHash: sha256('outside') });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(readFileSync(target, 'utf8')).toBe('outside');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes THROUGH a symlink whose target is inside the workspace', async () => {
|
||||||
|
writeFileSync(join(workDir, 'real.md'), 'original');
|
||||||
|
symlinkSync(join(workDir, 'real.md'), join(workDir, 'alias.md'));
|
||||||
|
|
||||||
|
const res = await putFile('alias.md', { content: 'via alias', baseHash: sha256('original') });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(readFileSync(join(workDir, 'real.md'), 'utf8')).toBe('via alias');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s a non-allowlisted extension', async () => {
|
||||||
|
writeFileSync(join(workDir, 'blob.xyz'), 'text');
|
||||||
|
const res = await putFile('blob.xyz', { content: 'nope', baseHash: sha256('text') });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(readFileSync(join(workDir, 'blob.xyz'), 'utf8')).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403s inside .git even for an allowlisted-looking name', async () => {
|
||||||
|
mkdirSync(join(workDir, '.git'));
|
||||||
|
writeFileSync(join(workDir, '.git', 'config.ini'), '[core]');
|
||||||
|
const res = await putFile('.git/config.ini', { content: 'x', baseHash: sha256('[core]') });
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a .env file (allowlist first, sensitive-path as backstop)', async () => {
|
||||||
|
writeFileSync(join(workDir, '.env'), 'SECRET=1');
|
||||||
|
const res = await putFile('.env', { content: 'SECRET=2', baseHash: sha256('SECRET=1') });
|
||||||
|
expect([400, 403]).toContain(res.statusCode);
|
||||||
|
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('SECRET=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when the current file contains a NUL byte', async () => {
|
||||||
|
writeFileSync(join(workDir, 'weird.txt'), Buffer.from([0x61, 0x00, 0x62]));
|
||||||
|
const res = await putFile('weird.txt', { content: 'ab', baseHash: sha256(Buffer.from([0x61, 0x00, 0x62])) });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s when the current file is not valid UTF-8 (latin-1)', async () => {
|
||||||
|
const latin1 = Buffer.from('caf\xe9 au lait', 'latin1');
|
||||||
|
writeFileSync(join(workDir, 'legacy.txt'), latin1);
|
||||||
|
const res = await putFile('legacy.txt', { content: 'cafe au lait', baseHash: sha256(latin1) });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(readFileSync(join(workDir, 'legacy.txt'))).toEqual(latin1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409s on a stale baseHash and succeeds with force:true', async () => {
|
||||||
|
writeFileSync(join(workDir, 'contested.md'), 'agent version');
|
||||||
|
|
||||||
|
const res = await putFile('contested.md', { content: 'my version', baseHash: sha256('older version') });
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
expect(res.json().errorCode).toBe('CONFLICT');
|
||||||
|
expect(readFileSync(join(workDir, 'contested.md'), 'utf8')).toBe('agent version');
|
||||||
|
|
||||||
|
const forced = await putFile('contested.md', {
|
||||||
|
content: 'my version',
|
||||||
|
baseHash: sha256('older version'),
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
expect(forced.statusCode).toBe(200);
|
||||||
|
expect(readFileSync(join(workDir, 'contested.md'), 'utf8')).toBe('my version');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects oversized ASCII content at the schema pre-filter (400)', async () => {
|
||||||
|
writeFileSync(join(workDir, 'small.md'), 'ok');
|
||||||
|
const res = await putFile('small.md', {
|
||||||
|
content: 'x'.repeat(MAX_EDITABLE_BYTES + 1),
|
||||||
|
baseHash: sha256('ok'),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(readFileSync(join(workDir, 'small.md'), 'utf8')).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('413s multibyte content that passes the code-unit pre-filter but exceeds the byte cap', async () => {
|
||||||
|
writeFileSync(join(workDir, 'small.md'), 'ok');
|
||||||
|
// '€' is 1 UTF-16 code unit but 3 UTF-8 bytes: 200k units (< 512Ki cap)
|
||||||
|
// becomes ~586KB on disk, so only the handler's byteLength check catches it.
|
||||||
|
const res = await putFile('small.md', {
|
||||||
|
content: '€'.repeat(200_000),
|
||||||
|
baseHash: sha256('ok'),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(413);
|
||||||
|
expect(readFileSync(join(workDir, 'small.md'), 'utf8')).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s a missing file and creates nothing (edit-in-place only)', async () => {
|
||||||
|
const res = await putFile('brand-new.md', { content: 'hello', baseHash: sha256('hello') });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(existsSync(join(workDir, 'brand-new.md'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400s a malformed baseHash at the schema layer', async () => {
|
||||||
|
writeFileSync(join(workDir, 'a.md'), 'x');
|
||||||
|
const res = await putFile('a.md', { content: 'y', baseHash: 'not-a-hash' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves CRLF line endings across a textarea-normalized save', async () => {
|
||||||
|
const original = 'first\r\nsecond\r\nthird';
|
||||||
|
writeFileSync(join(workDir, 'dos.txt'), original);
|
||||||
|
|
||||||
|
// Client sends LF-normalized content + the eol it was told at load time.
|
||||||
|
const res = await putFile('dos.txt', {
|
||||||
|
content: 'first\nsecond EDITED\nthird',
|
||||||
|
baseHash: sha256(original),
|
||||||
|
eol: 'crlf',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(readFileSync(join(workDir, 'dos.txt'), 'utf8')).toBe('first\r\nsecond EDITED\r\nthird');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-applies the original EOL even when the client omits eol', async () => {
|
||||||
|
const original = 'a\r\nb';
|
||||||
|
writeFileSync(join(workDir, 'implicit.txt'), original);
|
||||||
|
const res = await putFile('implicit.txt', { content: 'a\nb\nc', baseHash: sha256(original) });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(readFileSync(join(workDir, 'implicit.txt'), 'utf8')).toBe('a\r\nb\r\nc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the file mode across the temp+rename', async () => {
|
||||||
|
const p = join(workDir, 'script.sh');
|
||||||
|
writeFileSync(p, '#!/bin/sh\necho hi\n');
|
||||||
|
chmodSync(p, 0o750);
|
||||||
|
|
||||||
|
const res = await putFile('script.sh', {
|
||||||
|
content: '#!/bin/sh\necho bye\n',
|
||||||
|
baseHash: sha256('#!/bin/sh\necho hi\n'),
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(statSync(p).mode & 0o777).toBe(0o750);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown body keys (.strict() schema)', async () => {
|
||||||
|
writeFileSync(join(workDir, 'a.md'), 'x');
|
||||||
|
const res = await putFile('a.md', { content: 'y', baseHash: sha256('x'), evil: true });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== multi-user scoping ==========
|
||||||
|
|
||||||
|
describe('multi-user ownership', () => {
|
||||||
|
it("404s a non-admin writing to a session they don't own", async () => {
|
||||||
|
const prev = process.env.CODEMAN_MULTIUSER;
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
try {
|
||||||
|
const scoped = await createRouteTestHarness(registerFileRoutes, {
|
||||||
|
authUser: { username: 'mallory', role: 'user' },
|
||||||
|
});
|
||||||
|
scoped.ctx._session.workingDir = workDir;
|
||||||
|
writeFileSync(join(workDir, 'owned.md'), 'admin file');
|
||||||
|
|
||||||
|
const res = await scoped.app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sessions/${sessionId}/file-content`,
|
||||||
|
payload: { path: 'owned.md', content: 'stolen', baseHash: sha256('admin file') },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(readFileSync(join(workDir, 'owned.md'), 'utf8')).toBe('admin file');
|
||||||
|
await scoped.app.close();
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||||
|
else process.env.CODEMAN_MULTIUSER = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user