85187dfc78
The Memory tab could only show this machine's user memory and the
dashboard's own project file. Setting up a project you had not run
through the dashboard meant opening a terminal — `PUT /file`'s
"project" scope always resolves against the server's cwd, on purpose.
Add one narrow route per tool that writes into the directory you name:
CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same
`project-memory.js`. Being the only write path that honours a
caller-supplied directory, it pays for that:
- the directory must be absolute and already exist — nothing is
created for you;
- the filename comes from the route, never from the body;
- an existing file, symlink included, is a 409 — this can only
create, never overwrite;
- it sits behind the loopback-Origin guard /api/run uses, so a page
in the user's browser cannot plant agent instructions in a project
directory. That guard moves to lib/same-origin.js to be shared.
The modal takes the dashboard's known project dirs as suggestions on a
free-text input rather than a closed list: a project it has never seen
is exactly the case this exists for.
Mutation-checked: dropping the origin guard, and swapping lstat for
stat, each fail a test.
99 lines
3.8 KiB
JavaScript
99 lines
3.8 KiB
JavaScript
/**
|
|
* @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 `<dir>/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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 `<dir>/<CLAUDE.md|AGENTS.md>`.
|
|
*
|
|
* @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 };
|