Files
AgentHub/server/openapi-extra/ai-tools.js
T
nntrivi2001 497faae3bf feat(settings): admin switch to allow or block members' test runs
Settings → AI tools → Tests on this server (admin, S3 only). Stored in
/opt/agenthub/app/member-policy.json, which member-guard.js reads as the
member at each Bash call — no restart; no file = blocked. GET/PUT
/api/ai-tools/member-policy, audited, 409 ENOS3 outside S3.
2026-11-03 11:37:13 +07:00

375 lines
13 KiB
JavaScript

/**
* @file Supplementary OpenAPI 3.0 fragments for the AI Tools routes mounted at
* `/api/ai-tools` (see server/routes/ai-tools.js + server/lib/ai-tools.js):
* list the catalog with detection, re-detect after hydrating PATH,
* enable/disable a tool or make it the default, run an install/update job,
* read npm version badges, and fetch a tool's server-cached icon. Exports `{ tags, schemas,
* paths }` for server/openapi-extra.js; schemas are prefixed `AiTools`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "AI Tools",
description:
"The catalog of AI coding CLIs a lane can run (server/data/ai-tools/*.json + DASHBOARD_AI_TOOLS_DIR), what is installed on PATH, and the user's enable/default choice",
},
];
const errorRef = { $ref: "#/components/schemas/AiToolsError" };
const schemas = {
AiToolsTool: {
type: "object",
required: ["id", "label", "installed", "enabled", "source"],
properties: {
id: { type: "string", example: "antigravity" },
label: { type: "string", example: "Antigravity" },
command: { type: "string", nullable: true, example: "agy" },
homepageUrl: { type: "string", nullable: true },
installed: { type: "boolean" },
path: { type: "string", nullable: true, example: "/home/u/.local/bin/agy" },
enabled: { type: "boolean" },
source: { type: "string", enum: ["builtin", "remote", "custom"] },
launcher: { type: "string", nullable: true, enum: ["claude", "codex", null] },
prompt: {
type: "object",
nullable: true,
properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } },
},
resume: { type: "boolean", description: "Offers Resume last session" },
resumeById: {
type: "boolean",
description:
"Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
},
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
install: {
type: "object",
nullable: true,
description: "How Install runs: npm install -g <package>@latest, or a vendor script",
properties: {
kind: { type: "string", enum: ["npm", "script"] },
package: { type: "string", example: "@openai/codex" },
url: { type: "string", example: "https://cursor.com/install" },
},
},
update: {
type: "object",
nullable: true,
properties: {
kind: { type: "string", enum: ["npm", "script", "self"] },
args: { type: "array", items: { type: "string" }, description: "self only" },
},
},
},
},
AiToolsJob: {
type: "object",
properties: {
id: { type: "integer" },
tool: { type: "string", nullable: true, description: "null for update-all" },
action: { type: "string", enum: ["install", "update", "update-all"] },
status: { type: "string", enum: ["running", "ok", "failed"] },
startedAt: { type: "string" },
endedAt: { type: "string", nullable: true },
exitCode: { type: "integer", nullable: true },
log: { type: "string", description: "stdout+stderr, last 256 KB" },
},
},
AiToolsView: {
type: "object",
required: ["tools", "default"],
properties: {
tools: { type: "array", items: { $ref: "#/components/schemas/AiToolsTool" } },
default: { type: "string", nullable: true, description: "Effective default tool id" },
catalog: {
type: "object",
properties: {
source: { type: "string", enum: ["builtin", "remote"] },
tag: { type: "string", nullable: true },
fetchedAt: { type: "string", nullable: true },
},
},
},
},
AiToolsError: {
type: "object",
properties: {
error: {
type: "object",
properties: { code: { type: "string" }, message: { type: "string" } },
},
},
},
};
const ok = {
200: {
description: "The catalog with detection and the effective default",
content: { "application/json": { schema: { $ref: "#/components/schemas/AiToolsView" } } },
},
};
const paths = {
"/api/ai-tools": {
get: {
tags: ["AI Tools"],
summary: "List the AI Tools catalog",
description:
"Every catalog tool with whether it was found on PATH, whether it is enabled, and the effective default (stored default if installed+enabled, else claude, else the first installed enabled tool, else null). Arg arrays are never returned.",
operationId: "aiToolsList",
responses: ok,
},
},
"/api/ai-tools/refresh": {
post: {
tags: ["AI Tools"],
summary: "Re-detect installed tools",
description:
"Merges the login shell's PATH ($SHELL -ilc, 5 s timeout, off with AGENTHUB_SHELL_PATH=0) into the server's, re-reads the catalog and re-detects. Same-origin only.",
operationId: "aiToolsRefresh",
responses: ok,
},
},
"/api/ai-tools/omp-sharing": {
get: {
tags: ["AI Tools"],
summary: "Oh My Pi login sharing state",
description:
"Server mode with per-user accounts (S3): whether every member's omp pane runs on the HOST account's omp login through omp's own auth broker + gateway on loopback (server/lib/omp-share.js), and whether both daemons run. Never returns a token.",
operationId: "aiToolsOmpSharing",
responses: {
200: {
description:
"`{available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?}}`.",
},
},
},
put: {
tags: ["AI Tools"],
summary: "Turn Oh My Pi login sharing on or off",
description:
"An admin's switch (off by default). On starts `omp auth-broker serve` and `omp auth-gateway serve --clients=…` as the host; off stops them and empties the clients file, so no pane keeps a way in. Same-origin only; audited.",
operationId: "aiToolsSetOmpSharing",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["enabled"],
properties: { enabled: { type: "boolean" } },
},
},
},
},
responses: {
200: { description: "The new state, as GET." },
400: { description: "EBADFIELD" },
403: { description: "Not an admin" },
409: { description: "ENOS3 — not server mode with per-user accounts" },
},
},
},
"/api/ai-tools/member-policy": {
get: {
tags: ["AI Tools"],
summary: "What members' Claude may run on this server",
description:
"Server mode with per-user accounts (S3): whether members' Claude may run test runners and container commands (`allowTests`, off by default). Kept in `/opt/agenthub/app/member-policy.json` and enforced by the members' synchronous PreToolUse hook (scripts/member-guard.js).",
operationId: "aiToolsMemberPolicy",
responses: {
200: { description: "`{available, allowTests}`." },
},
},
put: {
tags: ["AI Tools"],
summary: "Allow or block members' test runs",
description:
"An admin's switch. Takes effect at the next Bash call of every member's Claude, no restart. Same-origin only; audited.",
operationId: "aiToolsSetMemberPolicy",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["allowTests"],
properties: { allowTests: { type: "boolean" } },
},
},
},
},
responses: {
200: { description: "The new state, as GET." },
400: { description: "EBADFIELD" },
403: { description: "Not an admin" },
409: { description: "ENOS3 — not server mode with per-user accounts" },
},
},
},
"/api/ai-tools/{id}": {
put: {
tags: ["AI Tools"],
summary: "Enable/disable a tool or make it the default",
description:
"A disabled tool is hidden from Add lane and a NEW lane for it answers 400 EDISABLED; existing lanes keep working. Disabling the default clears it. Same-origin only.",
operationId: "aiToolsUpdate",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
enabled: { type: "boolean" },
default: { type: "boolean", enum: [true] },
},
},
},
},
},
responses: {
...ok,
400: {
description: "EBADDEFAULT (not installed or disabled) or EBADFIELD",
content: { "application/json": { schema: errorRef } },
},
404: {
description: "ENOTOOL — unknown id",
content: { "application/json": { schema: errorRef } },
},
},
},
},
};
const idParam = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];
const jobStarted = {
202: {
description: "The job started; poll GET /api/ai-tools/job",
content: {
"application/json": {
schema: {
type: "object",
properties: { job: { $ref: "#/components/schemas/AiToolsJob" } },
},
},
},
},
};
const jobErr = (description) => ({
description,
content: { "application/json": { schema: errorRef } },
});
Object.assign(paths, {
"/api/ai-tools/{id}/install": {
post: {
tags: ["AI Tools"],
summary: "Install a tool",
description:
"Runs the catalog's install (npm install -g <package>@latest with the login shell's npm, or the vendor script downloaded by the server and run with bash). One job at a time. Same-origin only.",
operationId: "aiToolsInstall",
parameters: idParam,
responses: {
...jobStarted,
400: jobErr("ENOINSTALL"),
404: jobErr("ENOTOOL"),
409: jobErr("EINSTALLED, ENONPM or EBUSY"),
},
},
},
"/api/ai-tools/{id}/update": {
post: {
tags: ["AI Tools"],
summary: "Update a tool",
description:
"npm update only for a tool whose binary lives under npm's global prefix (else ENOTNPM); a self updater runs the tool's own command. Same-origin only.",
operationId: "aiToolsUpdateTool",
parameters: idParam,
responses: {
...jobStarted,
400: jobErr("ENOUPDATE"),
404: jobErr("ENOTOOL"),
409: jobErr("ENOTINSTALLED, ENOTNPM, ENONPM or EBUSY"),
},
},
},
"/api/ai-tools/update-all": {
post: {
tags: ["AI Tools"],
summary: "Update every tool with an update",
description:
"One job: every npm-managed tool with a newer version, then every installed self updater. Same-origin only.",
operationId: "aiToolsUpdateAll",
responses: { ...jobStarted, 400: jobErr("ENOTHING"), 409: jobErr("EBUSY") },
},
},
"/api/ai-tools/job": {
get: {
tags: ["AI Tools"],
summary: "The current or last install/update job",
operationId: "aiToolsJob",
responses: {
200: {
description: "The job, or null",
content: {
"application/json": { schema: { $ref: "#/components/schemas/AiToolsJob" } },
},
},
},
},
},
"/api/ai-tools/versions": {
get: {
tags: ["AI Tools"],
summary: "npm version badges",
description:
"For installed tools with an npm update: whether npm manages them, the installed and latest version, and updateAvailable. Cached 24 h.",
operationId: "aiToolsVersions",
responses: {
200: {
description: "Map of tool id to its versions",
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: {
type: "object",
properties: {
npmManaged: { type: "boolean" },
installed: { type: "string", nullable: true },
latest: { type: "string", nullable: true },
updateAvailable: { type: "boolean" },
},
},
},
},
},
},
},
},
},
"/api/ai-tools/{id}/icon": {
get: {
tags: ["AI Tools"],
summary: "A tool's icon",
description:
"The favicon of the tool's faviconDomain (or homepage host), fetched once by the server and cached. Raster only, never SVG; served with nosniff and CSP sandbox.",
operationId: "aiToolsIcon",
parameters: idParam,
responses: {
200: { description: "The image", content: { "image/*": {} } },
204: { description: "No icon (the client shows a letter avatar)" },
404: { description: "Unknown tool id" },
},
},
},
});
module.exports = { tags, schemas, paths };