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