dev-mcps/dev-server-mcp/src/instance-workspace.js

154 lines
7.0 KiB
JavaScript

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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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 });
}