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:
2026-09-09 01:45:00 +07:00
parent 697384145a
commit 85187dfc78
16 changed files with 833 additions and 36 deletions
@@ -0,0 +1,180 @@
/**
* @file ProjectMemoryModal.tsx
* @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a
* directory that is not the dashboard's own. Shared by the Claude Code and
* Codex config pages; the caller supplies its i18n namespace and the create
* call, so this component knows about neither tool.
*
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
* offered as suggestions on a plain text input rather than a closed dropdown:
* a project you have never run through the dashboard is exactly the case this
* modal exists for, so it must stay typeable.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
import { api, type CwdSuggestion } from "../../lib/api";
interface ProjectMemoryModalProps {
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
ns: string;
/** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */
filename: string;
/** Starting body text, so the user edits a skeleton instead of a blank box. */
template: string;
onClose: () => void;
/** Resolves with the path actually written (the server realpaths the dir). */
onCreate: (args: { dir: string; content: string }) => Promise<string>;
}
export function ProjectMemoryModal({
ns,
filename,
template,
onClose,
onCreate,
}: ProjectMemoryModalProps) {
// Every key lives under `projectMemory.*` in BOTH namespaces, so this
// component never depends on the calling page's other key groups lining up.
const { t } = useTranslation(ns);
const listId = useId();
const [dir, setDir] = useState("");
const [content, setContent] = useState(template);
const [suggestions, setSuggestions] = useState<CwdSuggestion[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.run
.cwds()
.then((r) => setSuggestions(r.items))
// Suggestions are a convenience; the input still works without them.
.catch(() => undefined);
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCreate = useCallback(async () => {
const target = dir.trim();
if (!target) {
setError(t("projectMemory.dirRequired"));
return;
}
setSaving(true);
setError(null);
try {
await onCreate({ dir: target, content });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
} finally {
setSaving(false);
}
}, [dir, content, onCreate, t]);
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("projectMemory.title", { file: filename })}
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<BookOpen className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1 truncate">
{t("projectMemory.title", { file: filename })}
</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("projectMemory.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto p-4 space-y-3">
<div>
<label
htmlFor={`${listId}-dir`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.dirLabel")}
</label>
<input
id={`${listId}-dir`}
list={listId}
value={dir}
onChange={(e) => setDir(e.target.value)}
placeholder="/home/you/projects/my-app"
spellCheck={false}
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
/>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s.path} value={s.path}>
{s.label}
</option>
))}
</datalist>
<p className="mt-1 text-[10px] text-fg-muted">
{t("projectMemory.dirHelp", { file: filename })}
</p>
</div>
<div>
<label
htmlFor={`${listId}-content`}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
>
{t("projectMemory.contentLabel")}
</label>
<textarea
id={`${listId}-content`}
value={content}
onChange={(e) => setContent(e.target.value)}
spellCheck={false}
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
/>
</div>
{error && (
<div className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</div>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
>
{t("projectMemory.cancel")}
</button>
<button
onClick={handleCreate}
disabled={saving}
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Save className="w-3.5 h-3.5" />
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
/**
* @file ProjectMemoryModal.test.tsx
* @description Unit tests for `ProjectMemoryModal`: it must send the directory
* the user typed (not a suggestion it picked for them), must refuse to submit
* an empty one, and must render its labels from whichever namespace the
* calling page passes — the Codex page shares this component with the Claude
* page, and a key group that only exists in `ccConfig` would render raw.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProjectMemoryModal } from "../ProjectMemoryModal";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { run: { cwds: vi.fn() } },
}));
const cwds = vi.mocked(api.run.cwds);
beforeEach(() => {
cwds.mockResolvedValue({
items: [{ kind: "recent", path: "/home/me/known-project", label: "known-project" }],
});
});
function renderModal(over: Partial<Parameters<typeof ProjectMemoryModal>[0]> = {}) {
const onCreate = vi.fn().mockResolvedValue("/home/me/typed/CLAUDE.md");
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template="# Project memory"
onClose={() => {}}
onCreate={onCreate}
{...over}
/>
);
return { onCreate };
}
describe("ProjectMemoryModal", () => {
it("sends the typed directory and the edited content", async () => {
const { onCreate } = renderModal();
await userEvent.type(screen.getByLabelText(/project directory/i), "/home/me/typed");
const body = screen.getByLabelText(/content/i);
await userEvent.clear(body);
await userEvent.type(body, "hello");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).toHaveBeenCalledWith({ dir: "/home/me/typed", content: "hello" });
});
it("refuses to submit without a directory", async () => {
const { onCreate } = renderModal();
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(onCreate).not.toHaveBeenCalled();
expect(await screen.findByText(/directory is required/i)).toBeInTheDocument();
});
it("offers the dashboard's known projects as suggestions, not as the only choice", async () => {
renderModal();
// A datalist suggests; it does not constrain. A project the dashboard has
// never run in is precisely this modal's reason to exist, so the input
// stays free text.
const input = screen.getByLabelText(/project directory/i);
expect(input.tagName).toBe("INPUT");
// jsdom exposes no accessible role for datalist options, so query the DOM.
await vi.waitFor(() => {
const list = document.getElementById(input.getAttribute("list")!);
expect(list?.querySelector('option[value="/home/me/known-project"]')).not.toBeNull();
});
});
it("renders from the Codex namespace too", async () => {
renderModal({ ns: "codexConfig", filename: "AGENTS.md" });
expect(await screen.findByText(/Create AGENTS\.md for another project/i)).toBeInTheDocument();
// A missing key renders as the bare key path — catch that directly.
expect(screen.queryByText(/projectMemory\./)).not.toBeInTheDocument();
});
it("shows the server's rejection instead of closing", async () => {
const onCreate = vi.fn().mockRejectedValue(new Error("CLAUDE.md already exists in /p"));
render(
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template=""
onClose={() => {}}
onCreate={onCreate}
/>
);
await userEvent.type(screen.getByLabelText(/project directory/i), "/p");
await userEvent.click(screen.getByRole("button", { name: /create/i }));
expect(await screen.findByText(/already exists in \/p/i)).toBeInTheDocument();
});
});
+14
View File
@@ -174,6 +174,20 @@
"projectLabel": "Project",
"projectHelp": "Created in this project's memory dir."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"edit": {
"newButton": "New",
"editButton": "Edit",
@@ -56,6 +56,20 @@
"memory": {
"missing": "No AGENTS.md found."
},
"projectMemory": {
"newButton": "New project memory",
"title": "Create {{file}} for another project",
"dirLabel": "Project directory",
"dirHelp": "Absolute path to a project that already exists on this machine. {{file}} is created inside it; an existing one is never overwritten.",
"dirRequired": "A project directory is required",
"createButton": "Create",
"created": "Created {{path}}",
"template": "# Project memory\n\nWhat this project is, and anything an agent should know before touching it.\n\n## Stack\n\n## Conventions\n\n## Commands\n",
"contentLabel": "Content",
"cancel": "Cancel",
"saving": "Creating…",
"close": "Close"
},
"config": {
"missing": "No config.toml found."
},
+14
View File
@@ -169,6 +169,20 @@
"projectLabel": "Dự án",
"projectHelp": "Được tạo trong thư mục bộ nhớ của dự án này."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"edit": {
"newButton": "Tạo mới",
"editButton": "Sửa",
@@ -56,6 +56,20 @@
"memory": {
"missing": "Không tìm thấy AGENTS.md."
},
"projectMemory": {
"newButton": "Memory cho project khác",
"title": "Tạo {{file}} cho một project khác",
"dirLabel": "Thư mục project",
"dirHelp": "Đường dẫn tuyệt đối tới một project đã có trên máy. {{file}} sẽ được tạo bên trong; file đã tồn tại thì không bao giờ bị ghi đè.",
"dirRequired": "Cần nhập thư mục project",
"createButton": "Tạo",
"created": "Đã tạo {{path}}",
"template": "# Project memory\n\nProject này là gì, và những gì agent cần biết trước khi đụng vào.\n\n## Stack\n\n## Quy ước\n\n## Lệnh thường dùng\n",
"contentLabel": "Nội dung",
"cancel": "Huỷ",
"saving": "Đang tạo…",
"close": "Đóng"
},
"config": {
"missing": "Không tìm thấy config.toml."
},
+40
View File
@@ -1470,6 +1470,22 @@ export const api = {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/cc-config/project-memory - create `CLAUDE.md` in a project the
* dashboard has never run in.
*
* Unlike {@link api.ccConfig.write}, whose "project" scope always means the
* dashboard server's own directory, this one writes into the directory you
* name. It only ever creates: an existing CLAUDE.md comes back as a 409.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/cc-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/cc-config/file - remove a config artifact (also backed up).
*
@@ -1702,6 +1718,19 @@ export const api = {
method: "PUT",
body: JSON.stringify(args),
}),
/**
* POST /api/codex-config/project-memory - create `AGENTS.md` in a project
* the dashboard has never run in. The Codex twin of
* {@link api.ccConfig.createProjectMemory}, same create-only rule.
*
* @param args `dir` (absolute, must already exist) and the file's `content`.
* @returns {@link ProjectMemoryResult} — the written path and its size.
*/
createProjectMemory: (args: { dir: string; content: string }) =>
request<ProjectMemoryResult>("/codex-config/project-memory", {
method: "POST",
body: JSON.stringify(args),
}),
/**
* DELETE /api/codex-config/file - remove a config artifact (also backed up).
*
@@ -2956,6 +2985,17 @@ export interface CodexMutationResult {
backup?: { [key: string]: unknown } | null;
}
/**
* Result of creating a project's memory file (CLAUDE.md / AGENTS.md) via
* POST /api/{cc,codex}-config/project-memory. `file` is the resolved path —
* worth showing back, since the server realpaths the directory it was given.
*/
export interface ProjectMemoryResult {
file: string;
size: number;
created: true;
}
/** One timestamped backup of a config artifact, from GET /api/codex-config/backups. */
export interface CodexBackup {
type: string;
+37
View File
@@ -105,6 +105,7 @@ import {
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { MdItemList } from "../components/config/MdItemList";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
SkeletonRows,
@@ -246,6 +247,7 @@ export function CcConfig() {
const [toast, setToast] = useState<Toast>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
// Auto-dismiss toasts after 5s
useEffect(() => {
@@ -453,6 +455,20 @@ export function CcConfig() {
[fetchAll, t]
);
// Create CLAUDE.md in ANOTHER project's directory. Deliberately not part of
// `handleSave`: that one writes this machine's user/project layers through
// the artifact writer, while this names its own target directory.
const handleCreateProjectMemory = useCallback(
async (args: { dir: string; content: string }) => {
const result = await api.ccConfig.createProjectMemory(args);
setProjectMemoryOpen(false);
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
void fetchAll();
return result.file;
},
[fetchAll, t]
);
const handleDelete = useCallback(async () => {
if (!confirmDelete) return;
try {
@@ -527,6 +543,18 @@ export function CcConfig() {
{t("edit.newButton")}
</button>
)}
{/* The memory tab's own "new" is a different thing: the two
CLAUDE.md cards below are this machine's user + current-project
files, while this creates one in ANOTHER project's directory. */}
{tab === "memory" && (
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 whitespace-nowrap"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
)}
</div>
)}
<div className="p-4">
@@ -563,6 +591,15 @@ export function CcConfig() {
onConfirm={handleDelete}
/>
)}
{projectMemoryOpen && (
<ProjectMemoryModal
ns="ccConfig"
filename="CLAUDE.md"
template={t("projectMemory.template")}
onClose={() => setProjectMemoryOpen(false)}
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
+39
View File
@@ -49,6 +49,7 @@ import {
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import {
Empty,
SkeletonRows,
@@ -152,6 +153,7 @@ export function CodexConfig() {
// (mirrors CcConfig.tsx's PluginsPanel: "a failure here is a normal state").
const [cliError, setCliError] = useState<string | null>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [search, setSearch] = useState("");
const [viewer, setViewer] = useState<{
@@ -322,6 +324,20 @@ export function CodexConfig() {
[fetchAll, t]
);
// Create AGENTS.md in ANOTHER project's directory — the Codex twin of
// CcConfig's handler; `handleSave` above only ever writes this machine's
// user/project layers.
const handleCreateProjectMemory = useCallback(
async (args: { dir: string; content: string }) => {
const result = await api.codexConfig.createProjectMemory(args);
setProjectMemoryOpen(false);
setToast({ kind: "success", message: t("projectMemory.created", { path: result.file }) });
void fetchAll();
return result.file;
},
[fetchAll, t]
);
const handleDelete = useCallback(async () => {
if (!confirmDelete) return;
try {
@@ -393,6 +409,20 @@ export function CodexConfig() {
)}
</div>
)}
{/* The memory tab hides the search bar, but still needs its own
"new": a CLAUDE.md-style AGENTS.md in ANOTHER project's dir, not
the user/project files listed below. */}
{tab === "memory" && (
<div className="flex items-center justify-end border-b border-border px-4 py-2.5">
<button
onClick={() => setProjectMemoryOpen(true)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
{t("projectMemory.newButton")}
</button>
</div>
)}
<div className="p-4">
<TabPanel
tab={tab}
@@ -429,6 +459,15 @@ export function CodexConfig() {
onConfirm={handleDelete}
/>
)}
{projectMemoryOpen && (
<ProjectMemoryModal
ns="codexConfig"
filename="AGENTS.md"
template={t("projectMemory.template")}
onClose={() => setProjectMemoryOpen(false)}
onCreate={handleCreateProjectMemory}
/>
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
</div>
);
+4
View File
@@ -1587,6 +1587,7 @@ POST /api/cc-config/profiles/activate Body: { name }
DELETE /api/cc-config/profiles Body: { name }
PUT /api/cc-config/file Body: { scope, type, name?, content }
DELETE /api/cc-config/file Body: { scope, type, name? }
POST /api/cc-config/project-memory Body: { dir, content }
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
GET /api/cc-config/plugins/available
POST /api/cc-config/plugins/install Body: { spec, scope? }
@@ -1603,6 +1604,8 @@ POST /api/cc-config/mcp/remove Body: { name, scope? }
`GET /api/cc-config/memory` also surfaces the per-project file-based memory store — every `*.md` under `~/.claude/projects/<slug>/memory/` (the common pattern of a `MEMORY.md` index plus one file per remembered fact). Those items have `scope: "auto-memory"` and carry `project` (the `projects/<slug>` dir name), `name` (filename), `isIndex` (true for `MEMORY.md` / `INDEX-*.md`, which sort first), and parsed `frontmatter`. They are **editable**: `PUT`/`DELETE /api/cc-config/file` accept `{ scope: "auto-memory", type: "auto-memory", project, name, content? }` and create a timestamped backup under `<memory-dir>/.cc-config-backups/auto-memory/` before mutating (an invalid `project` slug returns `EBADPROJECT`). `GET /api/cc-config/backups` lists these with `scope: "auto-memory"` and `project` set. Bodies are also readable via `GET /api/cc-config/file` (they live under `CLAUDE_HOME`).
**`POST /api/{cc,codex}-config/project-memory` (a new project's memory file).** Creates `CLAUDE.md` (Claude) or `AGENTS.md` (Codex) inside the directory named by `dir` — the one write path that honours a caller-supplied directory, because bootstrapping a project the dashboard has never run in is impossible from a writer pinned to the server's own cwd (which is what `PUT /file`'s `"project"` scope means). It stays narrow: `dir` must be absolute and already exist (nothing is created for you), the filename comes from the route and is never taken from the body, and an existing file — including a symlink under that name — returns **409 `EEXISTS`** rather than being overwritten, so this endpoint can only ever create. Bad or missing directories return 400 `EBADDIR`. Unlike the other config routes it sits behind the same loopback-`Origin` guard `/api/run` uses (403 `EBADORIGIN`): a page in the user's browser could otherwise plant agent instructions in a project directory. Returns `{ file, size, created: true }`, where `file` is the realpath'd target, and broadcasts `cc_config_changed`.
**`PATCH /api/cc-config/settings` (key merge).** `scope` is `"user"`, `"project"`, or `"project-local"` (`settings.local.json`). `set` maps dotted key paths to JSON values; `unset` is a list of dotted key paths to delete. The server re-reads the target file **unredacted**, applies only the named keys, backs it up under `<dir>/.cc-config-backups/settings/`, and writes atomically — so keys the UI never displayed (including secret-valued ones) survive byte-for-byte. Returns `{ path, backupPath, created, changed }`. Hooks are edited through this same route, as `set: { "hooks.PreToolUse": [{ matcher, hooks: [{ type, command, timeout? }] }] }` — i.e. `settings.json`'s own nested shape, not the flattened shape `GET /hooks` returns. Errors: `EBADSCOPE` (unknown scope), `EBADNAME` (a key segment outside `[A-Za-z0-9_$][A-Za-z0-9._$-]*`, or `__proto__`/`prototype`/`constructor`), `EREDACTED` (400 — the payload contains the `<redacted>` placeholder, which would destroy a real secret), `EBADJSON` (409 — the file on disk is not valid JSON; it is left untouched rather than clobbered), `ETOOLARGE`. The project layers always resolve against the **server's** cwd; `?cwd=` is honoured on reads only, never on this write.
**Plugins and marketplaces.** These five endpoints shell out to the `claude plugin …` CLI with an explicit argv array (never a shell string); mutating calls are serialized in-process. `CLAUDE_CONFIG_DIR` is pinned to `CLAUDE_HOME` only when the operator explicitly set `CLAUDE_HOME` — left unset (the default), the CLI's own default config dir already matches what `cc-discovery` reads, so the pin is left off rather than pointed at the wrong directory (see `runClaude()`'s comment in `cc-plugin-cli.js` for the incident this fixed). `source` must be `owner/repo`, an `http(s)://` URL, or an absolute path; `spec`/`key` must be `<name>` or `<name>@<marketplace>`; `scope` is `user` (default), `project` or `local`. Validation failures return 400 `EBADARG`; a missing `claude` binary returns 503 `ECLINOTFOUND`; a CLI timeout returns 504 `ETIMEOUT`; a non-zero CLI exit returns **422 `ECLIEXIT`** with the CLI's message plus `stdout`/`stderr`. Successful runs return `{ ok: true, stdout, stderr }` and broadcast `cc_config_changed`. `GET /plugins/available` proxies `claude plugin list --available --json` as `{ installed, available }`.
@@ -1637,6 +1640,7 @@ GET /api/codex-config/config
GET /api/codex-config/file?path=<absolute-path>
PUT /api/codex-config/file Body: { scope, type, name?, content }
DELETE /api/codex-config/file Body: { scope, type, name? }
POST /api/codex-config/project-memory Body: { dir, content }
GET /api/codex-config/backups[?scope=&type=]
GET /api/codex-config/mcp
GET /api/codex-config/plugins
+179
View File
@@ -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);
});
});
+98
View File
@@ -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 };
+50
View File
@@ -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 };
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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) {