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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user