From cddfb48ea69324fd10c7641f0ae8c8a0e59b826b Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:26:36 +0800 Subject: [PATCH 1/6] feat(picker): New folder button for Create New's custom folder picker Turns on the picker's New folder button for "Choose the folder to create the case in", the folder picker behind Create New's custom folder option. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- src/web/public/session-ui.js | 1 + test/path-picker-new-folder.test.ts | 9 ++++++++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f350fdb..166fe6a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. -**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`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**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`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) 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` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 479d279b..ed28e460 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -435,7 +435,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin ### Filesystem path picker -**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing has it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. +**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index fc0d2582..fd6341e6 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4051,6 +4051,7 @@ Object.assign(CodemanApp.prototype, { title: 'Choose the folder to create the case in', initialPath: input.value.trim(), directoriesOnly: true, + allowNewFolder: true, onSelect: (path) => { input.value = path; this.updateNewCasePathPreview(); diff --git a/test/path-picker-new-folder.test.ts b/test/path-picker-new-folder.test.ts index 6c3cc625..e690ce04 100644 --- a/test/path-picker-new-folder.test.ts +++ b/test/path-picker-new-folder.test.ts @@ -166,13 +166,20 @@ describe('PathPicker New folder', () => { expect(doc.querySelector('.path-picker-overlay')).not.toBeNull(); }); + it("is on for Create New's custom folder picker too", () => { + const start = sessionUiJs.indexOf('openNewCasePathPicker() {'); + expect(sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400)).toContain( + 'allowNewFolder: true' + ); + }); + it('is on for Link Existing and off for the other session-ui pickers', () => { const block = (name: string) => { const start = sessionUiJs.indexOf(`${name}() {`); return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400); }; expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true'); - for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openNewCasePathPicker']) { + for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker']) { expect(block(other), other).not.toContain('allowNewFolder'); } }); From b89de128e19a5431e36f9e8d7d86c14f41911460 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:15:40 +0800 Subject: [PATCH 2/6] docs(picker): name both pickers in the manual and cover the clone picker in the New folder test --- docs/wiki/Working-With-Files.md | 2 +- test/path-picker-new-folder.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 9486ac69..ed27a38b 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -171,7 +171,7 @@ choice is remembered per device. The picker inserts the path into your prompt **without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear` command. -In **Link Existing**'s picker, **+📁** (New folder) makes a folder inside the folder you +In the **Link Existing** and **Create New** (custom folder) pickers, **+📁** (New folder) makes a folder inside the folder you are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting with a dot or `~`). The cases folder is refused, since every folder there is already a case: use **Add Case → New Case** instead. diff --git a/test/path-picker-new-folder.test.ts b/test/path-picker-new-folder.test.ts index e690ce04..ef093ed2 100644 --- a/test/path-picker-new-folder.test.ts +++ b/test/path-picker-new-folder.test.ts @@ -179,7 +179,7 @@ describe('PathPicker New folder', () => { return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400); }; expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true'); - for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker']) { + for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openCloneCasePathPicker']) { expect(block(other), other).not.toContain('allowNewFolder'); } }); From dbb320216167d06c38f9814f36c852513a59d71f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 03:40:58 +0100 Subject: [PATCH 3/6] fix(picker): word the cases-folder refusal for both pickers, and finish the review items With the folder field empty, Create New's Browse opens in the cases folder, and New folder there answered "Use Add Case > New Case". There is no tab of that name (the label is Create New), and a Create New user is already on it. The refusal now reads right from Link Existing and from Create New's custom folder. Its test pin, the code comment, the manual, api-reference, CLAUDE.md and architecture-invariants move with it. The block() helper in the New folder picker test returned an empty string for a name that is not in session-ui.js, so .not.toContain passed and a renamed picker dropped out of the check. It now asserts the name was found. The manual's path picker list said two places and left out Create New, which the New folder paragraph names. It now lists every Browse that opens the picker, and that paragraph is rewrapped to the page's width. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/api-reference.md | 8 ++++---- docs/architecture-invariants.md | 2 +- docs/wiki/Working-With-Files.md | 14 +++++++++----- src/web/routes/file-routes.ts | 6 ++++-- test/path-picker-new-folder.test.ts | 1 + test/routes/filesystem-mkdir-routes.test.ts | 3 ++- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 166fe6a3..855d67db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. -**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`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**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`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) 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` diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a269ad3..7fb2acf6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -905,10 +905,10 @@ html and svg are the ones deliberately kept out. `POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one folder directly inside `path`, which must be a folder the path picker may browse, and answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not -starting with a dot or `~`). `400` for a cases folder (use Add Case → New Case) or a `path` -that is not a directory, `403` for a blocked tree or a `path` outside the browse roots, -`404` for a `path` that does not exist, `409` when the folder already exists, and `422` -when the mount under `path` does not answer. +starting with a dot or `~`). `400` for a cases folder (use Add Case → Create New with no +custom folder) or a `path` that is not a directory, `403` for a blocked tree or a `path` +outside the browse roots, `404` for a `path` that does not exist, `409` when the folder +already exists, and `422` when the mount under `path` does not answer. ## Rendered HTML files diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ed28e460..61837eda 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -435,7 +435,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin ### Filesystem path picker -**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > New Case makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. +**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index ed27a38b..0500b7da 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -157,9 +157,12 @@ their workspace is bind-mounted at the same path. ## The path picker -For choosing a path rather than typing one. It appears in two places: +For choosing a path rather than typing one. It appears in these places: - **Browse** in **Add Case → Link Existing**. +- **Browse** in **Add Case → Create New**, under **Create in a custom folder**. +- **Browse** in **Add Case → Clone Repo**, under **Clone into a custom folder**. +- **Browse** on the folder fields of **Add Case → Docker**. - The **📁 Path** key on the mobile keyboard bar. It browses one directory at a time and can show hidden entries on request. The current @@ -171,10 +174,11 @@ choice is remembered per device. The picker inserts the path into your prompt **without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear` command. -In the **Link Existing** and **Create New** (custom folder) pickers, **+📁** (New folder) makes a folder inside the folder you -are viewing and selects it. Names use letters, numbers, spaces and `_ - . ~` (not starting -with a dot or `~`). The cases folder is refused, since every folder there is already a -case: use **Add Case → New Case** instead. +In the **Link Existing** and **Create New** (custom folder) pickers, **+📁** (New folder) +makes a folder inside the folder you are viewing and selects it. Names use letters, +numbers, spaces and `_ - . ~` (not starting with a dot or `~`). The cases folder is +refused, since every folder there is already a case: pick a folder outside it, or make +the case with **Add Case → Create New** (no custom folder). This is a separate file-serving surface from the viewer, with its own rules: it allowlists your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 95896bfd..91b53614 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -1480,7 +1480,9 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even } // The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as // local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which - // deleting would take the linked folder with. A case there is made through Add Case > New Case. + // deleting would take the linked folder with. A case there is made through Add Case > Create New with no + // custom folder. The refusal has to read right from both pickers that offer New folder: a Create New user + // is already on that tab, with the custom folder ticked. const casesDirs = await Promise.all( [...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) => fs.realpath(dir).catch(() => resolve(dir)) @@ -1490,7 +1492,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even reply.code(400); return createErrorResponse( ApiErrorCode.INVALID_INPUT, - 'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.' + 'This is a cases folder: every folder in it is a case. Pick a folder outside it, or make the case with Add Case > Create New (no custom folder).' ); } // Like browse, a file is not a place to create in. diff --git a/test/path-picker-new-folder.test.ts b/test/path-picker-new-folder.test.ts index ef093ed2..3f13f3bf 100644 --- a/test/path-picker-new-folder.test.ts +++ b/test/path-picker-new-folder.test.ts @@ -176,6 +176,7 @@ describe('PathPicker New folder', () => { it('is on for Link Existing and off for the other session-ui pickers', () => { const block = (name: string) => { const start = sessionUiJs.indexOf(`${name}() {`); + expect(start, name).toBeGreaterThan(-1); return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400); }; expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true'); diff --git a/test/routes/filesystem-mkdir-routes.test.ts b/test/routes/filesystem-mkdir-routes.test.ts index 71c60943..d16146d0 100644 --- a/test/routes/filesystem-mkdir-routes.test.ts +++ b/test/routes/filesystem-mkdir-routes.test.ts @@ -51,7 +51,8 @@ describe('POST /api/filesystem/mkdir', () => { try { const res = await mkdir({ path: casesDir, name: 'myproj' }); expect(res.statusCode).toBe(400); - expect(res.json().error).toContain('Add Case > New Case'); + expect(res.json().error).toContain('Pick a folder outside it'); + expect(res.json().error).toContain('Add Case > Create New (no custom folder)'); expect(existsSync(join(casesDir, 'myproj'))).toBe(false); // A folder inside a case is fine. mkdirSync(join(casesDir, 'real-case')); From 3ef65d7c7453fb6b5a9bdeb99ba462d1bd079c5d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 04:02:11 +0100 Subject: [PATCH 4/6] fix: let the path picker's error status wrap (release review) The cases-folder refusal grew to 143 characters, and the status line is one line with an ellipsis and no title, so at 513px on desktop it ended at "or make the case with Ad..." and lost the instruction. .path-picker-status.error now wraps: 2 lines on desktop, 4 on a phone, measured clear in Chromium at both, and it covers the picker's other refusals too. Tests: a pin on that rule (fails without the fix), and the option check now reads each picker's whole options object instead of a fixed 400 characters, which stopped short of the container-workdir picker's 829. Retitled to say what it checks: Create New has the flag too. Co-Authored-By: Claude Opus 5.5 --- src/web/public/styles.css | 2 ++ test/path-picker-new-folder.test.ts | 34 +++++++++++++++++++---------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 19631492..9cb4a7d7 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -15977,8 +15977,10 @@ body.touch-device.cjk-input-visible .main { white-space: nowrap; } +/* An ordinary status fits one line; a refusal may not, and it must be readable whole. */ .path-picker-status.error { color: var(--danger, #ef4444); + white-space: normal; } .path-picker-list { diff --git a/test/path-picker-new-folder.test.ts b/test/path-picker-new-folder.test.ts index 3f13f3bf..03436d0d 100644 --- a/test/path-picker-new-folder.test.ts +++ b/test/path-picker-new-folder.test.ts @@ -14,6 +14,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8'); const sessionUiJs = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8'); +const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'); const dom = new JSDOM('', { url: 'https://localhost/' }); const jsdomWindow = dom.window as unknown as Window & typeof globalThis; @@ -166,22 +167,31 @@ describe('PathPicker New folder', () => { expect(doc.querySelector('.path-picker-overlay')).not.toBeNull(); }); + // A picker's whole options object: a fixed window stops short of the container picker's long one. + const pickerOptions = (name: string) => { + const start = sessionUiJs.indexOf(`${name}() {`); + expect(start, name).toBeGreaterThan(-1); + const open = sessionUiJs.indexOf('PathPicker.open', start); + expect(open, name).toBeGreaterThan(start); + const end = sessionUiJs.indexOf('\n });', open); + expect(end, name).toBeGreaterThan(open); + return sessionUiJs.slice(open, end); + }; + it("is on for Create New's custom folder picker too", () => { - const start = sessionUiJs.indexOf('openNewCasePathPicker() {'); - expect(sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400)).toContain( - 'allowNewFolder: true' - ); + expect(pickerOptions('openNewCasePathPicker')).toContain('allowNewFolder: true'); }); - it('is on for Link Existing and off for the other session-ui pickers', () => { - const block = (name: string) => { - const start = sessionUiJs.indexOf(`${name}() {`); - expect(start, name).toBeGreaterThan(-1); - return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400); - }; - expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true'); + it('is on for Link Existing and off for the Clone and Docker pickers', () => { + expect(pickerOptions('openLinkCasePathPicker')).toContain('allowNewFolder: true'); for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openCloneCasePathPicker']) { - expect(block(other), other).not.toContain('allowNewFolder'); + expect(pickerOptions(other), other).not.toContain('allowNewFolder'); } }); + + it('lets an error wrap: the status line has no other way to show the rest of a long refusal', () => { + const start = stylesCss.indexOf('.path-picker-status.error {'); + expect(start).toBeGreaterThan(-1); + expect(stylesCss.slice(start, stylesCss.indexOf('}', start))).toContain('white-space: normal'); + }); }); From 19a2197f846a6e4b00fa526de80a9c467db2b718 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 04:03:32 +0100 Subject: [PATCH 5/6] docs: say which picker each statement covers (release review) The picker is now reached from every Add Case Browse button, so three lines in the manual and the opening of the CLAUDE.md and architecture-invariants paragraphs read wrong. A Browse fills its form field, only the Path key types into a prompt, and Docker's Container Workdir picker lists the inside of the container through POST /api/docker-cases/browse: it always shows dot entries and the host allowlist does not apply to it. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Working-With-Files.md | 22 +++++++++++++--------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 855d67db..428df9f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. -**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`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, and none of the host rules below apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) 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` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 61837eda..36b9a386 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -435,7 +435,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin ### Filesystem path picker -**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. +**Filesystem path picker** (every Add Case "Browse" button: Link Existing, Create New's custom folder, Clone Repo, Docker; + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file (Docker's Container Workdir picker passes its own `fetchListing` over `POST /api/docker-cases/browse` instead, admin-only, which always lists dot entries and goes through none of the host rules below). It starts at the form field's value or, for the `📁 Path` key, the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root) and hides dot entries. A Browse fills its form field; the `📁 Path` key inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 0500b7da..baf039b0 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -165,14 +165,16 @@ For choosing a path rather than typing one. It appears in these places: - **Browse** on the folder fields of **Add Case → Docker**. - The **📁 Path** key on the mobile keyboard bar. -It browses one directory at a time and can show hidden entries on request. The current -folder is an editable field: type or paste a path and press Enter (or **Go**) to jump -straight there, and a full file path lands in its folder with that file selected. The -**Sort** control orders each listing by name or by modified time (newest first is the -quick way to the file an agent just wrote), with folders always ahead of files; the -choice is remembered per device. The picker inserts the path into your prompt -**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All** -key clears the unsent prompt, and never sends the agent's `/clear` command. +It browses one directory at a time and can show hidden entries on request (the Docker +**Container Workdir** picker always lists them). The current folder is an editable +field: type or paste a path and press Enter (or **Go**) to jump straight there, and a +full file path lands in its folder with that file selected. The **Sort** control orders +each listing by name or by modified time (newest first is the quick way to the file an +agent just wrote), with folders always ahead of files; the choice is remembered per +device. A **Browse** button fills the field beside it. The **📁 Path** key inserts the +path into your prompt **without** pressing Enter, so nothing is submitted by accident. +Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear` +command. In the **Link Existing** and **Create New** (custom folder) pickers, **+📁** (New folder) makes a folder inside the folder you are viewing and selects it. Names use letters, @@ -184,7 +186,9 @@ This is a separate file-serving surface from the viewer, with its own rules: it your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a root, because per-user spaces live inside the home directory and a home-directory root would -expose everyone. +expose everyone. The Docker **Container Workdir** picker lists the inside of the container +instead, read-only, so these host rules do not apply to it; it is admin-only in multi-user +mode. ## Files into a session From aae0951d4e112b9d894588579f471c66055ff6e5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 04:06:52 +0100 Subject: [PATCH 6/6] fix: name which picker rules skip the container picker (release review) The New folder rule in the same paragraph does cover that picker, so "none of the host rules" said too much. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 428df9f7..aa0abe6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. -**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, and none of the host rules below apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**Filesystem path picker** (every Add Case "Browse": Link Existing, Create New's custom folder, Clone Repo, Docker; + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file (Docker's Container Workdir picker lists through its own `fetchListing` over `POST /api/docker-cases/browse` instead, so the allowlist, blocked-tree and ownership rules below do not apply to it). A Browse fills its form field; the Path key inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping**: it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place: `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that writes file contents** (the path picker's New folder creates folders only): read-path confinement (realpath + workspace + ownership) 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` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 36b9a386..de07f654 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -435,7 +435,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin ### Filesystem path picker -**Filesystem path picker** (every Add Case "Browse" button: Link Existing, Create New's custom folder, Clone Repo, Docker; + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file (Docker's Container Workdir picker passes its own `fetchListing` over `POST /api/docker-cases/browse` instead, admin-only, which always lists dot entries and goes through none of the host rules below). It starts at the form field's value or, for the `📁 Path` key, the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root) and hides dot entries. A Browse fills its form field; the `📁 Path` key inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. +**Filesystem path picker** (every Add Case "Browse" button: Link Existing, Create New's custom folder, Clone Repo, Docker; + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file (Docker's Container Workdir picker passes its own `fetchListing` over `POST /api/docker-cases/browse` instead, admin-only, which always lists dot entries and skips the allowlist, blocked-tree and ownership rules below). It starts at the form field's value or, for the `📁 Path` key, the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root) and hides dot entries. A Browse fills its form field; the `📁 Path` key inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. **New folder** (opt-in per caller, `allowNewFolder`; Link Existing and Create New's custom folder have it): `POST /api/filesystem/mkdir` `{ path, name, sessionId? }` creates ONE folder (non-recursive) directly inside a folder the picker may browse, resolved through the same roots, ownership and blocked-tree rules as browse (`403` outside them or in a blocked tree or unwritable, `404` missing parent, `409` exists, `400` a parent that is a file, or a name outside letters, digits, space and `_ - . ~`, containing `..`, starting with a dot or a tilde or over 255 bytes, so every name it accepts is one Link Existing accepts, and `400` a parent that is a cases directory, where a folder is already a case (Add Case > Create New with no custom folder makes one there); `showHidden` is forwarded like browse's); the picker reloads the folder and selects the new one. A picker that browses somewhere this route cannot create in (the container-workdir picker, `fetchListing`) must not set the flag. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews.