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.
This commit is contained in:
2026-09-09 01:45:00 +07:00
parent 697384145a
commit 85187dfc78
16 changed files with 833 additions and 36 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* @file project-memory.test.js
* @description Tests for POST /api/{cc,codex}-config/project-memory — the one
* write path that honours a caller-supplied directory, so it is the one that
* has to prove its narrowing rules hold: absolute + existing dir only, a fixed
* filename, never an overwrite, and never reachable from a foreign origin.
*
* Every refusal asserts the file did NOT appear — a 4xx with the write already
* done is not a refusal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const http = require("node:http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "project-memory-test-"));
const FAKE_HOME = path.join(TMP, "home", ".claude");
const NEW_PROJECT = path.join(TMP, "brand-new-project");
fs.mkdirSync(FAKE_HOME, { recursive: true });
process.env.CLAUDE_HOME = FAKE_HOME;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard-test.db");
const { createApp } = require("../index");
let server;
let BASE;
function post(p, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(p, BASE);
const buf = Buffer.from(JSON.stringify(body));
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": buf.length,
...headers,
},
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let json;
try {
json = JSON.parse(text);
} catch {
json = text;
}
resolve({ status: res.statusCode, body: json });
});
}
);
req.on("error", reject);
req.write(buf);
req.end();
});
}
// Both routes are the same code with a different filename, so every rule is
// asserted once per tool rather than once overall.
const TOOLS = [
{ route: "/api/cc-config/project-memory", file: "CLAUDE.md" },
{ route: "/api/codex-config/project-memory", file: "AGENTS.md" },
];
describe("POST /project-memory", () => {
before(async () => {
server = http.createServer(createApp());
await new Promise((r) => server.listen(0, r));
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
await new Promise((r) => server.close(r));
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort temp cleanup */
}
});
beforeEach(() => {
fs.rmSync(NEW_PROJECT, { recursive: true, force: true });
fs.mkdirSync(NEW_PROJECT, { recursive: true });
});
for (const { route, file } of TOOLS) {
it(`${file}: creates it in a project the dashboard has never run in`, async () => {
const { status, body } = await post(route, { dir: NEW_PROJECT, content: "# Hello\n" });
assert.equal(status, 200);
assert.equal(body.created, true);
assert.equal(body.file, path.join(fs.realpathSync(NEW_PROJECT), file));
assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "# Hello\n");
});
it(`${file}: refuses to overwrite one that already exists`, async () => {
fs.writeFileSync(path.join(NEW_PROJECT, file), "ORIGINAL");
const { status, body } = await post(route, { dir: NEW_PROJECT, content: "REPLACED" });
assert.equal(status, 409);
assert.equal(body.error.code, "EEXISTS");
assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "ORIGINAL");
});
it(`${file}: refuses a relative dir`, async () => {
const { status, body } = await post(route, { dir: "some/project", content: "x" });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADDIR");
});
it(`${file}: refuses a dir that does not exist`, async () => {
const missing = path.join(TMP, "not-there");
const { status, body } = await post(route, { dir: missing, content: "x" });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADDIR");
assert.equal(fs.existsSync(missing), false);
});
it(`${file}: refuses a path that is a file, not a directory`, async () => {
const notADir = path.join(NEW_PROJECT, "README.md");
fs.writeFileSync(notADir, "x");
const { status, body } = await post(route, { dir: notADir, content: "y" });
assert.equal(status, 400);
assert.equal(body.error.code, "EBADDIR");
assert.equal(fs.readFileSync(notADir, "utf8"), "x");
});
it(`${file}: refuses a cross-origin request`, async () => {
const { status, body } = await post(
route,
{ dir: NEW_PROJECT, content: "drive-by" },
{ Origin: "https://evil.example" }
);
assert.equal(status, 403);
assert.equal(body.error.code, "EBADORIGIN");
assert.equal(fs.existsSync(path.join(NEW_PROJECT, file)), false);
});
it(`${file}: allows a loopback origin (the dashboard itself)`, async () => {
const { status } = await post(route, { dir: NEW_PROJECT, content: "ok" }, { Origin: BASE });
assert.equal(status, 200);
assert.equal(fs.readFileSync(path.join(NEW_PROJECT, file), "utf8"), "ok");
});
it(`${file}: refuses to follow a symlink planted under that name`, async () => {
// Dangling on purpose: `existsSync`/`statSync` both say "no file here"
// for a broken link, so only the lstat check refuses this — and writing
// through it would create the file at the link's target instead.
const outside = path.join(TMP, `outside-${file}`);
fs.rmSync(outside, { force: true });
fs.symlinkSync(outside, path.join(NEW_PROJECT, file));
const { status } = await post(route, { dir: NEW_PROJECT, content: "PWNED" });
assert.equal(status, 409);
assert.equal(fs.existsSync(outside), false);
});
}
it("writes only the tool's own filename — the caller cannot name the file", async () => {
// `filename` is not part of the request body at all; proving that here
// means a future refactor cannot quietly add it back as a passthrough.
await post("/api/cc-config/project-memory", {
dir: NEW_PROJECT,
content: "x",
filename: "../../escaped.md",
});
assert.equal(fs.existsSync(path.join(NEW_PROJECT, "CLAUDE.md")), true);
assert.equal(fs.existsSync(path.join(TMP, "escaped.md")), false);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* @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 };
+50
View File
@@ -0,0 +1,50 @@
/**
* @file same-origin.js
* @description Loopback-Origin guard for the routes that act on the machine
* rather than on the dashboard's own database — spawning a process, or
* writing a file into a directory the request names.
*
* The dashboard binds to localhost, so any page in the user's browser can
* reach it. Without this, a malicious site could POST to it and, say, drop a
* CLAUDE.md full of instructions into a project the user later opens.
*
* Browser requests carry `Origin`; it must be a loopback host. Requests with
* no `Origin` (curl, the CLI) pass — they are not the drive-by case. `Referer`
* is the fallback for older browsers.
*
* Extracted from routes/run.js so the config routes enforce the identical
* rule instead of a second, drifting copy.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
function isLoopback(raw) {
try {
return ALLOWED_ORIGIN_HOSTS.has(new URL(raw).hostname);
} catch {
return false;
}
}
function sameOriginGuard(req, res, next) {
const origin = req.headers.origin;
if (origin) {
if (!isLoopback(origin)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
const referer = req.headers.referer;
if (referer && !isLoopback(referer)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
module.exports = { sameOriginGuard, ALLOWED_ORIGIN_HOSTS };
+27
View File
@@ -29,6 +29,8 @@ const ccMutate = require("../lib/cc-mutate");
const ccCli = require("../lib/cc-plugin-cli");
const ccMcpCli = require("../lib/cc-mcp-cli");
const ccProfiles = require("../lib/cc-profiles");
const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket");
const router = Router();
@@ -48,6 +50,10 @@ const ERR_TO_STATUS = {
EBADNAME: 400,
EBADPROJECT: 400,
EBADCONTENT: 400,
EBADDIR: 400,
EBADTOOL: 400,
// The project already has a memory file; creating would clobber it.
EEXISTS: 409,
ETOOLARGE: 413,
EOUTOFROOT: 400,
ENOTFOUND: 404,
@@ -216,6 +222,27 @@ router.delete("/file", (req, res) => {
}
});
/**
* POST /api/cc-config/project-memory — create `CLAUDE.md` in a project the
* dashboard has never run in. Body: `{ dir, content }`.
*
* The one write here that honours a caller-supplied directory, which is the
* whole point: you cannot bootstrap a NEW project's memory from a writer
* pinned to the server's own cwd. It stays narrow — see project-memory.js —
* and sits behind the same loopback-origin guard the process-spawning routes
* use, so a random web page cannot plant instructions in a project directory.
*/
router.post("/project-memory", sameOriginGuard, (req, res) => {
const { dir, content } = req.body || {};
try {
const result = projectMemory.createProjectMemory({ tool: "claude", dir, content });
emitChanged({ action: "write", scope: "project", type: "memory", name: null });
res.json({ ...result, created: true });
} catch (err) {
return mutateError(res, err);
}
});
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
//
// These shell out to `claude plugin …` with an argv array — never a shell
+24
View File
@@ -24,6 +24,8 @@ const { Router } = require("express");
const codexDiscovery = require("../lib/codex-discovery");
const codexMutate = require("../lib/codex-mutate");
const codexCli = require("../lib/codex-cli");
const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket");
const router = Router();
@@ -56,9 +58,13 @@ const ERR_TO_STATUS = {
EBADSCOPE: 400,
EBADNAME: 400,
EBADCONTENT: 400,
EBADDIR: 400,
EBADTOOL: 400,
EOUTOFROOT: 400,
ETOOLARGE: 413,
ENOTFOUND: 404,
// The project already has an AGENTS.md; creating would clobber it.
EEXISTS: 409,
};
function mutateError(res, err) {
@@ -158,6 +164,24 @@ router.delete("/file", (req, res) => {
}
});
/**
* POST /api/codex-config/project-memory — create `AGENTS.md` in a project the
* dashboard has never run in. Body: `{ dir, content }`. The Codex twin of
* cc-config.js's route of the same name; both go through project-memory.js,
* which is where the narrowing rules for this one caller-chosen write path
* are documented.
*/
router.post("/project-memory", sameOriginGuard, (req, res) => {
const { dir, content } = req.body || {};
try {
const result = projectMemory.createProjectMemory({ tool: "codex", dir, content });
emitChanged({ action: "write", scope: "project", type: "memory", name: null });
res.json({ ...result, created: true });
} catch (err) {
return mutateError(res, err);
}
});
router.get("/backups", (req, res) => {
const scope =
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
+1 -36
View File
@@ -25,45 +25,10 @@ const path = require("node:path");
const runs = require("../lib/pty-run");
const tmux = require("../lib/tmux");
const lanesLib = require("../lib/lanes");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
const ALLOWED_ORIGIN_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
/**
* Loopback-Origin guard. Browser requests carry Origin; if it's not localhost,
* we reject. Server/CLI requests (curl) typically don't carry Origin and pass.
*
* Referer is checked as a fallback for older browsers / fetch with credentials
* disabled — the same loopback-host rule applies.
*/
function sameOriginGuard(req, res, next) {
const checkHost = (raw) => {
try {
const u = new URL(raw);
return ALLOWED_ORIGIN_HOSTS.has(u.hostname);
} catch {
return false;
}
};
const origin = req.headers.origin;
if (origin) {
if (!checkHost(origin)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
const referer = req.headers.referer;
if (referer && !checkHost(referer)) {
return res.status(403).json({
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
});
}
return next();
}
router.use(sameOriginGuard);
function isExistingDir(p) {