diff --git a/CLAUDE.md b/CLAUDE.md index 1cc001ef..fbcebcc2 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** (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/api-reference.md b/docs/api-reference.md index 2fba10e0..34c49843 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 2f050bfc..2a7e37e7 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -437,7 +437,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** (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. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index 7fb1e5a1..34a6ab61 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -160,30 +160,38 @@ 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 -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 **Link Existing**'s picker, **+📁** (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 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 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/src/web/public/styles.css b/src/web/public/styles.css index 03f59ab5..7beee233 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -16016,8 +16016,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/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 4d2a2b3c..0b018eda 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -1500,7 +1500,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)) @@ -1510,7 +1512,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 6c3cc625..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,14 +167,31 @@ describe('PathPicker New folder', () => { expect(doc.querySelector('.path-picker-overlay')).not.toBeNull(); }); - 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']) { - expect(block(other), other).not.toContain('allowNewFolder'); + // 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", () => { + expect(pickerOptions('openNewCasePathPicker')).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(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'); + }); }); 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'));