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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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), " +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user