Files
AgentHub/server/__tests__/project-memory.test.js
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

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