diff --git a/.changeset/mobile-filesystem-path-picker.md b/.changeset/mobile-filesystem-path-picker.md new file mode 100644 index 00000000..9427c9ad --- /dev/null +++ b/.changeset/mobile-filesystem-path-picker.md @@ -0,0 +1,10 @@ +--- +"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, 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 85e0f564..4c0c0d1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -211,6 +211,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. β†’ [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) +**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 does not inherit the attachment confinement** β€” it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. 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) + **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. β†’ [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) **Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. β†’ [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) @@ -281,7 +283,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### API Routes -~197 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (14), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~199 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. **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/docs/architecture-invariants.md b/docs/architecture-invariants.md index bdb6204c..c2af3081 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -74,6 +74,12 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` β†’ an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output β€” ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups _identical_ in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) β€” without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only β€” hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals. **Codex generated artifacts** (COD-166/#150, `generated-artifact-attachments.ts`): codex-mode sessions ALSO scan (ANSI-stripped) output for `Saved to: file:///…` lines and surface those files as attachment cards with a relaxed trust policy β€” the allow decision runs on the **realpath-resolved** path against `os.homedir()`-anchored `~/.codex` marker dirs (symlink escapes fall back to force-confinement); gated to `mode === 'codex'` only (`source` is a REQUIRED param through the listener-deps chain β€” a dropped arg here silently kills the feature). Image thumbnails pass through jpg/jpeg/gif/webp. +### 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 `/mnt/d`), 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. + ### Ultracode and workflow-run visualization **Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE β€” deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card β†’ its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF β€” independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) β€” same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / πŸ“„ transcripts) with a restore/dismiss dropdown β€” minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. diff --git a/src/types/common.ts b/src/types/common.ts index 67f9081f..5f55f074 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,34 @@ export interface ProcessStats { updatedAt: number; } +/** 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. */ +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 18e839ee..224280db 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2043,8 +2043,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..68e08fd2 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,338 @@ // 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, + _previewOverlay: null, + _previewRequestSequence: 0, + _previewPreviousFocus: null, + + /** + * 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(); + if (this._previewOverlay) this.closePreview(true); + else 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 && !entry.previewKind) { + 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 (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 { + 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'); + }); + }, + + 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; + 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.closePreview(false); + 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 +427,8 @@ const KeyboardAccessoryBar = { + + @@ -128,7 +465,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 +544,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 +608,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 75ef9513..d3cbec61 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1888,6 +1888,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 83f61194..67e2a847 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -11064,6 +11064,438 @@ 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, +.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; + 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; +} + +.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; + 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%; + } + + .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; + } +} + /* ═══════════════════════════════════════════════════════════════ Orchestrator Panel ═══════════════════════════════════════════════════════════════ */ diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 150a8fa2..3a9de819 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2506,6 +2506,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..58a8fc9e 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -4,9 +4,17 @@ */ import { FastifyInstance, type FastifyReply } from 'fastify'; -import { basename as pathBasename, join } 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, + FilesystemPreviewKind, +} from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; import { @@ -22,12 +30,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, FilesystemPreviewQuerySchema } from '../schemas.js'; const MIME_TYPES: Record = { png: 'image/png', @@ -45,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 { @@ -92,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); @@ -106,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); @@ -194,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'); @@ -260,6 +284,147 @@ 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 + // ~/.ssh/. 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; +} + +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`; } @@ -375,6 +540,177 @@ 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 { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + requestedPath, + sessionId + ); + + if (isBlockedPickerPath(resolvedPath, 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 = findMatchingPickerRoot(roots, targetPath); + if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) 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, 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, + previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : 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, + }, + }; + }); + + // 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 3ff916d1..a2034184 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -50,6 +50,39 @@ 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(), +}); + +/** 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 new file mode 100644 index 00000000..b64633e6 --- /dev/null +++ b/test/path-picker-ui.test.ts @@ -0,0 +1,189 @@ +/** + * @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('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(); + 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..c1e1cde8 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,189 @@ 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); + mockedReadFile.mockImplementation(async (path) => + String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' 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; previewKind?: string }) => [ + entry.name, + entry.type, + entry.previewKind, + ]) + ).toEqual([ + ['src', 'directory', undefined], + ['notes.txt', 'file', 'text'], + ]); + }); + + 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 }); + }); + + 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 ========== describe('GET /api/sessions/:id/files', () => {