From 2667150f33daa2f87cece0dd0020729cbd4465cc Mon Sep 17 00:00:00 2001 From: codeman-local Date: Mon, 20 Jul 2026 19:11:31 +0800 Subject: [PATCH 1/2] feat(mobile): add filesystem path picker --- .changeset/mobile-filesystem-path-picker.md | 9 + CLAUDE.md | 4 +- src/types/common.ts | 26 ++ src/web/public/index.html | 7 +- src/web/public/keyboard-accessory.js | 252 +++++++++++++++++- src/web/public/session-ui.js | 19 ++ src/web/public/styles.css | 277 ++++++++++++++++++++ src/web/public/terminal-ui.js | 44 ++++ src/web/routes/file-routes.ts | 218 ++++++++++++++- src/web/schemas.ts | 23 ++ test/path-picker-ui.test.ts | 180 +++++++++++++ test/routes/file-routes.test.ts | 91 ++++++- 12 files changed, 1141 insertions(+), 9 deletions(-) create mode 100644 .changeset/mobile-filesystem-path-picker.md create mode 100644 test/path-picker-ui.test.ts diff --git a/.changeset/mobile-filesystem-path-picker.md b/.changeset/mobile-filesystem-path-picker.md new file mode 100644 index 00000000..80378df6 --- /dev/null +++ b/.changeset/mobile-filesystem-path-picker.md @@ -0,0 +1,9 @@ +--- +"aicodeman": minor +--- + +feat(mobile): browse and insert local file and folder paths + +Add a root-confined filesystem picker to Link Existing and the extended mobile +keyboard bar. Selected paths remain editable at the active prompt, and a new +one-tap action clears only the current unsent input without invoking `/clear`. diff --git a/CLAUDE.md b/CLAUDE.md index 56ad327c..8c0e50e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,7 +261,9 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### API Routes -~190 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` β†’ spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~191 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` β†’ spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (15, incl. root-confined `GET /api/filesystem/browse`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. + +**Filesystem path picker**: Link Existing exposes a Browse button, and the extended mobile keyboard exposes `πŸ“ Path` (insert the chosen file/folder path without Enter) plus `⌫ All` (clear only the current unsent prompt, never the agent's `/clear` command). The picker lazily lists one directory through `GET /api/filesystem/browse`, starts at the active session working directory or `/mnt/d`, hides dot entries, blocks sensitive trees and symlink escapes, and only traverses Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured with `CODEMAN_FILE_PICKER_ROOTS`. **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope β€” `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). diff --git a/src/types/common.ts b/src/types/common.ts index 67f9081f..3aadb251 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -9,6 +9,7 @@ * - CleanupRegistration / CleanupResourceType β€” entries for the centralized CleanupManager * - 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 */ /** @@ -68,6 +69,31 @@ export interface ProcessStats { updatedAt: number; } +/** A selectable entry returned by the filesystem path-picker API. */ +export interface FilesystemBrowseEntry { + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; + symlink?: boolean; +} + +/** A named root the path picker may browse without escaping its allowlist. */ +export interface FilesystemBrowseRoot { + label: string; + path: string; +} + +/** Response payload for `GET /api/filesystem/browse`. */ +export interface FilesystemBrowseData { + path: string; + parent: string | null; + root: string; + roots: FilesystemBrowseRoot[]; + entries: FilesystemBrowseEntry[]; + truncated: boolean; +} + export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream'; /** diff --git a/src/web/public/index.html b/src/web/public/index.html index b51c96d1..ffd1a846 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1962,8 +1962,11 @@

Add Case

