chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes

Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
This commit is contained in:
2026-11-04 15:16:49 +07:00
parent 3daf7ed4cb
commit c946b73d11
95 changed files with 7918 additions and 340 deletions
+215
View File
@@ -874,6 +874,101 @@ describe("sharing and visibility", () => {
assert.equal(seen.unrecorded, 1);
});
it("shares with chosen people only: they see and list it, nobody else does", () => {
const addUser = db.prepare(
"INSERT OR IGNORE INTO users (id, username, role, password_hash, disabled) VALUES (?, ?, 'developer', 'x', ?)"
);
addUser.run(701, "owner701", 0);
addUser.run(702, "ann702", 0);
addUser.run(703, "bob703", 0);
addUser.run(704, "gone704", 1);
const as = (id) => ({ method: "GET", user: { id, role: "developer", username: `u${id}` } });
const id = Number(
db
.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'for-ann', 701, 'https://claude.ai/ann')"
)
.run().lastInsertRowid
);
process.env.AGENTHUB_MODE = "server";
try {
// Only enabled, existing users; the owner is dropped from the list.
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [704] }), {
code: "EBADFIELD",
});
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [99999] }), {
code: "EBADFIELD",
});
assert.throws(() => artifacts.update(as(701), id, { sharedWith: "702" }), {
code: "EBADFIELD",
});
// Nobody but its owner may choose who sees it.
assert.throws(() => artifacts.update(as(702), id, { sharedWith: [702] }), {
code: "ENOTFOUND",
});
const v = artifacts.update(as(701), id, { sharedWith: [702, 701, 702] });
assert.equal(v.shared, false, "never to everyone");
assert.deepEqual(
v.sharedWith.map((p) => p.id),
[702]
);
assert.equal(artifacts.canSeeId(as(702), id), true);
assert.equal(artifacts.canSeeId(as(703), id), false);
const ann = artifacts.list(as(702), { scope: "withme" });
assert.deepEqual(
ann.map((a) => a.title),
["for-ann"]
);
assert.equal(ann[0].sharedWithMe, true);
assert.deepEqual(ann[0].sharedWith, [], "a recipient does not see who else got it");
assert.ok(!artifacts.list(as(702), { scope: "hub" }).some((a) => a.id === id));
assert.ok(!artifacts.list(as(703)).some((a) => a.id === id));
assert.deepEqual(artifacts.list(as(703), { scope: "withme" }), []);
assert.deepEqual(artifacts.shareRecipients(id), [702]);
// People to pick from: enabled accounts but the caller, names only.
const people = artifacts.people(as(701));
assert.ok(people.some((p) => p.id === 702 && p.name === "ann702"));
assert.ok(!people.some((p) => p.id === 701 || p.id === 704));
assert.deepEqual(Object.keys(people[0]).sort(), ["id", "name", "username"]);
// A recipient disabled since is not listed, so it never blocks an edit.
artifacts.update(as(701), id, { sharedWith: [702, 703] });
db.prepare("UPDATE users SET disabled = 1 WHERE id = 703").run();
const listed = artifacts.view(
db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id),
as(701)
);
assert.deepEqual(
listed.sharedWith.map((p) => p.id),
[702]
);
artifacts.update(as(701), id, { sharedWith: [702] });
db.prepare("UPDATE users SET disabled = 0 WHERE id = 703").run();
// Out of its owner's library it stays with Ann; taking Ann off deletes it.
assert.equal(artifacts.remove(as(701), id).kept, true);
assert.equal(artifacts.canSeeId(as(702), id), true);
const gone = artifacts.update(as(701), id, { sharedWith: [] });
assert.equal(gone.removed, true);
assert.equal(
db.prepare("SELECT 1 FROM artifact_shares WHERE artifact_id = ?").get(id),
undefined
);
const audited = db
.prepare("SELECT path FROM audit_log WHERE path LIKE '%for-ann%' ORDER BY id")
.all()
.map((r) => r.path);
assert.ok(
audited.some((a) => /shared with ann702/.test(a)),
audited.join(" | ")
);
} finally {
delete process.env.AGENTHUB_MODE;
}
});
it("refuses a cross-origin write", async () => {
const r = await request(
"POST",
@@ -1063,3 +1158,123 @@ describe("unowned rows", () => {
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
});
});
describe("a page's own copy, viewable without its claude.ai account", () => {
it("copies the HTML file a page was published from, with the files it names", async () => {
const dir = path.join(ws, "pub");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "deck.html"), '<link href="style.css"><h1>v1</h1>');
fs.writeFileSync(path.join(dir, "style.css"), "h1{color:red}");
fs.writeFileSync(path.join(dir, ".env"), "SECRET=1");
const row = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: {
action: "publish",
file_path: "pub/deck.html",
files: { "style.css": "pub/style.css", ".env": "pub/.env", "x.txt": "/etc/hostname" },
},
tool_response: "Published: https://claude.ai/artifact/copy-1",
});
await Promise.all(artifacts.__pageCopies());
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(row.id);
const files = JSON.parse(kept.files);
assert.equal(files[0].path, "deck.html", "the page itself first");
// A secret-looking file and one outside the workspace are never copied.
assert.deepEqual(
files.map((f) => f.path),
["deck.html", "style.css"]
);
const shot = artifacts.snapshotFile(null, row.id, "deck.html");
assert.match(fs.readFileSync(shot.abs, "utf8"), /v1/);
// A republish of the same link takes the new version, same row.
fs.writeFileSync(path.join(dir, "deck.html"), "<h1>v2</h1>");
const again = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: path.join(dir, "deck.html") },
tool_response: "https://claude.ai/artifact/copy-1",
});
assert.equal(again.id, row.id);
await Promise.all(artifacts.__pageCopies());
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /v2/);
assert.ok(
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", kept.snapshot)),
"the old copy is dropped"
);
});
it("never copies a file outside the workspace or one that is not HTML", async () => {
const outside = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: "/etc/hosts.html" },
tool_response: "https://claude.ai/artifact/copy-out",
});
const typed = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: "main.js", type_url: "https://claude.ai/t" },
tool_response: "https://claude.ai/artifact/copy-typed",
});
await Promise.all(artifacts.__pageCopies());
for (const r of [outside, typed]) {
assert.equal(
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
null
);
}
});
it("takes an older page's copy on first view, once", async () => {
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
const id = Number(
db
.prepare(
"INSERT INTO artifacts (kind, title, lane_id, source, url) VALUES ('page', 'old', ?, ?, 'https://claude.ai/artifact/old-1')"
)
.run(lane.id, path.join(ws, "pub", "old.html")).lastInsertRowid
);
const r = await request("POST", `/api/artifacts/${id}/copy`, {});
assert.equal(r.status, 200, r.text);
assert.deepEqual(
r.body.item.files.map((f) => f.path),
["old.html"]
);
});
it("attaches an exported copy of a Doc or Slides to its page, not as a second card", async () => {
const page = artifacts.captureFromHook("s1", {
tool_input: { action: "publish", type_url: "https://claude.ai/types/slides" },
tool_response: "https://claude.ai/artifact/deck-77",
});
fs.mkdirSync(path.join(ws, "artifacts-export"), { recursive: true });
fs.writeFileSync(path.join(ws, "artifacts-export", "deck-77.pdf"), "%PDF-1.4 deck");
fs.writeFileSync(path.join(ws, "artifacts-export", "nobody-1.md"), "# not ours");
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n;
const done = await artifacts.captureOutputs("s1");
assert.ok(done.some((r) => r.id === page.id));
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(page.id);
assert.deepEqual(
JSON.parse(kept.files).map((f) => f.path),
["deck-77.pdf"]
);
assert.ok(
!done.some((r) => /artifacts-export/.test(r.source || "")),
"an export never becomes a file card"
);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n, before);
// Unchanged: not taken again.
assert.ok(!(await artifacts.captureOutputs("s1")).some((r) => r.id === page.id));
});
it("turns the copy hint off for new panes", async () => {
assert.equal(artifacts.exportsEnabled(), true);
const r = await request("PUT", "/api/artifacts/settings", { exportsEnabled: false });
assert.equal(r.status, 200, r.text);
assert.equal(r.body.exportsEnabled, false);
assert.equal(r.body.installsEnabled, true, "installs untouched");
assert.equal(
(await request("PUT", "/api/artifacts/settings", { exportsEnabled: "no" })).status,
400
);
artifacts.setExportsEnabled(true);
});
});