/** * @fileoverview File browser and streaming routes. * Provides directory listing, file content preview, raw file serving, tail * streaming, and the File Viewer edit-mode write path (edit=1 read + * PUT /api/sessions/:id/file-content; policy in src/config/file-editing.ts, * design in docs/file-viewer-edit-plan.md). */ import { FastifyInstance, type FastifyReply } from 'fastify'; import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { createReadStream, realpathSync, type ReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; import type { ApiResponse, FilesystemBrowseData, FilesystemBrowseEntry, FilesystemBrowseRoot, FilesystemPreviewKind, FileWriteData, } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; import { AttachmentRegistrationError, attachmentRecordToEvent, attachmentRegistry, buildFileThumbnailRoute, isSupportedAttachmentExtension, registerExternalAttachment, type AttachmentRecord, } from '../../attachment-registry.js'; 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 { isMultiUserMode, userSpacePath } from '../../config/multiuser.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, FileWriteSchema } from '../schemas.js'; import { MAX_EDITABLE_BYTES, applyEol, detectEol, isDeniedEditRelativePath, isEditableFileName, } from '../../config/file-editing.js'; const MIME_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', json: 'application/json', md: 'text/markdown', txt: 'text/plain', }; 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 { const headers = reply.getHeaders(); reply.hijack(); for (const [name, value] of Object.entries(headers)) { if (value !== undefined) { reply.raw.setHeader(name, value); } } content.on('error', (err) => { if (reply.raw.headersSent) { reply.raw.destroy(err); return; } reply.raw.statusCode = 500; reply.raw.end('Failed to read file'); }); content.pipe(reply.raw); } async function serveRawFile( reply: FastifyReply, resolvedPath: string, fileName: string, extension: string, download?: boolean ): Promise { const stat = await fs.stat(resolvedPath); const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download if (stat.size > MAX_RAW_ATTACHMENT_SIZE) { reply .code(413) .send( createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)` ) ); return; } const content = createReadStream(resolvedPath); if (download || extension === 'svg') { reply.header( 'Content-Type', extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream' ); reply.header('Content-Disposition', buildContentDisposition('attachment', fileName)); reply.header('Content-Length', stat.size); reply.header('X-Content-Type-Options', 'nosniff'); sendRawStream(reply, content); return; } reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream'); reply.header('Content-Disposition', buildContentDisposition('inline', fileName)); reply.header('Content-Length', stat.size); reply.header('X-Content-Type-Options', 'nosniff'); sendRawStream(reply, content); } function getAttachmentOr404( reply: FastifyReply, sessionId: string, attachmentId: string ): AttachmentRecord | undefined { const record = attachmentRegistry.get(sessionId, attachmentId); if (!record) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found')); return undefined; } return record; } /** * COD-53 defense-in-depth: refuse to stream a record whose underlying path is * blocked by the active attachment-guard policy, even though registration * already blocks them. Guards against records that predate the guard or were * crafted to point at a sensitive file. Resolves symlinks before the check so a * record pointing at a symlink that now resolves to a sensitive target is also * caught; if the path can't be resolved (deleted/unreadable) the check still * runs on the stored path. When workspace confinement is enabled it additionally * rejects any record outside the session workspace. Returns true (and sends a * 403) when blocked. */ async function resolveServableAttachmentPath( reply: FastifyReply, record: AttachmentRecord, sessionWorkingDir?: string ): Promise { let pathToCheck = record.filePath; let resolved = false; try { pathToCheck = realpathSync(record.filePath); resolved = true; } catch { // Fall back to the stored (already realpath-resolved at registration) path. } const guard = await loadAttachmentGuardConfig(); const blocked = isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) || isBlockedAttachmentPath(record.filePath, guard.blockedTrees) || (guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck))); if (blocked) { reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked')); return null; } // Serve the freshly-resolved path, not the stored one: if a path component // became a symlink after registration, the guard checked the resolved target // but streaming record.filePath would follow the symlink to a swapped file. return resolved ? pathToCheck : record.filePath; } /** * Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and * stream it inline. PDF/PNG and text formats don't need conversion — callers * redirect those to the raw route instead. */ async function serveConvertedPreview( reply: FastifyReply, resolvedPath: string, fileName: string, extension: string ): Promise { if (extension !== 'docx' && extension !== 'pptx') { reply .code(400) .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type')); return; } try { const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension); if (!previewPath) { reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed')); return; } const content = await fs.readFile(previewPath); reply.header('Content-Type', 'application/pdf'); 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'); reply.send(content); } catch (err) { reply .code(500) .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`)); } } /** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */ async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise { const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension); if (!thumbnail) { reply.code(204).send(); return; } reply.header('Content-Type', thumbnail.contentType); reply.header('Cache-Control', 'no-cache'); reply.header('X-Content-Type-Options', 'nosniff'); reply.send(thumbnail.content); } /** * Resolve a session's working dir from the live session, falling back to the * persisted record so preview/thumbnail requests keep working for a session * that has since detached. Sends a 404 and returns undefined when unknown. */ function getKnownSessionWorkingDir( ctx: SessionPort & ConfigPort, sessionId: string, reply: FastifyReply, req: FastifyRequest ): string | undefined { // Multi-user: a non-admin may only reach their OWN session's files. A foreign // (or missing) session is reported identically as 404 so existence isn't leaked. const user = getAuthUser(req); const liveSession = ctx.sessions.get(sessionId); if (liveSession && canAccessOwned(user, liveSession.owner)) return liveSession.workingDir; const stored = ctx.store.getSession(sessionId); if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir; reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`)); return undefined; } // Persisted sessions carry the private (externalPath-bearing) history under a // `__attachmentHistory` key so the list route can re-register external files. type StoredSessionWithPrivateAttachmentHistory = SessionState & { __attachmentHistory?: SessionAttachmentHistoryItem[]; }; type AttachmentHistoryRouteItem = Omit & { missing: boolean; rawUrl?: string; url?: string; previewUrl?: string; thumbnailUrl?: string; downloadUrl?: string; attachmentId?: string; }; const FILESYSTEM_PICKER_ENTRY_LIMIT = 500; const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024; const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024; const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']); const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']); const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']); function isPathWithinRoot(root: string, candidate: string): boolean { const rel = relative(root, candidate); return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)); } function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined { return roots .filter((root) => isPathWithinRoot(root.path, candidate)) .sort((a, b) => b.path.length - a.path.length)[0]; } /** * Whether a path has a dot-prefixed segment anywhere below its browse root. * * Checked against the REALPATH, so a plainly-named symlink pointing into a * hidden tree is caught too. Callers skip it when the request opts into hidden * entries (`showHidden`), which is why the sensitive-path blocklist and the * blocked-tree checks must stand on their own: with the toggle on, this is no * longer the thing keeping `~/.config/gh/hosts.yml` out of reach. */ function containsHiddenPickerSegment(root: string, candidate: string): boolean { const rel = relative(root, candidate); return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.')); } /** Parses the picker's opt-in `showHidden` query flag (absent means off). */ function wantsHiddenPickerEntries(showHidden?: string): boolean { return showHidden === 'true'; } 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 extraConfiguredPickerRoots(): Array<{ label: string; path: string }> { const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS; if (!extraRoots) return []; return extraRoots .split(',') .map((value) => value.trim()) .filter(Boolean) .map((path, index) => ({ label: `Configured ${index + 1}`, path })); } /** * Browse roots for the requesting identity. * * Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular * multi-user user must NOT: per-user spaces live at `/`, * which is *inside* `homedir()`, so handing out a `Home` root would let any * authenticated user browse and preview every other user's workspace. The * shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount * that a multi-user deployment should not expose by default. Operators who * genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`, * which stays an explicit opt-in in both modes. */ function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> { const user = getAuthUser(req); if (isMultiUserMode() && user.role !== 'admin') { return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()]; } return [ { label: 'Home', path: homedir() }, { label: 'Codeman Cases', path: CASES_DIR }, { label: 'WSL D:', path: '/mnt/d' }, ...extraConfiguredPickerRoots(), ]; } async function resolveFilesystemPickerRoots( ctx: SessionPort & ConfigPort, req: FastifyRequest, sessionId?: string ): Promise { const candidates = configuredFilesystemPickerRoots(req); if (sessionId) { const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId); // ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for // the other session-scoped handlers in this file. Without it a multi-user // caller could pin ANOTHER user's `workingDir` as a browse root just by // passing their sessionId. Report not-found rather than forbidden so the // endpoint does not confirm that a session id exists. if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) { 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, req: FastifyRequest, requestedPath: string | undefined, sessionId?: string, showHidden = false ): Promise { const roots = await resolveFilesystemPickerRoots(ctx, req, 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 (!showHidden && 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`; } // ===== File Viewer edit mode (issue #212) ===== // Policy lives in src/config/file-editing.ts; design in docs/file-viewer-edit-plan.md. function sha256Hex(buf: Buffer): string { return createHash('sha256').update(buf).digest('hex'); } /** NUL byte in the first 8KB — same binary signal the plain read path uses. */ function sniffsBinary(buf: Buffer): boolean { const sniffLength = Math.min(buf.length, 8192); for (let i = 0; i < sniffLength; i++) { if (buf[i] === 0) return true; } return false; } /** * Structured-throw variant for the edit read/write paths. Identical mechanics to * throwFilesystemPickerError (rendered by the central route error handler both * in prod and in the app.inject() test harness); a separate name only so edit * failures grep distinctly. */ function throwFileEditError(statusCode: number, code: ApiErrorCode, message: string): never { throw Object.assign(new Error(message), { statusCode, body: createErrorResponse(code, message), }); } /** * Gate a resolved workspace file for edit-mode read/write. Throws a structured * error when the file may not be edited; returns void when it may. Order * matters for the message a user sees: confinement (the caller's 404) → * sensitive/blocked (403) → .git (403) → extension allowlist (400). */ function assertEditableTarget(resolvedPath: string, relativePath: string, blockedTrees: readonly string[]): void { if (isSensitivePath(resolvedPath) || isBlockedAttachmentPath(resolvedPath, blockedTrees)) { throwFileEditError(403, ApiErrorCode.FORBIDDEN, 'Editing this file is blocked'); } if (isDeniedEditRelativePath(relativePath)) { throwFileEditError(403, ApiErrorCode.FORBIDDEN, 'Files under .git cannot be edited'); } if (!isEditableFileName(pathBasename(resolvedPath))) { throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'This file type is not editable'); } } /** * Decode a candidate edit buffer, refusing binary and non-UTF-8 content. The * round-trip compare is what protects against silent corruption: decoding * latin-1 (or any non-UTF-8) bytes yields U+FFFD replacements, and writing * those back would destroy the original bytes. A UTF-8 BOM round-trips and is * deliberately preserved. */ function decodeEditableText(buf: Buffer): string { if (sniffsBinary(buf)) { throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Binary files cannot be edited'); } const text = buf.toString('utf8'); if (!Buffer.from(text, 'utf8').equals(buf)) { throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only UTF-8 text files can be edited'); } return text; } function getSessionAttachmentHistory( ctx: SessionPort & ConfigPort, sessionId: string, req: FastifyRequest ): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined { const user = getAuthUser(req); const liveSession = ctx.sessions.get(sessionId); if (liveSession) { if (!canAccessOwned(user, liveSession.owner)) return undefined; return { workingDir: liveSession.workingDir, history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [], }; } const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined; if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined; return { workingDir: stored.workingDir, history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [], }; } // History item for a file detected inside the workspace: re-stat for live // size/mtime and resolve preview/thumbnail/raw routes off the relative path. async function buildDetectedAttachmentRouteItem( sessionId: string, workingDir: string, item: SessionAttachmentHistoryItem ): Promise { const safe = sanitizeAttachmentHistoryItem(item); if (!item.relativePath) { return { ...safe, missing: true }; } const validated = validateSessionFilePath(workingDir, item.relativePath); if (!validated) { return { ...safe, missing: true }; } let size = item.size; let mtimeMs = item.mtimeMs; try { const stat = await fs.stat(validated.resolvedPath); size = stat.size; mtimeMs = stat.mtimeMs ?? mtimeMs; } catch { return { ...safe, missing: true }; } const encodedPath = encodeURIComponent(item.relativePath); const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`; const previewUrl = item.extension === 'docx' || item.extension === 'pptx' ? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}` : rawUrl; const thumbnailUrl = isSupportedAttachmentExtension(item.extension) ? buildFileThumbnailRoute(sessionId, item.relativePath) : undefined; return { ...safe, size, mtimeMs, missing: false, rawUrl, url: rawUrl, previewUrl, thumbnailUrl, downloadUrl: appendDownloadFlag(rawUrl), }; } // History item for an explicitly published external file: re-register it to mint // a fresh id + by-id routes (the guard runs again), or mark it missing. async function buildExternalAttachmentRouteItem( sessionId: string, item: SessionAttachmentHistoryItem, sessionWorkingDir?: string ): Promise { const safe = sanitizeAttachmentHistoryItem(item); if (!item.externalPath) { return { ...safe, missing: true }; } try { const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir }); return { ...safe, fileName: event.fileName, extension: event.extension, attachmentType: event.attachmentType, size: event.size, missing: false, attachmentId: event.attachmentId, rawUrl: event.rawUrl, url: event.rawUrl, previewUrl: event.previewUrl, thumbnailUrl: event.thumbnailUrl, downloadUrl: appendDownloadFlag(event.rawUrl), }; } catch (err) { if (err instanceof AttachmentRegistrationError) { return { ...safe, missing: true }; } throw err; } } /** * Headers Fastify already put on the reply, in a shape `writeHead` accepts. * * `reply.raw.writeHead()` writes straight to the Node response and bypasses * Fastify's header store, so anything the security `onRequest` hook granted — CORS * for localhost origins, nosniff, frame-options, CSP — is silently dropped on every * route that answers this way. Spread this first and let the route's own headers * win over it. */ function inheritedHeaders(reply: { getHeaders(): NodeJS.Dict; }): Record { const out: Record = {}; for (const [name, value] of Object.entries(reply.getHeaders())) { if (value !== undefined) out[name] = value; } return out; } 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, showHidden } = parseBody(FilesystemBrowseQuerySchema, req.query); const includeHidden = wantsHiddenPickerEntries(showHidden); const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath( ctx, req, requestedPath, sessionId, includeHidden ); if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) { reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked'); } 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 (!includeHidden && 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) continue; if (!includeHidden && 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, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query); const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( ctx, req, requestedPath, sessionId, wantsHiddenPickerEntries(showHidden) ); if (isBlockedPickerPath(resolvedPath, blockedTrees)) { throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'); } let stat; try { stat = await fs.stat(resolvedPath); } catch { throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`); } if (!stat.isFile()) { throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, '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 }; const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; const session = findSessionOrFail(ctx, id, req); const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; const workingDir = session.workingDir; // Default excludes - large/generated directories const excludeDirs = new Set([ '.git', 'node_modules', 'dist', 'build', '__pycache__', '.cache', '.next', '.nuxt', 'coverage', '.venv', 'venv', '.tox', 'target', 'vendor', ]); interface FileTreeNode { name: string; path: string; type: 'file' | 'directory'; size?: number; extension?: string; children?: FileTreeNode[]; } let totalFiles = 0; let totalDirectories = 0; let truncated = false; const maxFiles = 5000; const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { truncated = true; return []; } try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const nodes: FileTreeNode[] = []; // Sort: directories first, then alphabetically entries.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); for (const entry of entries) { if (totalFiles + totalDirectories > maxFiles) { truncated = true; break; } // Skip hidden files unless requested if (!includeHidden && entry.name.startsWith('.')) continue; // Skip excluded directories if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; const fullPath = join(dirPath, entry.name); const relativePath = fullPath.slice(workingDir.length + 1); if (entry.isDirectory()) { totalDirectories++; const children = await scanDirectory(fullPath, currentDepth + 1); nodes.push({ name: entry.name, path: relativePath, type: 'directory', children, }); } else { totalFiles++; const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; let size: number | undefined; try { const stat = await fs.stat(fullPath); size = stat.size; } catch { // Skip if can't stat } nodes.push({ name: entry.name, path: relativePath, type: 'file', size, extension: ext, }); } } return nodes; } catch { // Can't read directory (permission denied, etc.) return []; } }; const tree = await scanDirectory(workingDir, 1); return { success: true, data: { root: workingDir, tree, totalFiles, totalDirectories, truncated, }, }; }); // Get file content for preview (File Browser) app.get('/api/sessions/:id/file-content', async (req) => { const { id } = req.params as { id: string }; const { path: filePath, lines, raw, edit, } = req.query as { path?: string; lines?: string; raw?: string; edit?: string }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); } // Validate path is within working directory (security: resolve symlinks to prevent traversal) const validated = validateSessionFilePath(session.workingDir, filePath); if (!validated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); } const { resolvedPath, relativePath } = validated; // Read-for-edit: never truncated (a truncated buffer must never become an // edit buffer), tighter size cap, full editability gate, and the hash/eol // the client must echo back on PUT. Outside the shared try/catch below so // its structured errors keep their status codes instead of collapsing into // OPERATION_FAILED. if (edit === '1' || edit === 'true') { const guard = await loadAttachmentGuardConfig(); assertEditableTarget(resolvedPath, relativePath, guard.blockedTrees); let editStat; try { editStat = await fs.stat(resolvedPath); } catch { throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found'); } if (!editStat.isFile()) { throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be edited'); } if (editStat.size > MAX_EDITABLE_BYTES) { throwFileEditError( 413, ApiErrorCode.INVALID_INPUT, `File too large to edit here (${Math.ceil(editStat.size / 1024)}KB > ${MAX_EDITABLE_BYTES / 1024}KB limit)` ); } const editBuf = await fs.readFile(resolvedPath); const editText = decodeEditableText(editBuf); return { success: true, data: { path: filePath, content: editText, size: editBuf.length, mtimeMs: editStat.mtimeMs, totalLines: editText.split('\n').length, truncated: false, extension: filePath.split('.').pop()?.toLowerCase() || '', editable: true, hash: sha256Hex(editBuf), eol: detectEol(editText), }, }; } try { const stat = await fs.stat(resolvedPath); // Classify by extension. Known media types render with a dedicated player; // other known-binary types are flagged so the client offers a download // affordance instead of trying to decode the bytes as text. Matches the // breadth of formats the attachments viewer renders (image/audio/video/pdf) // so the file viewer can open the same files. const ext = filePath.split('.').pop()?.toLowerCase() || ''; const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']); const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']); const otherBinaryExts = new Set([ 'pdf', 'zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'exe', 'dll', 'so', 'dylib', 'bin', 'wasm', 'class', 'o', 'a', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'xlsx', 'xls', 'doc', 'docx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'avi', 'mkv', 'wmv', 'flv', ]); const mediaType = imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : audioExts.has(ext) ? 'audio' : null; const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`; if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) { // Return metadata for media/binary files (no text body) return { success: true, data: { path: filePath, size: stat.size, type: mediaType ?? 'binary', extension: ext, url: fileRawUrl, }, }; } // Validate file size before reading (DoS protection - prevent memory exhaustion) const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB if (stat.size > MAX_TEXT_FILE_SIZE) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)` ); } // Read as raw bytes so we can sniff for binary content before decoding. An // unrecognized extension (none at all, or a format not listed above) that // is actually binary would otherwise be dumped to the viewer as UTF-8 // mojibake; a NUL byte in the first 8KB is a reliable binary signal that // (unlike a static extension list) catches arbitrary binary formats. const fileBuffer = await fs.readFile(resolvedPath); const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer)); const sniffLength = Math.min(buf.length, 8192); let looksBinary = false; for (let i = 0; i < sniffLength; i++) { if (buf[i] === 0) { looksBinary = true; break; } } if (looksBinary) { return { success: true, data: { path: filePath, size: stat.size, type: 'binary', extension: ext, url: fileRawUrl, }, }; } // Read text file with line limit (bounded to prevent DoS) const MAX_LINES_LIMIT = 10000; const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT); const content = buf.toString('utf-8'); const allLines = content.split('\n'); const truncatedContent = allLines.length > maxLines; const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content; // Additive edit-mode advertisement: whether an edit=1 re-fetch would // succeed. The UTF-8 round-trip compare is a cheap memcmp and mirrors // decodeEditableText; no hash here — the Edit action re-fetches with // edit=1, which is where the baseHash comes from. const guard = await loadAttachmentGuardConfig(); const editable = isEditableFileName(pathBasename(resolvedPath)) && !isDeniedEditRelativePath(relativePath) && !isSensitivePath(resolvedPath) && !isBlockedAttachmentPath(resolvedPath, guard.blockedTrees) && stat.size <= MAX_EDITABLE_BYTES && Buffer.from(content, 'utf8').equals(buf); return { success: true, data: { path: filePath, content: displayContent, size: stat.size, totalLines: allLines.length, truncated: truncatedContent, extension: ext, editable, }, }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`); } }); // File Viewer edit mode: save a text file back into the session workspace. // Edit-in-place ONLY — there is deliberately no O_CREAT path in this handler, // so it can never create, and it never deletes. Confinement is identical to // the read path (realpath + workspace boundary + ownership via // findSessionOrFail), plus the sensitive-path/attachment-guard blocklists and // the extension allowlist. Concurrency is optimistic: the client echoes the // sha256 it loaded (baseHash) and a mismatch is a 409 unless force is set. // bodyLimit: JSON escaping can expand content up to ~6x (each control char // becomes \uXXXX), so the 512KB content cap needs headroom over Fastify's // 1MB default. app.put( '/api/sessions/:id/file-content', { bodyLimit: 4 * 1024 * 1024 }, async (req): Promise> => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id, req); const body = parseBody(FileWriteSchema, req.body); // Exact byte cap — the schema's .max() counts UTF-16 code units and is // only a coarse pre-filter. if (Buffer.byteLength(body.content, 'utf8') > MAX_EDITABLE_BYTES) { throwFileEditError(413, ApiErrorCode.INVALID_INPUT, `Content too large (${MAX_EDITABLE_BYTES / 1024}KB limit)`); } const validated = validateSessionFilePath(session.workingDir, body.path); if (!validated) { // Covers missing files, traversal, and symlink escapes alike — a write // target that fails confinement is reported identically to a missing // one, matching the read route. throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found'); } const { resolvedPath, relativePath } = validated; const guard = await loadAttachmentGuardConfig(); assertEditableTarget(resolvedPath, relativePath, guard.blockedTrees); let stat; try { stat = await fs.stat(resolvedPath); } catch { throwFileEditError(404, ApiErrorCode.NOT_FOUND, 'File not found'); } if (!stat.isFile()) { throwFileEditError(400, ApiErrorCode.INVALID_INPUT, 'Only regular files can be edited'); } if (stat.size > MAX_EDITABLE_BYTES) { throwFileEditError( 413, ApiErrorCode.INVALID_INPUT, `File too large to edit here (${MAX_EDITABLE_BYTES / 1024}KB limit)` ); } const currentBuf = await fs.readFile(resolvedPath); const currentText = decodeEditableText(currentBuf); const currentHash = sha256Hex(currentBuf); if (currentHash !== body.baseHash && !body.force) { throwFileEditError( 409, ApiErrorCode.CONFLICT, 'File changed on disk since it was loaded — reload it or overwrite' ); } // Re-apply the file's original line endings (a