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
-1
View File
@@ -94,7 +94,6 @@ const EXPECTED_API_PATHS = [
"/api/run/files",
"/api/run/binary",
"/api/run/{id}",
"/api/run/{id}/message",
"/api/webhooks",
"/api/webhooks/providers",
"/api/webhooks/{id}",
+11
View File
@@ -1215,4 +1215,15 @@ describe("/api/cc-config", () => {
assert.equal(status, 400);
assert.equal(body.error.code, "EBADARG");
});
it("refuses a write from a foreign origin before it reaches the CLI", async () => {
for (const p of ["/api/cc-config/mcp/add", "/api/cc-config/plugins/install"]) {
const { status } = await fetchJson(p, {
method: "POST",
body: { name: "x", command: "true", plugin: "x@y" },
headers: { Origin: "https://evil.example" },
});
assert.equal(status, 403, p);
}
});
});
+8
View File
@@ -20,6 +20,11 @@ const misc = require("./openapi-extra/misc");
const auth = require("./openapi-extra/auth");
const files = require("./openapi-extra/files");
const artifacts = require("./openapi-extra/artifacts");
// Routes the spec had missed (audit 2026-10-22): new path keys only — a key
// that already existed got its missing method in place instead.
const lanesMore = require("./openapi-extra/lanes-more");
const toolsMore = require("./openapi-extra/tools-more");
const opsMore = require("./openapi-extra/ops-more");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
// richer descriptions. Listed last so they win the merge.
@@ -58,6 +63,9 @@ module.exports = combine(
auth,
files,
artifacts,
lanesMore,
toolsMore,
opsMore,
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
+59
View File
@@ -1903,6 +1903,65 @@ const paths = {
},
},
},
patch: {
tags: ["CcConfig"],
summary: "Merge keys into one settings layer",
description:
'Sets and/or unsets dotted keys (`set: {"hooks.PreToolUse": [...]}`, `unset: ["model"]`) in one settings.json layer. The file is re-read unredacted and only the named keys change; a value containing the `<redacted>` placeholder is refused (`EREDACTED`). The project layers resolve against the SERVER\'s cwd, never `?cwd=`. An existing file is backed up first. Broadcasts `cc_config_changed`. Admin-only in server mode.',
operationId: "ccConfigPatchSettings",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["scope"],
properties: {
scope: { type: "string", enum: ["user", "project", "project-local"] },
set: { type: "object", additionalProperties: true },
unset: { type: "array", items: { type: "string" } },
},
},
},
},
},
responses: {
200: {
description: "Written (or nothing changed).",
content: {
"application/json": {
schema: {
type: "object",
properties: {
path: { type: "string" },
backupPath: { type: "string", nullable: true },
created: { type: "boolean" },
changed: { type: "array", items: { type: "string" } },
},
},
},
},
},
400: {
description: "`EBADSCOPE`, `EBADCONTENT` (nothing to change) or `EREDACTED`.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
409: {
description: "`EBADJSON` — the file on disk is not a JSON object; fix it by hand.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
413: {
description: "`ETOOLARGE`.",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/cc-config/memory": {
+762
View File
@@ -0,0 +1,762 @@
/**
* @file OpenAPI fragment for the workspace (lane) routes the base spec and
* server/openapi-extra/lanes.js did not cover: the lane list and create, the
* folder browser, pipeline/feature/stage reads and declarations, proof
* screenshots, the MCP/profile/integration/CI/sync-base runtime primitives,
* removing a session's worktree, housekeeping (`/gc`), a workspace's own
* context (server/routes/lane-sources.js: instructions, extra folders,
* context files, memory), plus the cross-lane named locks (/api/locks) and
* the project tree (/api/projects). Exports `{ tags, schemas, paths }` for
* server/openapi-extra.js. Every /api/lanes path is also served under
* /api/workspaces by the same handlers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Locks",
description:
"Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.",
},
{
name: "Projects",
description:
"Workspaces grouped by the git repository they belong to, with that repository's worktrees (server/lib/projects.js).",
},
];
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
const json = (schema) => ({ content: { "application/json": { schema } } });
const obj = { type: "object", additionalProperties: true };
const notFound = { description: "ENOLANE — workspace not found." };
const crossOrigin = { description: "The browser request was not same-origin." };
const s3Refused = {
description:
"Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member's folder — the runtime layer runs as the dashboard's own account).",
};
const SOURCES_VIEW = {
type: "object",
description: "A workspace's own context, as the Context panel shows it.",
properties: {
instructions: {
type: "string",
description: "The workspace's instructions (`''` when unset).",
},
supported: {
type: "boolean",
description: "Whether the workspace's tool takes instructions and sources (Claude, Codex).",
},
maxFileBytes: { type: "integer", example: 10485760 },
maxInstructions: { type: "integer", example: 16000 },
sources: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
kind: { type: "string", enum: ["folder", "file"] },
path: { type: "string" },
name: { type: "string" },
size: { type: "integer", nullable: true },
added_at: { type: "string", format: "date-time" },
missing: { type: "boolean", description: "The path no longer exists on disk." },
},
},
},
},
};
const sourcesErrors = {
400: { description: "EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code." },
403: {
description: "EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder).",
},
404: { description: "ENOLANE, or ENOSOURCE for an unknown source id." },
};
const paths = {
"/api/lanes": {
get: {
tags: ["Lanes"],
summary: "List workspaces with live counts",
description:
"Every workspace the caller may see (server mode: a developer only their own), each as the full lane payload (stage, pipeline nodes, liveness, run state). One `tmux list-sessions` answers every lane's run. Working-copy facts are NOT here — see `GET /api/lanes/{id}/git`.",
operationId: "listLanes",
responses: {
200: {
description: "The lanes and their counts.",
...json({
type: "object",
properties: {
lanes: { type: "array", items: obj },
counts: {
type: "object",
properties: {
total: { type: "integer" },
running: { type: "integer" },
needs_you: { type: "integer" },
dead: { type: "integer" },
},
},
},
}),
},
},
},
post: {
tags: ["Lanes"],
summary: "Create a workspace for an existing folder",
description:
"Inserts a lane row (owner = the caller, never from the body) and broadcasts `lane_update`. A non-admin may only name a folder inside their own (EFOLDER), never one another user's workspace owns (EOWNED). Uniqueness is per (cwd, tool). Same-origin only.",
operationId: "createLane",
requestBody: {
required: true,
...json({
type: "object",
required: ["cwd"],
properties: {
cwd: { type: "string", description: "Absolute folder path." },
title: { type: "string" },
branch: { type: "string", nullable: true },
pipeline: { type: "string", default: "default" },
kind: { type: "string", enum: ["adopted", "managed"], default: "adopted" },
tool: { type: "string", default: "claude", description: "An AI Tools catalog id." },
},
}),
},
responses: {
201: { description: "`{lane}` — the new lane payload.", ...json(obj) },
400: {
description: "EBADCWD, EBADPIPELINE, EBADKIND, EBADFIELD, EBADTOOL or EDISABLED.",
},
403: { description: "EFOLDER, or not same-origin." },
409: {
description:
"EDUPCWD (a workspace already owns that cwd for this tool), EOWNED, EOWNERFOLDER or EMEMBERGONE.",
},
},
},
},
"/api/lanes/browse": {
get: {
tags: ["Lanes"],
summary: "List a server folder for the folder browser",
description:
"Read-only directory listing for the Add workspace dialogs (a browser cannot expose absolute paths from its own picker). Folders first; each entry carries `mtime` (ms) and, for a folder, `isGitRepo`. A developer browses only inside their own folder (by real path; EFOLDER), and never sees the dot entries at its top. An admin with their own Linux account (S3) starts in that home; `host=1` opens the host account's home instead.",
operationId: "browseLaneFolders",
parameters: [
{
name: "path",
in: "query",
schema: { type: "string" },
description: "Default: the caller's start home.",
},
{
name: "files",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "List files too; `path` may then name a file (its folder opens).",
},
{
name: "hidden",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "List dot entries too.",
},
{
name: "host",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "S3 admin: open the host account's home.",
},
],
responses: {
200: {
description:
"`{path, parent, entries:[{name, path, isGitRepo, file?, mtime}], home, hostHome?}` — `parent` is null at the top.",
...json(obj),
},
400: { description: "ENOTFOUND (path does not exist) or ENOTADIR." },
403: { description: "EFOLDER — outside the caller's own folder." },
},
},
},
"/api/lanes/pipelines": {
get: {
tags: ["Lanes"],
summary: "List loaded pipeline templates",
description:
"Templates loaded from DASHBOARD_PIPELINES_DIR. AgentHub ships none, so this is empty unless a template was dropped in (pipelines are dormant).",
operationId: "listLanePipelines",
responses: { 200: { description: "`{pipelines:[…template]}`.", ...json(obj) } },
},
},
"/api/lanes/pipeline-stats": {
get: {
tags: ["Lanes"],
summary: "Per-node pass statistics across lanes and archived features",
description:
"Folds `lane_stage_events` into one verdict per (run, node): `entered`, `passed`, `failed`, `withEvidence` count declarations; `detected` counts nodes only inferred from hooks. Pipelines of in-scope lanes with no events appear at zero. The `lanes` list (picker) is never narrowed by `lane`. Server mode: a developer's aggregate covers their own workspaces.",
operationId: "lanePipelineStats",
parameters: [
{
name: "lane",
in: "query",
schema: { type: "string" },
description: "A lane id, or `all`/empty for every lane.",
},
],
responses: {
200: {
description:
"`{pipelines:[{id, name, runs, nodes:[{id, label, icon, gate, command, entered, passed, withEvidence, failed, detected}]}], lanes:[{id, title, cwd}]}`, busiest pipeline first.",
...json(obj),
},
400: { description: "EBADLANE — `lane` is not an id." },
},
},
},
"/api/lanes/gc": {
post: {
tags: ["Lanes"],
summary: "Machine-wide lane housekeeping",
description:
"Kills orphaned (parent pid 1) Playwright-MCP process trees under LANES_ROOT and caps every hook log over 10 MB under `LANES_ROOT/.state/lane<N>/logs/` to its last 2 MB, in place. `dryRun: true` only reports. Admin only in server mode (EADMIN); same-origin.",
operationId: "laneHousekeeping",
requestBody: json({ type: "object", properties: { dryRun: { type: "boolean" } } }),
responses: {
200: {
description: "`{reaped:[pid], capped:[{path, sizeBefore}]}`.",
...json(obj),
},
403: { description: "EADMIN, or not same-origin." },
500: { description: "The sweep failed." },
},
},
},
"/api/lanes/{id}/stage": {
post: {
tags: ["Lanes"],
summary: "Declare a lane's stage",
description:
"The driving session's own claim of progress (`agenthub stage`). Clears detection columns on a real transition; a declaration on a node BEFORE the current one drops the stage records of every node ahead. The dashboard UI never calls this. With no pipeline template installed the lane has no stages (ENOPIPELINE). Broadcasts `lane_update`. Same-origin.",
operationId: "declareLaneStage",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
properties: {
stage: { type: "string", description: "A node id or alias of the lane's pipeline." },
status: { type: "string" },
evidence: { type: "string", nullable: true },
note: { type: "string" },
result: { type: "string", description: "`fail` marks the node failed." },
},
}),
},
responses: {
200: { description: "`{lane}`.", ...json(obj) },
400: {
description:
"ENOPIPELINE (no template installed) or EBADSTAGE (not a node of this pipeline; `error.stages` lists the valid ids).",
},
403: crossOrigin,
404: notFound,
},
},
},
"/api/lanes/{id}/features": {
get: {
tags: ["Lanes"],
summary: "List a lane's features",
description:
"Every feature the lane has activated, live or archived, each with its computed pipeline view (`pipeline_name`, `pipeline_nodes`, `progress`).",
operationId: "listLaneFeatures",
parameters: [idParam],
responses: { 200: { description: "`{features:[…]}`.", ...json(obj) }, 404: notFound },
},
},
"/api/lanes/{id}/features/{slug}": {
get: {
tags: ["Lanes"],
summary: "One feature of a lane",
description: "Including an archived feature's saved pipeline state.",
operationId: "getLaneFeature",
parameters: [
idParam,
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
],
responses: {
200: { description: "`{feature}`.", ...json(obj) },
404: { description: "ENOLANE or ENOFEATURE." },
},
},
},
"/api/lanes/{id}/features/activate": {
post: {
tags: ["Lanes"],
summary: "Switch the lane to a feature",
description:
"`agenthub feature activate`: archives the outgoing feature's pipeline and clears detection (a detection belongs to the feature that produced it), then makes `slug` the live feature. Broadcasts `lane_update`. Same-origin.",
operationId: "activateLaneFeature",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["slug"],
properties: { slug: { type: "string" }, title: { type: "string" } },
}),
},
responses: {
200: { description: "`{lane, feature}`.", ...json(obj) },
400: { description: "EBADSLUG." },
403: crossOrigin,
404: notFound,
},
},
},
"/api/lanes/{id}/proof": {
get: {
tags: ["Lanes"],
summary: "List a lane's proof screenshots",
description:
"Feature slugs under the lane's proof root (`.playwright-mcp/proof`), plus activated features with no proof yet; most recently touched first.",
operationId: "listLaneProof",
parameters: [idParam],
responses: {
200: {
description: "`{features:[{slug, groups:{<group>:[image]}, ticket_report, mtime}]}`.",
...json(obj),
},
404: notFound,
},
},
},
"/api/lanes/{id}/proof/{slug}": {
delete: {
tags: ["Lanes"],
summary: "Delete proof screenshots",
description:
"Body `{group, images}` unlinks those images (and an emptied group dir); `{group}` removes the whole group; no body removes every group except `ticket/`. Containment-checked to the proof root. Same-origin; S3 guard.",
operationId: "deleteLaneProof",
parameters: [
idParam,
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
],
requestBody: json({
type: "object",
properties: {
group: { type: "string" },
images: { type: "array", items: { type: "string" } },
},
}),
responses: {
200: { description: "`{deleted}` — number of files removed.", ...json(obj) },
400: { description: "EBADPATH." },
403: s3Refused,
404: { description: "ENOLANE or ENOFEATURE." },
},
},
},
"/api/lanes/{id}/proof/{slug}/{group}/{file}": {
get: {
tags: ["Lanes"],
summary: "Serve one proof file",
description:
"A proof image or the ticket's REPORT.html, containment-checked (no traversal or symlink escape, image/HTML extensions only). Plain-text `not found` otherwise.",
operationId: "getLaneProofFile",
parameters: [
idParam,
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
{ name: "group", in: "path", required: true, schema: { type: "string" } },
{ name: "file", in: "path", required: true, schema: { type: "string" } },
],
responses: {
200: { description: "The file (`text/html`, `image/png` or `image/jpeg`)." },
404: { description: "Lane or file not found." },
},
},
},
"/api/lanes/{id}/proof-link": {
post: {
tags: ["Lanes"],
summary: "Converge the lane's proof/ onto .playwright-mcp/proof",
description:
"Merges a real `proof/` dir into the canonical root and replaces it with a relative symlink; idempotent, never clobbers a stray file named `proof`. Never automatic (`agenthub lanes proof-link`). Same-origin; S3 guard.",
operationId: "linkLaneProof",
parameters: [idParam],
responses: {
200: { description: "`{linked}` — false when nothing changed.", ...json(obj) },
403: s3Refused,
404: notFound,
},
},
},
"/api/lanes/{id}/mcp/sync": {
post: {
tags: ["Lanes"],
summary: "Give the lane its source repo's MCP servers",
description:
"Relocates the source repo's configured `mcpServers` (from ~/.claude.json) into `<lane>/.mcp.json` (git-excluded), pins Playwright's output dir, seeds Chromium profiles. Never touches permissions or settings.local.json. Same-origin; S3 guard.",
operationId: "syncLaneMcp",
parameters: [idParam],
responses: {
200: { description: "`{servers:[name], profilesSeeded:[name]}`.", ...json(obj) },
400: { description: "ENOMCPCONFIG — the source repo has no MCP config." },
403: s3Refused,
404: notFound,
500: { description: "ERUNTIME." },
},
},
},
"/api/lanes/{id}/profile/init": {
post: {
tags: ["Lanes"],
summary: "Scaffold .agenthub/profile/ for a detected Node.js project",
description:
"Always targets the lane's own folder. No detectable Node.js project is a normal 200 (`scaffolded:false`). Same-origin; S3 guard.",
operationId: "initLaneProfile",
parameters: [idParam],
requestBody: json({
type: "object",
properties: { force: { type: "boolean", description: "Overwrite an existing profile." } },
}),
responses: {
200: {
description: "`{scaffolded:true, written, todos}` or `{scaffolded:false, reason}`.",
...json(obj),
},
400: { description: "EPROFILEEXISTS (without `force`)." },
403: s3Refused,
404: notFound,
},
},
},
"/api/lanes/{id}/integrations/{name}": {
get: {
tags: ["Lanes"],
summary: "Whether a profile integration is enabled",
description:
"Reads `<NAME>_ENABLED` from `.agenthub/profile/integrations.env` in the lane folder or its source repo. Read-only.",
operationId: "laneIntegrationEnabled",
parameters: [
idParam,
{
name: "name",
in: "path",
required: true,
schema: { type: "string" },
example: "tracker",
},
],
responses: { 200: { description: "`{enabled}`.", ...json(obj) }, 404: notFound },
},
},
"/api/lanes/{id}/ci/status": {
get: {
tags: ["Lanes"],
summary: "GitHub Actions status snapshot for a ref",
description:
"One `gh` read for a branch or sha in the lane's repo — not a poll loop; the caller owns any wait/retry. Read-only.",
operationId: "laneCiStatus",
parameters: [
idParam,
{ name: "ref", in: "query", required: true, schema: { type: "string" } },
],
responses: {
200: {
description: "`{state, ref, checks:[…]}` (`pending` with no checks yet).",
...json(obj),
},
400: { description: "EBADREF — ref is required." },
404: notFound,
500: { description: "ERUNTIME (gh failed)." },
},
},
},
"/api/lanes/{id}/sync-base": {
post: {
tags: ["Lanes"],
summary: "Merge the base branch into the feature branch (gated)",
description:
"The one sanctioned merge of the ship-feature pipeline: fetch, a migration-number collision preflight, then merge — under the lane lock, synchronously. Every documented outcome is 200 with `code`: 0 (ok; `check` adds `devDelta`/`overlap`), 4 (conflict left in place, `conflictedFiles`), 5 (migration collision, `collisions`). Requires a lane profile. Never writes stage/status/notes. Same-origin; S3 guard.",
operationId: "syncLaneBase",
parameters: [idParam],
requestBody: json({
type: "object",
properties: {
mode: { type: "string", enum: ["check", "merge", "continue"], default: "merge" },
branch: { type: "string" },
},
}),
responses: {
200: { description: "`{code, …}` as above.", ...json(obj) },
400: {
description:
"ENOPROFILE, EBADBRANCH, EUNRESOLVED, EMERGEUNCOMMITTED or another runtime input code.",
},
403: s3Refused,
404: notFound,
500: { description: "ERUNTIME." },
},
},
},
"/api/lanes/{id}/session-worktree/remove": {
post: {
tags: ["Lanes"],
summary: "Remove a session's `claude --worktree` tree",
description:
"The one dashboard path that deletes a worktree: resolved from the session row (`sessionWorktree`), never a path. `git worktree remove` without `--force`, then `git branch -d` (never `-D`) when git agrees the branch is merged — else the branch is kept and reported. Refused while a live pane holds the session. Same-origin.",
operationId: "removeSessionWorktree",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["session", "confirm"],
properties: { session: { type: "string" }, confirm: { type: "boolean", enum: [true] } },
}),
},
responses: {
200: { description: "`{removed:true, branch, branchKept}`.", ...json(obj) },
400: { description: "ECONFIRM or ENOWORKTREE." },
403: crossOrigin,
404: notFound,
409: {
description:
"ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).",
},
},
},
},
"/api/lanes/{id}/sources": {
get: {
tags: ["Lanes"],
summary: "A workspace's instructions, extra folders and context files",
operationId: "getLaneSources",
parameters: [idParam],
responses: {
200: { description: "The context view.", ...json(SOURCES_VIEW) },
404: notFound,
},
},
},
"/api/lanes/{id}/instructions": {
put: {
tags: ["Lanes"],
summary: "Set a workspace's instructions",
description:
"Stored on the lane row and given to every new Claude/Codex pane at spawn (never written into the repo). `null` clears. Capped at `maxInstructions`. Broadcasts `lane_update`. Same-origin.",
operationId: "setLaneInstructions",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["instructions"],
properties: { instructions: { type: "string", nullable: true } },
}),
},
responses: {
200: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
},
},
},
"/api/lanes/{id}/sources/folder": {
post: {
tags: ["Lanes"],
summary: "Add an extra folder",
description:
"An absolute, existing directory that is not (and does not contain) the OS home and is not the workspace's own folder. A developer may name only folders in their own (checked before the path is probed, and again on the real path). The folder stays the user's — AgentHub never deletes it. Same-origin.",
operationId: "addLaneSourceFolder",
parameters: [idParam],
requestBody: {
required: true,
...json({ type: "object", required: ["path"], properties: { path: { type: "string" } } }),
},
responses: {
201: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
},
},
},
"/api/lanes/{id}/sources/file": {
post: {
tags: ["Lanes"],
summary: "Upload a context file",
description:
"Raw bytes (≤ 10 MB) with the name in the `X-File-Name` header (URI-encoded). Stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives. Same-origin.",
operationId: "addLaneSourceFile",
parameters: [
idParam,
{ name: "X-File-Name", in: "header", required: true, schema: { type: "string" } },
],
requestBody: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
responses: {
201: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
413: { description: "ETOOBIG — over 10 MB." },
},
},
},
"/api/lanes/{id}/sources/clone": {
post: {
tags: ["Lanes"],
summary: "Clone a repo as an extra folder",
description:
"Clones `url` (the `publish` URL allowlist; run as the folder's account with that account's own git identity) BESIDE the workspace folder — no path from the request — then adds it as an extra folder. The clone is the user's; removing the source never deletes it. Same-origin.",
operationId: "cloneLaneSource",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["url"],
properties: {
url: { type: "string" },
name: { type: "string", description: "Folder name; default derived from the URL." },
credentials: {
type: "object",
additionalProperties: true,
description: "A typed HTTPS login for this one clone.",
},
},
}),
},
responses: {
201: { description: "The context view plus `path` (the clone).", ...json(obj) },
400: {
description:
"EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).",
},
403: { description: "EFOLDER, or not same-origin." },
404: notFound,
409: {
description:
"EEXISTS (name taken), ECREDENTIALS / ESSHAUTH (the remote wants a login), ENOREPO, EOWNERFOLDER or EMEMBERGONE.",
},
},
},
},
"/api/lanes/{id}/sources/{sourceId}": {
delete: {
tags: ["Lanes"],
summary: "Remove an extra folder or context file",
description:
"Drops the row; for an uploaded file AgentHub also deletes its own stored copy. A folder on disk is never deleted. Same-origin.",
operationId: "removeLaneSource",
parameters: [
idParam,
{ name: "sourceId", in: "path", required: true, schema: { type: "integer" } },
],
responses: {
200: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
},
},
},
"/api/lanes/{id}/memory": {
get: {
tags: ["Lanes"],
summary: "The memory files the workspace's agent already reads",
description:
"Read-only: the folder's CLAUDE.md / .claude/CLAUDE.md / CLAUDE.local.md / AGENTS.md and, for Claude, up to 50 `.md` files of its auto-memory dir `<CLAUDE_HOME>/projects/<encoded cwd>/memory/`. Every path derives from the lane row.",
operationId: "getLaneMemory",
parameters: [idParam],
responses: { 200: { description: "`{files:[…]}`.", ...json(obj) }, 404: notFound },
},
},
"/api/locks": {
get: {
tags: ["Locks"],
summary: "List held named locks",
description:
"Server mode: a developer sees only their own namespace, with the prefix stripped.",
operationId: "listLocks",
responses: {
200: { description: "`{locks:[{name, holder, since, ageSec}]}`.", ...json(obj) },
},
},
},
"/api/locks/{name}/acquire": {
post: {
tags: ["Locks"],
summary: "Try to take a named lock (never waits)",
description:
"Atomic mkdir under the locks root. A lock held longer than the max hold time is stale and broken. `name` is one plain name (no path). Same-origin.",
operationId: "acquireLock",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
...json({
type: "object",
required: ["holder"],
properties: { holder: { type: "string" } },
}),
},
responses: {
200: { description: "`{acquired:true}`.", ...json(obj) },
400: { description: "EBADNAME or EBADHOLDER." },
403: crossOrigin,
409: {
description: "`{acquired:false, holder, since, ageSec}` — held by someone else.",
...json(obj),
},
},
},
},
"/api/locks/{name}/release": {
post: {
tags: ["Locks"],
summary: "Release a named lock",
description: "Only the recorded holder may release it. Same-origin.",
operationId: "releaseLock",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
...json({
type: "object",
required: ["holder"],
properties: { holder: { type: "string" } },
}),
},
responses: {
200: { description: "`{ok:true}`.", ...json(obj) },
400: { description: "EBADNAME or EBADHOLDER." },
403: crossOrigin,
404: { description: "ENOLOCK — not held." },
409: { description: "ENOTHOLDER — `error.currentHolder` names the owner." },
},
},
},
"/api/projects": {
get: {
tags: ["Projects"],
summary: "Workspaces grouped by git repository",
description:
"Each project: `{root, name, workspaces:[id], worktrees:[{path, branch, main, workspace_id, source?}]}` (`source` is `claude` for a `.claude/worktrees` tree, `agenthub` under LANES_ROOT); non-repo folders last as `Folders` (`root:null`). Roots and `git worktree list` are cached in memory (worktrees 30 s). Read on demand, never polled. Server mode: a developer's own workspaces, without worktrees another user's workspace sits on.",
operationId: "listProjects",
responses: { 200: { description: "`{projects:[…]}`.", ...json(obj) } },
},
},
"/api/projects/refresh": {
post: {
tags: ["Projects"],
summary: "Drop the project caches and list again",
operationId: "refreshProjects",
responses: {
200: { description: "`{projects:[…]}`.", ...json(obj) },
403: crossOrigin,
},
},
},
};
module.exports = { tags, schemas: {}, paths };
+57 -1
View File
@@ -6,7 +6,8 @@
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
* Workspace page opens a directory with, and the Files tab's reads (tree,
* quick-open, download — also in a folder that is not a git repo) and upload.
* quick-open, download — also in a folder that is not a git repo) and upload,
* one lane's GET/DELETE, and the editor's whole-file save.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -299,6 +300,30 @@ const paths = {
},
},
"/api/lanes/{id}": {
get: {
tags: ["Lanes"],
summary: "One workspace",
description: "The same lane payload `GET /api/lanes` lists.",
operationId: "getLane",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "`{lane}`." },
404: { description: "ENOLANE — workspace not found." },
},
},
delete: {
tags: ["Lanes"],
summary: "Delete a workspace row",
description:
"Deletes the lane row only (its `lane_sources` rows cascade, and AgentHub's own copies of uploaded context files go with them). Stops nothing and never touches the folder, worktree or branch — `POST /api/lanes/{id}/remove` is the UI's path that also stops runs and stacks. Broadcasts `lane_update` with `{removed: id}`. Same-origin.",
operationId: "deleteLane",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "`{ok: true}`." },
403: { description: "The browser request was not same-origin." },
404: { description: "ENOLANE — workspace not found." },
},
},
patch: {
tags: ["Lanes"],
summary: "Partially update a lane",
@@ -635,6 +660,37 @@ const paths = {
404: { description: "Lane not found." },
},
},
put: {
tags: ["Lanes"],
summary: "Save one text file from the viewer's editor",
description:
"lane-file-write.js: the read path's guards (lane confinement, secret refusal) plus existing-text-file-only (never creates a file or touches a binary) and a `baseSha` stale check. Answers the file re-read at rev `work`, fresh `sha` included. Same-origin.",
operationId: "writeLaneFile",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["path", "content", "baseSha"],
properties: {
path: { type: "string" },
content: { type: "string" },
baseSha: { type: "string", description: "The `sha` the file was opened at." },
},
},
},
},
},
responses: {
200: { description: "`{available: true, …}` — the same shape as the GET." },
400: { description: "EBADPATH, ENOFILE, EBADCONTENT, ETOOBIG or EBINARY." },
403: { description: "ESECRET or EFOREIGN, or not same-origin." },
404: { description: "Lane not found." },
409: { description: "ESTALE — the file changed since `baseSha`." },
},
},
},
"/api/lanes/{id}/git/blame": {
get: {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+798
View File
@@ -0,0 +1,798 @@
/**
* @file OpenAPI fragment for the AI-tool configuration routes the base spec
* and the other fragments left out: AI Proxy profiles (`/api/ai-proxies`, also
* mounted at the pre-rename `/api/ai-routers`), the non-Claude login profiles
* (`/api/tool-profiles`), a tool's catalog-listed config files, model list and
* usage (`/api/ai-tools/{id}/…`), the Claude plugin/marketplace CLI routes and
* project-memory bootstrap under `/api/cc-config` (plus its Codex twin), and
* AgentHub's own git credential store (`/api/git-credentials`). No operation
* here returns key material — profiles and credentials report `hasKey` /
* `hasToken` only. Exports `{ tags, schemas, paths }` for
* server/openapi-extra.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "AI Proxies",
description:
"AI Proxy profiles (endpoint + key + model) a new Claude/Codex session may name. Also mounted at /api/ai-routers. No route returns a key.",
},
{
name: "Tool Profiles",
description:
"Saved login profiles of the AI tools other than Claude Code (Codex, Oh My Pi). Identity metadata only, never a token.",
},
{
name: "Git Credentials",
description:
"AgentHub's own git credential store (one login per protocol + host). Passwords go in, never out.",
},
];
const err = { $ref: "#/components/schemas/ErrorResponse" };
const json = (schema, description) => ({
description,
content: { "application/json": { schema } },
});
const errResp = (description) => json(err, description);
const body = (schema, required = true) => ({
required,
content: { "application/json": { schema } },
});
const schemas = {
AiProxyProfile: {
type: "object",
description:
"The browser-safe view of a profile: every stored field except the key, plus `hasKey` and `scope`.",
properties: {
name: { type: "string", description: "Server-made slug; never changes once saved." },
label: { type: "string" },
baseUrl: { type: "string", description: "The proxy's ROOT (a trailing /v1 is dropped)." },
model: { type: "string" },
models: { type: "object", additionalProperties: { type: "string" } },
behavesAs: {
type: "string",
description: "User-chosen Claude model the proxy model acts as; empty when unset.",
},
savedAt: { type: "string", format: "date-time" },
builtin: { type: "boolean", description: "True only for the built-in `native` profile." },
scope: { type: "string", enum: ["shared", "personal"] },
hasKey: { type: "boolean" },
},
},
AiProxyWriteRequest: {
type: "object",
properties: {
label: { type: "string" },
baseUrl: { type: "string" },
apiKey: {
type: "string",
description: "Write-only. On PATCH an omitted/blank key keeps the stored one.",
},
model: { type: "string" },
models: { type: "object", additionalProperties: { type: "string" } },
behavesAs: { type: "string" },
scope: {
type: "string",
enum: ["shared", "personal"],
description:
"POST only (server mode). `shared` is admin-only; a non-admin always writes a personal profile.",
},
},
},
AiProxyTestResult: {
type: "object",
properties: {
ok: { type: "boolean" },
status: {
type: "integer",
nullable: true,
description: "HTTP status the proxy answered with; null when unreachable.",
},
message: {
type: "string",
description:
"The model id on success, else up to 300 chars of the proxy's reply or the transport error.",
},
},
},
ToolProfileIdentity: {
type: "object",
properties: {
kind: { type: "string" },
email: { type: "string", nullable: true },
detail: { type: "string", nullable: true },
},
},
ToolProfileItem: {
type: "object",
properties: {
name: { type: "string" },
label: { type: "string" },
savedAt: { type: "integer", nullable: true, description: "Epoch ms." },
active: { type: "boolean", description: "This profile holds the login that is live now." },
kind: { type: "string" },
email: { type: "string", nullable: true },
detail: { type: "string", nullable: true },
},
},
ToolProfilesListResponse: {
type: "object",
properties: {
tools: {
type: "array",
items: {
type: "object",
properties: {
tool: { type: "string", example: "codex" },
supported: { type: "boolean" },
reason: {
type: "string",
description: "Why the tool cannot be switched (supported: false).",
},
items: { type: "array", items: { $ref: "#/components/schemas/ToolProfileItem" } },
active: {
allOf: [{ $ref: "#/components/schemas/ToolProfileIdentity" }],
nullable: true,
},
error: { allOf: [{ $ref: "#/components/schemas/ErrorObject" }], nullable: true },
},
},
},
},
},
ToolProfileNameRequest: {
type: "object",
properties: {
name: { type: "string", description: "Profile name (`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`)." },
label: { type: "string", description: "PATCH only: the new display label." },
},
},
GitCredentialItem: {
type: "object",
properties: {
protocol: { type: "string", enum: ["http", "https"] },
host: { type: "string", example: "git.example.com" },
username: { type: "string" },
savedAt: { type: "string" },
hasToken: { type: "boolean", enum: [true] },
},
},
GitCredentialsListResponse: {
type: "object",
properties: {
items: { type: "array", items: { $ref: "#/components/schemas/GitCredentialItem" } },
},
},
CcConfigCliResult: {
type: "object",
description:
"A finished `claude plugin …` run. A non-zero exit answers 422 `ECLIEXIT` with `code`, `stdout`, `stderr` beside the error.",
properties: {
ok: { type: "boolean", enum: [true] },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
ProjectMemoryCreateRequest: {
type: "object",
required: ["dir", "content"],
properties: {
dir: { type: "string", description: "Absolute path of an existing directory." },
content: { type: "string", description: "File text, at most 1 MB." },
},
},
ProjectMemoryCreateResponse: {
type: "object",
properties: {
file: { type: "string" },
size: { type: "integer" },
created: { type: "boolean", enum: [true] },
},
},
};
const cliErrors = {
400: errResp("`EBADARG` (invalid identifier, source or scope) or `EBADACTION`."),
422: errResp(
"The CLI exited non-zero (`ECLIEXIT`); the body also carries `code`, `stdout`, `stderr`."
),
502: errResp("`ECLIFAILED`."),
503: errResp("`ECLINOTFOUND` — no `claude` binary."),
504: errResp("`ETIMEOUT`."),
};
const cliOk = json(
{ $ref: "#/components/schemas/CcConfigCliResult" },
"The CLI succeeded; a `cc_config_changed` WebSocket message is broadcast."
);
const nameParam = { name: "name", in: "path", required: true, schema: { type: "string" } };
const toolParam = {
name: "tool",
in: "path",
required: true,
schema: { type: "string", example: "codex" },
description: "A tool the profiles module covers (`codex`, `omp`).",
};
const toolErrors = {
400: errResp("`EBADNAME` / `EBADLABEL`."),
404: errResp("`EBADTOOL` (unknown tool) or `ENOTFOUND` (no such profile)."),
409: errResp(
"`EUNSUPPORTED`, `ENOTINSTALLED`, `EBADJSON`, `EACTIVE`, `EUNSAVED`, `ENOAUTH`, `EDUPLICATE` or `ELABELTAKEN`."
),
502: errResp("`EOMP` — Oh My Pi's own auth command failed."),
};
const toolOk = json(
{
type: "object",
properties: {
ok: { type: "boolean", enum: [true] },
name: { type: "string" },
label: { type: "string" },
},
},
"Done."
);
const idParam = {
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "AI Tools catalog id.",
};
const indexParam = {
name: "index",
in: "path",
required: true,
schema: { type: "integer", minimum: 0 },
description: "Index into the tool's catalog-declared `config` list.",
};
const configErrors = {
400: errResp("`EBADFIELD` (text not a string) or `EBADSYNTAX` (JSON/JSONC/TOML does not parse)."),
403: errResp("`ESECRET` — the file (or its symlink target) has a credential-like name."),
404: errResp("`ENOTOOL`, `ENOFILE` (no such index) or `ENOENT` (missing / not a regular file)."),
409: errResp("`ESTALE` — the file changed since it was opened (PUT)."),
413: errResp("`ETOOBIG` — over 1 MB."),
415: errResp("`EBINARY` — not a text file."),
};
const projectMemoryOp = (tool, file, operationId, tag) => ({
post: {
tags: [tag],
summary: `Create ${file} in a project directory`,
description: `Creates \`<dir>/${file}\` for a project the dashboard has never run in — the one ${tag} write that takes a caller-chosen directory. \`dir\` must be an absolute, existing directory (resolved with realpath); an existing \`${file}\` — a symlink included — is refused (\`EEXISTS\`), never overwritten. Same-origin guarded; admin-only in server mode. Broadcasts \`${tool === "claude" ? "cc_config_changed" : "codex_config_changed"}\`.`,
operationId,
requestBody: body({ $ref: "#/components/schemas/ProjectMemoryCreateRequest" }),
responses: {
200: json({ $ref: "#/components/schemas/ProjectMemoryCreateResponse" }, "Created."),
400: errResp("`EBADDIR`, `EBADCONTENT` or `EOUTOFROOT`."),
409: errResp("`EEXISTS`."),
413: errResp("`ETOOLARGE`."),
},
},
});
const paths = {
"/api/ai-proxies": {
get: {
tags: ["AI Proxies"],
summary: "List AI Proxy profiles",
description:
"Every profile the viewer may choose: the built-in `native` first, then the shared ones, then the viewer's own personal ones (nobody lists another user's personal profiles, an admin included). Never carries a key — `hasKey` only.",
operationId: "listAiProxies",
responses: {
200: json(
{
type: "object",
properties: {
items: { type: "array", items: { $ref: "#/components/schemas/AiProxyProfile" } },
},
},
"Profiles."
),
},
},
post: {
tags: ["AI Proxies"],
summary: "Create an AI Proxy profile",
description:
'Saves a profile under a server-made name derived from `label`. In server mode a non-admin always creates a PERSONAL profile; `scope: "shared"` from a non-admin is `EADMIN`. Local mode writes the shared store. Same-origin guarded. The response is the browser-safe view (no key).',
operationId: "createAiProxy",
requestBody: body({ $ref: "#/components/schemas/AiProxyWriteRequest" }),
responses: {
201: json({ $ref: "#/components/schemas/AiProxyProfile" }, "Created."),
400: errResp("`EBADNAME`, `EBADLABEL`, `EBADURL` or `EBADMODEL`."),
403: errResp("`EADMIN` — only an admin may write a shared profile."),
409: errResp("`EBUILTIN` (the name `native`) or `EBADJSON`."),
},
},
},
"/api/ai-proxies/9router": {
get: {
tags: ["AI Proxies"],
summary: "Is 9router installed",
description:
"Reports whether a `9router` binary is on the server's login-shell PATH, so Settings can offer a link to its own UI. A file lookup only; nothing is spawned.",
operationId: "getAiProxy9router",
responses: {
200: json(
{ type: "object", properties: { installed: { type: "boolean" } } },
"Lookup result."
),
},
},
},
"/api/ai-proxies/test": {
post: {
tags: ["AI Proxies"],
summary: "Test an AI Proxy endpoint",
description:
"Probes `<baseUrl>/v1/messages` (Claude Code's shape, 1 output token) and `<baseUrl>/v1/responses` (Codex's, 16 tokens) in parallel, 15 s timeout, redirects not followed. A reachable proxy is a 200 whatever it replies. A blank `apiKey` falls back to the stored key of profile `name` ONLY while the submitted `baseUrl` equals the saved one (and only for the profile's owner); otherwise `EBADKEY`. The key is never echoed. Same-origin guarded.",
operationId: "testAiProxy",
requestBody: body({
type: "object",
required: ["baseUrl", "model"],
properties: {
baseUrl: { type: "string" },
model: { type: "string" },
apiKey: { type: "string" },
name: { type: "string", description: "Saved profile whose key to fall back to." },
},
}),
responses: {
200: json(
{
type: "object",
properties: {
claude: { $ref: "#/components/schemas/AiProxyTestResult" },
codex: { $ref: "#/components/schemas/AiProxyTestResult" },
},
},
"Probe outcomes."
),
400: errResp("`EBADKEY` (no key to test with), `EBADURL` or `EBADMODEL`."),
},
},
},
"/api/ai-proxies/{name}": {
patch: {
tags: ["AI Proxies"],
summary: "Update an AI Proxy profile",
description:
"Merges the given fields; the name never changes and an omitted/blank `apiKey` keeps the stored one. Also rewrites the per-session settings files that copy this profile, so a rotated key reaches live sessions. A personal profile is writable only by its owner (anyone else gets 404); a shared one only by an admin. Same-origin guarded.",
operationId: "updateAiProxy",
parameters: [nameParam],
requestBody: body({ $ref: "#/components/schemas/AiProxyWriteRequest" }),
responses: {
200: json({ $ref: "#/components/schemas/AiProxyProfile" }, "Updated."),
400: errResp("`EBADLABEL`, `EBADURL` or `EBADMODEL`."),
403: errResp("`EADMIN`."),
404: errResp("`ENOTFOUND`."),
409: errResp("`EBUILTIN` or `EBADJSON`."),
},
},
delete: {
tags: ["AI Proxies"],
summary: "Delete an AI Proxy profile",
description:
"Removes the profile and the per-session files that copy its key. Same ownership rules as PATCH. Same-origin guarded.",
operationId: "deleteAiProxy",
parameters: [nameParam],
responses: {
200: json({ type: "object", properties: { removed: { type: "string" } } }, "Removed."),
403: errResp("`EADMIN`."),
404: errResp("`ENOTFOUND`."),
409: errResp("`EBUILTIN`."),
},
},
},
"/api/tool-profiles": {
get: {
tags: ["Tool Profiles"],
summary: "List login profiles of every non-Claude tool",
description:
"One entry per covered tool. A tool that cannot be switched (Antigravity keeps its login in the OS keyring) is `supported: false` with a `reason`; one tool failing (Oh My Pi not installed) sets that entry's `error` without hiding the others. Identity metadata only, never a token. Staff-only in server mode. Claude's own profiles are `/api/cc-config/profiles`.",
operationId: "listToolProfiles",
responses: {
200: json({ $ref: "#/components/schemas/ToolProfilesListResponse" }, "Profiles by tool."),
},
},
},
"/api/tool-profiles/{tool}": {
post: {
tags: ["Tool Profiles"],
summary: "Save the tool's live login as a profile",
description:
"Snapshots the login the tool holds right now (Codex: `auth.json`; Oh My Pi: `omp auth export`). Without `name`, re-saves into the login's own profile or picks a free name. A second copy of the same account is `EDUPLICATE`; no live login is `ENOAUTH`. Same-origin guarded; admin-only in server mode.",
operationId: "saveToolProfile",
parameters: [toolParam],
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }, false),
responses: { 200: toolOk, ...toolErrors },
},
patch: {
tags: ["Tool Profiles"],
summary: "Rename a tool login profile",
description:
"Changes the display `label` of profile `name` (`ELABELTAKEN` when another profile already uses it).",
operationId: "renameToolProfile",
parameters: [toolParam],
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }),
responses: { 200: toolOk, ...toolErrors },
},
delete: {
tags: ["Tool Profiles"],
summary: "Delete a tool login profile",
description:
"Deletes profile `name` (body). The saved copy of the login in use right now is not deletable (`EACTIVE`).",
operationId: "deleteToolProfile",
parameters: [toolParam],
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }),
responses: {
200: json(
{ type: "object", properties: { ok: { type: "boolean" }, name: { type: "string" } } },
"Deleted."
),
...toolErrors,
},
},
},
"/api/tool-profiles/{tool}/activate": {
post: {
tags: ["Tool Profiles"],
summary: "Switch the tool to a saved login",
description:
"Makes profile `name` the tool's live login. The outgoing login is first saved back into its own profile; switching away from a login that was never saved is refused (`EUNSAVED`). Changes the HOST's login — admin-only in server mode, same-origin guarded.",
operationId: "activateToolProfile",
parameters: [toolParam],
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }),
responses: {
200: json(
{
type: "object",
properties: {
ok: { type: "boolean" },
name: { type: "string" },
savedOutgoing: {
type: "string",
nullable: true,
description: "Profile the outgoing login was saved into.",
},
written: {
type: "array",
items: { type: "string" },
description: "Files the switch wrote.",
},
},
},
"Switched."
),
...toolErrors,
},
},
},
"/api/ai-tools/{id}/models": {
get: {
tags: ["AI Tools"],
summary: "Model choices for a tool",
description:
"The models a run of this tool offers, from the tool's own source (Claude's and Codex's lists, else the catalog file's, plus models seen in its recorded token usage), and the user's chosen default. `flag` is false when the tool takes no model flag.",
operationId: "getAiToolModels",
parameters: [idParam],
responses: {
200: json(
{
type: "object",
properties: {
flag: { type: "boolean" },
models: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
label: { type: "string" },
hint: { type: "string" },
},
},
},
defaultModel: { type: "string", nullable: true },
},
},
"Models."
),
404: errResp("`ENOTOOL`."),
},
},
},
"/api/ai-tools/{id}/usage": {
get: {
tags: ["AI Tools"],
summary: "How much a tool has been used",
description:
"Count of the tool's sessions and the priced cost of their token usage (same `calculateCost` as everywhere else). A developer in server mode sees only their own sessions.",
operationId: "getAiToolUsage",
parameters: [idParam],
responses: {
200: json(
{
type: "object",
properties: { sessions: { type: "integer" }, cost: { type: "number" } },
},
"Usage."
),
404: errResp("`ENOTOOL`."),
},
},
},
"/api/ai-tools/{id}/config": {
get: {
tags: ["AI Tools"],
summary: "List a tool's editable config files",
description:
"The `config` files a RELEASE catalog file declares for this tool (never `DASHBOARD_AI_TOOLS_DIR`), resolved under the home directory, with whether each exists. Staff-only in server mode (they may hold keys).",
operationId: "listAiToolConfig",
parameters: [idParam],
responses: {
200: json(
{
type: "object",
properties: {
files: {
type: "array",
items: {
type: "object",
properties: {
index: { type: "integer" },
path: { type: "string" },
format: { type: "string", example: "json" },
exists: { type: "boolean" },
size: { type: "integer", nullable: true },
},
},
},
},
},
"Declared files."
),
404: errResp("`ENOTOOL`."),
},
},
},
"/api/ai-tools/{id}/config/{index}": {
get: {
tags: ["AI Tools"],
summary: "Read one config file",
description:
"Returns the text of an existing regular text file ≤ 1 MB from the tool's declared list, with its sha to save against. Refuses a credential-named file (`ESECRET`, checked on the basename and its symlink target). The guard is by name only — a settings file may still contain an API key and is returned whole. Staff-only in server mode.",
operationId: "readAiToolConfig",
parameters: [idParam, indexParam],
responses: {
200: json(
{
type: "object",
properties: {
path: { type: "string" },
format: { type: "string" },
text: { type: "string" },
sha: { type: "string" },
},
},
"File text."
),
...configErrors,
},
},
put: {
tags: ["AI Tools"],
summary: "Save one config file",
description:
"Overwrites an EXISTING declared file (never creates one) only when `baseSha` matches the file on disk (`ESTALE` otherwise); JSON/JSONC/TOML text must parse. The old text is backed up under `<data dir>/ai-tool-config-backups/` first. Same-origin guarded; admin-only in server mode.",
operationId: "writeAiToolConfig",
parameters: [idParam, indexParam],
requestBody: body({
type: "object",
required: ["text", "baseSha"],
properties: { text: { type: "string" }, baseSha: { type: "string" } },
}),
responses: {
200: json(
{ type: "object", properties: { sha: { type: "string" }, backup: { type: "string" } } },
"Saved."
),
...configErrors,
},
},
},
"/api/cc-config/plugins/available": {
get: {
tags: ["CcConfig"],
summary: "List installable plugins",
description:
"Runs `claude plugin list --available --json` (30 s timeout) and returns its installed and available lists. Read-only; skips the CLI mutation queue.",
operationId: "ccConfigListAvailablePlugins",
responses: {
200: json(
{
type: "object",
properties: {
installed: { type: "array", items: { type: "object" } },
available: { type: "array", items: { type: "object" } },
},
},
"CLI output."
),
502: errResp("`ECLIFAILED` — the CLI failed or printed unparseable JSON."),
503: errResp("`ECLINOTFOUND`."),
504: errResp("`ETIMEOUT`."),
},
},
},
"/api/cc-config/plugins/install": {
post: {
tags: ["CcConfig"],
summary: "Install a plugin",
description:
"Runs `claude plugin install <spec> --scope <scope> -y` as an argv array on the serialized CLI queue. `spec` is `<name>` or `<name>@<marketplace>`; `scope` is `user`, `project` or `local`. Admin-only in server mode.",
operationId: "ccConfigInstallPlugin",
requestBody: body({
type: "object",
required: ["spec", "scope"],
properties: {
spec: { type: "string" },
scope: { type: "string", enum: ["user", "project", "local"] },
},
}),
responses: { 200: cliOk, ...cliErrors },
},
},
"/api/cc-config/plugins/action": {
post: {
tags: ["CcConfig"],
summary: "Enable, disable, update or uninstall a plugin",
description:
"Runs `claude plugin <action> <key>` (`update` adds `-y`). With `reload: true` and a successful run, `/reload-plugins` + `/reload-skills` are typed into the open Claude lane panes (`reloaded` in the response); a failed reload never fails the action. Admin-only in server mode.",
operationId: "ccConfigPluginAction",
requestBody: body({
type: "object",
required: ["key", "action"],
properties: {
key: { type: "string", example: "agenthub@agenthub" },
action: { type: "string", enum: ["enable", "disable", "update", "uninstall"] },
reload: { type: "boolean" },
},
}),
responses: {
200: json(
{
allOf: [
{ $ref: "#/components/schemas/CcConfigCliResult" },
{
type: "object",
properties: {
reloaded: { type: "object", description: "Reload outcome, when requested." },
},
},
],
},
"The CLI succeeded."
),
...cliErrors,
},
},
},
"/api/cc-config/marketplaces/add": {
post: {
tags: ["CcConfig"],
summary: "Add a plugin marketplace",
description:
"Runs `claude plugin marketplace add <source> --scope <scope>`. `source` is `owner/repo`, an http(s) URL or an absolute path (no `..`, no leading `-`). Admin-only in server mode.",
operationId: "ccConfigAddMarketplace",
requestBody: body({
type: "object",
required: ["source", "scope"],
properties: {
source: { type: "string" },
scope: { type: "string", enum: ["user", "project", "local"] },
},
}),
responses: { 200: cliOk, ...cliErrors },
},
},
"/api/cc-config/marketplaces/action": {
post: {
tags: ["CcConfig"],
summary: "Remove or update a plugin marketplace",
description:
"Runs `claude plugin marketplace remove|update <name>`. Admin-only in server mode.",
operationId: "ccConfigMarketplaceAction",
requestBody: body({
type: "object",
required: ["name", "action"],
properties: {
name: { type: "string" },
action: { type: "string", enum: ["remove", "update"] },
},
}),
responses: { 200: cliOk, ...cliErrors },
},
},
"/api/cc-config/project-memory": projectMemoryOp(
"claude",
"CLAUDE.md",
"ccConfigCreateProjectMemory",
"CcConfig"
),
"/api/codex-config/project-memory": projectMemoryOp(
"codex",
"AGENTS.md",
"codexConfigCreateProjectMemory",
"CodexConfig"
),
"/api/git-credentials": {
get: {
tags: ["Git Credentials"],
summary: "List stored git credentials",
description:
"One row per protocol + host. Never returns a password — `hasToken` only. Staff-only in server mode.",
operationId: "listGitCredentials",
responses: {
200: json({ $ref: "#/components/schemas/GitCredentialsListResponse" }, "Credentials."),
},
},
post: {
tags: ["Git Credentials"],
summary: "Save or replace a git credential",
description:
"Stores a username + password/token for `protocol` (`http`/`https`) + plain `host[:port]` in AgentHub's own store (0600), replacing any record for that key. Same-origin guarded; admin-only in server mode. Returns the updated list.",
operationId: "saveGitCredential",
requestBody: body({
type: "object",
required: ["protocol", "host", "username", "password"],
properties: {
protocol: { type: "string", enum: ["http", "https"] },
host: { type: "string" },
username: { type: "string" },
password: { type: "string", description: "Write-only." },
},
}),
responses: {
200: json({ $ref: "#/components/schemas/GitCredentialsListResponse" }, "Saved."),
400: errResp("`EBADHOST` or `EBADCRED`."),
},
},
},
"/api/git-credentials/{protocol}/{host}": {
delete: {
tags: ["Git Credentials"],
summary: "Delete a git credential",
description:
"Removes the record for this protocol + host. Same-origin guarded; admin-only in server mode.",
operationId: "deleteGitCredential",
parameters: [
{
name: "protocol",
in: "path",
required: true,
schema: { type: "string", enum: ["http", "https"] },
},
{ name: "host", in: "path", required: true, schema: { type: "string" } },
],
responses: {
200: json(
{ $ref: "#/components/schemas/GitCredentialsListResponse" },
"Deleted; the remaining list."
),
400: errResp("`EBADHOST`."),
404: errResp("`ENOTFOUND`."),
},
},
},
};
module.exports = { tags, schemas, paths };
+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) {