Merge #761 feat(picker): New folder button for Create New's custom folder picker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:02:08 +01:00
commit 9ba5a89123
9 files changed
+63 -31

No files matched your search

+1 -1
View File
@@ -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`
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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.
+22 -14
View File
@@ -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
+1
View File
@@ -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();
+2
View File
@@ -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 {
+4 -2
View File
@@ -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.
+26 -8
View File
@@ -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('<!DOCTYPE html><html><body></body></html>', { 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');
});
});
+2 -1
View File
@@ -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'));