import { Router, Request, Response } from "express"; import { Dirent, ReadStream, Stats, createReadStream, readFileSync, realpathSync } from "fs"; import { mkdir, realpath, writeFile } from "fs/promises"; import path from "path"; import { workspacePath } from "../../workspace/workspace.js"; import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot, writeFileAtomic } from "../../utils/files/index.js"; import { stripDataUri } from "../../utils/files/attachment-store.js"; import { writeNewFileExclusive } from "../../utils/files/upload-io.js"; import { MAX_RENAME_ATTEMPTS, renamedCandidate, sanitizeUploadFilename, uploadRelPath } from "../../utils/files/upload-name.js"; import { joinPosixRelPath, toPosixRelPath } from "@mulmoclaude/core/files"; import { errorMessage } from "../../utils/errors.js"; import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js"; import { jsonSyntaxError, MAX_PREVIEW_BYTES } from "../../utils/files/content-write-validate.js"; import { respondWithWrittenFile, validateWriteRequestOr400, type WriteContentResponse } from "./filesWriteResponse.js"; import { getOptionalStringQuery } from "../../utils/request.js"; import { API_ROUTES } from "../../../src/config/apiRoutes.js"; import { GitignoreFilter } from "../../utils/gitignore.js"; import { getCachedReferenceDirs } from "../../workspace/reference-dirs.js"; import { classifyAsWikiPage, writeWikiPage } from "../../workspace/wiki-pages/io.js"; import { log } from "../../system/logger/index.js"; import { previewSnippet } from "../../utils/logPreview.js"; import { publishFileChange } from "../../events/file-change.js"; import { spawn } from "node:child_process"; import { isErrorWithCode } from "../../utils/types.js"; // Cross-platform "open this file with the host's default handler" that // never lets the path travel through a shell parser. Windows earlier // used `cmd /c start "" ` — `cmd` DOES tokenise the arguments, // so a workspace filename with `&` / `|` / `^` / a leading `-` / etc. // could be reinterpreted as command syntax (Codex + CodeQL flagged // this on #1985). `explorer.exe ` is fed to CreateProcess as an // array argv (no shell parsing) and treats the argument as a // filesystem path. // // Returns a promise that settles based on `spawn` vs `error` events — // NOT process exit. We can't tell whether the associated app actually // opened a window (explorer.exe returns exit code 1 even on success), // but we CAN distinguish "spawn succeeded" from "command not found / // permission denied" (e.g. `xdg-open` missing on a headless Linux // host). Client-side error handling depends on this signal. /** The argv for opening a path in the host file manager, per platform. Pure, * so the per-OS choice can be tested without spawning anything — running the * real command in a test opens Finder on macOS and Explorer on Windows. */ export function openArgv(absPath: string, platform: typeof process.platform): { command: string; args: string[] } { if (platform === "darwin") return { command: "open", args: [absPath] }; if (platform === "win32") return { command: "explorer.exe", args: [absPath] }; return { command: "xdg-open", args: [absPath] }; } /** The argv for revealing a path (folder opened, file selected). macOS `open -R` * and Windows `explorer /select,` select the file; Linux `xdg-open ` only * opens the folder — there is no portable "select this item" across Linux file * managers, and landing next to the file is enough for drag-and-drop (#1985). * Same argv-array (no shell) discipline as `openArgv`, so a filename with shell * metacharacters can never be reinterpreted as command syntax. */ export function revealArgv(absPath: string, platform: typeof process.platform): { command: string; args: string[] } { if (platform === "darwin") return { command: "open", args: ["-R", absPath] }; if (platform === "win32") return { command: "explorer.exe", args: [`/select,${absPath}`] }; return { command: "xdg-open", args: [path.dirname(absPath)] }; } /** Injectable so a test can assert the argv without launching the real file * manager. Defaults to Node's `spawn`. */ export type Spawner = typeof spawn; function spawnDetachedOsCommand(command: string, args: readonly string[], label: string, spawner: Spawner): Promise { return new Promise((resolve) => { const child = spawner(command, args, { detached: true, stdio: "ignore" }); let settled = false; child.once("error", (err) => { if (settled) return; settled = true; log.warn("files", `${label}: spawn error`, { platform: process.platform, error: err.message }); resolve(false); }); child.once("spawn", () => { if (settled) return; settled = true; child.unref(); resolve(true); }); }); } export function openInHostOs(absPath: string, spawner: Spawner = spawn): Promise { const { command, args } = openArgv(absPath, process.platform); return spawnDetachedOsCommand(command, args, "open", spawner); } export function revealInHostOs(absPath: string, spawner: Spawner = spawn): Promise { const { command, args } = revealArgv(absPath, process.platform); return spawnDetachedOsCommand(command, args, "reveal", spawner); } const router = Router(); const MAX_RAW_BYTES = 50 * 1024 * 1024; // 50 MB — cap for non-media streaming (images/pdf/binary load whole into the browser) // Audio/video are streamed via HTTP Range requests (see GET /raw), // so the browser never buffers the whole file. Podcasts commonly // run 100–300 MB and recorded video can run multi-GB; cap at 4 GB // just to keep an obviously-pathological file from being served. const MAX_MEDIA_BYTES = 4 * 1024 * 1024 * 1024; const HIDDEN_DIRS = new Set([".git"]); // Files whose basename exactly matches one of these is refused by // every file-API endpoint. Used to keep workspace secrets // (credentials, API keys, SSH / TLS private keys) off the HTTP // surface. Compared against `path.basename(...).toLowerCase()`. const SENSITIVE_BASENAMES = new Set([ "credentials.json", // Claude Code credentials file written by server/credentials.ts. ".session-token", // Bearer auth token file — readable without auth via /api/files/* // exemption, so it must be blocked here (defense in depth). ".npmrc", ".htpasswd", "id_rsa", "id_ecdsa", "id_ed25519", "id_dsa", ]); // File extensions whose contents are almost always secret. Compared // against `path.extname(...).toLowerCase()`. Note: `.env` is matched // separately below because `path.extname(".env")` returns "" — // dotfiles with no second extension don't carry an extname. const SENSITIVE_EXTENSIONS = new Set([".pem", ".key", ".crt"]); // Decide whether `relPath` names a file whose contents should NEVER // be served by the file API. Applied in three places: // // 1. `resolveSafe` returns null for sensitive paths so every // endpoint (content, raw, anything future) rejects them with a // generic 400. // 2. `buildTreeAsync` / `listDirShallow` filter them out of // `/files/tree` and `/files/dir`, so the file explorer never // lists them in the first place. // 3. The `.env` blocklist below is what keeps `/files/content` // from leaking credentials on a matching-name lookup. // // Exported so `test/routes/test_filesRoute.ts` can pin the matching // rules down table-driven — regressions here silently reopen a // credential-exfil surface. export function isSensitivePath(relPath: string): boolean { const base = path.basename(relPath).toLowerCase(); if (SENSITIVE_BASENAMES.has(base)) return true; // `.env` and every `.env.` variant // (`.env.local`, `.env.production`, ...). The startsWith check // is scoped to `.env` to avoid false-positives on names like // `.environment-notes` — we only match `.env` exact or // `.env.`. if (base === ".env") return true; if (base.startsWith(".env.")) return true; const ext = path.extname(base); if (SENSITIVE_EXTENSIONS.has(ext)) return true; return false; } const TEXT_EXTENSIONS = new Set([ ".md", ".markdown", ".txt", ".json", ".jsonl", ".ndjson", ".yaml", ".yml", ".js", ".ts", ".jsx", ".tsx", ".vue", ".html", ".htm", ".css", ".csv", ".log", // `.env` intentionally removed — see `isSensitivePath` below. // It used to be here, making `/files/content?path=.env` return // the workspace credentials as JSON text over an open CORS // endpoint. The file API now refuses sensitive paths outright; // this set is kept for genuine plain-text previews only. ".gitignore", ".sh", ".py", ]); const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]); const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".m4a", ".ogg", ".oga", ".flac", ".aac"]); const VIDEO_EXTENSIONS = new Set([".mp4", ".webm", ".mov", ".m4v", ".ogv"]); const MIME_BY_EXT: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".flac": "audio/flac", ".aac": "audio/aac", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".m4v": "video/x-m4v", ".ogv": "video/ogg", }; export interface TreeNode { name: string; path: string; type: "file" | "dir"; size?: number; modifiedMs?: number; children?: TreeNode[]; } interface ErrorResponse { error: string; } interface FileContentText { kind: "text"; path: string; content: string; size: number; modifiedMs: number; } interface WriteContentRequest { path?: unknown; content?: unknown; } interface FileContentMeta { kind: "image" | "pdf" | "audio" | "video" | "binary" | "too-large"; path: string; size: number; modifiedMs: number; message?: string; } type FileContentResponse = FileContentText | FileContentMeta; export type ContentKind = "text" | "image" | "pdf" | "audio" | "video" | "binary"; // Exported for unit tests. Classification is purely extension-based // and case-insensitive (via `path.extname(...).toLowerCase()`). export function classify(filename: string): ContentKind { const ext = path.extname(filename).toLowerCase(); if (TEXT_EXTENSIONS.has(ext)) return "text"; if (IMAGE_EXTENSIONS.has(ext)) return "image"; if (AUDIO_EXTENSIONS.has(ext)) return "audio"; if (VIDEO_EXTENSIONS.has(ext)) return "video"; if (ext === ".pdf") return "pdf"; // Files with no extension (e.g. README, LICENSE) — treat as text if (!ext) return "text"; return "binary"; } // Cached realpath of the workspace. Computed once at module load so // every request avoids the syscall. resolveWithinRoot needs an // already-realpath'd root. const workspaceReal = realpathSync(workspacePath); // Windows-only: cached **async-realpath** form of the workspace. On // Windows, `realpathSync` (sync) and `realpath` (async) can return // the same path in two different forms — 8.3 short-name (`RUNNER~1`) // vs. long-name (`runneradmin`) — depending on which syscall path // was used to open the dir entry. Comparing across forms via // `path.relative` then produces a false "outside workspace" verdict // (`..\..\runneradmin\...`). This cache mirrors `workspaceReal` but // is guaranteed to share the form the async `realpath` returns, so // `resolveNewFilePath`'s containment check has matching ends. On // non-Windows hosts both syscalls return the same string, so the // cache equals `workspaceReal` and the extra lookup is harmless. let workspaceRealAsyncCache: string | null = null; async function getWorkspaceRealAsync(): Promise { if (workspaceRealAsyncCache !== null) return workspaceRealAsyncCache; try { workspaceRealAsyncCache = await realpath(workspaceReal); } catch { workspaceRealAsyncCache = workspaceReal; } return workspaceRealAsyncCache; } // Wraps the shared resolveWithinRoot helper with the additional // hidden-dir traversal check (e.g. `.git/config`). `buildTreeAsync` // / `listDirShallow` hide these from the listing, but the URL // endpoints are reachable directly so they need their own check. function resolveSafe(relPath: string): string | null { const resolved = resolveWithinRoot(workspaceReal, relPath); if (!resolved) return null; const relativeFromWorkspace = path.relative(workspaceReal, resolved); if (relativeFromWorkspace) { for (const seg of relativeFromWorkspace.split(path.sep)) { if (HIDDEN_DIRS.has(seg)) return null; } } // Reject workspace-sensitive filenames outright. `isSensitivePath` // matches on the basename so it catches `.env`, `id_rsa`, and // friends regardless of which directory they sit in. if (isSensitivePath(resolved)) return null; return resolved; } // ── Reference directory path resolution ────────────────────────── const REF_PREFIX = "@ref/"; /** The prefix without its separator — a directory named exactly this is still * reference territory even though it fails the `@ref/` prefix test. */ const REF_ROOT_SEGMENT = REF_PREFIX.slice(0, -1); function isRefPath(relPath: string): boolean { return relPath.startsWith(REF_PREFIX); } /** * Resolve a `@ref/