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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user