Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/picker-file-transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---

Path picker downloads and uploads files: a download link on every file row and an Upload button for the folder on screen, opened from the mobile Path key or the new quick-browser button in the Files panel header, so desktop browsers get the same fast browser.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph

**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)

**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`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key + the Files panel's quick browser): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file, and (not in Link Existing) `GET /api/filesystem/download` + `POST /api/filesystem/upload`, which must resolve through the same `resolveFilesystemPickerPath()`; upload creates with `O_EXCL|O_NOFOLLOW`, never overwrites. 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`. → [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`

Expand Down
4 changes: 3 additions & 1 deletion docs/architecture-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,10 +394,12 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli

### 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.
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key + the Files panel's quick-browser button): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. With `fileTransfer` (Path key and Files panel, never Link Existing) it adds `GET /api/filesystem/download` (any regular file, always `attachment`) and `POST /api/filesystem/upload` (multipart field `file`, into the folder named by `path`). 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.

⚠️ **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.

⚠️ **Download and upload resolve through the SAME `resolveFilesystemPickerPath()` as browse** (roots, realpath containment, hidden opt-in, `isBlockedPickerPath`), so they can never reach a path a listing could not show; never give them their own resolution. The upload is the picker's only WRITE: same-origin multipart check (`isSameOriginMultipartRequest`, shared with paste-image), a per-IP bucket, a bounded probe of the folder (#516), a sanitized basename (`sanitizePickerUploadName`: no separators, control characters or leading dots), the blocked-path check on the FINAL name, and `O_CREAT|O_EXCL|O_NOFOLLOW` creation with ` (n)` suffixes, so it never overwrites and never follows a planted symlink; a truncated or failed stream is unlinked. Tests: `test/routes/filesystem-transfer.test.ts`.

⚠️ **Ownership scoping is also not inherited, and both endpoints must do it themselves.** Two separate holes shipped in the original version and are now regression-guarded in `test/routes/file-routes.test.ts`:

1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check has to be written out by hand. Without it a multi-user caller pins **another user's** working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists.
Expand Down
14 changes: 13 additions & 1 deletion docs/wiki/Working-With-Files.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,12 @@ 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 three places:

- **Browse** in **Add Case → Link Existing**.
- The **📁 Path** key on the mobile keyboard bar.
- The **📂** quick-browser button in the Files panel header, so a desktop browser gets the
same fast browser as a phone.

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
Expand All @@ -162,6 +164,16 @@ 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.

Opened from the Path key or the Files panel, it also transfers files: every file row has a
**⬇** download link (any file type, sent as a download, never rendered), and **⬆ Upload**
sends one or more files from your device into the folder on screen. An upload never
overwrites: a name already taken is saved as `name (1).ext`, and the uploaded file is
selected afterwards, so **Insert Path** puts it on the prompt in one tap. Uploads are capped
at 1GB per file (`CODEMAN_MAX_PICKER_UPLOAD_BYTES`) and 60 per minute; downloads follow the
global download cap. Both reach exactly what the picker can list, under the same roots and
blocks, and a sensitive name (`credentials.json`, a key file) is refused for upload as it is
for download. Link Existing's picker stays browse-only.

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
Expand Down
9 changes: 9 additions & 0 deletions src/config/buffer-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAG
*/
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB

/**
* Maximum size (bytes) of one file uploaded through the filesystem path picker
* (`POST /api/filesystem/upload`, the picker's Upload button). Like the video
* cap it streams to disk, so it bounds disk use, not memory. 0 is not accepted
* here: an upload always has a ceiling. Override: CODEMAN_MAX_PICKER_UPLOAD_BYTES (bytes)
*/
export const MAX_PICKER_UPLOAD_BYTES =
parseInt(process.env.CODEMAN_MAX_PICKER_UPLOAD_BYTES || '') || 1024 * 1024 * 1024; // 1GB

// ============================================================================
// File Download Limits
// ============================================================================
Expand Down
11 changes: 11 additions & 0 deletions src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
* - FilesystemUploadData — where a path-picker upload was written
*/

/**
Expand Down Expand Up @@ -99,6 +100,16 @@ export interface FilesystemBrowseData {
truncated: boolean;
}

/** Response payload for `POST /api/filesystem/upload` (path picker Upload). */
export interface FilesystemUploadData {
/** Path the file was written to, under the requested (unresolved) folder path */
path: string;
/** Final file name, after sanitizing and any ` (n)` collision suffix */
name: string;
/** Bytes written */
size: number;
}

/** Response payload for `PUT /api/sessions/:id/file-content` (File Viewer edit mode). */
export interface FileWriteData {
/** Workspace-relative path as submitted */
Expand Down
11 changes: 11 additions & 0 deletions src/web/public/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
'应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。',
'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"':
'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"',
'Upload files to this folder': '上传文件到此文件夹',
'\u2B06 Upload': '\u2B06 上传',
'Insert Path': '插入路径',
'Quick browser: browse any allowed folder, upload and download files': '快速浏览:浏览允许的文件夹,上传和下载文件',
'Skip to terminal': '跳转到终端',
'Go to main page': '返回主页',
'Session tabs': '会话标签页',
Expand Down Expand Up @@ -1245,6 +1249,13 @@
[/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`],
[/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`],
[/^Selected: (.+)$/, (_m, value) => `已选择:${value}`],
// Path picker transfer: file names and paths are user text, passed through.
[/^Download (.+)$/, (_m, name) => `下载 ${name}`],
[/^Uploading (\d+)\/(\d+): (.+)$/, (_m, n, total, rest) => `正在上传 ${n}/${total}:${rest}`],
[/^Uploading (.+)$/, (_m, rest) => `正在上传 ${rest}`],
[/^Upload failed: (.+)$/, (_m, error) => `上传失败:${error}`],
[/^Uploaded (\d+) files$/, (_m, n) => `已上传 ${n} 个文件`],
[/^Uploaded (.+)$/, (_m, path) => `已上传 ${path}`],
[/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`],
[/^Will create: (.+)$/, (_m, path) => `将创建:${path}`],
// The spreadsheet preview: an HTTP status, and the notice bar's counts.
Expand Down
1 change: 1 addition & 0 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ <h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<div class="file-browser-header">
<span class="file-browser-title">Files</span>
<div class="file-browser-actions">
<button class="btn-icon-sm btn-file-browser-quick" onclick="app.openQuickFileBrowser()" title="Quick browser: browse any allowed folder, upload and download files" aria-label="Quick browser: browse any allowed folder, upload and download files">&#x1F4C2;</button>
<button class="btn-icon-sm btn-file-browser-hidden" onclick="app.toggleFileBrowserHidden()" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false" id="fileBrowserHiddenBtn">.*</button>
<button class="btn-icon-sm" onclick="app.refreshFileBrowser()" title="Refresh">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">&#x229E;</button>
Expand Down
Loading
Loading