/** * @file project-memory.js * @description Create a project's top-level memory file — `CLAUDE.md` for * Claude Code, `AGENTS.md` for Codex — in ANY existing directory on this * machine, so the dashboard can bootstrap a project it has never run in. * * Why this is not `cc-mutate`/`codex-mutate`: those writers resolve "project" * scope against the server's own cwd on purpose, and `?cwd=` is ignored there * precisely so a request can never redirect a write. This surface is the * deliberate, narrow exception, and it pays for that with: * - the directory must be absolute and already exist (we never mkdir -p a * path someone typed); * - the filename is not caller-supplied — it comes from {@link FILENAMES}; * - the file must not exist yet, so this can only ever create, never * clobber an existing memory file; * - a symlink at `/CLAUDE.md` pointing elsewhere is refused. * * The capability is not new in kind: the Run page already spawns `claude` in * any existing directory the same way. What it must not become is a general * "write arbitrary content to an arbitrary path" endpoint — hence the fixed * filename and the no-overwrite rule. * * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { atomicWriteFile } = require("./config-write"); /** The only two filenames this module will ever write. Keyed by tool. */ const FILENAMES = { claude: "CLAUDE.md", codex: "AGENTS.md" }; /** Same cap the other config writers use — a memory file is prose, not a blob. */ const MAX_CONTENT_BYTES = 1024 * 1024; function makeError(code, message) { const err = new Error(message); err.code = code; return err; } /** * Create `/`. * * @param {{tool: "claude"|"codex", dir: string, content: string}} args * @returns {{file: string, size: number}} * @throws EBADTOOL, EBADDIR, EEXISTS, EBADCONTENT, EOUTOFROOT */ function createProjectMemory({ tool, dir, content }) { const filename = FILENAMES[tool]; if (!filename) throw makeError("EBADTOOL", `tool must be one of ${Object.keys(FILENAMES)}`); if (typeof dir !== "string" || !dir.trim()) { throw makeError("EBADDIR", "dir is required"); } if (!path.isAbsolute(dir)) { throw makeError("EBADDIR", "dir must be an absolute path"); } // eslint-disable-next-line no-control-regex if (/\u0000/.test(dir)) throw makeError("EBADDIR", "dir contains a NUL byte"); let realDir; try { realDir = fs.realpathSync(path.resolve(dir)); } catch { throw makeError("EBADDIR", `directory does not exist: ${dir}`); } if (!fs.statSync(realDir).isDirectory()) { throw makeError("EBADDIR", `not a directory: ${dir}`); } if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string"); if (Buffer.byteLength(content, "utf8") > MAX_CONTENT_BYTES) { throw makeError("EBADCONTENT", "content is too large"); } const file = path.join(realDir, filename); // lstat, not existsSync: a symlink named CLAUDE.md pointing somewhere else — // dangling or not — counts as taken, so we refuse instead of writing through // it. That, plus `realDir` being already realpath'd, is the whole symlink // story here; the usual `assertNoSymlinkEscape` walk has nothing left to // catch. And if someone plants the link between this check and the write, // `atomicWriteFile`'s rename replaces the link itself rather than following // it. let taken = true; try { fs.lstatSync(file); } catch (err) { if (err.code !== "ENOENT") throw err; taken = false; } if (taken) throw makeError("EEXISTS", `${filename} already exists in ${realDir}`); atomicWriteFile(file, content); return { file, size: Buffer.byteLength(content, "utf8") }; } module.exports = { createProjectMemory, FILENAMES, MAX_CONTENT_BYTES };