Files
AgentHub/server/openapi-extra/files.js
T
nntrivi2001 7e41c81d53 feat(git-identity): import an existing SSH key and sign commits with it
Settings → Git gains Commit signing: one picker signs every commit
(terminal, Changes panel, agents) with one of the account's keys —
gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign
true in its own ~/.gitconfig; a signing key the user set up themselves is
replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key
you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048)
into ~/.config/agenthub/ssh/ as a named key, written like a generated one
(member: write-as, 0600), and by default signs with it in the same step.
A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key
twice are refused; only the public half ever comes back. Deleting the
signing key stops signing so commits keep working.

The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the
Gitea and Backlog steps, and the File Explorer's ESECRET toast now says
that top-level dot folders of a home are edited in a terminal, and where
the SSH keys live instead.
2026-11-02 16:12:57 +07:00

585 lines
21 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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.";
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 },
],
body: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
status: 201,
},
],
[
"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 },
],
body: {
required: true,
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}${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 `<data dir>/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",
requestBody: json({ key: { type: "string", nullable: true }, replace: { 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/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.<ssh://user@host/>.insteadOf ssh://<alias or host>/, 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 };