import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; const KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git"]); const INSTALL_MARKER = ".dev-server-install"; export function assertInstanceKey(value) { if (typeof value !== "string" || !KEY.test(value)) throw new Error("Invalid execution key"); return value; } /** * The directory an execution's copy lives in. One path segment, derived from the key and never * from anything the caller wrote, so there is no path to traverse out of. */ export function instanceDirectory(instancesRoot, key) { return path.join(instancesRoot, assertInstanceKey(key)); } /** * A HOME of its own for each execution, which is how the caches of every toolchain but npm end up * isolated without a line of per-toolchain code: Maven's ~/.m2 and pip's ~/.cache both follow HOME. * *

Maven matters most here. npm's shared cache is safe because `npm ci` verifies every package * against the lockfile, so a tampered cache entry fails the install; Maven has no lockfile to * verify against, so a shared local repository would be a channel from one execution into the * next. It lives outside the copied tree so a refresh does not throw it away. * *

The leading dot is what keeps it from colliding with a copy: an execution key can never start * with one. */ export function homeDirectory(instancesRoot, key) { return path.join(instancesRoot, ".homes", assertInstanceKey(key)); } function sourceDirectory(workspaceRoot, key) { const root = fs.realpathSync(workspaceRoot); const candidate = path.join(root, assertInstanceKey(key)); if (!fs.existsSync(candidate)) throw new Error(`No workspace has been written for this execution yet: ${key}`); const real = fs.realpathSync(candidate); const relative = path.relative(root, real); // The execution's own directory is written by the coding agent, so it can be replaced by a // symlink to somewhere else entirely. Refusing here is what keeps one execution out of another. if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Workspace for ${key} escapes the workspace root`); if (!fs.statSync(real).isDirectory()) throw new Error(`Workspace for ${key} is not a directory`); return real; } /** * Copies one execution's tree, dropping every symlink and skipping node_modules and .git. * *

Symlinks are dropped rather than followed or preserved: the tree is untrusted input, and a * link to another execution's directory would turn the copy - whose whole purpose is isolation - * into a window onto it. A project that genuinely needs a symlink will notice its absence, which * is a better failure than a silent hole in the boundary. * *

node_modules is skipped because the install recreates it, and copying 333 MB of it would be * the slowest part of every start. .git is skipped because a dev server has no use for it. */ function copyTree(source, target, limits, counters = { entries: 0, bytes: 0 }) { fs.mkdirSync(target, { recursive: true, mode: 0o755 }); for (const entry of fs.readdirSync(source, { withFileTypes: true })) { if (entry.isSymbolicLink()) continue; if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) continue; if (++counters.entries > limits.maxEntries) throw new Error(`Workspace exceeds ${limits.maxEntries} entries`); const from = path.join(source, entry.name); const to = path.join(target, entry.name); if (entry.isDirectory()) { copyTree(from, to, limits, counters); } else if (entry.isFile()) { const size = fs.statSync(from).size; counters.bytes += size; if (counters.bytes > limits.maxBytes) throw new Error(`Workspace exceeds ${limits.maxBytes} bytes`); fs.copyFileSync(from, to); } // Sockets, fifos and devices are neither copied nor reported: they cannot be part of a // project a dev server serves, and refusing the whole start over one would be worse. } return counters; } /** Everything in the copy except what the install produced, so a refresh keeps the slow part. */ function pruneProjectFiles(target) { for (const entry of fs.readdirSync(target, { withFileTypes: true })) { if (entry.name === INSTALL_MARKER || (entry.isDirectory() && entry.name === "node_modules")) continue; fs.rmSync(path.join(target, entry.name), { recursive: true, force: true }); } } /** * Makes the execution's copy current with the workspace, and reports whether the dependency * install still matches the lockfile it was run for. */ export function prepareInstanceWorkspace({ workspaceRoot, instancesRoot, key, limits }) { const source = sourceDirectory(workspaceRoot, key); const target = instanceDirectory(instancesRoot, key); if (fs.existsSync(target)) pruneProjectFiles(target); const counters = copyTree(source, target, { maxEntries: limits?.maxEntries ?? 20000, maxBytes: limits?.maxBytes ?? 512 * 1024 * 1024 }); const home = homeDirectory(instancesRoot, key); fs.mkdirSync(home, { recursive: true, mode: 0o700 }); return { source, target, home, ...counters, installIsCurrent: installIsCurrent(target) }; } /** * The files that decide what an install produces, across the toolchains the image carries. * *

A fingerprint of these is what makes a repeated start cheap. It deliberately does not look * for node_modules as evidence of an install: a Maven or Python project never has one, and * treating its absence as "not installed yet" would re-run every install forever. */ const DEPENDENCY_MANIFESTS = [ "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "pom.xml", "requirements.txt", "poetry.lock", "pyproject.toml", "Pipfile.lock" ]; function lockfileFingerprint(target) { const hash = crypto.createHash("sha256"); let found = false; for (const name of DEPENDENCY_MANIFESTS) { const file = path.join(target, name); if (fs.existsSync(file)) { hash.update(name).update(fs.readFileSync(file)); found = true; } } return found ? hash.digest("hex") : null; } function installIsCurrent(target) { const marker = path.join(target, INSTALL_MARKER); if (!fs.existsSync(marker)) return false; const fingerprint = lockfileFingerprint(target); // With no manifest there is nothing to compare, so the install can never be declared current: // re-running it is slow, while serving stale dependencies is wrong. return fingerprint !== null && fs.readFileSync(marker, "utf8").trim() === fingerprint; } export function recordInstall(target) { const fingerprint = lockfileFingerprint(target); if (fingerprint) fs.writeFileSync(path.join(target, INSTALL_MARKER), fingerprint); } export function removeInstanceWorkspace(instancesRoot, key) { // Both, or the disk is never really reclaimed: a Maven repository outlives the tree that pulled // it and is the larger half of what an execution leaves behind. fs.rmSync(instanceDirectory(instancesRoot, key), { recursive: true, force: true }); fs.rmSync(homeDirectory(instancesRoot, key), { recursive: true, force: true }); }