/** * @file OpenAPI fragment for the File Explorer — the same operations on a * workspace (`/api/lanes/{id}/fs/*`, also under /api/workspaces) and on a * user's home (`/api/homes/{who}/fs/*`), generated from one table so the two * cannot drift — plus `GET /api/homes`, Add workspace → Clone * (`POST /api/lanes/clone`) and the user's own git identity (`/api/me/git`). * @author Nguyễn Ngọc Trí Vĩ */ const tags = [ { name: "Files", description: "The File Explorer on a workspace or a user's home: list, make, rename, move, copy, delete into a restorable trash, streamed upload, zip download. Writes run as the folder's account; a taken name is settled by `conflict`.", }, { name: "Git identity", description: "A user's own SSH key and HTTPS logins and commit author, and cloning a repository into a new workspace with them.", }, ]; const errorCodes = "400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG; a resumable upload adds 409 EOFFSET/EINCOMPLETE (with `error.received`)/EBUSY and 429 ETOOMANY."; const schemas = { FsEntry: { type: "object", properties: { name: { type: "string" }, path: { type: "string", description: "Relative to the workspace or home." }, type: { type: "string", enum: ["dir", "file", "link", "linkdir", "other"] }, size: { type: "integer", nullable: true }, mtime: { type: "number", description: "Milliseconds since the epoch." }, }, }, FsListing: { type: "object", properties: { path: { type: "string" }, parent: { type: "string", nullable: true }, entries: { type: "array", items: { $ref: "#/components/schemas/FsEntry" } }, truncated: { type: "boolean" }, }, }, FsConflict: { type: "string", enum: ["fail", "skip", "keep", "replace"], default: "fail", description: "A taken name: `fail` answers 409 EEXISTS with the names, `skip` leaves it, `keep` uses a free `name (1).ext`, `replace` sends the old one to the trash first.", }, }; const json = (properties, required = []) => ({ required: true, content: { "application/json": { schema: { type: "object", required, properties } } }, }); const pathsArr = { type: "array", items: { type: "string" } }; const conflict = { $ref: "#/components/schemas/FsConflict" }; /** The operations, without their prefix: [method, sub, summary, extra]. */ const OPS = [ [ "get", "/list", "List a folder (folders first)", { parameters: [{ name: "path", in: "query", schema: { type: "string", default: "" } }], ok: { description: "The folder.", content: { "application/json": { schema: { $ref: "#/components/schemas/FsListing" } } }, }, }, ], [ "post", "/mkdir", "Make a folder", { body: json({ path: { type: "string" } }, ["path"]), status: 201 }, ], [ "post", "/newfile", "Make an empty file", { body: json({ path: { type: "string" } }, ["path"]), status: 201 }, ], [ "post", "/rename", "Rename in place", { body: json({ path: { type: "string" }, name: { type: "string" } }, ["path", "name"]) }, ], [ "post", "/move", "Move items into a folder", { body: json({ paths: pathsArr, dest: { type: "string" }, conflict }, ["paths", "dest"]) }, ], [ "post", "/copy", "Copy items into a folder — from here, another workspace (`fromLane`) or a home (`fromHome`) of the same account", { body: json( { paths: pathsArr, dest: { type: "string" }, conflict, fromLane: { type: "integer" }, fromHome: { type: "string" }, }, ["paths", "dest"] ), }, ], [ "post", "/delete", "Move items into the account's trash (restorable for 7 days)", { body: json({ paths: pathsArr }, ["paths"]) }, ], ["get", "/trash", "The trash, newest first (entries older than 7 days are dropped)", {}], [ "post", "/trash/restore", "Put a trash item back where it was", { body: json({ id: { type: "string" } }, ["id"]) }, ], [ "post", "/trash/purge", "Delete a trash item (or `all`) for good", { body: json({ id: { type: "string" } }, ["id"]) }, ], [ "post", "/upload", "Upload one file, streamed, making missing sub-folders (≤ 500 MB)", { parameters: [ { name: "path", in: "query", required: true, schema: { type: "string" } }, { name: "conflict", in: "query", schema: conflict }, { name: "staged", in: "query", schema: { type: "string" }, description: "Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.", }, ], body: { required: false, content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } }, }, status: 201, }, ], [ "get", "/upload-part", "How much of a resumable upload arrived", { opId: "UploadPartStatus", note: "`{received, total, done}` for the caller's own upload id (`received: 0, total: null` when unknown). The browser asks this after the dashboard came back, then sends the rest.", parameters: [ { name: "upload", in: "query", required: true, schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" }, }, ], ok: { description: "`{received, total, done}`", content: { "application/json": { schema: { type: "object", properties: { received: { type: "integer" }, total: { type: "integer", nullable: true }, done: { type: "boolean" }, }, }, }, }, }, }, ], [ "post", "/upload-part", "Send one part of a resumable upload (≤ 16 MB a part, 512 MB in all)", { opId: "UploadPart", note: "Raw bytes appended at `offset` to the caller's upload `upload` of `total` bytes, staged on the host (`/upload-staging/`) until `/upload` or `/upload-batch` names it with `?staged=`. A part must start where the staged bytes end (409 EOFFSET with `error.received`), one at a time per id (409 EBUSY); a part cut short keeps what arrived. At most 8 unfinished uploads per user (429 ETOOMANY); one untouched for a day is swept.", parameters: [ { name: "upload", in: "query", required: true, schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" }, }, { name: "offset", in: "query", required: true, schema: { type: "integer", minimum: 0 } }, { name: "total", in: "query", required: true, schema: { type: "integer", minimum: 0 } }, ], body: { required: true, content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } }, }, ok: { description: "`{received, total}`", content: { "application/json": { schema: { type: "object", properties: { received: { type: "integer" }, total: { type: "integer" } }, }, }, }, }, }, ], [ "delete", "/upload-part", "Forget a resumable upload's staged bytes", { opId: "UploadPartDrop", parameters: [ { name: "upload", in: "query", required: true, schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" }, }, ], ok: { description: "`{dropped: true}`", content: { "application/json": { schema: { type: "object", properties: { dropped: { type: "boolean" } } }, }, }, }, }, ], [ "post", "/upload-check", "Before a batch: the names already taken, and whether a batch can go there", { note: "`taken`: which of `paths` (relative to `dir`) exist; `batch: false` = send the files one by one (a member's folder whose `write-as` predates `extract`).", body: json({ dir: { type: "string", default: "" }, paths: pathsArr }, ["paths"]), ok: { description: "`{taken, batch}`", content: { "application/json": { schema: { type: "object", properties: { taken: pathsArr, batch: { type: "boolean" } }, }, }, }, }, }, ], [ "post", "/upload-batch", "Upload many small files in one request (≤ 5,000 files, 16 MB each, 256 MB in all)", { note: "Body: a 4-byte big-endian length, that many bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `dir`), then each file's bytes in that order. Planned whole before the first write — a taken name under `conflict=fail` is ONE 409 naming them all; a member's batch is one `write-as extract` (409 EUPGRADE until `agenthub server install-system` is re-run). 400 EBADBODY for a malformed or short body.", parameters: [ { name: "dir", in: "query", schema: { type: "string", default: "" } }, { name: "conflict", in: "query", schema: conflict }, { name: "staged", in: "query", schema: { type: "string" }, description: "Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.", }, ], body: { required: false, content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } }, }, status: 201, ok: { description: "`{saved, skipped, paths}`", content: { "application/json": { schema: { type: "object", properties: { saved: { type: "integer" }, skipped: { type: "integer" }, paths: pathsArr, }, }, }, }, }, }, ], [ "get", "/zip", "Files and folders as one streamed zip (≤ 2 GB, 50,000 files)", { parameters: [ { name: "p", in: "query", required: true, schema: { type: "array", items: { type: "string" } }, style: "form", explode: true, }, ], ok: { description: "application/zip", content: { "application/zip": { schema: { type: "string", format: "binary" } } }, }, }, ], ]; function operations(prefix, idParam, opPrefix, note) { const out = {}; for (const [method, sub, summary, x] of OPS) { const key = `${prefix}${sub}`; out[key] = out[key] || {}; out[key][method] = { tags: ["Files"], summary, description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`, operationId: `${opPrefix}${x.opId || sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`, parameters: [idParam, ...(x.parameters || [])], ...(x.body ? { requestBody: x.body } : {}), responses: { [x.status || 200]: x.ok || { description: "Done." } }, }; } return out; } const paths = { ...operations( "/api/lanes/{id}/fs", { name: "id", in: "path", required: true, schema: { type: "integer" } }, "laneFs", "On a workspace (`?session=` / `?source=` pick the session worktree / extra folder, like the git reads)." ), ...operations( "/api/homes/{who}/fs", { name: "who", in: "path", required: true, schema: { type: "string" }, description: "`me`, or another user's name (an admin's read-only view).", }, "homeFs", "On a user's home. Every dot entry at the top of the home is hidden and refused." ), "/api/lanes/{id}/fs/download": undefined, "/api/homes/{who}/fs/download": { get: { tags: ["Files"], summary: "Download one file of a home", operationId: "homeFsDownload", parameters: [ { name: "who", in: "path", required: true, schema: { type: "string" } }, { name: "path", in: "query", required: true, schema: { type: "string" } }, ], responses: { 200: { description: "The file, as a sandboxed attachment." } }, }, }, "/api/homes": { get: { tags: ["Files"], summary: "The homes the caller may open", description: "`me` (their own), and for an admin every other user's — read-only.", operationId: "listHomes", responses: { 200: { description: "{homes: [{id, label, readOnly}]}" } }, }, }, "/api/lanes/clone": { post: { tags: ["Git identity"], summary: "Clone a repository into a new folder and make it a workspace", description: "`url`: https:// (no userinfo), ssh:// or user@host:path. Runs as the folder's account with that account's own SSH key or HTTPS login. 409 ECREDENTIALS (`error.auth` names the host) asks for `credentials`; 409 ESSHAUTH: the key was refused; 409 ENOREPO: no such repository.", operationId: "cloneLane", requestBody: json( { url: { type: "string" }, parent: { type: "string" }, name: { type: "string" }, title: { type: "string" }, tool: { type: "string" }, credentials: { type: "object", properties: { username: { type: "string" }, password: { type: "string" }, remember: { type: "boolean" }, }, }, partial: { type: "boolean", description: "Blobless clone (`--filter=blob:none`): file contents fetched on checkout.", }, progressToken: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$", description: "The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.", }, background: { type: "boolean", description: "Run every check now, then clone as a background job that outlives the request and a server restart (a detached runner): 202 `{job}`. 400 EBADFIELD when not a boolean.", }, }, ["url", "parent"] ), responses: { 201: { description: "{lane, created: true, path}" }, 202: { description: "`background: true`: {job} — a CloneJob (see `/api/lanes/clone/jobs`).", }, }, }, }, "/api/lanes/clone/jobs": { get: { tags: ["Git identity"], summary: "The caller's background clone jobs, newest first", description: "CloneJob: {id, kind: workspace|source, state: queued|running|done|failed|interrupted|cancelled, url, name, parent, target, laneId, position (1-based while queued), phase, label, percent, detail, error: {code, message, auth?} | null, result: {laneId, path, created?} | null, createdAt, finishedAt, request: {url, parent, name, tool?, title?, partial?}, pid}. `request` never holds a credential. Kept in `/runtime/clone-jobs.json`; a job whose runner still runs after a restart is adopted, a dead one becomes `interrupted` (EINTERRUPTED). Done jobs are dropped after 24 h, the rest after 7 days. Only the caller's own (local mode: all).", operationId: "listCloneJobs", responses: { 200: { description: "{jobs: CloneJob[]}" } }, }, }, "/api/lanes/clone/jobs/{id}": { delete: { tags: ["Git identity"], summary: "Dismiss a finished background clone job", operationId: "dismissCloneJob", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "{ok: true}" }, 404: { description: "ENOTFOUND — not the caller's job" }, 409: { description: "EBUSY — still queued or running" }, }, }, }, "/api/lanes/clone/jobs/{id}/cancel": { post: { tags: ["Git identity"], summary: "Cancel a queued or running background clone", description: "Queued: taken out of the queue, never started. Running: the runner's process group is SIGTERMed (git removes its half-made folder). The job becomes `cancelled` with ECANCELLED and never makes its workspace / extra folder. Same-origin.", operationId: "cancelCloneJob", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "{job}" }, 404: { description: "ENOTFOUND — not the caller's job" }, 409: { description: "EDONE — it already finished" }, }, }, }, "/api/lanes/clone/jobs/{id}/retry": { post: { tags: ["Git identity"], summary: "Retry a failed, interrupted or cancelled background clone", description: "A NEW job from the old one's `request` (and kind / workspace), through every check of a fresh clone POST; the old job is dropped once the new one exists. `credentials` as on the clone POST, for this run only. Same-origin.", operationId: "retryCloneJob", parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], requestBody: json({ credentials: { type: "object", properties: { username: { type: "string" }, password: { type: "string" }, remember: { type: "boolean" }, }, }, }), responses: { 202: { description: "{job} — the new CloneJob" }, 404: { description: "ENOTFOUND — not the caller's job (ENOLANE: its workspace is gone)" }, 409: { description: "EBUSY — queued, running or done" }, }, }, }, "/api/lanes/clone/progress/{token}": { get: { tags: ["Git identity"], summary: "A clone's place in the queue, then git's progress", description: "Clones run a few at a time machine-wide (`AGENTHUB_CLONE_CONCURRENCY`). Only the user who started the clone sees it; kept 60 s after it ends.", operationId: "cloneProgress", parameters: [{ name: "token", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "{state: queued|running|done|failed, position, running, waiting, limit, phase: remote|receiving|resolving|checkout|other|null, label, percent, detail}", }, 404: { description: "ENOTFOUND" }, }, }, }, "/api/me/git": { get: { tags: ["Git identity"], summary: "The caller's own git identity", description: "Only the public SSH key and `hasToken` are ever returned.", operationId: "getGitIdentity", responses: { 200: { description: "{account, writable, ssh: {type, publicKey, fingerprint, terminal} | null, https: [...], author: {name, email}}", }, }, }, }, "/api/me/git/ssh-key": { post: { tags: ["Git identity"], summary: "Make (or with `replace`, replace) the default SSH key of `type`, or with `name` one more beside it", operationId: "generateSshKey", requestBody: json({ replace: { type: "boolean" }, type: { type: "string", enum: ["ed25519", "rsa"] }, name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" }, }), responses: { 201: { description: "The identity." }, 400: { description: "EBADKEYNAME" }, 409: { description: "EEXISTS (without replace, or the name is taken) / ETOOMANYKEYS" }, }, }, delete: { tags: ["Git identity"], summary: "Delete one SSH key (?id=, or the default of ?type=ed25519|rsa), or every key", operationId: "deleteSshKey", parameters: [ { name: "type", in: "query", required: false, schema: { type: "string", enum: ["ed25519", "rsa"] }, }, { name: "id", in: "query", required: false, schema: { type: "string" } }, ], responses: { 200: { description: "The identity." } }, }, }, "/api/me/git/ssh-key/import": { post: { tags: ["Git identity"], summary: "Import a private key the user already has as a named key; with `sign`, sign commits with it at once", operationId: "importSshKey", requestBody: json( { privateKey: { type: "string", description: "The key file's text (OpenSSH or PEM)." }, name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" }, sign: { type: "boolean" }, replace: { type: "boolean" }, }, ["privateKey"] ), responses: { 201: { description: "The identity, plus `imported` (the key's public half) and `signError` (a refused `sign`, else null).", }, 400: { description: "EBADKEY / EKEYPASSPHRASE / EBADKEYTYPE / EBADKEYNAME" }, 409: { description: "EEXISTS (the same key, or the name is taken) / ETOOMANYKEYS" }, }, }, }, "/api/me/git/signing": { put: { tags: ["Git identity"], summary: "Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)", operationId: "setCommitSigning", description: "The account-wide setting (every repository of the account). `clearForeign: true` with `key: null` also takes out a signing setup the user made themselves — the three settings only, never their key file. Per workspace: `PUT /api/me/git/signing/lane`.", requestBody: json({ key: { type: "string", nullable: true }, replace: { type: "boolean" }, clearForeign: { type: "boolean" }, }), responses: { 200: { description: "The identity; `signing.state` is on, off or foreign." }, 400: { description: "ENOKEY" }, 409: { description: "EFOREIGNSIGN — ~/.gitconfig already signs with a key of its own." }, }, }, }, "/api/me/git/signing/lane": { put: { tags: ["Git identity"], summary: "Sign ONE workspace's commits with one of the account's keys (`key`: its id), or stop (`key: null`)", description: "Writes a small file of AgentHub's (`lane-.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign) and includes it from the account's `~/.gitconfig` with `includeIf \"gitdir:\"` and `gitdir:/worktrees/ah--*` (its sessions' worktrees) — nothing is written into the repository. The workspace must be one the caller may see whose folder runs as this same account. `GET /api/me/git` answers `laneSigning: [{laneId, key}]`; deleting a key stops every workspace that signs with it.", operationId: "setLaneCommitSigning", requestBody: json({ laneId: { type: "integer" }, key: { type: "string", nullable: true } }, [ "laneId", ]), responses: { 200: { description: "The identity, with `laneSigning`." }, 400: { description: "ENOKEY" }, 403: { description: "EFOREIGN — the workspace runs as another account; EHOSTIDENTITY." }, 404: { description: "ENOTFOUND — no such workspace (or not the caller's)." }, 409: { description: "ENOREPO — the workspace's folder is not a git repository." }, }, }, }, "/api/me/git/ssh-terminal": { put: { tags: ["Git identity"], summary: "Use the SSH key for git in the account's own terminal (core.sshCommand)", operationId: "setTerminalSsh", requestBody: json({ on: { type: "boolean" } }, ["on"]), responses: { 200: { description: "The identity; `ssh.terminal` is on, off or foreign." }, 409: { description: "EFOREIGNSSH — ~/.gitconfig already sets its own core.sshCommand." }, }, }, }, "/api/me/git/ssh-user": { put: { tags: ["Git identity"], summary: "An SSH login: url..insteadOf ssh:///, optionally tied to one key", operationId: "setSshUser", requestBody: json( { host: { type: "string" }, user: { type: "string" }, alias: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,62}$" }, key: { type: "string" }, }, ["host", "user"] ), responses: { 200: { description: "The identity; `sshUsers: [{host, user, alias, key}]`." }, 400: { description: "EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY" }, }, }, }, "/api/me/git/ssh-user/{host}": { delete: { tags: ["Git identity"], summary: "Drop the SSH login of a host or alias (and its key link)", operationId: "deleteSshUser", parameters: [ { name: "host", in: "path", required: true, description: "The host or alias the login was saved under.", schema: { type: "string" }, }, ], responses: { 200: { description: "The identity." }, 404: { description: "ENOTFOUND" } }, }, }, "/api/me/git/https": { post: { tags: ["Git identity"], summary: "Save an HTTPS login (never returned)", operationId: "saveHttpsLogin", requestBody: json( { host: { type: "string" }, username: { type: "string" }, password: { type: "string" } }, ["host", "username", "password"] ), responses: { 200: { description: "The identity." } }, }, }, "/api/me/git/https/{host}": { delete: { tags: ["Git identity"], summary: "Delete an HTTPS login", operationId: "deleteHttpsLogin", parameters: [{ name: "host", in: "path", required: true, schema: { type: "string" } }], responses: { 200: { description: "The identity." } }, }, }, "/api/me/git/author": { put: { tags: ["Git identity"], summary: "Set the commit author (`git config --global user.name/email`)", operationId: "setGitAuthor", requestBody: json({ name: { type: "string" }, email: { type: "string" } }, ["name", "email"]), responses: { 200: { description: "The identity." }, 400: { description: "EBADAUTHOR" } }, }, }, }; delete paths["/api/lanes/{id}/fs/download"]; module.exports = { tags, schemas, paths };