Files
AgentHub/server/openapi-extra/run.js
T
nntrivi2001 57f02f0d89 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.
2026-10-22 12:17:18 +07:00

736 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file OpenAPI 3.0 path + schema fragments for the dashboard's Run feature
* (`server/routes/run.js`, mounted at `/api/run`). A run is a tmux pane
* (`agenthub-lane-<id>` or `agenthub-lane-<id>-<1-9>`) started by
* `server/lib/pty-run.js` for a lane's AI CLI (`claude`, `codex`, or another
* AI Tools catalog id) or a plain shell. Its terminal is streamed over the
* binary `/ws-pty/:runId` WebSocket, not over this REST surface, which only
* starts, lists, reads and kills panes and reads the persisted run history.
*
* Merged into the base spec by `server/openapi-extra.js` -> `createOpenApiSpec()`.
* Exports exactly `{ tags, schemas, paths }`. Every schema name is prefixed
* `Run`. The shared error envelope `ErrorResponse` (`{ error: { code, message } }`)
* is defined in the base spec and only referenced here — never redefined.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Run",
description:
"Start, list, read and stop the tmux panes a lane runs its AI CLI (or a plain shell) in; the terminal itself streams over the `/ws-pty/:runId` WebSocket. Also run history, drag-and-drop file handoff and a claude pane's permission mode.",
},
];
/**
* Reusable response fragment for the same-origin guard applied to EVERY route
* in `server/routes/run.js` (router-level `sameOriginGuard`): a browser request
* whose Origin/Referer is neither a loopback host nor the dashboard's own host
* is refused before any handler runs. Requests with no Origin (curl) pass.
*/
const ebadOrigin403 = {
description:
"Cross-origin rejected (EBADORIGIN). Enforced by the router-level same-origin guard on every /api/run route: a browser request whose Origin/Referer is neither a loopback host nor the dashboard's own host is refused. Requests without an Origin header (e.g. curl) are allowed.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: {
error: { code: "EBADORIGIN", message: "cross-origin requests are not allowed" },
},
},
},
};
const errorJson = (description, example) => ({
description,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
...(example ? { example } : {}),
},
},
});
const schemas = {
RunHandle: {
type: "object",
description:
"A dashboard pane, computed fresh from tmux plus its `dashboard_runs` row (`publicRun` in server/lib/pty-run.js). Returned by GET /api/run (in `items`), POST /api/run (201) and GET /api/run/{id}. There is no in-memory process handle: a pane is live exactly while its tmux session exists.",
required: ["id", "laneId", "slot", "status", "tool"],
properties: {
id: {
type: "string",
description:
"Run id = the tmux session name: `agenthub-lane-<laneId>` for slot 0, `agenthub-lane-<laneId>-<slot>` for slots 1–9.",
example: "agenthub-lane-5",
},
laneId: { type: "integer", description: "Lane (workspace) the pane belongs to.", example: 5 },
slot: {
type: "integer",
minimum: 0,
maximum: 9,
description: "Pane slot. Only slot 0 is the lane's run (`run_id`/`status`).",
example: 0,
},
status: {
type: "string",
enum: ["running", "gone"],
description:
"`running` while the tmux session exists. GET /api/run/{id} answers 404 rather than return a `gone` handle.",
example: "running",
},
tool: {
type: "string",
description:
"AI Tools catalog id the pane runs (`claude`, `codex`, …) or `shell` for a plain terminal tab. Defaults to `claude` when no row is recorded.",
example: "claude",
},
cwd: { type: "string", nullable: true, example: "/home/dev/projects/my-app" },
model: { type: "string", nullable: true, example: "opus" },
permissionMode: {
type: "string",
nullable: true,
description: "Permission mode recorded at spawn (claude panes).",
example: "auto",
},
effort: { type: "string", nullable: true, example: "high" },
resumeSessionId: {
type: "string",
nullable: true,
description: "Session this pane resumed, or null for a fresh session.",
},
sessionId: {
type: "string",
nullable: true,
description:
"Session the pane holds once known (a new claude pane is started with `--session-id`, so it is known at spawn).",
},
worktree: {
type: "boolean",
description: "The pane works in a git worktree of its own (`claude --worktree`).",
example: false,
},
startedAt: {
type: "string",
nullable: true,
description: "Spawn time as stored in `dashboard_runs.started_at` (ISO-8601).",
},
promptPreview: {
type: "string",
nullable: true,
description: "First characters of the initial prompt, or null.",
},
},
},
RunListResponse: {
type: "object",
description:
"Every live dashboard pane (tmux sessions named `agenthub-lane-*`). In server mode a developer sees only the panes of their own workspaces.",
required: ["items"],
properties: {
items: { type: "array", items: { $ref: "#/components/schemas/RunHandle" } },
},
},
RunHistoryItem: {
type: "object",
description:
"A persisted run record from the `dashboard_runs` sqlite table (snake_case DB columns, ISO-8601 timestamps), plus the computed `isLive`. A new run in a pane archives the pane's previous row under `<id>@<started_at>`.",
required: ["id", "cwd", "status", "started_at", "isLive"],
properties: {
id: {
type: "string",
description:
"Run id (the pane's tmux session name; archived rows carry an `@<started_at>` suffix).",
example: "agenthub-lane-5",
},
session_id: {
type: "string",
nullable: true,
description: "Session the pane held, once known.",
},
mode: {
type: "string",
nullable: true,
description: "`interactive` for every tmux pane run (older rows may carry a legacy value).",
example: "interactive",
},
cwd: { type: "string", example: "/home/dev/projects/my-app" },
model: { type: "string", nullable: true, example: "opus" },
permission_mode: { type: "string", nullable: true, example: "auto" },
effort: { type: "string", nullable: true, example: "high" },
resume_session_id: { type: "string", nullable: true },
prompt_preview: { type: "string", nullable: true },
status: {
type: "string",
description:
"`running` at spawn, `killed` after DELETE /api/run/{id}, `interrupted` when the pane died under a session (offered by GET /api/run/interrupted), `abandoned` for a replaced or dismissed row. Older rows may carry legacy values.",
example: "killed",
},
exit_code: { type: "integer", nullable: true },
started_at: { type: "string", format: "date-time" },
ended_at: { type: "string", format: "date-time", nullable: true },
lane_id: { type: "integer", nullable: true, example: 5 },
tool: { type: "string", example: "claude" },
worktree: {
type: "integer",
enum: [0, 1],
description: "1 when the pane ran in its own `claude --worktree`.",
},
proxy: {
type: "string",
nullable: true,
description: "AI Proxy profile name the session ran on, or null (native).",
},
account: {
type: "string",
nullable: true,
description: "Pool account the pane was given, or null.",
},
isLive: {
type: "boolean",
description: "Computed at request time: a live tmux pane with this id exists.",
example: false,
},
},
},
RunHistoryResponse: {
type: "object",
description:
"Persisted run history, most recent first. Empty `items` when the persistence DB is unavailable.",
required: ["items"],
properties: {
items: {
type: "array",
items: { $ref: "#/components/schemas/RunHistoryItem" },
},
},
},
RunCwdSuggestion: {
type: "object",
description:
"A suggested working directory. Only directories that still exist on disk are returned.",
required: ["kind", "path", "label"],
properties: {
kind: {
type: "string",
enum: ["dashboard", "home", "repo", "recent"],
description:
"`dashboard`: the dashboard server's own cwd. `home`: $HOME (both omitted for a server-mode developer). `repo`: source repos lanes were provisioned from. `recent`: distinct cwds sessions ran in.",
example: "recent",
},
path: { type: "string", example: "/home/dev/projects/my-app" },
label: { type: "string", example: "my-app" },
},
},
RunCwdsResponse: {
type: "object",
required: ["items"],
properties: {
items: { type: "array", items: { $ref: "#/components/schemas/RunCwdSuggestion" } },
},
},
RunFilesResponse: {
type: "object",
description:
"File-path autocomplete results for the prompt editor's `@` references — up to 40 paths relative to the given cwd, shortest first.",
required: ["items"],
properties: {
items: {
type: "array",
items: { type: "string" },
description:
"Paths relative to the resolved cwd (folders end in `/` with `?dirs=1`). Dotfiles (except .env/.gitignore) and heavy build dirs are skipped.",
example: ["package.json", "src/index.ts", "server/routes/run.js"],
},
},
},
RunBinaryResponse: {
type: "object",
description:
"Whether the AI tool's CLI is installed, from a fresh catalog detection (the binary is not invoked).",
required: ["found", "path"],
properties: {
found: { type: "boolean", example: true },
path: { type: "string", nullable: true, example: "/home/dev/.local/bin/claude" },
},
},
RunSpawnRequest: {
type: "object",
description:
"Request body for starting the lane's slot-0 pane. SIDE EFFECT: creates (or takes over an idle) tmux session `agenthub-lane-<laneId>` running the lane's AI CLI, and records a `dashboard_runs` row. The tool is always the lane's own (`lanes.tool`); to pick another tool, a slot or a worktree, use POST /api/lanes/{id}/start.",
required: ["laneId"],
properties: {
laneId: { type: "integer", description: "Lane to start the pane for.", example: 5 },
cwd: {
type: "string",
description:
"Absolute existing directory. Omitted: the dashboard's cwd, or the lane's cwd for a server-mode developer. Must lie inside the caller's own workspaces (EFOLDER).",
example: "/home/dev/projects/my-app",
},
initialPrompt: {
type: "string",
description: "First message for the CLI (at most 128 KB, else EBADPROMPT). May be empty.",
},
model: { type: "string", nullable: true, example: "opus" },
permissionMode: {
type: "string",
enum: ["auto", "acceptEdits", "default", "plan", "bypassPermissions"],
default: "auto",
description: "claude only. An unknown value falls back to `auto`.",
},
effort: { type: "string", nullable: true, example: "high" },
sandbox: {
type: "string",
nullable: true,
description: "codex only, checked against pty-run's fixed allowlist (EBADSANDBOX).",
},
approval: {
type: "string",
nullable: true,
description: "codex only, checked against pty-run's fixed allowlist.",
},
resumeSessionId: {
type: "string",
nullable: true,
description:
"Session to resume (EBADSESSION for a malformed id; EUNSUPPORTED for a tool that cannot resume by id).",
},
resumeLast: {
type: "boolean",
description:
"Resume the most recent session (codex `resume --last`; another tool's catalog `resume` args). Ignored when resumeSessionId is set.",
},
bypass: {
type: "boolean",
description:
"Only when `true`: append the tool catalog file's bypass args for this run (never remembered by the server).",
},
proxy: {
type: "string",
nullable: true,
description:
"AI Proxy profile name (claude/codex only; EBADPROXY / EUNSUPPORTED). Omitted on a resume: the proxy the session last ran on. `router` is the accepted pre-rename alias.",
},
},
},
RunKillResponse: {
type: "object",
required: ["ok"],
properties: {
ok: { type: "boolean", enum: [true], example: true },
archived: {
type: "string",
nullable: true,
description:
"Only with `?archive=1`: the id of the session the pane held, now archived, or null when the pane had no known session (a shell).",
},
},
},
};
const paths = {
"/api/run": {
get: {
tags: ["Run"],
summary: "List live panes",
description:
"Every live dashboard pane, computed from tmux on each call. Server mode: a developer sees only the panes of their own workspaces. Read-only. The same-origin guard applies.",
operationId: "runList",
responses: {
200: {
description: "Live panes",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RunListResponse" },
example: {
items: [
{
id: "agenthub-lane-5",
laneId: 5,
slot: 0,
status: "running",
tool: "claude",
cwd: "/home/dev/projects/my-app",
model: "opus",
permissionMode: "auto",
effort: null,
resumeSessionId: null,
sessionId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
worktree: false,
startedAt: "2026-10-22T08:00:00.000Z",
promptPreview: "Refactor the auth module",
},
],
},
},
},
},
403: ebadOrigin403,
},
},
post: {
tags: ["Run"],
summary: "Start a lane's slot-0 pane",
description:
"Starts the lane's AI CLI (its own `lanes.tool`) in tmux session `agenthub-lane-<laneId>` via `spawnRun`. Argv comes from the tool's catalog file / launcher, never from the request; workspace instructions and extra folders are added by `launchExtras`. The terminal is reached over `/ws-pty/:runId`; nothing is streamed over the JSON WebSocket. A `dashboard_runs` row is recorded. Same-origin guard; `cwd` must be inside the caller's own workspaces.",
operationId: "runSpawn",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RunSpawnRequest" },
example: { laneId: 5, initialPrompt: "Run the tests", permissionMode: "acceptEdits" },
},
},
},
responses: {
201: {
description: "Pane started",
content: { "application/json": { schema: { $ref: "#/components/schemas/RunHandle" } } },
},
400: errorJson(
"Invalid input: EBADLANE (laneId missing), EBADCWD, EBADPROMPT, ENOTOOL, EBADMODEL, EBADEFFORT, EBADSESSION, EBADSANDBOX, EBADPROXY, EUNSUPPORTED, or another E-code from spawnRun."
),
403: {
description:
"EBADORIGIN (cross-origin) or EFOLDER (cwd is not one of the caller's workspaces).",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
409: errorJson(
"Conflict with live state: ERUNLIVE (the pane is busy), ENOTSHARED, EMEMBERGONE, EOWNERFOLDER or ECWDACCOUNT."
),
503: errorJson("tmux is not installed (ETMUXMISSING)."),
500: errorJson("Unexpected failure (EINTERNAL)."),
},
},
},
"/api/run/history": {
get: {
tags: ["Run"],
summary: "List persisted run history",
description:
"Returns the persistent history of panes started via the dashboard, from the `dashboard_runs` sqlite table (newest first). Unlike GET /api/run it keeps runs whose pane is gone, so past sessions stay visible and resumable. Each item is cross-referenced against live tmux panes to set `isLive`. Server mode: a developer without `?laneId` gets the newest runs of their own workspaces only. Returns `{ items: [] }` if the persistence DB is unavailable. Read-only. The same-origin guard applies.",
operationId: "runHistory",
parameters: [
{
name: "limit",
in: "query",
required: false,
schema: { type: "integer", minimum: 1, maximum: 500, default: 50 },
description: "Maximum number of history rows to return (default 50, clamped to 1–500).",
example: 50,
},
{
name: "laneId",
in: "query",
required: false,
schema: { type: "integer" },
description: "Return only the runs of this lane. Omit for every run.",
example: 5,
},
{
name: "tool",
in: "query",
required: false,
schema: { type: "string" },
description:
"Return only the runs of this AI Tools id — `shell` for a plain terminal tab (a closed one's row is dropped on the next run-heal beat).",
example: "shell",
},
],
responses: {
200: {
description: "Run history, most recent first",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RunHistoryResponse" },
example: {
items: [
{
id: "agenthub-lane-5",
session_id: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
mode: "interactive",
cwd: "/home/dev/projects/my-app",
model: "opus",
permission_mode: "acceptEdits",
effort: "high",
resume_session_id: null,
prompt_preview: "Refactor the auth module and add tests.",
status: "killed",
exit_code: null,
started_at: "2026-10-22T08:00:00.000Z",
ended_at: "2026-10-22T09:12:42.500Z",
lane_id: 5,
tool: "claude",
worktree: 0,
proxy: null,
account: null,
isLive: false,
},
],
},
},
},
},
403: ebadOrigin403,
},
},
},
"/api/run/history/{id}": {
delete: {
tags: ["Run"],
summary: "Forget a closed Terminal tab",
description:
"Deletes one `dashboard_runs` row whose tool is `shell` — a closed Terminal tab, which has no session (the run-heal beat drops these rows anyway; this removes one at once). Rows of any other tool are never deleted (404). The loopback same-origin guard applies.",
operationId: "runHistoryForget",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "The history row's id (RunHistoryItem.id).",
example: "agenthub-lane-5-2@2026-10-06T08:00:00.000Z",
},
],
responses: {
200: {
description: "Removed",
content: { "application/json": { example: { removed: "agenthub-lane-5-2" } } },
},
403: ebadOrigin403,
404: {
description: "No closed Terminal tab with that id (ENOTFOUND)",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
409: {
description: "That terminal is still open (ERUNLIVE)",
content: {
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
},
},
},
},
},
"/api/run/cwds": {
get: {
tags: ["Run"],
summary: "Suggest working directories",
description:
"Suggests plausible working directories: the dashboard server's cwd and $HOME (not for a server-mode developer), source repos lanes were provisioned from (up to 20), then distinct recent session cwds (up to 30) — the last two scoped to the caller's own rows in server mode. Only directories that still exist are returned; the DB lookups are best-effort. Read-only. The same-origin guard applies.",
operationId: "runCwds",
parameters: [
{
name: "q",
in: "query",
required: false,
schema: { type: "string" },
description: "Optional case-insensitive substring filter (also applied client-side).",
example: "my-app",
},
],
responses: {
200: {
description: "Working-directory suggestions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RunCwdsResponse" },
example: {
items: [
{
kind: "dashboard",
path: "/Users/dev/AgentHub",
label: "Dashboard server",
},
{ kind: "home", path: "/Users/dev", label: "Home" },
{ kind: "repo", path: "/Users/dev/src/project", label: "project" },
{ kind: "recent", path: "/Users/dev/projects/my-app", label: "my-app" },
],
},
},
},
},
403: ebadOrigin403,
},
},
},
"/api/run/files": {
get: {
tags: ["Run"],
summary: "Autocomplete files within a cwd",
description:
"Walks the given `cwd` (which must lie inside the caller's own workspaces, else 403 EFOLDER) and returns up to 40 paths (relative to that cwd, shortest first) for the prompt editor's `@` references. The cwd is validated via the same sanitiser as spawning (must be an existing absolute directory). Dotfiles (except .env/.gitignore) and heavy build dirs (node_modules, .git, dist, build, out, .next, coverage, target, .venv, __pycache__, etc.) are skipped; the walk is bounded (≤5000 entries visited). Read-only — no process is spawned. The loopback same-origin guard applies.",
operationId: "runFiles",
parameters: [
{
name: "cwd",
in: "query",
required: false,
schema: { type: "string" },
description:
"Absolute directory to walk. Must exist; defaults to the dashboard server's cwd when omitted. Invalid values yield 400 EBADCWD.",
example: "/Users/dev/projects/my-app",
},
{
name: "q",
in: "query",
required: false,
schema: { type: "string" },
description: "Optional case-insensitive substring filter applied to the relative path.",
example: "route",
},
{
name: "dirs",
in: "query",
required: false,
schema: { type: "string", enum: ["1"] },
description:
"`1` also returns matching folders (as `dir/`), for the composer's Add from project.",
},
],
responses: {
200: {
description: "Matching file paths relative to cwd",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RunFilesResponse" },
example: { items: ["package.json", "src/index.ts", "server/routes/run.js"] },
},
},
},
400: {
description: "Invalid cwd (EBADCWD): not a string, not absolute, or does not exist.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code: "EBADCWD", message: "cwd does not exist: /no/such/dir" } },
},
},
},
403: ebadOrigin403,
},
},
},
"/api/run/binary": {
get: {
tags: ["Run"],
summary: "Check whether an AI tool's CLI is installed",
description:
"Detects the CLI of an AI Tools catalog id (fresh, not the catalog cache, so a CLI installed while the dashboard is up clears the missing-binary banner). The query only picks a catalog entry — it never names a command. The binary is not invoked. The same-origin guard applies.",
operationId: "runBinary",
parameters: [
{
name: "tool",
in: "query",
required: false,
schema: { type: "string", default: "claude" },
description: "AI Tools catalog id. Unknown → 400 EBADTOOL.",
},
],
responses: {
200: {
description: "Binary resolution result",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RunBinaryResponse" },
example: { found: true, path: "/usr/local/bin/claude" },
},
},
},
400: errorJson("Unknown tool id (EBADTOOL)."),
403: ebadOrigin403,
},
},
},
"/api/run/{id}": {
get: {
tags: ["Run"],
summary: "Get a single pane",
description:
"Returns one live pane, computed from tmux and its `dashboard_runs` row. A pane whose tmux session is gone, an id that is not a dashboard pane, or one the caller may not see (server mode) answers 404 — use GET /api/run/history for past runs. Read-only. The same-origin guard applies.",
operationId: "runGet",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Run id (RunHandle.id).",
example: "agenthub-lane-5",
},
],
responses: {
200: {
description: "The pane",
content: { "application/json": { schema: { $ref: "#/components/schemas/RunHandle" } } },
},
403: ebadOrigin403,
404: errorJson("No live pane with this id (ENOTFOUND).", {
error: { code: "ENOTFOUND", message: "run not found" },
}),
},
},
delete: {
tags: ["Run"],
summary: "Kill a pane",
description:
"Kills the pane's tmux session and marks its `dashboard_runs` row `killed`. Only a dashboard lane pane (`agenthub-lane-<id>[-<slot>]`) can be killed — any other tmux session name answers 404. With `?archive=1` (the UI's Stop) the session the pane held is also archived (`sessions.archived_at`, broadcast as `session_updated`); without it (Sleep, the CLI) the session stays listed to be resumed. The same-origin guard applies.",
operationId: "runKill",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Run id (RunHandle.id).",
example: "agenthub-lane-5",
},
{
name: "archive",
in: "query",
required: false,
schema: { type: "string", enum: ["1"] },
description: "`1` also archives the session the pane held.",
},
],
responses: {
200: {
description: "Killed",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/RunKillResponse" },
example: { ok: true },
},
},
},
403: ebadOrigin403,
404: errorJson("No live dashboard pane with this id (ENOTFOUND).", {
error: { code: "ENOTFOUND", message: "run not found" },
}),
},
},
},
};
module.exports = { tags, schemas, paths };