docs: OpenAPI and API.md cover every route; drop removed endpoints; guard config writes

- openapi-extra/run.js describes tmux pane runs only: the removed
  POST /api/run/{id}/message and stream-json run_stream/run_input_ack are gone.
- 108 operations added to OpenAPI (lanes, run, settings, reports, cc-config,
  pets, ai-proxies, tool-profiles, ai-tools, snapshots, locks, ...);
  openapi.yaml regenerated.
- docs/API.md: 74 routes added, 4 endpoints that no longer exist removed.
- Role is `developer`, not `member` (CLI.md, DATABASE.md, OVERVIEW.md);
  `agenthub update` -> `update-check`; help lists `server install-system|tools`
  and `users provision`; ARCHITECTURE.md's stale paths and names fixed.
- cc-config/codex-config writes carry sameOriginGuard (an MCP server or plugin
  is code Claude runs later), with a test; `command -v` takes its name as $1.
This commit is contained in:
2026-10-22 12:17:18 +07:00
parent 50ac0f0daf
commit 57f02f0d89
19 changed files with 8345 additions and 1531 deletions
+18 -17
View File
@@ -14,7 +14,8 @@
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
* write is preceded by a timestamped backup; the CLI-delegated writes are
* not files this server touches directly, so there is nothing to back up —
* the CLI owns `~/.claude.json`.
* the CLI owns `~/.claude.json`. Every write carries `sameOriginGuard`: an
* MCP server or a plugin is code Claude Code later runs.
*
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
* account the local CLI runs as; those carry identity metadata only, never
@@ -199,7 +200,7 @@ router.get("/keybindings", (_req, res) => {
// PUT /api/cc-config/keybindings — structured overwrite of the user's
// ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key,
// action }] }] }. Backs the file up first and preserves top-level metadata.
router.put("/keybindings", (req, res) => {
router.put("/keybindings", sameOriginGuard, (req, res) => {
const { groups } = req.body || {};
if (!Array.isArray(groups)) {
return res
@@ -245,7 +246,7 @@ router.get("/file", (req, res) => {
// Plugins, MCP, hooks-in-settings, and settings.json files are intentionally
// not mutable here. See cc-mutate.js for the rationale.
router.put("/file", (req, res) => {
router.put("/file", sameOriginGuard, (req, res) => {
const { scope, type, name, content, project } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
@@ -271,7 +272,7 @@ router.put("/file", (req, res) => {
}
});
router.delete("/file", (req, res) => {
router.delete("/file", sameOriginGuard, (req, res) => {
const { scope, type, name, project } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
@@ -355,7 +356,7 @@ router.get("/plugins/available", async (_req, res) => {
}
});
router.post("/plugins/install", async (req, res) => {
router.post("/plugins/install", sameOriginGuard, async (req, res) => {
const { spec, scope } = req.body || {};
try {
const result = await ccCli.pluginInstall({ spec, scope });
@@ -372,7 +373,7 @@ const PLUGIN_ACTIONS = {
uninstall: ccCli.pluginUninstall,
};
router.post("/plugins/action", async (req, res) => {
router.post("/plugins/action", sameOriginGuard, async (req, res) => {
const { key, action, reload } = req.body || {};
const run = PLUGIN_ACTIONS[action];
if (!run) {
@@ -409,7 +410,7 @@ router.post("/plugins/action", async (req, res) => {
* sessions to re-read what is already there — so it neither queues on the CLI
* mutation chain nor emits `cc_config_changed`.
*/
router.post("/reload", async (req, res) => {
router.post("/reload", sameOriginGuard, async (req, res) => {
const { kinds } = req.body || {};
if (kinds !== undefined && !Array.isArray(kinds)) {
return res.status(400).json({
@@ -436,7 +437,7 @@ router.get("/plugins/update-all", (_req, res) => {
res.json({ batch: readUpdateAll() });
});
router.post("/plugins/update-all", async (req, res) => {
router.post("/plugins/update-all", sameOriginGuard, async (req, res) => {
if (isRunning()) {
return res.status(409).json({
error: { code: "EBUSY", message: "an update-all batch is already running" },
@@ -481,7 +482,7 @@ router.post("/plugins/update-all", async (req, res) => {
}
});
router.post("/marketplaces/add", async (req, res) => {
router.post("/marketplaces/add", sameOriginGuard, async (req, res) => {
const { source, scope } = req.body || {};
try {
const result = await ccCli.marketplaceAdd({ source, scope });
@@ -496,7 +497,7 @@ const MARKETPLACE_ACTIONS = {
update: ccCli.marketplaceUpdate,
};
router.post("/marketplaces/action", async (req, res) => {
router.post("/marketplaces/action", sameOriginGuard, async (req, res) => {
const { name, action } = req.body || {};
const run = MARKETPLACE_ACTIONS[action];
if (!run) {
@@ -523,7 +524,7 @@ router.post("/marketplaces/action", async (req, res) => {
// /mcp above stays on `readMcpServers`; the client refetches it after either
// call below succeeds.
router.post("/mcp/add", async (req, res) => {
router.post("/mcp/add", sameOriginGuard, async (req, res) => {
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
try {
const result = await ccMcpCli.mcpAdd({
@@ -542,7 +543,7 @@ router.post("/mcp/add", async (req, res) => {
}
});
router.post("/mcp/remove", async (req, res) => {
router.post("/mcp/remove", sameOriginGuard, async (req, res) => {
const { name, scope } = req.body || {};
try {
const result = await ccMcpCli.mcpRemove({ name, scope });
@@ -560,7 +561,7 @@ router.post("/mcp/remove", async (req, res) => {
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
// target chosen by the caller would let any request create a settings.json
// anywhere on disk. Reads keep the override; writes do not.
router.patch("/settings", (req, res) => {
router.patch("/settings", sameOriginGuard, (req, res) => {
const { scope, set, unset } = req.body || {};
try {
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
@@ -607,7 +608,7 @@ router.get("/profiles", async (_req, res) => {
}
});
router.post("/profiles", async (req, res) => {
router.post("/profiles", sameOriginGuard, async (req, res) => {
try {
const owner = await ccUsage.liveOwner();
const result = ccProfiles.saveProfile((req.body || {}).name, { owner });
@@ -619,7 +620,7 @@ router.post("/profiles", async (req, res) => {
});
// Rename is a label edit; the profile's `name` identifier never moves.
router.patch("/profiles", (req, res) => {
router.patch("/profiles", sameOriginGuard, (req, res) => {
try {
const body = req.body || {};
const result = ccProfiles.renameProfile(body.name, body.label);
@@ -630,7 +631,7 @@ router.patch("/profiles", (req, res) => {
}
});
router.post("/profiles/activate", async (req, res) => {
router.post("/profiles/activate", sameOriginGuard, async (req, res) => {
try {
const owner = await ccUsage.liveOwner();
const result = ccProfiles.activateProfile((req.body || {}).name, { owner });
@@ -673,7 +674,7 @@ router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
});
// Identifier travels in the body, not the URL — same reason as DELETE /file.
router.delete("/profiles", (req, res) => {
router.delete("/profiles", sameOriginGuard, (req, res) => {
try {
const result = ccProfiles.deleteProfile((req.body || {}).name);
emitChanged({ action: "delete", type: "profile", name: result.name, project: null });
+7 -7
View File
@@ -135,7 +135,7 @@ router.get("/file", (req, res) => {
// anywhere on disk (same reasoning cc-config.js's PATCH /settings gives for
// refusing `?cwd=` there). Reads keep the override.
router.put("/file", (req, res) => {
router.put("/file", sameOriginGuard, (req, res) => {
const { scope, type, name, content } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
@@ -151,7 +151,7 @@ router.put("/file", (req, res) => {
}
});
router.delete("/file", (req, res) => {
router.delete("/file", sameOriginGuard, (req, res) => {
const { scope, type, name } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
@@ -282,7 +282,7 @@ router.get("/marketplaces", async (_req, res) => {
}
});
router.post("/mcp/add", async (req, res) => {
router.post("/mcp/add", sameOriginGuard, async (req, res) => {
try {
const result = await codexCli.mcpAdd(req.body || {});
return cliResult(res, result, { action: "add", type: "mcp", name: (req.body || {}).name });
@@ -291,7 +291,7 @@ router.post("/mcp/add", async (req, res) => {
}
});
router.post("/mcp/remove", async (req, res) => {
router.post("/mcp/remove", sameOriginGuard, async (req, res) => {
const { name } = req.body || {};
try {
const result = await codexCli.mcpRemove(name);
@@ -306,7 +306,7 @@ const PLUGIN_ACTIONS = {
remove: codexCli.pluginRemove,
};
router.post("/plugins/action", async (req, res) => {
router.post("/plugins/action", sameOriginGuard, async (req, res) => {
const { spec, action } = req.body || {};
const run = PLUGIN_ACTIONS[action];
if (!run) {
@@ -325,7 +325,7 @@ router.post("/plugins/action", async (req, res) => {
}
});
router.post("/marketplaces/add", async (req, res) => {
router.post("/marketplaces/add", sameOriginGuard, async (req, res) => {
const { url } = req.body || {};
try {
const result = await codexCli.marketplaceAdd(url);
@@ -339,7 +339,7 @@ const MARKETPLACE_ACTIONS = {
remove: codexCli.marketplaceRemove,
};
router.post("/marketplaces/action", async (req, res) => {
router.post("/marketplaces/action", sameOriginGuard, async (req, res) => {
const { name, action } = req.body || {};
const run = MARKETPLACE_ACTIONS[action];
if (!run) {