diff --git a/CLAUDE.md b/CLAUDE.md index 2dfef482..61f081b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,9 +230,9 @@ 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 name with a separator, `.`/`..` or a leading dot); 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 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) -**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` +**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` **Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 01638455..e5df5fb9 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 name with a separator, `.`/`..` or a leading dot); 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 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. ⚠️ **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/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 1457ae3f..95e7fafa 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -222,12 +222,20 @@ const PathPicker = { const name = input.value.trim(); if (!name || !this._currentPath) return; const status = overlay.querySelector('.path-picker-status'); + const create = overlay.querySelector('.path-picker-newfolder-create'); const parent = this._currentPath; + // One request at a time: a double tap would send a second one whose 409 shows after the first succeeded. + create.disabled = true; try { const res = await fetch('/api/filesystem/mkdir', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: parent, name, sessionId: this._options?.sessionId || undefined }), + body: JSON.stringify({ + path: parent, + name, + sessionId: this._options?.sessionId || undefined, + showHidden: this._showHidden ? 'true' : undefined, + }), }); const result = await res.json(); if (!result?.success) throw new Error(result?.error || 'Could not create the folder'); @@ -240,6 +248,8 @@ const PathPicker = { if (!this.overlay) return; status.textContent = error.message || 'Could not create the folder'; status.classList.add('error'); + } finally { + create.disabled = false; } }); }, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e5ce9e2b..557ceaf0 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12140,45 +12140,6 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat gap: 0.25rem; } -.path-picker-newfolder { - flex-basis: auto; - padding: 0 10px; - white-space: nowrap; -} - -.path-picker-newfolder[hidden], -.path-picker-newfolder-form[hidden] { - display: none; -} - -.path-picker-newfolder-form { - display: flex; - gap: 8px; - padding: 0 12px 10px; -} - -.path-picker-newfolder-name { - flex: 1 1 auto; - min-width: 0; - height: 38px; - padding: 0 10px; - color: var(--text); - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; -} - -.path-picker-newfolder-create, -.path-picker-newfolder-cancel { - height: 38px; - padding: 0 12px; - color: var(--text); - background: var(--bg-input); - border: 1px solid var(--border); - border-radius: 8px; - cursor: pointer; -} - /* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning * (dot-prefixed files and folders) survives every skin and font stack. */ .btn-file-browser-hidden { @@ -15828,6 +15789,45 @@ body.touch-device.cjk-input-visible .main { border-color: var(--accent); } +.path-picker-newfolder { + flex: 0 0 auto; + padding: 0 10px; + white-space: nowrap; +} + +.path-picker-newfolder[hidden], +.path-picker-newfolder-form[hidden] { + display: none; +} + +.path-picker-newfolder-form { + display: flex; + gap: 8px; + padding: 0 12px 10px; +} + +.path-picker-newfolder-name { + flex: 1 1 auto; + min-width: 0; + height: 38px; + padding: 0 10px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; +} + +.path-picker-newfolder-create, +.path-picker-newfolder-cancel { + height: 38px; + padding: 0 12px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} + .path-picker-up:disabled { opacity: 0.35; cursor: default; diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 870eec92..704ccc92 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -1453,21 +1453,28 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even }; }); - // Inline preview for files selected through the root-confined filesystem picker. // "New folder" in the path picker: creates ONE folder directly inside a folder the picker may browse. app.post('/api/filesystem/mkdir', async (req, reply): Promise> => { - const { path: parentPath, name, sessionId } = parseBody(FilesystemMkdirSchema, req.body); + const { path: parentPath, name, sessionId, showHidden } = parseBody(FilesystemMkdirSchema, req.body); const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( ctx, req, parentPath, - sessionId + sessionId, + wantsHiddenPickerEntries(showHidden) ); const target = join(resolvedPath, name); if (isBlockedPickerPath(resolvedPath, blockedTrees, true) || isBlockedPickerPath(target, blockedTrees, true)) { reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folders cannot be created here'); } + // Like browse, a file is not a place to create in. + try { + if (!(await fs.stat(resolvedPath)).isDirectory()) throw new Error('not a directory'); + } catch { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The folder to create in must be a directory'); + } try { // Not recursive: the parent was just resolved inside an allowed root, and an existing folder must be a 409. await fs.mkdir(target); @@ -1481,12 +1488,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder is not writable'); } + if (code === 'ENAMETOOLONG') { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder name is too long'); + } + // Never the errno text: it names the resolved, symlink-followed path, not the one the picker shows. reply.code(500); - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Could not create the folder: ${getErrorMessage(err)}`); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Could not create the folder${code ? ` (${code})` : ''}` + ); } return { success: true, data: { path: join(candidatePath, name) } }; }); + // Inline preview for files selected through the root-confined filesystem picker. app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise => { const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query); const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 0be22336..c15333a5 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -83,8 +83,11 @@ const filesystemPickerPathSchema = z 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 a - * single path segment (no separator, no `.`/`..`, not dot-prefixed, which the picker hides by default). + * 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 + * most 255 BYTES, the usual filesystem limit. */ export const FilesystemMkdirSchema = z.object({ path: filesystemPickerPathSchema, @@ -92,16 +95,20 @@ export const FilesystemMkdirSchema = z.object({ .string() .trim() .min(1, 'Folder name is required') - .max(255) - .refine((n) => !/[/\\\0\n\r]/.test(n), { message: 'Folder name cannot contain slashes or control characters' }) - .refine((n) => n !== '.' && n !== '..' && !n.startsWith('.'), { - message: 'Folder name cannot start with a dot', + .refine((n) => Buffer.byteLength(n) <= 255, { message: 'Folder name is too long' }) + .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', }), sessionId: z .string() .max(100) .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id') .optional(), + /** Same opt-in flag as browse: lets the picker create inside a hidden folder it is showing. */ + showHidden: showHiddenQuerySchema, }); /** Query validation for the lazy, allowlisted filesystem path picker. */ diff --git a/test/path-picker-new-folder.test.ts b/test/path-picker-new-folder.test.ts index 18b43574..1ffe54dd 100644 --- a/test/path-picker-new-folder.test.ts +++ b/test/path-picker-new-folder.test.ts @@ -128,6 +128,29 @@ describe('PathPicker New folder', () => { expect(form().hidden).toBe(false); }); + it('forwards showHidden while the picker is showing hidden entries', async () => { + jsdomWindow.localStorage.setItem('codeman:pathPickerShowHidden', '1'); + await open({ allowNewFolder: true }); + submit('fresh'); + await vi.waitFor(() => expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(true)); + const post = calls.find((c) => c.url === '/api/filesystem/mkdir'); + expect(JSON.parse(post?.init?.body ?? '{}').showHidden).toBe('true'); + }); + + it('disables Create while the request runs, so a double tap sends one', async () => { + let finish: (v: unknown) => void = () => {}; + mkdirReply = () => + new Promise((resolve) => { + finish = resolve; + }); + await open({ allowNewFolder: true }); + const create = doc.querySelector('.path-picker-newfolder-create') as HTMLButtonElement; + submit('fresh'); + await vi.waitFor(() => expect(create.disabled).toBe(true)); + finish({ json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) }); + await vi.waitFor(() => expect(create.disabled).toBe(false)); + }); + it('sends nothing for a blank name', async () => { await open({ allowNewFolder: true }); submit(' '); diff --git a/test/routes/filesystem-mkdir-routes.test.ts b/test/routes/filesystem-mkdir-routes.test.ts index 5b5177ed..3d712a96 100644 --- a/test/routes/filesystem-mkdir-routes.test.ts +++ b/test/routes/filesystem-mkdir-routes.test.ts @@ -9,6 +9,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { createRouteTestHarness } from './_route-test-utils.js'; import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; +import { LinkCaseSchema } from '../../src/web/schemas.js'; import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js'; describe('POST /api/filesystem/mkdir', () => { @@ -35,6 +36,33 @@ describe('POST /api/filesystem/mkdir', () => { expect(readdirSync(workDir)).toEqual(['fresh']); }); + it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', '~tilde', 'Café 2026'])( + 'accepts %s, and Link Existing then accepts the folder it made', + async (name) => { + const res = await mkdir({ path: workDir, name }); + expect(res.statusCode).toBe(200); + expect(LinkCaseSchema.safeParse({ name: 'x', path: res.json().data.path }).success).toBe(true); + } + ); + + 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' }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('The folder to create in must be a directory'); + }); + + it('creates inside a hidden folder only when the picker is showing hidden entries', async () => { + mkdirSync(join(workDir, '.hid')); + const denied = await mkdir({ path: join(workDir, '.hid'), name: 'x' }); + expect(denied.statusCode).toBe(403); + const ok = await mkdir({ path: join(workDir, '.hid'), name: 'x', showHidden: 'true' }); + expect(ok.statusCode).toBe(200); + expect(existsSync(join(workDir, '.hid', 'x'))).toBe(true); + // A dot name is still refused there. + expect((await mkdir({ path: join(workDir, '.hid'), name: '.y', showHidden: 'true' })).statusCode).toBe(400); + }); + it('trims the name', async () => { const res = await mkdir({ path: workDir, name: ' spaced ' }); expect(res.json().data.path).toBe(join(workDir, 'spaced')); @@ -56,6 +84,18 @@ describe('POST /api/filesystem/mkdir', () => { ['a dot name', '.'], ['a hidden name', '.secret'], ['an empty name', ' '], + ['an apostrophe', "Kay's app"], + ['parentheses', 'project (v2)'], + ['a plus', 'a+b'], + ['a comma', 'notes, 2026'], + ['a hash', 'c#'], + ['brackets', '[draft]'], + ['a double dot inside', 'v1..2'], + ['a tab', 'a\tb'], + ['an escape', 'a\x1b[31mb'], + ['a bell', 'a\x07b'], + ['a delete char', 'a\x7fb'], + ['a name over 255 bytes', 'é'.repeat(200)], ])('refuses %s in the name (400) and creates nothing', async (_label, name) => { const res = await mkdir({ path: workDir, name }); expect(res.statusCode).toBe(400); @@ -78,4 +118,60 @@ describe('POST /api/filesystem/mkdir', () => { expect((await mkdir({ path: 'relative', name: 'x' })).statusCode).toBe(400); expect((await mkdir({ path: `${workDir}/../x`, name: 'x' })).statusCode).toBe(400); }); + + describe('multi-user scoping', () => { + let prevMultiUser: string | undefined; + let prevSpaces: string | undefined; + let spaces = ''; + + beforeEach(() => { + prevMultiUser = process.env.CODEMAN_MULTIUSER; + prevSpaces = process.env.CODEMAN_USER_SPACES_DIR; + spaces = join(workDir, 'spaces'); + mkdirSync(join(spaces, 'alice'), { recursive: true }); + mkdirSync(join(spaces, 'bob')); + process.env.CODEMAN_MULTIUSER = '1'; + process.env.CODEMAN_USER_SPACES_DIR = spaces; + }); + afterEach(() => { + if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = prevMultiUser; + if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR; + else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces; + }); + + it("refuses a non-admin creating inside another user's space (403) and creates nothing", async () => { + const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); + const res = await app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: join(spaces, 'alice'), name: 'x' }, + }); + expect(res.statusCode).toBe(403); + expect(readdirSync(join(spaces, 'alice'))).toEqual([]); + }); + + 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({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: join(spaces, 'bob'), name: 'mine' }, + }); + expect(res.statusCode).toBe(200); + expect(existsSync(join(spaces, 'bob', 'mine'))).toBe(true); + }); + + it('404s a sessionId owned by another user, and adopts none of its folder', async () => { + const scoped = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } }); + scoped.ctx._session.owner = 'alice'; + const res = await scoped.app.inject({ + method: 'POST', + url: '/api/filesystem/mkdir', + payload: { path: join(spaces, 'bob'), name: 'x', sessionId: scoped.ctx._sessionId }, + }); + expect(res.statusCode).toBe(404); + expect(existsSync(join(spaces, 'bob', 'x'))).toBe(false); + }); + }); });