chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions

Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
+3
View File
@@ -100,6 +100,9 @@ const EXPECTED_API_PATHS = [
"/api/webhooks/{id}",
"/api/webhooks/{id}/test",
"/api/webhooks/{id}/deliveries",
"/api/artifacts",
"/api/artifacts/{id}/install",
"/api/host",
"/api/openapi.json",
"/api/docs",
"/api/redoc",
+132
View File
@@ -0,0 +1,132 @@
/**
* @file Tests for `server/lib/clone-queue.js`: no more clones run at once
* than the limit, the rest start in arrival order as slots free, a failing
* clone frees its slot too, the per-clone thread share, and a tracked
* clone's progress: its queue position, git's --progress lines parsed, and
* shown only to its owner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const os = require("node:os");
const queue = require("../lib/clone-queue");
const saved = process.env.AGENTHUB_CLONE_CONCURRENCY;
function gate() {
let open;
const p = new Promise((r) => (open = r));
return { p, open };
}
describe("clone-queue", () => {
afterEach(() => {
if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY;
else process.env.AGENTHUB_CLONE_CONCURRENCY = saved;
});
it("runs at most the limit at once, the rest in arrival order", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "2";
const started = [];
const gates = [gate(), gate(), gate(), gate()];
const runs = gates.map((g, i) =>
queue.withCloneSlot(async () => {
started.push(i);
await g.p;
return i;
})
);
await new Promise((r) => setImmediate(r));
assert.deepEqual(started, [0, 1]);
assert.deepEqual(queue.cloneQueueState(), { running: 2, waiting: 2, limit: 2 });
gates[1].open();
await runs[1];
await new Promise((r) => setImmediate(r));
assert.deepEqual(started, [0, 1, 2]);
gates[0].open();
gates[2].open();
gates[3].open();
assert.deepEqual(await Promise.all(runs), [0, 1, 2, 3]);
assert.deepEqual(queue.cloneQueueState(), { running: 0, waiting: 0, limit: 2 });
});
it("frees the slot when a clone fails", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
await assert.rejects(
queue.withCloneSlot(async () => {
throw new Error("boom");
}),
/boom/
);
assert.equal(await queue.withCloneSlot(async () => "ok"), "ok");
assert.equal(queue.cloneQueueState().running, 0);
});
it("splits the cores between the running clones", () => {
const cores = os.availableParallelism?.() ?? os.cpus().length;
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
assert.equal(queue.threads(), Math.max(1, cores));
process.env.AGENTHUB_CLONE_CONCURRENCY = String(cores * 4);
assert.equal(queue.threads(), 1);
delete process.env.AGENTHUB_CLONE_CONCURRENCY;
assert.equal(queue.concurrency(), Math.max(1, Math.floor(cores / 2)));
});
it("reports a tracked clone's queue position, then git's progress, to its owner only", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
const first = gate();
const a = queue.track("tok-aaaaaaaaaaaaaaaa", 7);
const b = queue.track("tok-bbbbbbbbbbbbbbbb", 7);
const ra = queue.withCloneSlot(() => first.p, a);
let release;
const rb = queue.withCloneSlot(() => new Promise((r) => (release = r)), b);
await new Promise((r) => setImmediate(r));
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "running");
const waitingView = queue.progress("tok-bbbbbbbbbbbbbbbb", 7);
assert.equal(waitingView.state, "queued");
assert.equal(waitingView.position, 1);
assert.equal(waitingView.running, 1);
// Another user — or nobody — never sees it.
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 8), null);
assert.equal(queue.progress("tok-cccccccccccccccc", 7), null);
queue.report(
a,
"Cloning into 'r'...\nremote: Counting objects: 100% (5/5), done.\n" +
"Receiving objects: 12% (120/1000), 3.00 MiB | 1.50 MiB/s\rReceiving objects: 45% (450/1000), 120.50 MiB | 5.20 MiB/s"
);
const v = queue.progress("tok-aaaaaaaaaaaaaaaa", 7);
assert.equal(v.phase, "receiving");
assert.equal(v.percent, 45);
assert.equal(v.detail, "450/1000, 120.50 MiB | 5.20 MiB/s");
queue.report(a, "Resolving deltas: 100% (300/300), done.\n");
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).phase, "resolving");
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).detail, "300/300");
first.open();
await ra;
queue.finish(a, true);
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "done");
await new Promise((r) => setImmediate(r));
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "running");
release();
await rb;
queue.finish(b, false);
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "failed");
});
it("refuses a malformed or reused token, and keeps progress out of an error", () => {
assert.throws(() => queue.track("short", null), { code: "EBADFIELD" });
assert.throws(() => queue.track("../../etc/passwd-xxxxxxxx", null), { code: "EBADFIELD" });
queue.track("tok-dddddddddddddddd", null);
assert.throws(() => queue.track("tok-dddddddddddddddd", null), { code: "EBADFIELD" });
assert.equal(
queue.stripProgress(
"Cloning into 'r'...\nReceiving objects: 45% (450/1000)\rfatal: early EOF\n"
),
"Cloning into 'r'...\nfatal: early EOF"
);
});
});
+59
View File
@@ -64,6 +64,8 @@ before(async () => {
"init",
]);
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
// A forge serves a partial clone only when it allows filters.
execFileSync("git", ["-C", bare, "config", "uploadpack.allowFilter", "true"]);
remote = http.createServer((_req, res) => {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
@@ -183,6 +185,61 @@ describe("POST /api/lanes/clone", () => {
assert.equal(r.body.error.code, "ENOREPO");
});
it("makes a blobless clone only when asked, and refuses a non-boolean", async () => {
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-partial",
partial: true,
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const dir = path.join(WORK, "r-partial");
assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hi\n");
const cfg = (k) =>
execFileSync("git", ["-C", dir, "config", "--get", k], { encoding: "utf8" }).trim();
assert.equal(cfg("remote.origin.partialclonefilter"), "blob:none");
// The full clone above is not a promisor remote.
assert.throws(() =>
execFileSync("git", ["-C", path.join(WORK, "r"), "config", "--get", "remote.origin.promisor"])
);
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-bad",
partial: "yes",
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
});
it("reports a clone's progress by its token, and refuses a bad token", async () => {
const token = "progress-test-0000001";
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-progress",
progressToken: token,
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const p = await request("GET", `/api/lanes/clone/progress/${token}`);
assert.equal(p.status, 200);
assert.equal(p.body.state, "done");
assert.equal(p.body.position, null);
assert.equal(
(await request("GET", "/api/lanes/clone/progress/nope-nope-nope-nope")).status,
404
);
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-badtoken",
progressToken: "x",
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
assert.ok(!fs.existsSync(path.join(WORK, "r-badtoken")));
});
it("refuses a cross-origin request", async () => {
const r = await request(
"POST",
@@ -649,6 +706,8 @@ describe("git-identity, member account (S3, stubbed)", () => {
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
);
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
// index-pack gets a share of the cores, never persisted into the clone.
assert.match(args.slice(0, i).join(" "), /-c pack\.threads=\d+/);
});
it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => {
+182
View File
@@ -0,0 +1,182 @@
/**
* @file The machine-wide limit on `git clone`s running at once, and each
* clone's progress. A clone's index-pack resolves deltas on every core it
* finds, so a few big (~1 GB) clones started together pinned all of them and
* stalled the dashboard and every pane beside it. Clones past the limit wait
* here, in arrival order, and each one gets its share of the cores
* (`threads`) — the clone's own timeout starts only once it runs.
* `AGENTHUB_CLONE_CONCURRENCY` overrides the default (half the cores, at
* least 1). A clone the browser named with a token (`track`) reports its
* place in the queue, then git's own `--progress` lines (`report`), to
* `GET /api/lanes/clone/progress/:token` — only to the user who started it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const os = require("node:os");
/** A finished clone's progress stays readable this long, then is dropped. */
const KEEP_DONE_MS = 60_000;
const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
function cores() {
return Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) || 1);
}
/** How many clones may run at once. */
function concurrency() {
const set = Number.parseInt(process.env.AGENTHUB_CLONE_CONCURRENCY, 10);
if (Number.isInteger(set) && set > 0) return set;
return Math.max(1, Math.floor(cores() / 2));
}
/** index-pack threads for one clone: the cores split between the running ones. */
function threads() {
return Math.max(1, Math.floor(cores() / concurrency()));
}
let running = 0;
/** `{start, job}` in arrival order; `job` is null for an untracked clone. */
const waiting = [];
/** token → job: `{owner, state, phase, label, percent, detail, finishedAt}`. */
const jobs = new Map();
function next() {
while (running < concurrency() && waiting.length) {
running++;
const { start, job } = waiting.shift();
if (job) job.state = "running";
start();
}
}
/** Runs `fn` once a clone slot is free; resolves or rejects with it. */
async function withCloneSlot(fn, job = null) {
await new Promise((start) => {
waiting.push({ start, job });
next();
});
try {
return await fn();
} finally {
running--;
next();
}
}
/** `{running, waiting, limit}` — for a status read or a test. */
function cloneQueueState() {
return { running, waiting: waiting.length, limit: concurrency() };
}
function sweep(now = Date.now()) {
for (const [token, job] of jobs) {
if (job.finishedAt && now - job.finishedAt > KEEP_DONE_MS) jobs.delete(token);
}
}
/**
* A progress record for the clone the browser named `token`, owned by
* `owner` (`scope.ownerFor`). Throws `EBADFIELD` for a malformed token or one
* still in use.
*/
function track(token, owner) {
sweep();
if (typeof token !== "string" || !TOKEN_RE.test(token) || jobs.has(token)) {
throw Object.assign(new Error("progressToken: 16–64 of [A-Za-z0-9_-], unused"), {
code: "EBADFIELD",
});
}
const job = {
owner,
state: "queued",
phase: null,
label: null,
percent: null,
detail: null,
finishedAt: null,
};
jobs.set(token, job);
return job;
}
/** Marks a tracked clone over: `done` or `failed`. */
function finish(job, ok) {
if (!job) return;
job.state = ok ? "done" : "failed";
job.finishedAt = Date.now();
}
/** git's progress words → a phase the UI translates; anything else is `other`. */
const PHASES = [
[/^remote: /, "remote"],
[/^Receiving objects/, "receiving"],
[/^Resolving deltas/, "resolving"],
[/^Updating files/, "checkout"],
];
const LINE_RE = /^((?:remote: )?[A-Za-z][A-Za-z ]*?):\s+(\d{1,3})% \(([^)]*)\)(.*)$/;
/**
* Feeds a chunk of `git clone --progress` stderr into `job`: the LAST
* complete progress line in it wins (git redraws a line with `\r`).
*/
function report(job, chunk) {
if (!job) return;
const lines = String(chunk).split(/[\r\n]+/);
for (let i = lines.length - 1; i >= 0; i--) {
const m = LINE_RE.exec(lines[i].trim());
if (!m) continue;
job.label = m[1].replace(/^remote: /, "");
job.phase = (PHASES.find(([re]) => re.test(m[1])) || [null, "other"])[1];
job.percent = Math.min(100, Number(m[2]));
// "1234/5678), 120.50 MiB | 5.20 MiB/s, done." → counts + size + speed
job.detail =
`${m[3]}${m[4]}`
.replace(/,\s*done\.?$/, "")
.replace(/^,\s*/, "")
.trim() || null;
return;
}
}
/**
* What `GET /api/lanes/clone/progress/:token` answers, or null when there is
* no such clone or it is not `owner`'s. A queued clone carries its 1-based
* `position` among the waiting ones.
*/
function progress(token, owner) {
sweep();
const job = jobs.get(token);
if (!job || job.owner !== owner) return null;
const position = waiting.findIndex((w) => w.job === job);
return {
state: job.state,
position: position >= 0 ? position + 1 : null,
...cloneQueueState(),
phase: job.phase,
label: job.label,
percent: job.percent,
detail: job.detail,
};
}
/** A progress-free tail of git's stderr for an error message. */
function stripProgress(stderr) {
return String(stderr || "")
.split(/[\r\n]+/)
.filter((l) => l.trim() && !LINE_RE.test(l.trim()))
.join("\n");
}
module.exports = {
withCloneSlot,
cloneQueueState,
concurrency,
threads,
track,
finish,
report,
progress,
stripProgress,
};
+28 -8
View File
@@ -44,8 +44,8 @@ const gitCredentials = require("./git-credentials");
const runAs = require("./run-as");
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
/** A clone may take a while on a big repo; past this it is killed. */
const CLONE_TIMEOUT_MS = 10 * 60_000;
/** A clone may take a while on a big repo (~1 GB); past this it is killed. */
const CLONE_TIMEOUT_MS = 30 * 60_000;
const HELPER = path.join(__dirname, "git-credential-helper.js");
const err = (code, message) => Object.assign(new Error(message), { code });
@@ -841,15 +841,33 @@ function sshFailure(stderr) {
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
* that account's own identity. `once` is a login typed for this clone
* (`{username, password, remember}`) — https only, for this URL's host only.
* `partial` makes it a blobless clone (`--filter=blob:none`): every commit
* and tree, but a file's contents only when a checkout first needs them —
* far less to download and unpack for a big repo, at the cost of a fetch
* from the remote when an old revision is checked out later.
* Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}`
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
* was refused, else the git error.
*/
async function cloneRepo({ url, parent, name, once = null }) {
function cloneRepo({ job = null, ...opts }) {
// Waits for a free clone slot (clone-queue.js) before anything — a typed
// login sits in a member's store only while its own clone runs, and the
// timeout counts from there. `job` (clone-queue `track`) gets the queue
// position and git's progress.
const queue = require("./clone-queue");
return queue.withCloneSlot(
() => cloneNow(opts, queue.threads(), (chunk) => queue.report(job, chunk)),
job
);
}
async function cloneNow({ url, parent, name, once = null, partial = false }, threads, onProgress) {
const { git } = require("./worktree");
const account = accountForDir(parent);
const remote = remoteOf(url);
const pre = [];
// index-pack's delta threads: this clone's share of the cores, not all of
// them (clone-queue.js). Not persisted — a later fetch keeps git's default.
const pre = ["-c", `pack.threads=${threads}`];
const persist = [];
const env = {};
let restore = null;
@@ -911,9 +929,13 @@ async function cloneRepo({ url, parent, name, once = null }) {
let ok = false;
try {
await git(parent, [...pre, "clone", ...persist, "--", url, name], {
const filter = partial ? ["--filter=blob:none"] : [];
// --progress: git prints it to a pipe too, for the UI's progress line.
await git(parent, [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name], {
timeout: CLONE_TIMEOUT_MS,
env,
background: true,
onStderr: onProgress,
});
ok = true;
} catch (e) {
@@ -932,9 +954,7 @@ async function cloneRepo({ url, parent, name, once = null }) {
}
throw err(
"ECLONE",
String(stderr || e.message)
.trim()
.slice(-800)
(require("./clone-queue").stripProgress(stderr) || e.message).trim().slice(-800)
);
} finally {
// A login typed without "remember" — or one that did not work — leaves
+43 -2
View File
@@ -19,6 +19,26 @@ const PROTECTED_BRANCHES = new Set(["main", "master"]);
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
const STATUS_FILE_CAP = 20;
/**
* `nice`/`ionice` before a heavy git (a clone) so the dashboard and the panes
* beside it keep their share of the machine; children (index-pack, sudo's
* git) inherit both. Empty where neither exists (Windows).
*/
let lowPriorityPrefix = null;
function lowPriority() {
if (lowPriorityPrefix) return lowPriorityPrefix;
const find = (name) =>
["/usr/bin", "/bin"].map((d) => path.join(d, name)).find((f) => fs.existsSync(f));
const nice = process.platform === "win32" ? null : find("nice");
const ionice = process.platform === "linux" ? find("ionice") : null;
lowPriorityPrefix = [
...(nice ? [nice, "-n", "10"] : []),
// best-effort class, lowest level: never the idle class, which can wait forever
...(ionice ? [ionice, "-c", "2", "-n", "7"] : []),
];
return lowPriorityPrefix;
}
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
* Treats zero exit code as success even if stderr has hints.
@@ -42,6 +62,10 @@ async function git(
encoding = "utf8",
// stdin (a patch for `apply -`): never a file the dashboard writes.
input = null,
// true: run under nice/ionice (a clone — see lowPriority above).
background = false,
// Called with each stderr chunk as it arrives (a clone's --progress).
onStderr = null,
} = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
@@ -88,6 +112,12 @@ async function git(
if (literal) pass.GIT_LITERAL_PATHSPECS = "1";
[cmd, argv] = require("./run-as").sudoArgv(member, "git", args, pass);
}
const gitCmd = cmd;
if (background && lowPriority().length) {
const [first, ...rest] = lowPriority();
argv = [...rest, cmd, ...argv];
cmd = first;
}
try {
const run =
@@ -100,7 +130,7 @@ async function git(
);
child.stdin.end(input);
});
const result = await run(cmd, argv, {
const pending = run(cmd, argv, {
cwd,
env,
// A read with its own size cap passes a bigger buffer and treats an
@@ -113,12 +143,23 @@ async function git(
timeout,
encoding,
});
// promisify(execFile) hands back its child; the stdin runner has none.
if (onStderr && pending.child?.stderr) {
pending.child.stderr.on("data", (d) => {
try {
onStderr(String(d));
} catch {
/* a progress reader never fails the git call */
}
});
}
const result = await pending;
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
// ENOENT from execFile is ambiguous: a missing binary OR a missing cwd.
// With the folder present it is git itself that is absent — say so, with
// the fix, rather than `git status failed` (like ETMUXMISSING for tmux).
if (err.code === "ENOENT" && cmd === "git" && fs.existsSync(cwd)) {
if (err.code === "ENOENT" && gitCmd === "git" && fs.existsSync(cwd)) {
const error = new Error(
"git is not installed (or not on the dashboard's PATH), and every git action needs it. " +
"Install it (sudo apt install git / brew install git / https://git-scm.com/download/win), " +
+2
View File
@@ -19,6 +19,7 @@ const aiTools = require("./openapi-extra/ai-tools");
const misc = require("./openapi-extra/misc");
const auth = require("./openapi-extra/auth");
const files = require("./openapi-extra/files");
const artifacts = require("./openapi-extra/artifacts");
// 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.
@@ -56,6 +57,7 @@ module.exports = combine(
misc,
auth,
files,
artifacts,
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
+359
View File
@@ -0,0 +1,359 @@
/**
* @file OpenAPI fragment for the Artifacts hub (`/api/artifacts/*`, server/routes/artifacts.js)
* and the Administration page's Server tab (`GET /api/host`, server/routes/host.js).
* Hand-written from the routes; docs/API.md § Artifacts hub is the long form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Artifacts",
description:
"What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller's own account only. Every write is same-origin.",
},
{
name: "Host",
description:
"Administration → Server: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, staff only in server mode.",
},
];
const errorCodes =
"400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.";
const schemas = {
Artifact: {
type: "object",
properties: {
id: { type: "string" },
kind: { type: "string", enum: ["page", "file", "skill", "plugin"] },
title: { type: "string" },
description: { type: "string", nullable: true },
ownerId: { type: "integer", nullable: true },
owner: { type: "string", nullable: true, description: "Display name or username." },
mine: { type: "boolean" },
canChange: { type: "boolean" },
laneId: { type: "integer", nullable: true },
sessionId: { type: "string", nullable: true },
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." },
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." },
size: { type: "integer", nullable: true },
shared: { type: "boolean" },
sharedAt: { type: "string", nullable: true },
installs: { type: "integer" },
createdAt: { type: "string" },
updatedAt: { type: "string" },
},
},
};
const json = (properties, required = []) => ({
required: true,
content: { "application/json": { schema: { type: "object", required, properties } } },
});
const multipart = (properties, required = []) => ({
required: true,
content: { "multipart/form-data": { schema: { type: "object", required, properties } } },
});
const item = {
type: "object",
properties: { item: { $ref: "#/components/schemas/Artifact" } },
};
const items = {
type: "object",
properties: { items: { type: "array", items: { $ref: "#/components/schemas/Artifact" } } },
};
const idParam = { name: "id", in: "path", required: true, schema: { type: "string" } };
const laneQuery = { name: "laneId", in: "query", required: false, schema: { type: "integer" } };
const errs = { description: `Error \`{error:{code,message}}\`: ${errorCodes}` };
const created = (schema, description = "Created.") => ({
201: { description, content: { "application/json": { schema } } },
400: errs,
});
const paths = {
"/api/artifacts": {
get: {
tags: ["Artifacts"],
summary: "List artifacts",
operationId: "listArtifacts",
description:
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
parameters: [
{ name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } },
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
{ name: "q", in: "query", schema: { type: "string" } },
laneQuery,
{ name: "sessionId", in: "query", schema: { type: "string" } },
],
responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } },
},
},
"/api/artifacts/settings": {
get: {
tags: ["Artifacts"],
summary: "Hub switches and what the caller may do",
operationId: "getArtifactSettings",
responses: {
200: {
description: "`{serverMode, canShare, installsEnabled, canManage}`.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
serverMode: { type: "boolean" },
canShare: { type: "boolean" },
installsEnabled: { type: "boolean" },
canManage: { type: "boolean" },
},
},
},
},
},
},
},
put: {
tags: ["Artifacts"],
summary: "Turn installs on or off (admin; any caller in local mode)",
operationId: "setArtifactSettings",
description: "Audited in server mode (Settings → AI tools → Artifacts Hub).",
requestBody: json({ installsEnabled: { type: "boolean" } }, ["installsEnabled"]),
responses: {
200: { description: "`{installsEnabled}`." },
400: { description: "EBADFIELD" },
403: { description: "EADMIN / EBADORIGIN" },
},
},
},
"/api/artifacts/candidates": {
get: {
tags: ["Artifacts"],
summary: "Documents a workspace's sessions wrote (to pin)",
operationId: "listArtifactCandidates",
parameters: [laneQuery],
responses: {
200: { description: "`{items: [{path, sessionId, at, exists, pinned}]}`." },
404: errs,
},
},
},
"/api/artifacts/skills": {
get: {
tags: ["Artifacts"],
summary: "Skills the caller can share (user + workspace)",
operationId: "listArtifactSkills",
parameters: [laneQuery],
responses: { 200: { description: '`{items: [{name, from: "user"|"workspace"}]}`.' } },
},
},
"/api/artifacts/page": {
post: {
tags: ["Artifacts"],
summary: "Add a claude.ai page link",
operationId: "addArtifactPage",
description: "Only `https://claude.ai/…` is accepted; one row per owner and link.",
requestBody: json(
{ url: { type: "string" }, title: { type: "string" }, description: { type: "string" } },
["url"]
),
responses: created(item),
},
},
"/api/artifacts/file": {
post: {
tags: ["Artifacts"],
summary: "Pin a workspace file (snapshotted)",
operationId: "pinArtifactFile",
description:
"Read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB) and copied once.",
requestBody: json(
{
laneId: { type: "integer" },
path: { type: "string" },
title: { type: "string" },
sessionId: { type: "string" },
},
["laneId", "path"]
),
responses: created(item),
},
},
"/api/artifacts/file/upload": {
post: {
tags: ["Artifacts"],
summary: "Keep one uploaded file (≤ 25 MB)",
operationId: "uploadArtifactFile",
requestBody: multipart({
files: { type: "string", format: "binary" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
responses: created(item),
},
},
"/api/artifacts/skill": {
post: {
tags: ["Artifacts"],
summary: "Share an existing skill",
operationId: "addArtifactSkill",
requestBody: json(
{
name: { type: "string" },
laneId: { type: "integer" },
title: { type: "string" },
description: { type: "string" },
},
["name"]
),
responses: created(item),
},
},
"/api/artifacts/skill/upload": {
post: {
tags: ["Artifacts"],
summary: "Skills from files: a file, a folder, a .zip/.skill or several skills",
operationId: "uploadArtifactSkills",
description:
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
requestBody: multipart({
files: { type: "array", items: { type: "string", format: "binary" } },
paths: { type: "string" },
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
responses: created(items),
},
},
"/api/artifacts/skill/new": {
post: {
tags: ["Artifacts"],
summary: "Write a skill in the dialog",
operationId: "writeArtifactSkill",
requestBody: json(
{
name: { type: "string" },
description: { type: "string" },
content: { type: "string" },
title: { type: "string" },
},
["name", "description", "content"]
),
responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }),
},
},
"/api/artifacts/plugin/inspect": {
post: {
tags: ["Artifacts"],
summary: "Read a marketplace's plugin list",
operationId: "inspectArtifactMarket",
description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
requestBody: json({ market: { type: "string" } }, ["market"]),
responses: {
200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." },
502: errs,
},
},
},
"/api/artifacts/plugin": {
post: {
tags: ["Artifacts"],
summary: "Share a plugin from a marketplace",
operationId: "addArtifactPlugin",
requestBody: json(
{
key: { type: "string", description: "`name@marketplace`." },
market: { type: "string" },
title: { type: "string" },
description: { type: "string" },
},
["key", "market"]
),
responses: created(item),
},
},
"/api/artifacts/{id}": {
get: {
tags: ["Artifacts"],
summary: "One artifact",
operationId: "getArtifact",
parameters: [idParam],
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs },
},
patch: {
tags: ["Artifacts"],
summary: "Rename, describe, share or unshare (owner or admin)",
operationId: "updateArtifact",
parameters: [idParam],
requestBody: json({
title: { type: "string" },
description: { type: "string" },
shared: { type: "boolean" },
source: { type: "string", description: "Plugin only." },
market: { type: "string", description: "Plugin only." },
}),
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs },
},
delete: {
tags: ["Artifacts"],
summary: "Delete an artifact and its snapshot (owner or admin)",
operationId: "deleteArtifact",
parameters: [idParam],
responses: { 200: { description: "`{ok: true}`." }, 403: errs, 404: errs },
},
},
"/api/artifacts/{id}/raw": {
get: {
tags: ["Artifacts"],
summary: "One recorded file of a snapshot",
operationId: "getArtifactRaw",
description:
"Always served under a `sandbox` CSP with `nosniff`; an unlisted type, or `download=1`, is an attachment. Only a file the snapshot recorded can be named.",
parameters: [
idParam,
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "download", in: "query", schema: { type: "string", enum: ["1"] } },
],
responses: { 200: { description: "The file bytes." }, 400: errs, 404: errs },
},
},
"/api/artifacts/{id}/install": {
post: {
tags: ["Artifacts"],
summary: "Install a shared skill or plugin into the caller's own account",
operationId: "installArtifact",
description:
"A skill goes to the caller's `~/.claude/skills/` (never over an existing one without `replace`; the old one is moved aside). A plugin runs `claude plugin install --scope user` as the caller.",
parameters: [idParam],
requestBody: json({ replace: { type: "boolean" } }),
responses: {
200: { description: "`{installed, replaced?}`." },
403: errs,
409: errs,
502: errs,
},
},
},
"/api/host": {
get: {
tags: ["Host"],
summary: "The machine and each user's live share of it",
operationId: "getHost",
description:
"Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).",
responses: {
200: {
description:
"`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.",
content: { "application/json": { schema: { type: "object" } } },
},
500: { description: "EHOST" },
},
},
},
};
module.exports = { tags, schemas, paths };
+28
View File
@@ -253,12 +253,40 @@ const paths = {
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}`.",
},
},
["url", "parent"]
),
responses: { 201: { description: "{lane, created: true, path}" } },
},
},
"/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"],
+32
View File
@@ -777,6 +777,10 @@ async function cloneInto(req, res, body, before) {
res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
return null;
}
if (body.partial !== undefined && typeof body.partial !== "boolean") {
res.status(400).json({ error: { code: "EBADFIELD", message: "partial: boolean" } });
return null;
}
const once = body.credentials;
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
@@ -813,17 +817,32 @@ async function cloneInto(req, res, body, before) {
return null;
}
const ownerId = scope.ownerFor(req);
// The browser's own name for this clone, so it can poll its progress.
const queue = require("../lib/clone-queue");
let job = null;
if (body.progressToken !== undefined) {
try {
job = queue.track(body.progressToken, ownerId);
} catch (err) {
res.status(400).json({ error: { code: err.code, message: err.message } });
return null;
}
}
try {
await before(target, ownerId);
await identity.cloneRepo({
job,
url,
parent: dir,
name,
once: once
? { username: once.username, password: once.password, remember: once.remember === true }
: null,
partial: body.partial === true,
});
queue.finish(job, true);
} catch (err) {
queue.finish(job, false);
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
? 400
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
@@ -843,6 +862,19 @@ async function cloneInto(req, res, body, before) {
return { target, name, ownerId };
}
/**
* A clone's place in the machine-wide queue, then git's own progress
* (`server/lib/clone-queue.js`), by the `progressToken` its POST carried —
* only to the user who started it (404 otherwise, and once it is long over).
*/
router.get("/clone/progress/:token", (req, res) => {
const view = require("../lib/clone-queue").progress(req.params.token, scope.ownerFor(req));
if (!view) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such clone" } });
}
res.json(view);
});
router.post("/clone", sameOriginGuard, async (req, res) => {
const body = req.body || {};
// Before the clone: a workspace this user may not own there, or a tool
+3
View File
@@ -57,6 +57,9 @@ const PREF_KEYS = [
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.codex.sandbox",
"agenthub.codex.approval",
"agenthub.codex.bypass",
"agenthub.tour",
"agenthub.chatUi",
"agenthub.workspace.allTabs",