Files
nntrivi2001 85187dfc78 feat(config): create a new project's memory file from Settings
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.
2026-09-09 01:45:00 +07:00

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 };