- - Absolute path to an existing project folder, e.g. /home/you/my-project +
+ + +
+ Choose an existing folder from this computer or enter its absolute path
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 467a92ae..4cde64d8 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -1,7 +1,7 @@ /** * @fileoverview Mobile keyboard accessory bar and modal focus trap. * - * Defines two exports: + * Defines three exports: * * - KeyboardAccessoryBar (singleton object) β€” Quick action buttons shown above the virtual * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss. @@ -10,12 +10,15 @@ * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). + * - PathPicker (singleton object) β€” Lazy server-side file/folder browser shared + * by Link Existing and the extended mobile keyboard bar. * * - FocusTrap (class) β€” Traps Tab/Shift+Tab keyboard focus within a modal element. * Saves and restores previously focused element on deactivate. Used by Ralph wizard * and other modal dialogs. * * @globals {object} KeyboardAccessoryBar + * @globals {object} PathPicker * @globals {class} FocusTrap * * @dependency mobile-handlers.js (MobileDetection.isTouchDevice) @@ -26,6 +29,227 @@ // Codeman β€” Keyboard accessory bar and focus trap for modals // Loaded after mobile-handlers.js, before app.js +// ═══════════════════════════════════════════════════════════════ +// Shared Filesystem Path Picker +// ═══════════════════════════════════════════════════════════════ + +const PathPicker = { + overlay: null, + _options: null, + _selectedPath: '', + _previousFocus: null, + _keydownHandler: null, + _loadSequence: 0, + + /** + * Open the lazy filesystem browser. + * @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean, + * title?: string, onSelect: (path: string) => void}} options + */ + open(options) { + this.close(false); + this._options = options; + this._selectedPath = ''; + this._previousFocus = document.activeElement; + this._previousFocus?.blur?.(); + + const overlay = document.createElement('div'); + overlay.className = 'path-picker-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', options.title || 'Select a path'); + overlay.innerHTML = ` +
+
+ + +
+
+ + +
+
+ +
+ +
+
Loading...
+
+
+ Selected + None +
+
+ + + + +
+
`; + + this.overlay = overlay; + overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path'; + 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()); + overlay.querySelector('.path-picker-current-select').addEventListener('click', () => { + const current = overlay.querySelector('.path-picker-current').textContent; + if (current) this.select(current); + }); + overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load()); + overlay.querySelector('.path-picker-up').addEventListener('click', () => { + const parent = overlay.querySelector('.path-picker-up').dataset.parent; + if (parent) this.load(parent); + }); + overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value)); + overlay.addEventListener('click', (event) => { + if (event.target === overlay) this.close(true); + }); + this._keydownHandler = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + this.close(true); + } + }; + document.addEventListener('keydown', this._keydownHandler); + document.body.appendChild(overlay); + this.load(options.initialPath || ''); + }, + + async load(path) { + if (!this.overlay || !this._options) return; + const loadSequence = ++this._loadSequence; + const list = this.overlay.querySelector('.path-picker-list'); + const status = this.overlay.querySelector('.path-picker-status'); + list.replaceChildren(); + status.textContent = 'Loading...'; + + const params = new URLSearchParams(); + if (path) params.set('path', path); + if (this._options.sessionId) params.set('sessionId', this._options.sessionId); + try { + const response = await fetch(`/api/filesystem/browse?${params.toString()}`); + const result = await response.json(); + if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder'); + if (!this.overlay || loadSequence !== this._loadSequence) return; + this.render(result.data); + } catch (error) { + if (!this.overlay || loadSequence !== this._loadSequence) return; + if (path) { + this.load(''); + return; + } + status.textContent = error.message || 'Failed to browse this folder'; + status.classList.add('error'); + } + }, + + render(data) { + const rootSelect = this.overlay.querySelector('.path-picker-roots'); + rootSelect.replaceChildren(); + for (const root of data.roots) { + const option = document.createElement('option'); + option.value = root.path; + option.textContent = `${root.label} β€” ${root.path}`; + option.selected = data.path === root.path || data.root === root.path; + rootSelect.appendChild(option); + } + + this.overlay.querySelector('.path-picker-current').textContent = data.path; + const up = this.overlay.querySelector('.path-picker-up'); + up.dataset.parent = data.parent || ''; + up.disabled = !data.parent; + const status = this.overlay.querySelector('.path-picker-status'); + status.classList.remove('error'); + status.textContent = data.entries.length === 0 + ? 'This folder is empty' + : `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`; + + const list = this.overlay.querySelector('.path-picker-list'); + list.replaceChildren(); + for (const entry of data.entries) { + const row = document.createElement('div'); + row.className = 'path-picker-item'; + if (entry.type === 'file' && this._options.directoriesOnly) row.classList.add('not-selectable'); + row.dataset.path = entry.path; + row.dataset.type = entry.type; + row.setAttribute('role', 'option'); + + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'path-picker-item-main'; + const icon = document.createElement('span'); + icon.className = 'path-picker-item-icon'; + icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4'; + const name = document.createElement('span'); + name.className = 'path-picker-item-name'; + name.textContent = entry.name; + open.append(icon, name); + if (entry.symlink) { + const link = document.createElement('span'); + link.className = 'path-picker-item-link'; + link.textContent = '\u2197'; + open.appendChild(link); + } + if (entry.type === 'directory') { + const chevron = document.createElement('span'); + chevron.className = 'path-picker-item-chevron'; + chevron.textContent = '\u203A'; + open.appendChild(chevron); + open.addEventListener('click', () => this.load(entry.path)); + } else if (!this._options.directoriesOnly) { + open.addEventListener('click', () => this.select(entry.path)); + } else { + open.disabled = true; + } + row.appendChild(open); + + if (entry.type === 'directory' || !this._options.directoriesOnly) { + const choose = document.createElement('button'); + choose.type = 'button'; + choose.className = 'path-picker-item-select'; + choose.textContent = 'Choose'; + choose.addEventListener('click', () => this.select(entry.path)); + row.appendChild(choose); + } + list.appendChild(row); + } + }, + + select(path) { + if (!this.overlay) return; + this._selectedPath = path; + this.overlay.querySelector('.path-picker-selection-value').textContent = path; + this.overlay.querySelector('.path-picker-confirm').disabled = false; + this.overlay.querySelectorAll('.path-picker-item').forEach((row) => { + const selected = row.dataset.path === path; + row.classList.toggle('selected', selected); + row.setAttribute('aria-selected', selected ? 'true' : 'false'); + }); + }, + + confirm() { + if (!this._selectedPath || !this._options) return; + const selectedPath = this._selectedPath; + const onSelect = this._options.onSelect; + this.close(false); + onSelect(selectedPath); + }, + + close(restoreFocus = true) { + if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler); + this._keydownHandler = null; + this._loadSequence += 1; + this.overlay?.remove(); + this.overlay = null; + const previousFocus = this._previousFocus; + this._previousFocus = null; + this._options = null; + this._selectedPath = ''; + if (restoreFocus) previousFocus?.focus?.(); + }, +}; + // ═══════════════════════════════════════════════════════════════ // Mobile Keyboard Accessory Bar // ═══════════════════════════════════════════════════════════════ @@ -92,6 +316,8 @@ const KeyboardAccessoryBar = { + + @@ -128,7 +354,7 @@ const KeyboardAccessoryBar = { this.handleAction(action, btn); // Refocus terminal so keyboard stays open (tap blurs terminal β†’ keyboard dismisses β†’ toolbar shifts) - const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']); + const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']); if (refocusActions.has(action) || ((action === 'clear' || action === 'compact') && this._confirmAction)) { if (typeof app !== 'undefined' && app.terminal) { @@ -207,6 +433,12 @@ const KeyboardAccessoryBar = { case 'paste': this.pasteFromClipboard(); break; + case 'pick-path': + this.pickPath(); + break; + case 'clear-input': + app.clearTerminalInput?.(); + break; case 'dismiss': // Blur active element to dismiss keyboard document.activeElement?.blur(); @@ -265,6 +497,22 @@ const KeyboardAccessoryBar = { }).catch(() => {}); }, + /** Browse the active session's workspace and insert a selected path without Enter. */ + pickPath() { + if (!app.activeSessionId) return; + const session = app.sessions?.get(app.activeSessionId); + PathPicker.open({ + title: 'Insert File or Folder Path', + sessionId: app.activeSessionId, + initialPath: session?.workingDir || '', + directoriesOnly: false, + onSelect: (path) => { + app.insertTerminalText?.(path); + setTimeout(() => app.terminal?.focus(), 100); + }, + }); + }, + /** Show a paste overlay for iOS compatibility. * Handles three input paths from one dialog: * - Text: long-press the textarea β†’ Paste β†’ Send (unchanged). diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index a3e01b71..44d57bee 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1863,6 +1863,25 @@ Object.assign(CodemanApp.prototype, { } }, + openLinkCasePathPicker() { + const pathInput = document.getElementById('linkCasePath'); + PathPicker.open({ + title: 'Select Existing Project Folder', + initialPath: pathInput.value.trim(), + directoriesOnly: true, + onSelect: (path) => { + pathInput.value = path; + const nameInput = document.getElementById('linkCaseName'); + if (!nameInput.value.trim()) { + const folderName = path.split('/').filter(Boolean).pop() || ''; + if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName; + } + pathInput.focus(); + pathInput.setSelectionRange(path.length, path.length); + }, + }); + }, + async linkRemoteCase() { const name = document.getElementById('remoteCaseName').value.trim(); const remotePath = document.getElementById('remoteCasePath').value.trim(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index a29a8606..e29149b8 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -10845,6 +10845,283 @@ body.touch-device.cjk-input-visible .main { font-weight: 600; } +/* Shared lazy filesystem path picker (case linking + mobile input). */ +.path-input-group { + display: flex; + gap: 8px; + width: 100%; +} + +.path-input-group input { + flex: 1 1 auto; + min-width: 0; +} + +.path-input-browse { + flex: 0 0 auto; + min-height: 38px; +} + +.path-picker-overlay { + position: fixed; + inset: 0; + z-index: 10020; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.path-picker-dialog { + display: flex; + flex-direction: column; + width: min(680px, 100%); + height: min(720px, calc(100dvh - 32px)); + overflow: hidden; + color: var(--text); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); +} + +.path-picker-header, +.path-picker-nav, +.path-picker-roots-row, +.path-picker-selection, +.path-picker-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.path-picker-header { + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.path-picker-title { + font-size: 0.95rem; +} + +.path-picker-close { + width: 36px; + height: 36px; + color: var(--text-muted); + font-size: 1.5rem; + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; +} + +.path-picker-roots-row { + padding: 10px 12px 0; + color: var(--text-muted); + font-size: 0.75rem; +} + +.path-picker-roots { + flex: 1; + min-width: 0; + padding: 8px 10px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; +} + +.path-picker-nav { + padding: 10px 12px; +} + +.path-picker-up, +.path-picker-refresh { + flex: 0 0 38px; + height: 38px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} + +.path-picker-up:disabled { + opacity: 0.35; + cursor: default; +} + +.path-picker-current { + flex: 1; + min-width: 0; + padding: 9px 11px; + overflow-x: auto; + color: var(--accent); + font-family: var(--font-mono, monospace); + font-size: 0.75rem; + white-space: nowrap; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; +} + +.path-picker-status { + padding: 0 14px 8px; + color: var(--text-dim); + font-size: 0.7rem; +} + +.path-picker-status.error { + color: var(--danger, #ef4444); +} + +.path-picker-list { + flex: 1; + min-height: 140px; + overflow-y: auto; + padding: 0 10px 10px; +} + +.path-picker-item { + display: flex; + align-items: stretch; + margin: 2px 0; + border: 1px solid transparent; + border-radius: 9px; +} + +.path-picker-item:hover, +.path-picker-item.selected { + background: var(--bg-hover); + border-color: var(--border); +} + +.path-picker-item.selected { + border-color: var(--accent); +} + +.path-picker-item.not-selectable { + opacity: 0.62; +} + +.path-picker-item-main { + display: flex; + flex: 1; + align-items: center; + min-width: 0; + padding: 10px 8px; + color: var(--text); + text-align: left; + background: transparent; + border: 0; + cursor: pointer; +} + +.path-picker-item-name { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 0.8rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.path-picker-item-icon { + margin-right: 8px; +} + +.path-picker-item-link, +.path-picker-item-chevron { + margin-left: 8px; + color: var(--text-dim); +} + +.path-picker-item-select { + align-self: center; + margin-right: 6px; + padding: 6px 9px; + color: var(--accent); + background: transparent; + border: 1px solid var(--border); + border-radius: 7px; + cursor: pointer; +} + +.path-picker-selection { + padding: 9px 14px; + color: var(--text-muted); + font-size: 0.72rem; + border-top: 1px solid var(--border); +} + +.path-picker-selection-value { + min-width: 0; + overflow: hidden; + color: var(--text); + font-family: var(--font-mono, monospace); + text-overflow: ellipsis; + white-space: nowrap; +} + +.path-picker-actions { + padding: 10px 12px 12px; +} + +.path-picker-actions button { + min-height: 40px; + padding: 8px 13px; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; +} + +.path-picker-action-spacer { + flex: 1; +} + +.path-picker-actions .path-picker-confirm { + color: #fff; + background: var(--accent); + border-color: var(--accent); + font-weight: 600; +} + +.path-picker-actions .path-picker-confirm:disabled { + opacity: 0.4; + cursor: default; +} + +@media (max-width: 600px) { + .path-picker-overlay { + align-items: flex-end; + padding: 0; + } + + .path-picker-dialog { + width: 100%; + height: min(82dvh, 720px); + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 16px 16px 0 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + } + + .path-picker-actions { + flex-wrap: wrap; + } + + .path-picker-current-select { + flex: 1 1 100%; + } +} + /* ═══════════════════════════════════════════════════════════════ Orchestrator Panel ═══════════════════════════════════════════════════════════════ */ diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index f309a31b..b5faac7f 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2418,6 +2418,50 @@ Object.assign(CodemanApp.prototype, { this.terminal.clear(); }, + /** Insert editable text at the active prompt without pressing Enter. */ + insertTerminalText(text) { + if (!this.activeSessionId || !text) return; + if (this._localEchoEnabled && this._localEchoOverlay) { + this._localEchoOverlay.appendText(text); + } else { + this.sendInput(text).catch(() => {}); + } + this.terminal?.focus(); + }, + + /** + * Clear only the current editable prompt. This is intentionally distinct + * from Ctrl+L (clear display) and the agent's destructive `/clear` command. + */ + clearTerminalInput() { + if (!this.activeSessionId) return; + + if (typeof CjkInput !== 'undefined') CjkInput.clear(); + if (this._inputFlushTimeout) { + clearTimeout(this._inputFlushTimeout); + this._inputFlushTimeout = null; + } + this._pendingInput = ''; + + if (this._localEchoEnabled && this._localEchoOverlay) { + const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' }; + this._localEchoOverlay.clear(); + this._localEchoOverlay.suppressBufferDetection(); + this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); + if (flushed.count > 0) { + this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {}); + } + } else { + // In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U + // is the conventional kill-line key supported by shells and agent TUIs. + this.sendInput('\x15').catch(() => {}); + } + + this.showToast?.('Input cleared', 'success'); + this.terminal?.focus(); + }, + /** * Restore terminal size to match web UI dimensions. * Use this after mobile screen attachment has squeezed the terminal. diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 8d34e096..07b88665 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -4,9 +4,11 @@ */ import { FastifyInstance, type FastifyReply } from 'fastify'; -import { basename as pathBasename, join } from 'node:path'; +import { basename as pathBasename, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createReadStream, realpathSync, type ReadStream } from 'node:fs'; import fs from 'node:fs/promises'; +import { homedir } from 'node:os'; +import type { ApiResponse, FilesystemBrowseData, FilesystemBrowseEntry, FilesystemBrowseRoot } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; import { @@ -22,12 +24,20 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; -import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js'; +import { + CASES_DIR, + canAccessOwned, + findSessionOrFail, + getAuthUser, + parseBody, + validateSessionFilePath, +} from '../route-helpers.js'; import type { FastifyRequest } from 'fastify'; import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js'; import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; +import { FilesystemBrowseQuerySchema } from '../schemas.js'; const MIME_TYPES: Record = { png: 'image/png', @@ -260,6 +270,75 @@ type AttachmentHistoryRouteItem = Omit. Probe a child path as well so the directory itself cannot be + // opened and used to enumerate those filenames. + return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees); +} + +function configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> { + const candidates: Array<{ label: string; path: string }> = [ + { label: 'Home', path: homedir() }, + { label: 'Codeman Cases', path: CASES_DIR }, + { label: 'WSL D:', path: '/mnt/d' }, + ]; + const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS; + if (extraRoots) { + for (const [index, path] of extraRoots + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .entries()) { + candidates.push({ label: `Configured ${index + 1}`, path }); + } + } + return candidates; +} + +async function resolveFilesystemPickerRoots( + ctx: SessionPort & ConfigPort, + sessionId?: string +): Promise { + const candidates = configuredFilesystemPickerRoots(); + if (sessionId) { + const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId); + if (!session) { + throw Object.assign(new Error(`Session ${sessionId} not found`), { + statusCode: 404, + body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), + }); + } + candidates.unshift({ label: 'Current Folder', path: session.workingDir }); + } + + const guard = await loadAttachmentGuardConfig(); + const roots: FilesystemBrowseRoot[] = []; + const seen = new Set(); + for (const candidate of candidates) { + if (!isAbsolute(candidate.path)) continue; + try { + const resolved = realpathSync(candidate.path); + if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue; + const stat = await fs.stat(resolved); + if (!stat.isDirectory()) continue; + seen.add(resolved); + roots.push({ label: candidate.label, path: resolved }); + } catch { + // Optional roots (for example /mnt/d on non-WSL hosts) are omitted. + } + } + return roots; +} + function appendDownloadFlag(url: string): string { return `${url}${url.includes('?') ? '&' : '?'}download=true`; } @@ -375,6 +454,141 @@ async function buildExternalAttachmentRouteItem( } export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void { + // Lazy filesystem listing for the Link Existing and mobile input path pickers. + app.get('/api/filesystem/browse', async (req, reply): Promise> => { + const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query); + const roots = await resolveFilesystemPickerRoots(ctx, sessionId); + if (roots.length === 0) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available'); + } + + const fallbackRoot = + roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0]; + const candidatePath = resolve(requestedPath ?? fallbackRoot.path); + + let resolvedPath: string; + try { + resolvedPath = realpathSync(candidatePath); + } catch { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`); + } + + const matchingRoots = roots + .filter((root) => isPathWithinRoot(root.path, resolvedPath)) + .sort((a, b) => b.path.length - a.path.length); + const matchingRoot = matchingRoots[0]; + if (!matchingRoot) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots'); + } + + const guard = await loadAttachmentGuardConfig(); + if (isBlockedPickerPath(resolvedPath, guard.blockedTrees, true)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked'); + } + + try { + const stat = await fs.stat(resolvedPath); + if (!stat.isDirectory()) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory'); + } + } catch { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`); + } + + let dirEntries; + try { + dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true }); + } catch { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read'); + } + + dirEntries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1; + if (!a.isDirectory() && b.isDirectory()) return 1; + return a.name.localeCompare(b.name); + }); + + const entries: FilesystemBrowseEntry[] = []; + let truncated = false; + for (const entry of dirEntries) { + if (entry.name.startsWith('.')) continue; + if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) { + truncated = true; + break; + } + + const visiblePath = join(candidatePath, entry.name); + let targetPath: string; + try { + targetPath = realpathSync(visiblePath); + } catch { + continue; + } + + const targetRoot = roots.some((root) => isPathWithinRoot(root.path, targetPath)); + if (!targetRoot) continue; + + let type: FilesystemBrowseEntry['type']; + let size: number | undefined; + const symlink = entry.isSymbolicLink(); + if (entry.isDirectory()) { + type = 'directory'; + } else if (entry.isFile()) { + type = 'file'; + } else if (symlink) { + try { + const targetStat = await fs.stat(targetPath); + type = targetStat.isDirectory() ? 'directory' : 'file'; + if (type === 'file') size = targetStat.size; + } catch { + continue; + } + } else { + continue; + } + + if (isBlockedPickerPath(targetPath, guard.blockedTrees, type === 'directory')) continue; + if (type === 'file' && size === undefined) { + try { + size = (await fs.stat(targetPath)).size; + } catch { + // The path is still selectable even when a size lookup races a change. + } + } + entries.push({ name: entry.name, path: visiblePath, type, size, symlink: symlink || undefined }); + } + + const parentCandidate = resolve(candidatePath, '..'); + let parent: string | null = null; + if (candidatePath !== matchingRoot.path) { + try { + const resolvedParent = realpathSync(parentCandidate); + if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate; + } catch { + // A concurrently removed parent simply disables upward navigation. + } + } + + return { + success: true, + data: { + path: candidatePath, + parent, + root: matchingRoot.path, + roots, + entries, + truncated, + }, + }; + }); + // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index cbbcf6d2..af704547 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -49,6 +49,29 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, { message: 'Invalid path: must be absolute, no shell metacharacters or traversal', }); +/** + * Filesystem picker paths are never interpolated into a shell command, so legal + * filename characters such as spaces, quotes, and parentheses are accepted. + * Containment and symlink resolution are enforced by the route after parsing. + */ +const filesystemPickerPathSchema = z + .string() + .max(4096) + .refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), { + message: 'Path must be an absolute filesystem path', + }) + .refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' }); + +/** Query validation for the lazy, allowlisted filesystem path picker. */ +export const FilesystemBrowseQuerySchema = z.object({ + path: filesystemPickerPathSchema.optional(), + sessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id') + .optional(), +}); + // ========== Env Var Allowlist ========== /** Allowlisted env var key prefixes */ diff --git a/test/path-picker-ui.test.ts b/test/path-picker-ui.test.ts new file mode 100644 index 00000000..93e3fee7 --- /dev/null +++ b/test/path-picker-ui.test.ts @@ -0,0 +1,180 @@ +/** + * @fileoverview Fast VM/static regressions for the shared filesystem picker and + * extended mobile keyboard actions. No browser or real server required. + */ + +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const keyboardSource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8'); +const terminalSource = readFileSync(resolve('src/web/public/terminal-ui.js'), 'utf8'); +const sessionSource = readFileSync(resolve('src/web/public/session-ui.js'), 'utf8'); +const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8'); + +function loadTerminalMixin() { + const FakeCodemanApp = function () {} as unknown as { prototype: Record unknown> }; + const cjkClear = vi.fn(); + const context = vm.createContext({ + console, + performance, + setTimeout, + clearTimeout, + setInterval: vi.fn(), + clearInterval: vi.fn(), + requestAnimationFrame: vi.fn(), + CodemanApp: FakeCodemanApp, + CjkInput: { clear: cjkClear }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + document: { addEventListener: vi.fn() }, + }); + vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' }); + return { mixin: FakeCodemanApp.prototype, cjkClear }; +} + +const terminalHarness = loadTerminalMixin(); + +function loadKeyboardModule() { + const app = { + activeSessionId: 'session-1', + sessions: new Map([['session-1', { workingDir: '/mnt/d/AI' }]]), + terminal: { focus: vi.fn() }, + clearTerminalInput: vi.fn(), + insertTerminalText: vi.fn(), + sendInput: vi.fn(), + }; + const context = vm.createContext({ + app, + MobileDetection: { isTouchDevice: () => false }, + URLSearchParams, + fetch: vi.fn(), + document: {}, + setTimeout: (fn: () => void) => { + fn(); + return 1; + }, + clearTimeout: vi.fn(), + }); + vm.runInContext( + `${keyboardSource}\nglobalThis.__bar = KeyboardAccessoryBar; globalThis.__picker = PathPicker;`, + context + ); + return { + app, + bar: (context as unknown as { __bar: { handleAction(action: string): void } }).__bar, + picker: (context as unknown as { __picker: { open: ReturnType } }).__picker, + }; +} + +describe('mobile filesystem picker actions', () => { + it('keeps clear-input separate from the destructive /clear command', () => { + const { app, bar } = loadKeyboardModule(); + bar.handleAction('clear-input'); + + expect(app.clearTerminalInput).toHaveBeenCalledOnce(); + expect(app.sendInput).not.toHaveBeenCalled(); + expect(keyboardSource).toContain('data-action="clear-input"'); + expect(keyboardSource).toContain('data-action="clear" title="/clear"'); + }); + + it('opens at the active working directory and inserts the selected path without Enter', () => { + const { app, bar, picker } = loadKeyboardModule(); + picker.open = vi.fn(); + + bar.handleAction('pick-path'); + + expect(picker.open).toHaveBeenCalledOnce(); + const options = picker.open.mock.calls[0][0]; + expect(options).toMatchObject({ + sessionId: 'session-1', + initialPath: '/mnt/d/AI', + directoriesOnly: false, + }); + options.onSelect('/mnt/d/AI/project/file.ts'); + expect(app.insertTerminalText).toHaveBeenCalledWith('/mnt/d/AI/project/file.ts'); + expect(app.sendInput).not.toHaveBeenCalled(); + }); + + it('wires Link Existing to the shared folder-only picker', () => { + expect(indexSource).toContain('onclick="app.openLinkCasePathPicker()"'); + expect(indexSource).toContain('id="linkCasePath"'); + expect(sessionSource).toContain('openLinkCasePathPicker()'); + expect(sessionSource).toContain('directoriesOnly: true'); + }); + + it('inserts a selected path into the editable local-echo prompt without sending it', () => { + const appendText = vi.fn(); + const sendInput = vi.fn(); + const focus = vi.fn(); + const app = { + activeSessionId: 'session-1', + _localEchoEnabled: true, + _localEchoOverlay: { appendText }, + terminal: { focus }, + sendInput, + }; + + terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project'); + + expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project'); + expect(sendInput).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalledOnce(); + }); + + it('clears pending and already-flushed prompt text without invoking /clear', () => { + const clear = vi.fn(); + const suppressBufferDetection = vi.fn(); + const sendInput = vi.fn(() => Promise.resolve()); + const showToast = vi.fn(); + const focus = vi.fn(); + const app = { + activeSessionId: 'session-1', + _inputFlushTimeout: null, + _pendingInput: 'pending text', + _localEchoEnabled: true, + _localEchoOverlay: { + getFlushed: () => ({ count: 4, text: 'sent' }), + clear, + suppressBufferDetection, + }, + _flushedOffsets: new Map([['session-1', 4]]), + _flushedTexts: new Map([['session-1', 'sent']]), + sendInput, + showToast, + terminal: { focus }, + }; + + terminalHarness.mixin.clearTerminalInput.call(app); + + expect(app._pendingInput).toBe(''); + expect(clear).toHaveBeenCalledOnce(); + expect(suppressBufferDetection).toHaveBeenCalledOnce(); + expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4)); + expect(sendInput).not.toHaveBeenCalledWith('/clear'); + expect(app._flushedOffsets.size).toBe(0); + expect(app._flushedTexts.size).toBe(0); + expect(showToast).toHaveBeenCalledWith('Input cleared', 'success'); + expect(focus).toHaveBeenCalledOnce(); + expect(terminalHarness.cjkClear).toHaveBeenCalled(); + }); + + it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => { + const sendInput = vi.fn(() => Promise.resolve()); + const app = { + activeSessionId: 'session-1', + _inputFlushTimeout: null, + _pendingInput: '', + _localEchoEnabled: false, + _localEchoOverlay: null, + sendInput, + showToast: vi.fn(), + terminal: { focus: vi.fn() }, + }; + + terminalHarness.mixin.clearTerminalInput.call(app); + + expect(sendInput).toHaveBeenCalledWith('\x15'); + }); +}); diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts index bec8ab04..fb725145 100644 --- a/test/routes/file-routes.test.ts +++ b/test/routes/file-routes.test.ts @@ -8,13 +8,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; +import { ApiErrorCode } from '../../src/types.js'; // Mock fs/promises for file operations vi.mock('node:fs/promises', () => ({ default: { readdir: vi.fn(async () => []), readFile: vi.fn(async () => 'file content'), - stat: vi.fn(async () => ({ size: 100, isFile: () => true })), + stat: vi.fn(async () => ({ size: 100, isFile: () => true, isDirectory: () => true })), }, })); @@ -55,13 +56,99 @@ describe('file-routes', () => { // Default: realpathSync returns the path unchanged mockedRealpathSync.mockImplementation((p: string) => p as never); // Default stat - mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never); + mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never); }); afterEach(async () => { await harness.app.close(); }); + // ========== GET /api/filesystem/browse ========== + + describe('GET /api/filesystem/browse', () => { + it('lists the active session folder lazily with directories first', async () => { + mockedReaddir.mockResolvedValueOnce([ + { + name: 'notes.txt', + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, + { + name: 'src', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + ] as never); + + const path = harness.ctx._session.workingDir; + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.path).toBe(path); + expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path }); + expect(body.data.entries.map((entry: { name: string; type: string }) => [entry.name, entry.type])).toEqual([ + ['src', 'directory'], + ['notes.txt', 'file'], + ]); + }); + + it('rejects paths outside the configured roots', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/browse?path=${encodeURIComponent('/tmp/not-an-allowed-root')}`, + }); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT }); + }); + + it('does not expose hidden entries or symlinks that escape the allowed roots', async () => { + const root = harness.ctx._session.workingDir; + mockedReaddir.mockResolvedValueOnce([ + { + name: '.secret', + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, + { + name: 'outside-link', + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => true, + }, + ] as never); + mockedRealpathSync.mockImplementation((path: string) => + path === `${root}/outside-link` ? ('/etc/shadow' as never) : (path as never) + ); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}`, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).data.entries).toEqual([]); + }); + + it('returns 404 for an unknown session scope', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/filesystem/browse?sessionId=missing-session', + }); + + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND }); + }); + }); + // ========== GET /api/sessions/:id/files ========== describe('GET /api/sessions/:id/files', () => { From f812f65a33300647feb59f8efb33d66dda25bc11 Mon Sep 17 00:00:00 2001 From: codeman-local Date: Mon, 20 Jul 2026 19:50:20 +0800 Subject: [PATCH 2/2] feat(files): preview picker documents and images --- .changeset/mobile-filesystem-path-picker.md | 5 +- CLAUDE.md | 2 + src/types/common.ts | 3 + src/web/public/keyboard-accessory.js | 115 ++++++++++- src/web/public/styles.css | 157 ++++++++++++++- src/web/routes/file-routes.ts | 204 ++++++++++++++++---- src/web/schemas.ts | 10 + test/path-picker-ui.test.ts | 9 + test/routes/file-routes.test.ts | 96 ++++++++- 9 files changed, 552 insertions(+), 49 deletions(-) diff --git a/.changeset/mobile-filesystem-path-picker.md b/.changeset/mobile-filesystem-path-picker.md index 80378df6..9427c9ad 100644 --- a/.changeset/mobile-filesystem-path-picker.md +++ b/.changeset/mobile-filesystem-path-picker.md @@ -5,5 +5,6 @@ feat(mobile): browse and insert local file and folder paths Add a root-confined filesystem picker to Link Existing and the extended mobile -keyboard bar. Selected paths remain editable at the active prompt, and a new -one-tap action clears only the current unsent input without invoking `/clear`. +keyboard bar. Selected paths remain editable at the active prompt, supported +images/documents/text files open in a safe inline preview, and a new one-tap +action clears only the current unsent input without invoking `/clear`. diff --git a/CLAUDE.md b/CLAUDE.md index 8c0e50e7..71d68557 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,8 +262,10 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### API Routes ~191 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` β†’ spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (15, incl. root-confined `GET /api/filesystem/browse`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~192 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` β†’ spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (16, incl. root-confined `GET /api/filesystem/browse` and `GET /api/filesystem/preview`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. **Filesystem path picker**: Link Existing exposes a Browse button, and the extended mobile keyboard exposes `πŸ“ Path` (insert the chosen file/folder path without Enter) plus `⌫ All` (clear only the current unsent prompt, never the agent's `/clear` command). The picker lazily lists one directory through `GET /api/filesystem/browse`, starts at the active session working directory or `/mnt/d`, hides dot entries, blocks sensitive trees and symlink escapes, and only traverses Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured with `CODEMAN_FILE_PICKER_ROOTS`. +For supported files, tapping the file opens the responsive preview layer while `Choose` remains the separate path-selection action: `GET /api/filesystem/preview` serves images/PDF inline, converts DOCX/PPTX through the shared conversion cache/limiter, and returns Markdown/TXT/JSON as inert `text/plain`; text is capped at 2MB and binary/document previews at 50MB. **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope β€” `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). diff --git a/src/types/common.ts b/src/types/common.ts index 3aadb251..5f55f074 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -70,12 +70,15 @@ export interface ProcessStats { } /** A selectable entry returned by the filesystem path-picker API. */ +export type FilesystemPreviewKind = 'image' | 'text' | 'document'; + export interface FilesystemBrowseEntry { name: string; path: string; type: 'file' | 'directory'; size?: number; symlink?: boolean; + previewKind?: FilesystemPreviewKind; } /** A named root the path picker may browse without escaping its allowlist. */ diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 4cde64d8..68e08fd2 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -40,6 +40,9 @@ const PathPicker = { _previousFocus: null, _keydownHandler: null, _loadSequence: 0, + _previewOverlay: null, + _previewRequestSequence: 0, + _previewPreviousFocus: null, /** * Open the lazy filesystem browser. @@ -108,7 +111,8 @@ const PathPicker = { this._keydownHandler = (event) => { if (event.key === 'Escape') { event.preventDefault(); - this.close(true); + if (this._previewOverlay) this.closePreview(true); + else this.close(true); } }; document.addEventListener('keydown', this._keydownHandler); @@ -170,7 +174,9 @@ const PathPicker = { for (const entry of data.entries) { const row = document.createElement('div'); row.className = 'path-picker-item'; - if (entry.type === 'file' && this._options.directoriesOnly) row.classList.add('not-selectable'); + if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) { + row.classList.add('not-selectable'); + } row.dataset.path = entry.path; row.dataset.type = entry.type; row.setAttribute('role', 'option'); @@ -197,6 +203,14 @@ const PathPicker = { chevron.textContent = '\u203A'; open.appendChild(chevron); open.addEventListener('click', () => this.load(entry.path)); + } else if (entry.previewKind) { + const preview = document.createElement('span'); + preview.className = 'path-picker-item-preview'; + preview.textContent = '\uD83D\uDC41'; + open.appendChild(preview); + open.title = `Preview ${entry.name}`; + open.setAttribute('aria-label', `Preview ${entry.name}`); + open.addEventListener('click', () => this.openPreview(entry)); } else if (!this._options.directoriesOnly) { open.addEventListener('click', () => this.select(entry.path)); } else { @@ -228,6 +242,102 @@ const PathPicker = { }); }, + openPreview(entry) { + this.closePreview(false); + this._previewPreviousFocus = document.activeElement; + const requestSequence = ++this._previewRequestSequence; + const params = new URLSearchParams({ path: entry.path }); + if (this._options?.sessionId) params.set('sessionId', this._options.sessionId); + const previewUrl = `/api/filesystem/preview?${params.toString()}`; + + const overlay = document.createElement('div'); + overlay.className = 'path-preview-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', `Preview ${entry.name}`); + overlay.innerHTML = ` +
+
+
+ + +
+ Open + +
+
Loading preview...
+
`; + overlay.querySelector('.path-preview-title').textContent = entry.name; + overlay.querySelector('.path-preview-path').textContent = entry.path; + overlay.querySelector('.path-preview-open').href = previewUrl; + overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true)); + overlay.addEventListener('click', (event) => { + if (event.target === overlay) this.closePreview(true); + }); + document.body.appendChild(overlay); + this._previewOverlay = overlay; + + const body = overlay.querySelector('.path-preview-body'); + if (entry.previewKind === 'image') { + const image = document.createElement('img'); + image.className = 'path-preview-image'; + image.alt = entry.name; + image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove()); + image.addEventListener('error', () => this.showPreviewError('Image preview failed to load')); + image.src = previewUrl; + body.appendChild(image); + } else if (entry.previewKind === 'text') { + fetch(previewUrl) + .then(async (response) => { + const content = await response.text(); + if (!response.ok) { + let message = 'Text preview failed to load'; + try { + message = JSON.parse(content).error || message; + } catch {} + throw new Error(message); + } + return content; + }) + .then((content) => { + if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return; + const pre = document.createElement('pre'); + pre.className = 'path-preview-text'; + pre.textContent = content; + body.replaceChildren(pre); + }) + .catch((error) => { + if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message); + }); + } else { + const frame = document.createElement('iframe'); + frame.className = 'path-preview-frame'; + frame.title = entry.name; + frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove()); + frame.src = previewUrl; + body.appendChild(frame); + } + overlay.querySelector('.path-preview-close').focus(); + }, + + showPreviewError(message) { + const body = this._previewOverlay?.querySelector('.path-preview-body'); + if (!body) return; + const error = document.createElement('div'); + error.className = 'path-preview-error'; + error.textContent = message || 'Preview failed to load'; + body.replaceChildren(error); + }, + + closePreview(restoreFocus = true) { + this._previewRequestSequence += 1; + this._previewOverlay?.remove(); + this._previewOverlay = null; + const previousFocus = this._previewPreviousFocus; + this._previewPreviousFocus = null; + if (restoreFocus) previousFocus?.focus?.(); + }, + confirm() { if (!this._selectedPath || !this._options) return; const selectedPath = this._selectedPath; @@ -240,6 +350,7 @@ const PathPicker = { if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; this._loadSequence += 1; + this.closePreview(false); this.overlay?.remove(); this.overlay = null; const previousFocus = this._previousFocus; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e29149b8..843b4c79 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -11035,11 +11035,16 @@ body.touch-device.cjk-input-visible .main { } .path-picker-item-link, -.path-picker-item-chevron { +.path-picker-item-chevron, +.path-picker-item-preview { margin-left: 8px; color: var(--text-dim); } +.path-picker-item-preview { + font-size: 0.9rem; +} + .path-picker-item-select { align-self: center; margin-right: 6px; @@ -11097,6 +11102,139 @@ body.touch-device.cjk-input-visible .main { cursor: default; } +.path-preview-overlay { + position: fixed; + inset: 0; + z-index: 10030; + display: flex; + align-items: center; + justify-content: center; + padding: 18px; + background: rgba(0, 0, 0, 0.82); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); +} + +.path-preview-dialog { + display: flex; + flex-direction: column; + width: min(1000px, 100%); + height: min(860px, calc(100dvh - 36px)); + overflow: hidden; + color: var(--text); + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 24px 72px rgba(0, 0, 0, 0.62); +} + +.path-preview-header { + display: flex; + align-items: center; + gap: 10px; + padding: 11px 13px; + border-bottom: 1px solid var(--border); +} + +.path-preview-heading { + flex: 1; + min-width: 0; +} + +.path-preview-title, +.path-preview-path { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.path-preview-title { + font-size: 0.9rem; +} + +.path-preview-path { + margin-top: 2px; + color: var(--text-dim); + font-family: var(--font-mono, monospace); + font-size: 0.65rem; +} + +.path-preview-open, +.path-preview-close { + flex: 0 0 auto; + color: var(--text); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; +} + +.path-preview-open { + padding: 8px 11px; + color: var(--accent); + font-size: 0.75rem; + text-decoration: none; +} + +.path-preview-close { + width: 38px; + height: 38px; + font-size: 1.5rem; + cursor: pointer; +} + +.path-preview-body { + position: relative; + display: flex; + flex: 1; + min-height: 0; + align-items: center; + justify-content: center; + overflow: auto; + background: #111; +} + +.path-preview-loading, +.path-preview-error { + padding: 24px; + color: var(--text-muted); + font-size: 0.8rem; + text-align: center; +} + +.path-preview-error { + color: var(--danger, #ef4444); +} + +.path-preview-image { + display: block; + max-width: 100%; + max-height: 100%; + margin: auto; + object-fit: contain; +} + +.path-preview-frame { + width: 100%; + height: 100%; + background: #fff; + border: 0; +} + +.path-preview-text { + width: 100%; + min-height: 100%; + margin: 0; + padding: 18px; + overflow: visible; + color: #e5e7eb; + font-family: var(--font-mono, monospace); + font-size: 0.78rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + @media (max-width: 600px) { .path-picker-overlay { align-items: flex-end; @@ -11120,6 +11258,23 @@ body.touch-device.cjk-input-visible .main { .path-picker-current-select { flex: 1 1 100%; } + + .path-preview-overlay { + align-items: stretch; + padding: 0; + } + + .path-preview-dialog { + width: 100%; + height: 100dvh; + border: 0; + border-radius: 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + } + + .path-preview-open { + padding: 8px; + } } /* ═══════════════════════════════════════════════════════════════ diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 07b88665..58a8fc9e 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -4,11 +4,17 @@ */ import { FastifyInstance, type FastifyReply } from 'fastify'; -import { basename as pathBasename, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createReadStream, realpathSync, type ReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import { homedir } from 'node:os'; -import type { ApiResponse, FilesystemBrowseData, FilesystemBrowseEntry, FilesystemBrowseRoot } from '../../types.js'; +import type { + ApiResponse, + FilesystemBrowseData, + FilesystemBrowseEntry, + FilesystemBrowseRoot, + FilesystemPreviewKind, +} from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; import { @@ -37,7 +43,7 @@ import type { SessionAttachmentHistoryItem, SessionState } from '../../types/ses import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; -import { FilesystemBrowseQuerySchema } from '../schemas.js'; +import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js'; const MIME_TYPES: Record = { png: 'image/png', @@ -55,8 +61,14 @@ const MIME_TYPES: Record = { txt: 'text/plain', }; -function sanitizeDownloadName(fileName: string): string { - return fileName.replace(/["\\\r\n]/g, '_'); +function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string { + const cleaned = fileName.replace(/["\\\r\n]/g, '_'); + const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file'; + const encoded = encodeURIComponent(cleaned).replace( + /['()*]/g, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}` + ); + return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`; } function sendRawStream(reply: FastifyReply, content: ReadStream): void { @@ -102,13 +114,12 @@ async function serveRawFile( return; } const content = createReadStream(resolvedPath); - const safeName = sanitizeDownloadName(fileName); if (download || extension === 'svg') { reply.header( 'Content-Type', extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream' ); - reply.header('Content-Disposition', `attachment; filename="${safeName}"`); + reply.header('Content-Disposition', buildContentDisposition('attachment', fileName)); reply.header('Content-Length', stat.size); reply.header('X-Content-Type-Options', 'nosniff'); sendRawStream(reply, content); @@ -116,7 +127,7 @@ async function serveRawFile( } reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream'); - reply.header('Content-Disposition', `inline; filename="${safeName}"`); + reply.header('Content-Disposition', buildContentDisposition('inline', fileName)); reply.header('Content-Length', stat.size); reply.header('X-Content-Type-Options', 'nosniff'); sendRawStream(reply, content); @@ -204,7 +215,10 @@ async function serveConvertedPreview( const content = await fs.readFile(previewPath); reply.header('Content-Type', 'application/pdf'); - reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`); + reply.header( + 'Content-Disposition', + buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension)) + ); reply.header('Cache-Control', 'no-cache'); reply.header('Content-Length', content.length); reply.header('X-Content-Type-Options', 'nosniff'); @@ -271,12 +285,36 @@ type AttachmentHistoryRouteItem = Omit isPathWithinRoot(root.path, candidate)) + .sort((a, b) => b.path.length - a.path.length)[0]; +} + +function containsHiddenPickerSegment(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.')); +} + +function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined { + const extension = extname(fileName).slice(1).toLowerCase(); + if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image'; + if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text'; + if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document'; + return undefined; +} + function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean { if (isBlockedAttachmentPath(path, blockedTrees)) return true; // The shared sensitive-path matcher describes file locations such as @@ -339,6 +377,54 @@ async function resolveFilesystemPickerRoots( return roots; } +type ResolvedFilesystemPickerPath = { + candidatePath: string; + resolvedPath: string; + roots: FilesystemBrowseRoot[]; + matchingRoot: FilesystemBrowseRoot; + blockedTrees: readonly string[]; +}; + +function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never { + throw Object.assign(new Error(message), { + statusCode, + body: createErrorResponse(code, message), + }); +} + +async function resolveFilesystemPickerPath( + ctx: SessionPort & ConfigPort, + requestedPath: string | undefined, + sessionId?: string +): Promise { + const roots = await resolveFilesystemPickerRoots(ctx, sessionId); + if (roots.length === 0) { + throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available'); + } + + const fallbackRoot = + roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0]; + const candidatePath = resolve(requestedPath ?? fallbackRoot.path); + + let resolvedPath: string; + try { + resolvedPath = realpathSync(candidatePath); + } catch { + throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`); + } + + const matchingRoot = findMatchingPickerRoot(roots, resolvedPath); + if (!matchingRoot) { + throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots'); + } + if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) { + throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker'); + } + + const guard = await loadAttachmentGuardConfig(); + return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees }; +} + function appendDownloadFlag(url: string): string { return `${url}${url.includes('?') ? '&' : '?'}download=true`; } @@ -457,35 +543,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // Lazy filesystem listing for the Link Existing and mobile input path pickers. app.get('/api/filesystem/browse', async (req, reply): Promise> => { const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query); - const roots = await resolveFilesystemPickerRoots(ctx, sessionId); - if (roots.length === 0) { - reply.code(403); - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available'); - } - - const fallbackRoot = - roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0]; - const candidatePath = resolve(requestedPath ?? fallbackRoot.path); - - let resolvedPath: string; - try { - resolvedPath = realpathSync(candidatePath); - } catch { - reply.code(404); - return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`); - } - - const matchingRoots = roots - .filter((root) => isPathWithinRoot(root.path, resolvedPath)) - .sort((a, b) => b.path.length - a.path.length); - const matchingRoot = matchingRoots[0]; - if (!matchingRoot) { - reply.code(403); - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots'); - } + const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + requestedPath, + sessionId + ); - const guard = await loadAttachmentGuardConfig(); - if (isBlockedPickerPath(resolvedPath, guard.blockedTrees, true)) { + if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) { reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked'); } @@ -532,8 +596,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even continue; } - const targetRoot = roots.some((root) => isPathWithinRoot(root.path, targetPath)); - if (!targetRoot) continue; + const targetRoot = findMatchingPickerRoot(roots, targetPath); + if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue; let type: FilesystemBrowseEntry['type']; let size: number | undefined; @@ -554,7 +618,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even continue; } - if (isBlockedPickerPath(targetPath, guard.blockedTrees, type === 'directory')) continue; + if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue; if (type === 'file' && size === undefined) { try { size = (await fs.stat(targetPath)).size; @@ -562,7 +626,14 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // The path is still selectable even when a size lookup races a change. } } - entries.push({ name: entry.name, path: visiblePath, type, size, symlink: symlink || undefined }); + entries.push({ + name: entry.name, + path: visiblePath, + type, + size, + symlink: symlink || undefined, + previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined, + }); } const parentCandidate = resolve(candidatePath, '..'); @@ -589,6 +660,57 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even }; }); + // Inline preview for files selected through the root-confined filesystem picker. + app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise => { + const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query); + const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + requestedPath, + sessionId + ); + 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, 'The preview path must be a file'); + } + + const fileName = pathBasename(candidatePath); + const extension = extname(fileName).slice(1).toLowerCase(); + const previewKind = getFilesystemPreviewKind(fileName); + if (!previewKind) { + throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed'); + } + const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT; + if (stat.size > sizeLimit) { + throwFilesystemPickerError( + 413, + ApiErrorCode.INVALID_INPUT, + `File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)` + ); + } + + reply.header('Cache-Control', 'no-cache'); + reply.header('X-Content-Type-Options', 'nosniff'); + if (previewKind === 'text') { + const content = await fs.readFile(resolvedPath, 'utf8'); + reply.type('text/plain; charset=utf-8').send(content); + return; + } + if (extension === 'docx' || extension === 'pptx') { + await serveConvertedPreview(reply, resolvedPath, fileName, extension); + return; + } + await serveRawFile(reply, resolvedPath, fileName, extension); + }); + // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index af704547..1eef7321 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -72,6 +72,16 @@ export const FilesystemBrowseQuerySchema = z.object({ .optional(), }); +/** Query validation for a single allowlisted path-picker file preview. */ +export const FilesystemPreviewQuerySchema = z.object({ + path: filesystemPickerPathSchema, + sessionId: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id') + .optional(), +}); + // ========== Env Var Allowlist ========== /** Allowlisted env var key prefixes */ diff --git a/test/path-picker-ui.test.ts b/test/path-picker-ui.test.ts index 93e3fee7..b64633e6 100644 --- a/test/path-picker-ui.test.ts +++ b/test/path-picker-ui.test.ts @@ -104,6 +104,15 @@ describe('mobile filesystem picker actions', () => { expect(sessionSource).toContain('directoriesOnly: true'); }); + it('keeps Choose separate from safe inline file preview', () => { + expect(keyboardSource).toContain('openPreview(entry)'); + expect(keyboardSource).toContain('/api/filesystem/preview?'); + expect(keyboardSource).toContain("entry.previewKind === 'image'"); + expect(keyboardSource).toContain("entry.previewKind === 'text'"); + expect(keyboardSource).toContain("choose.textContent = 'Choose'"); + expect(keyboardSource).toContain('pre.textContent = content'); + }); + it('inserts a selected path into the editable local-echo prompt without sending it', () => { const appendText = vi.fn(); const sendInput = vi.fn(); diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts index fb725145..c1e1cde8 100644 --- a/test/routes/file-routes.test.ts +++ b/test/routes/file-routes.test.ts @@ -57,6 +57,9 @@ describe('file-routes', () => { mockedRealpathSync.mockImplementation((p: string) => p as never); // Default stat mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never); + mockedReadFile.mockImplementation(async (path) => + String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' as never) + ); }); afterEach(async () => { @@ -93,9 +96,15 @@ describe('file-routes', () => { expect(body.success).toBe(true); expect(body.data.path).toBe(path); expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path }); - expect(body.data.entries.map((entry: { name: string; type: string }) => [entry.name, entry.type])).toEqual([ - ['src', 'directory'], - ['notes.txt', 'file'], + expect( + body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [ + entry.name, + entry.type, + entry.previewKind, + ]) + ).toEqual([ + ['src', 'directory', undefined], + ['notes.txt', 'file', 'text'], ]); }); @@ -147,6 +156,87 @@ describe('file-routes', () => { expect(res.statusCode).toBe(404); expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND }); }); + + it('rejects direct navigation into a hidden descendant', async () => { + const hidden = `${harness.ctx._session.workingDir}/.git`; + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}`, + }); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT }); + }); + }); + + // ========== GET /api/filesystem/preview ========== + + describe('GET /api/filesystem/preview', () => { + it('serves Markdown as inert plain text inside the active session root', async () => { + const path = `${harness.ctx._session.workingDir}/notes.md`; + mockedReadFile.mockImplementation(async (candidate) => + candidate === path ? ('# Safe heading\n' as never) : ('{}' as never) + ); + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('text/plain'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.body).toContain(''); + }); + + it('rejects unsupported file types', async () => { + const path = `${harness.ctx._session.workingDir}/archive.exe`; + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`, + }); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT }); + }); + + it('rejects hidden files even when requested directly', async () => { + const path = `${harness.ctx._session.workingDir}/.env`; + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`, + }); + + expect(res.statusCode).toBe(403); + }); + + it('rejects a preview symlink whose real path escapes every allowed root', async () => { + const path = `${harness.ctx._session.workingDir}/outside.png`; + mockedRealpathSync.mockImplementation((candidate: string) => + candidate === path ? ('/etc/shadow' as never) : (candidate as never) + ); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`, + }); + + expect(res.statusCode).toBe(403); + }); + + it('caps text previews at 2MB', async () => { + const path = `${harness.ctx._session.workingDir}/large.txt`; + mockedStat.mockImplementation(async (candidate) => + candidate === path + ? ({ size: 2 * 1024 * 1024 + 1, isFile: () => true, isDirectory: () => false } as never) + : ({ size: 100, isFile: () => true, isDirectory: () => true } as never) + ); + const res = await harness.app.inject({ + method: 'GET', + url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`, + }); + + expect(res.statusCode).toBe(413); + }); }); // ========== GET /api/sessions/:id/files ==========