diff --git a/.changeset/picker-file-transfer.md b/.changeset/picker-file-transfer.md new file mode 100644 index 000000000..3c535d9cc --- /dev/null +++ b/.changeset/picker-file-transfer.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index adb384009..7b6f9f363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//...` (`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` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf5..3e480185a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index fe7835013..a691b3fa1 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -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 @@ -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 diff --git a/src/config/buffer-limits.ts b/src/config/buffer-limits.ts index d0b092d1a..17bcad0da 100644 --- a/src/config/buffer-limits.ts +++ b/src/config/buffer-limits.ts @@ -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 // ============================================================================ diff --git a/src/types/common.ts b/src/types/common.ts index 6ebebbbd6..3d35818bd 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -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 */ /** @@ -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 */ diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index e9cb509bd..9d9df44d8 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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': '会话标签页', @@ -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. diff --git a/src/web/public/index.html b/src/web/public/index.html index 1e8e3da5e..a3e7233c4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -580,6 +580,7 @@

Resume Conversation

Files
+ diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 8423427e9..c0831da00 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -23,7 +23,9 @@ * use, on a second tap, on any other accessory key, on a session switch * (refreshForActiveSession) and when the keyboard is dismissed (hide). * - PathPicker (singleton object) — Lazy server-side file/folder browser shared - * by Link Existing and the extended mobile keyboard bar. + * by Link Existing, the extended mobile keyboard bar and the Files panel's + * quick browser. With `fileTransfer` it also downloads files and uploads into + * the folder on screen (`/api/filesystem/download` and `/upload`). * * - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element. * Saves and restores previously focused element on deactivate. Used by Ralph wizard @@ -76,11 +78,17 @@ const PathPicker = { _previewPreviousFocus: null, _showHidden: false, _sortMode: PATH_PICKER_DEFAULT_SORT, + _uploading: false, + _uploadXhr: null, /** * Open the lazy filesystem browser. * @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean, - * title?: string, onSelect: (path: string) => void}} options + * title?: string, confirmLabel?: string, fileTransfer?: boolean, + * onSelect: (path: string) => void}} options + * `fileTransfer` adds per-file Download links and an Upload button for the + * folder on screen. Ignored with `fetchListing` (a container listing is not + * this host's filesystem). */ open(options) { this.close(false); @@ -119,6 +127,8 @@ const PathPicker = {
Loading...
+ + @@ -126,7 +136,7 @@ const PathPicker = {
Selected - None + None
@@ -138,6 +148,18 @@ const PathPicker = { this.overlay = overlay; overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path'; + if (options.confirmLabel) overlay.querySelector('.path-picker-confirm').textContent = options.confirmLabel; + if (this._transferEnabled()) { + const uploadBtn = overlay.querySelector('.path-picker-upload'); + const uploadInput = overlay.querySelector('.path-picker-upload-input'); + uploadBtn.hidden = false; + uploadBtn.addEventListener('click', () => uploadInput.click()); + uploadInput.addEventListener('change', () => { + const files = Array.from(uploadInput.files || []); + uploadInput.value = ''; + if (files.length) this.uploadFiles(files); + }); + } overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true)); overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true)); overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm()); @@ -408,6 +430,7 @@ const PathPicker = { icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4'; const name = document.createElement('span'); name.className = 'path-picker-item-name'; + name.setAttribute('data-i18n-skip', ''); name.textContent = entry.name; open.append(icon, name); const modified = this._formatModified(entry.mtimeMs); @@ -445,6 +468,17 @@ const PathPicker = { } row.appendChild(open); + if (entry.type === 'file' && this._transferEnabled()) { + const download = document.createElement('a'); + download.className = 'path-picker-item-download'; + download.href = this._transferUrl('download', entry.path); + download.setAttribute('download', entry.name); + download.textContent = '\u2B07'; + download.title = `Download ${entry.name}`; + download.setAttribute('aria-label', `Download ${entry.name}`); + row.appendChild(download); + } + if (entry.type === 'directory' || !this._options.directoriesOnly) { const choose = document.createElement('button'); choose.type = 'button'; @@ -457,6 +491,88 @@ const PathPicker = { } }, + _transferEnabled() { + return !!(this._options?.fileTransfer && !this._options.fetchListing); + }, + + /** Picker download/upload URL for `path` (a file to fetch, or the folder to write into). */ + _transferUrl(kind, path) { + const params = new URLSearchParams({ path }); + if (this._options?.sessionId) params.set('sessionId', this._options.sessionId); + // Hidden entries resolve only with the toggle on, exactly like browse. + if (this._showHidden) params.set('showHidden', 'true'); + return (window.CodemanBase?.url || ((p) => p))(`/api/filesystem/${kind}?${params.toString()}`); + }, + + /** + * Upload `files` one at a time into the folder on screen, then reload it and + * select the last file written, so Select (or Insert) is one tap away. Sequential + * on purpose: one progress line, and a failure names the file it stopped at + * instead of interleaving errors. + */ + async uploadFiles(files) { + if (!this.overlay || this._uploading || !this._currentPath) return; + const folder = this._currentPath; + const loadSequence = this._loadSequence; + const status = this.overlay.querySelector('.path-picker-status'); + const uploadBtn = this.overlay.querySelector('.path-picker-upload'); + this._uploading = true; + uploadBtn.disabled = true; + let lastPath = ''; + let done = 0; + try { + for (const file of files) { + const label = files.length > 1 ? `Uploading ${done + 1}/${files.length}: ${file.name}` : `Uploading ${file.name}`; + status.classList.remove('error'); + status.textContent = `${label}...`; + const data = await this._uploadOne(folder, file, (percent) => { + if (this.overlay) status.textContent = `${label} (${percent}%)`; + }); + lastPath = data.path; + done += 1; + } + } catch (error) { + if (this.overlay) { + status.textContent = `Upload failed: ${error.message || 'unknown error'}`; + status.classList.add('error'); + } + } finally { + this._uploading = false; + this._uploadXhr = null; + if (this.overlay) uploadBtn.disabled = false; + } + if (!done || !this.overlay) return; + app.showToast?.(done === 1 ? `Uploaded ${lastPath}` : `Uploaded ${done} files`, 'success'); + // Stay put if the user navigated away mid-upload; otherwise show the new file. + if (loadSequence === this._loadSequence && this._currentPath === folder) { + await this.load(folder); + if (this._entries.some((entry) => entry.path === lastPath)) this.select(lastPath); + } + }, + + /** POST one file; resolves with the server's `{path, name, size}`. XHR for upload progress. */ + _uploadOne(folder, file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + this._uploadXhr = xhr; + xhr.open('POST', this._transferUrl('upload', folder)); + xhr.responseType = 'json'; + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) onProgress(Math.floor((event.loaded / event.total) * 100)); + }; + xhr.onload = () => { + const body = xhr.response; + if (xhr.status >= 200 && xhr.status < 300 && body?.success) resolve(body.data); + else reject(new Error(body?.error || `HTTP ${xhr.status}`)); + }; + xhr.onerror = () => reject(new Error('Network error')); + xhr.onabort = () => reject(new Error('Upload cancelled')); + const form = new FormData(); + form.append('file', file, file.name); + xhr.send(form); + }); + }, + select(path) { if (!this.overlay) return; this._selectedPath = path; @@ -577,6 +693,8 @@ const PathPicker = { }, close(restoreFocus = true) { + this._uploadXhr?.abort(); + this._uploadXhr = null; if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; this._loadSequence += 1; @@ -1161,10 +1279,12 @@ const KeyboardAccessoryBar = { if (!app.activeSessionId) return; const session = app.sessions?.get(app.activeSessionId); PathPicker.open({ - title: 'Insert File or Folder Path', + title: 'Files', + confirmLabel: 'Insert Path', sessionId: app.activeSessionId, initialPath: session?.workingDir || '', directoriesOnly: false, + fileTransfer: true, onSelect: (path) => { app.insertTerminalText?.(path); setTimeout(() => app.terminal?.focus(), 100); diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 2d447dbc4..2eb203244 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3405,6 +3405,20 @@ Object.assign(CodemanApp.prototype, { ); }, + /** + * Open the lazy path picker (the mobile keyboard's Path key) from the Files + * panel: one folder per request instead of a 5,000-node tree walk, any allowed + * root rather than only this workspace, plus Upload and Download. Its Insert + * puts the chosen path on the prompt without Enter, like the Path key. + */ + openQuickFileBrowser() { + if (!this.activeSessionId) { + this.showToast?.('Select a session first', 'info'); + return; + } + KeyboardAccessoryBar.pickPath(); + }, + async toggleFileBrowserHidden() { const state = this._ensureFileBrowserState(); const rawInput = typeof state.filter === 'string' ? state.filter : ''; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f8efa7d75..cbf664d58 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -15042,6 +15042,53 @@ body.touch-device.cjk-input-visible .main { color: var(--danger, #ef4444); } +/* Upload (fileTransfer pickers only): sits between the status line and Sort, + * which the status line's flex pushes to the right. */ +.path-picker-toolbar:has(.path-picker-upload:not([hidden])) .path-picker-status { + flex: 1; +} + +.path-picker-upload { + flex: 0 0 auto; + padding: 3px 9px; + color: var(--accent); + font-size: 0.7rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; +} + +.path-picker-upload[hidden] { + display: none; +} + +.path-picker-upload:disabled { + opacity: 0.55; + cursor: progress; +} + +.path-picker-item-download { + display: inline-flex; + align-self: center; + align-items: center; + justify-content: center; + min-width: 32px; + min-height: 30px; + margin-right: 6px; + color: var(--text-dim); + font-size: 0.9rem; + text-decoration: none; + border: 1px solid var(--border); + border-radius: 7px; +} + +.path-picker-item-download:hover, +.path-picker-item-download:focus-visible { + color: var(--accent); + border-color: var(--accent); +} + .path-picker-list { flex: 1; min-height: 140px; diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 75a92fcb2..2e441e3a9 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -236,6 +236,26 @@ export async function isWorkingDirAllowedForUsername( return isWorkingDirAllowed({ username, role: user?.role ?? 'user' }, workingDir); } +/** + * Same-origin check for multipart uploads (paste-image, the path picker's + * upload). Cookies are SameSite=lax and multipart/form-data is a "simple" CORS + * request (no preflight), so a cross-origin `
` + * submit would attach the session cookie unimpeded. Origin (else Referer) must + * match the request's Host; a non-browser client that sends neither must supply + * `X-Codeman-CSRF`, a header browsers cannot add cross-origin without a + * preflight, which the CORS config does not allow from other origins. + */ +export function isSameOriginMultipartRequest(req: FastifyRequest): boolean { + const reqHost = req.headers.host; + const source = req.headers.origin ?? req.headers.referer; + if (!source) return !!req.headers['x-codeman-csrf']; + try { + return new URL(source).host === reqHost; + } catch { + return false; + } +} + /** Whether the caller is an admin (or single-user mode, where the sole user is admin). */ export function isAdmin(req: FastifyRequest): boolean { return !isMultiUserMode() || getAuthUser(req).role === 'admin'; diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 395e6af20..e8def141b 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -8,8 +8,9 @@ import { FastifyInstance, type FastifyReply } from 'fastify'; import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { createReadStream, realpathSync } from 'node:fs'; +import { constants as fsConstants, createReadStream, realpathSync } from 'node:fs'; import type { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; import fs from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; @@ -19,6 +20,7 @@ import type { FilesystemBrowseEntry, FilesystemBrowseRoot, FilesystemPreviewKind, + FilesystemUploadData, FileWriteData, } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; @@ -56,6 +58,7 @@ import { canAccessOwned, findSessionOrFail, getAuthUser, + isSameOriginMultipartRequest, parseBody, validateSessionFilePath, validateSessionFilePathLexical, @@ -69,12 +72,17 @@ import { remoteReadFile, type RemoteProbe, } from '../../remote-files.js'; -import { downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js'; +import { MAX_PICKER_UPLOAD_BYTES, downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js'; import { parseByteRange } from '../http-range.js'; import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; -import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js'; +import { + FilesystemBrowseQuerySchema, + FilesystemPreviewQuerySchema, + FilesystemUploadQuerySchema, + FileWriteSchema, +} from '../schemas.js'; import { MAX_EDITABLE_BYTES, applyEol, @@ -999,6 +1007,61 @@ async function resolveFilesystemPickerPath( }; } +// ===== Path picker file transfer ===== +// Download and upload ride the picker's own confinement (resolveFilesystemPickerPath: +// allowlisted roots, realpath containment, hidden opt-in, blocked/sensitive trees), +// so they reach exactly what the picker can already list, and nothing more. + +/** Uploads per client IP per minute: a disk-fill bound, generous for a batch pick. */ +const PICKER_UPLOADS_PER_MINUTE = 60; +const pickerUploadBuckets = new Map(); + +function consumePickerUploadToken(key: string, now = Date.now()): boolean { + const bucket = pickerUploadBuckets.get(key); + if (!bucket || now - bucket.windowStart >= 60_000) { + if (pickerUploadBuckets.size > 1000) pickerUploadBuckets.clear(); + pickerUploadBuckets.set(key, { windowStart: now, count: 1 }); + return true; + } + if (bucket.count >= PICKER_UPLOADS_PER_MINUTE) return false; + bucket.count += 1; + return true; +} + +/** Longest file name an upload keeps, in UTF-16 code units (ext4 allows 255 bytes). */ +const PICKER_UPLOAD_NAME_MAX = 120; + +/** + * The name an uploaded file is written under. The client-supplied name is only a + * hint: anything that could leave the folder or hide the file is removed (path + * separators, control characters, leading dots and spaces), and the stem is cut + * so the name stays well inside a filesystem's length limit with its extension. + */ +export function sanitizePickerUploadName(raw: string | undefined): string { + let name = String(raw ?? '') + .split(/[\\/]/) + .pop()! + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, '') + .trim() + .replace(/^[.\s]+/, ''); + if (name.length > PICKER_UPLOAD_NAME_MAX) { + const ext = extname(name).slice(0, 20); + name = name.slice(0, PICKER_UPLOAD_NAME_MAX - ext.length) + ext; + } + return name || 'upload'; +} + +/** `name`, then `stem (1).ext`, `stem (2).ext`, ... so an upload never overwrites. */ +function pickerUploadCandidateName(name: string, attempt: number): string { + if (attempt === 0) return name; + const ext = extname(name); + const stem = ext && ext !== name ? name.slice(0, -ext.length) : name; + return `${stem} (${attempt})${ext && ext !== name ? ext : ''}`; +} + +const PICKER_UPLOAD_MAX_RENAMES = 100; + function appendDownloadFlag(url: string): string { return `${url}${url.includes('?') ? '&' : '?'}download=true`; } @@ -1505,6 +1568,159 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even ); }); + // Download any regular file the picker can list (the preview route serves only + // the previewable kinds). Served as an attachment, never rendered. + app.get('/api/filesystem/download', { compress: false }, async (req, reply): Promise => { + const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query); + const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + req, + requestedPath, + sessionId, + wantsHiddenPickerEntries(showHidden) + ); + if (isBlockedPickerPath(resolvedPath, blockedTrees)) { + throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'); + } + let stat; + try { + stat = await fs.stat(resolvedPath); + } catch { + throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`); + } + if (!stat.isFile()) { + throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'Only files can be downloaded'); + } + const fileName = pathBasename(candidatePath); + reply.header('Cache-Control', 'no-cache'); + await serveRawFile( + reply, + { kind: 'local', resolvedPath, relativePath: '' }, + stat.size, + fileName, + extname(fileName).slice(1).toLowerCase(), + true, + req.headers.range + ); + }); + + // Upload one file into the folder the picker is showing. Multipart field `file`; + // the destination folder is the `path` query, confined like a browse. The file is + // created exclusively (never overwrites, never follows a planted symlink): a name + // already taken gets a ` (n)` suffix, and the answer carries the path written. + app.post('/api/filesystem/upload', async (req, reply): Promise> => { + if (!isSameOriginMultipartRequest(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed'); + } + if (!consumePickerUploadToken(req.ip)) { + reply.code(429); + reply.header('Retry-After', '60'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Rate limit exceeded (${PICKER_UPLOADS_PER_MINUTE} uploads/min)` + ); + } + const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemUploadQuerySchema, req.query); + const includeHidden = wantsHiddenPickerEntries(showHidden); + const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + req, + requestedPath, + sessionId, + includeHidden + ); + if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked'); + } + // The folder can sit on a network mount that stopped answering (#516): probe + // it bounded before the first write, as paste-image does for a workspace. + const kind = await probePathKind(resolvedPath, { pastCap: true }); + if (kind === 'unknown') { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + describeUnknownPath('Folder', candidatePath, { pastCap: true }) + ); + } + if (kind !== 'directory') { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The upload path must be a folder'); + } + if (!req.isMultipart()) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data'); + } + + let part: import('@fastify/multipart').MultipartFile | undefined; + try { + part = await req.file({ limits: { fileSize: MAX_PICKER_UPLOAD_BYTES }, throwFileSizeLimit: false }); + } catch (err: unknown) { + reply.code(413); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload'); + } + if (!part || part.fieldname !== 'file') { + if (part) await finished(part.file.resume()).catch(() => {}); + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected one file in the "file" field'); + } + + const baseName = sanitizePickerUploadName(part.filename); + let finalName = ''; + let handle: import('node:fs/promises').FileHandle | undefined; + for (let attempt = 0; attempt < PICKER_UPLOAD_MAX_RENAMES && !handle; attempt++) { + const name = pickerUploadCandidateName(baseName, attempt); + if (isBlockedPickerPath(join(resolvedPath, name), blockedTrees)) { + await finished(part.file.resume()).catch(() => {}); + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Uploading a file named "${name}" is blocked`); + } + try { + handle = await fs.open( + join(resolvedPath, name), + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o644 + ); + finalName = name; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + await finished(part.file.resume()).catch(() => {}); + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be written'); + } + } + } + if (!handle) { + await finished(part.file.resume()).catch(() => {}); + reply.code(409); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Too many files named like "${baseName}"`); + } + + const target = join(resolvedPath, finalName); + let size = 0; + try { + for await (const chunk of part.file) { + size += chunk.length; + // writeFile loops until the whole chunk is on disk; a bare write() may be short. + await handle.writeFile(chunk); + } + } catch (err: unknown) { + await handle.close().catch(() => {}); + await fs.unlink(target).catch(() => {}); + throw err; + } + await handle.close(); + if (part.file.truncated) { + await fs.unlink(target).catch(() => {}); + reply.code(413); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `File too large (max ${Math.round(MAX_PICKER_UPLOAD_BYTES / (1024 * 1024))}MB)` + ); + } + return { success: true, data: { path: join(candidatePath, finalName), name: finalName, size } }; + }); + // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 953cc5533..43d9c6328 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -101,6 +101,7 @@ import { getAuthUser, isAdmin, isPlainPromptInput, + isSameOriginMultipartRequest, isWorkingDirAllowed, ownerFor, parseBody, @@ -5360,32 +5361,8 @@ export function registerSessionRoutes( // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts). app.post('/api/sessions/:id/paste-image', async (req, reply) => { - // CSRF defense: state-changing routes must come from same origin. - // Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request - // (no preflight), so a cross-origin - // submit attaches the session cookie unimpeded. Reject unless Origin/Referer - // matches req.host. Non-browser clients (no Origin AND no Referer) must - // supply X-Codeman-CSRF — a header browsers cannot add cross-origin without - // a preflight, which our CORS config does not allow from other origins. - const reqHost = req.headers.host; - const origin = req.headers.origin; - const referer = req.headers.referer; - let csrfOk = false; - if (origin) { - try { - csrfOk = new URL(origin).host === reqHost; - } catch { - /* invalid Origin → not ok */ - } - } else if (referer) { - try { - csrfOk = new URL(referer).host === reqHost; - } catch { - /* invalid Referer → not ok */ - } - } else { - csrfOk = !!req.headers['x-codeman-csrf']; - } + // CSRF defense: state-changing routes must come from same origin (see the helper). + const csrfOk = isSameOriginMultipartRequest(req); if (!csrfOk) { reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index f40a2a163..15975525a 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -103,6 +103,12 @@ export const FilesystemPreviewQuerySchema = z.object({ showHidden: showHiddenQuerySchema, }); +/** + * Query validation for a path-picker upload: `path` names the destination + * FOLDER, resolved and confined exactly like a browse. + */ +export const FilesystemUploadQuerySchema = FilesystemPreviewQuerySchema; + /** * Body validation for `PUT /api/sessions/:id/file-content` (File Viewer edit * mode). `content.max()` counts UTF-16 code units, which for UTF-8 output is diff --git a/test/routes/filesystem-transfer.test.ts b/test/routes/filesystem-transfer.test.ts new file mode 100644 index 000000000..39ff3b461 --- /dev/null +++ b/test/routes/filesystem-transfer.test.ts @@ -0,0 +1,219 @@ +/** + * @fileoverview Path picker file transfer: `GET /api/filesystem/download` and + * `POST /api/filesystem/upload`. Both ride the picker's own confinement, so the + * tests pin that they reach what a browse can list and nothing more: the folder + * on screen, never outside the allowed roots, never a blocked/sensitive name, and + * an upload never overwrites or follows a planted symlink. + * + * Runs against a real temp directory (the session's workingDir is the "Current + * Folder" root); uses app.inject(), so no port is needed. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createMockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerFileRoutes, sanitizePickerUploadName } from '../../src/web/routes/file-routes.js'; + +const BOUNDARY = 'codemanboundary'; + +function multipartBody(fieldName: string, fileName: string, content: string | Buffer): Buffer { + return Buffer.concat([ + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\n` + + 'Content-Type: application/octet-stream\r\n\r\n' + ), + Buffer.isBuffer(content) ? content : Buffer.from(content), + Buffer.from(`\r\n--${BOUNDARY}--\r\n`), + ]); +} + +describe('path picker file transfer', () => { + let app: FastifyInstance; + let ctx: ReturnType; + let workspace: string; + let outside: string; + + beforeEach(async () => { + workspace = await realpath(await mkdtemp(join(tmpdir(), 'codeman-picker-transfer-'))); + outside = await realpath(await mkdtemp(join(tmpdir(), 'codeman-picker-outside-'))); + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4 } }); + ctx = createMockRouteContext(); + ctx._session.workingDir = workspace; + registerFileRoutes(app, ctx as never); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + await rm(workspace, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + const upload = (folder: string, fileName: string, content: string | Buffer, headers: Record = {}) => + app.inject({ + method: 'POST', + url: `/api/filesystem/upload?sessionId=${ctx._sessionId}&path=${encodeURIComponent(folder)}`, + headers: { 'x-codeman-csrf': '1', 'content-type': `multipart/form-data; boundary=${BOUNDARY}`, ...headers }, + payload: multipartBody('file', fileName, content), + }); + + describe('GET /api/filesystem/download', () => { + it('serves any regular file in an allowed root as an attachment', async () => { + await writeFile(join(workspace, 'build.tar.gz'), 'archive-bytes'); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, 'build.tar.gz'))}`, + }); + expect(res.statusCode).toBe(200); + expect(res.body).toBe('archive-bytes'); + expect(res.headers['content-disposition']).toMatch(/^attachment; filename="build\.tar\.gz"/); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('refuses a path outside the allowed roots', async () => { + await writeFile(join(outside, 'secret.txt'), 'nope'); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(outside, 'secret.txt'))}`, + }); + expect(res.statusCode).toBe(403); + }); + + it('refuses a symlink that escapes the roots', async () => { + await writeFile(join(outside, 'secret.txt'), 'nope'); + await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt')); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, 'link.txt'))}`, + }); + expect(res.statusCode).toBe(403); + expect(res.body).not.toContain('nope'); + }); + + it('refuses a sensitive file', async () => { + await writeFile(join(workspace, '.env'), 'TOKEN=x'); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, '.env'))}&showHidden=true`, + }); + expect(res.statusCode).toBe(403); + expect(res.body).not.toContain('TOKEN'); + }); + + it('refuses a folder', async () => { + await mkdir(join(workspace, 'sub')); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, 'sub'))}`, + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe('POST /api/filesystem/upload', () => { + it('writes the file into the requested folder and returns its path', async () => { + await mkdir(join(workspace, 'docs')); + const res = await upload(join(workspace, 'docs'), 'report.pdf', 'pdf-bytes'); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toEqual({ + success: true, + data: { path: join(workspace, 'docs', 'report.pdf'), name: 'report.pdf', size: 9 }, + }); + expect(await readFile(join(workspace, 'docs', 'report.pdf'), 'utf8')).toBe('pdf-bytes'); + }); + + it('never overwrites: a taken name gets a numbered suffix', async () => { + await writeFile(join(workspace, 'notes.txt'), 'original'); + const first = JSON.parse((await upload(workspace, 'notes.txt', 'second')).body); + const second = JSON.parse((await upload(workspace, 'notes.txt', 'third')).body); + expect(first.data.name).toBe('notes (1).txt'); + expect(second.data.name).toBe('notes (2).txt'); + expect(await readFile(join(workspace, 'notes.txt'), 'utf8')).toBe('original'); + expect(await readFile(join(workspace, 'notes (1).txt'), 'utf8')).toBe('second'); + }); + + it('does not follow a symlink planted under the target name', async () => { + await writeFile(join(outside, 'victim.txt'), 'untouched'); + await symlink(join(outside, 'victim.txt'), join(workspace, 'victim.txt')); + const body = JSON.parse((await upload(workspace, 'victim.txt', 'payload')).body); + expect(body.data.name).toBe('victim (1).txt'); + expect(await readFile(join(outside, 'victim.txt'), 'utf8')).toBe('untouched'); + }); + + it('strips path components from the client file name', async () => { + const body = JSON.parse((await upload(workspace, '../../escape.sh', 'x')).body); + expect(body.data.path).toBe(join(workspace, 'escape.sh')); + expect(await readdir(outside)).toEqual([]); + }); + + it('refuses a folder outside the allowed roots', async () => { + const res = await upload(outside, 'a.txt', 'x'); + expect(res.statusCode).toBe(403); + expect(await readdir(outside)).toEqual([]); + }); + + it('refuses a sensitive file name', async () => { + const res = await upload(workspace, 'credentials.json', '{}'); + expect(res.statusCode).toBe(403); + expect(await readdir(workspace)).toEqual([]); + }); + + it('refuses a cross-site request', async () => { + const res = await upload(workspace, 'a.txt', 'x', { origin: 'https://evil.example', 'x-codeman-csrf': '' }); + expect(res.statusCode).toBe(403); + expect(await readdir(workspace)).toEqual([]); + }); + + it('refuses an unknown session (ownership boundary)', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/filesystem/upload?sessionId=missing&path=${encodeURIComponent(workspace)}`, + headers: { 'x-codeman-csrf': '1', 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }, + payload: multipartBody('file', 'a.txt', 'x'), + }); + expect(res.statusCode).toBe(404); + }); + + it('refuses the wrong multipart field', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/filesystem/upload?sessionId=${ctx._sessionId}&path=${encodeURIComponent(workspace)}`, + headers: { 'x-codeman-csrf': '1', 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }, + payload: multipartBody('image', 'a.txt', 'x'), + }); + expect(res.statusCode).toBe(400); + expect(await readdir(workspace)).toEqual([]); + }); + }); + + describe('sanitizePickerUploadName', () => { + it.each([ + ['report.pdf', 'report.pdf'], + ['a/b/c.txt', 'c.txt'], + ['C:\\Users\\me\\photo.jpg', 'photo.jpg'], + ['..', 'upload'], + ['.bashrc', 'bashrc'], + [' spaced.txt ', 'spaced.txt'], + ['bad\u0000name\n.txt', 'badname.txt'], + ['', 'upload'], + [undefined, 'upload'], + ])('%j -> %j', (raw, expected) => { + expect(sanitizePickerUploadName(raw)).toBe(expected); + }); + + it('keeps the extension when cutting a long name', () => { + const name = sanitizePickerUploadName(`${'x'.repeat(300)}.docx`); + expect(name.length).toBeLessThanOrEqual(120); + expect(name.endsWith('.docx')).toBe(true); + }); + }); +});