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.
180 lines
6.6 KiB
JavaScript
180 lines
6.6 KiB
JavaScript
/**
|
|
* @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);
|
|
});
|
|
});
|