diff --git a/CLAUDE.md b/CLAUDE.md index 61f081b1..6523f794 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,7 +230,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 over 255 bytes, so every folder it makes is one Link Existing accepts; `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 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) **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 e5df5fb9..5d5cafd1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -429,7 +429,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 over 255 bytes, so every folder it makes is one Link Existing accepts; `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 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. ⚠️ **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 eb5611b8..23992acf 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -159,6 +159,8 @@ For choosing a path rather than typing one. It appears in two places: - **Browse** in **Add Case → Link Existing**. - The **📁 Path** key on the mobile keyboard bar. +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 `~`), and it is not offered inside the cases folder, where every folder is already a case. + 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 diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 704ccc92..47327c79 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -57,6 +57,7 @@ import { findSessionOrFail, getAuthUser, parseBody, + resolveCasesDir, validateSessionFilePath, validateSessionFilePathLexical, } from '../route-helpers.js'; @@ -1468,6 +1469,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here'); } + // 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. + const casesDirs = await Promise.all( + [...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) => + fs.realpath(dir).catch(() => resolve(dir)) + ) + ); + if (casesDirs.includes(resolvedPath)) { + 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.' + ); + } // Like browse, a file is not a place to create in. try { if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory'); @@ -1488,6 +1504,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable'); } + if (code === 'ENOENT') { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`); + } if (code === 'ENAMETOOLONG') { reply.code(400); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c15333a5..08f522a2 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -85,8 +85,8 @@ const showHiddenQuerySchema = z.enum(['true', 'false']).optional(); /** * Body for the path picker's "New folder": one folder name created directly inside `path`. The name is held * to the character set `safePathSchema` accepts (letters, digits, `_ - . ~` and space, no `..`), so every - * folder this makes is one Link Existing and a session working directory will accept, and never a control - * character. It is also one path segment and not dot-prefixed (the picker hides those by default), and at + * name it accepts is one Link Existing and a session working directory accept (the parent's own path is + * judged there separately), and never a control character. It is also one path segment, not dot-prefixed (the picker hides those by default) and not tilde-prefixed (a folder named `~` is an `rm -rf ~` accident), and at * most 255 BYTES, the usual filesystem limit. */ export const FilesystemMkdirSchema = z.object({ @@ -99,8 +99,8 @@ export const FilesystemMkdirSchema = z.object({ .refine((n) => /^[\p{L}\p{N}_\-. ~]+$/u.test(n), { message: 'Folder name can only use letters, numbers, spaces and _ - . ~', }) - .refine((n) => !n.includes('..') && !n.startsWith('.'), { - message: 'Folder name cannot contain ".." or start with a dot', + .refine((n) => !n.includes('..') && !n.startsWith('.') && !n.startsWith('~'), { + message: 'Folder name cannot contain "..", or start with a dot or a tilde', }), sessionId: z .string() diff --git a/test/path-picker-new-folder.test.ts b/test/path-picker-new-folder.test.ts index 1ffe54dd..6c3cc625 100644 --- a/test/path-picker-new-folder.test.ts +++ b/test/path-picker-new-folder.test.ts @@ -172,6 +172,8 @@ describe('PathPicker New folder', () => { return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400); }; expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true'); - expect(block('openDockerWorkspacePathPicker')).not.toContain('allowNewFolder'); + for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openNewCasePathPicker']) { + expect(block(other), other).not.toContain('allowNewFolder'); + } }); }); diff --git a/test/routes/filesystem-mkdir-routes.test.ts b/test/routes/filesystem-mkdir-routes.test.ts index 3d712a96..71c60943 100644 --- a/test/routes/filesystem-mkdir-routes.test.ts +++ b/test/routes/filesystem-mkdir-routes.test.ts @@ -36,7 +36,7 @@ describe('POST /api/filesystem/mkdir', () => { expect(readdirSync(workDir)).toEqual(['fresh']); }); - it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', '~tilde', 'Café 2026'])( + it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', 'Café 2026'])( 'accepts %s, and Link Existing then accepts the folder it made', async (name) => { const res = await mkdir({ path: workDir, name }); @@ -45,6 +45,27 @@ describe('POST /api/filesystem/mkdir', () => { } ); + it('refuses the cases directory as the parent (400): a folder there is already a case', async () => { + const casesDir = join(homedir(), 'codeman-cases'); + mkdirSync(casesDir, { recursive: true }); + try { + const res = await mkdir({ path: casesDir, name: 'myproj' }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('Add Case > New Case'); + expect(existsSync(join(casesDir, 'myproj'))).toBe(false); + // A folder inside a case is fine. + mkdirSync(join(casesDir, 'real-case')); + expect((await mkdir({ path: join(casesDir, 'real-case'), name: 'sub' })).statusCode).toBe(200); + } finally { + safeRmHomeTree(casesDir); + } + }); + + it('answers 404 for a parent that is gone, the same answer browse gives', async () => { + const res = await mkdir({ path: join(workDir, 'gone'), name: 'x' }); + expect(res.statusCode).toBe(404); + }); + it('refuses a parent that is a file (400), like browse', async () => { writeFileSync(join(workDir, 'afile'), 'x'); const res = await mkdir({ path: join(workDir, 'afile'), name: 'x' }); @@ -83,6 +104,8 @@ describe('POST /api/filesystem/mkdir', () => { ['a traversal', '..'], ['a dot name', '.'], ['a hidden name', '.secret'], + ['a lone tilde', '~'], + ['a leading tilde', '~tilde'], ['an empty name', ' '], ['an apostrophe', "Kay's app"], ['parentheses', 'project (v2)'], @@ -151,6 +174,19 @@ describe('POST /api/filesystem/mkdir', () => { expect(readdirSync(join(spaces, 'alice'))).toEqual([]); }); + it("refuses a non-admin's own cases folder (400) but not the top of their space", async () => { + const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); + const own = join(spaces, 'bob', 'cases'); + mkdirSync(own, { recursive: true }); + const res = await app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: own, name: 'myproj' }, + }); + expect(res.statusCode).toBe(400); + expect(existsSync(join(own, 'myproj'))).toBe(false); + }); + it('lets a non-admin create inside their own space', async () => { const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); const res = await app.inject({