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 { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + requestedPath, + sessionId + ); - 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)) { + 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 ==========