8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
109 lines
4.3 KiB
JavaScript
109 lines
4.3 KiB
JavaScript
/**
|
|
* @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.
|
|
*
|
|
* Under S3 the file is written AS the account whose home the folder is in
|
|
* (`run-as.accountForPath`), never by the host.
|
|
*
|
|
* 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}`);
|
|
|
|
// S3: a folder in a member's home is written AS that member (`write-as`,
|
|
// content on stdin) — an admin's create there included; the host never
|
|
// writes inside a member's home itself. EMEMBERGONE (no usable account)
|
|
// refuses, never "the host then".
|
|
const runAs = require("./run-as");
|
|
const acct = runAs.accountForPath(file);
|
|
if (acct) runAs.writeAs(acct, file, "replace", content);
|
|
else atomicWriteFile(file, content);
|
|
return { file, size: Buffer.byteLength(content, "utf8") };
|
|
}
|
|
|
|
module.exports = { createProjectMemory, FILENAMES, MAX_CONTENT_BYTES };
|