fix(picker): do not offer New folder inside a cases directory

A folder made in a cases directory is already a case (no scaffold), which Link
Existing then refuses by name and which deleting would take the linked folder
with. The route refuses a cases directory as the parent (the shared one and the
caller's own), a leading tilde in the name, and maps a vanished parent to 404.
Docs now say every name it accepts is one Link Existing accepts, and the wiki
describes the button.
This commit is contained in:
Devvyn committed 2026-10-31 21:12:46 +08:00
1 parent 869633ae6d
commit 7a3629e890
7 files changed
+68 -8

No files matched your search

+1 -1
View File
@@ -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`
+1 -1
View File
@@ -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.
+2
View File
@@ -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
+20
View File
@@ -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');
+4 -4
View File
@@ -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()
+3 -1
View File
@@ -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');
}
});
});
+37 -1
View File
@@ -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({