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:
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file Tests for scripts/artifact-export-hint.js: when Claude is asked to save
|
||||
* a viewable copy of a published Doc / typed artifact, and when it is not.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const { hintFor } = require("../../scripts/artifact-export-hint");
|
||||
|
||||
const PANE = { AGENTHUB_LANE_ID: "3" };
|
||||
const docBirth = {
|
||||
tool_name: "mcp__claude_ai_Claude_Docs__batch",
|
||||
tool_input: { container: { kind: "project", create: { name: "Plan" } } },
|
||||
tool_response: [{ type: "text", text: '{"frame":{"url":"https://claude.ai/artifact/doc-9"}}' }],
|
||||
};
|
||||
const slides = {
|
||||
tool_name: "Artifact",
|
||||
tool_input: { type_url: "https://claude.ai/types/slides", title: "Deck" },
|
||||
tool_response: "Created https://claude.ai/artifact/deck-1",
|
||||
};
|
||||
|
||||
describe("artifact export hint", () => {
|
||||
it("asks for a Markdown copy of a new doc and a PDF/HTML copy of a typed artifact", () => {
|
||||
assert.match(hintFor(docBirth, PANE), /artifacts-export\/doc-9\.md/);
|
||||
const s = hintFor(slides, PANE);
|
||||
assert.match(s, /artifacts-export\/deck-1\.pdf/);
|
||||
assert.match(s, /artifacts-export\/deck-1\.html/);
|
||||
});
|
||||
|
||||
it("stays silent everywhere else", () => {
|
||||
assert.equal(hintFor(slides, {}), null, "not an AgentHub pane");
|
||||
assert.equal(hintFor(slides, { ...PANE, AGENTHUB_ARTIFACT_EXPORT: "0" }), null, "turned off");
|
||||
const html = { ...slides, tool_input: { file_path: "/w/page.html" } };
|
||||
assert.equal(hintFor(html, PANE), null, "an HTML page is copied by AgentHub itself");
|
||||
const edit = {
|
||||
...docBirth,
|
||||
tool_input: { container: { kind: "project", id: "p1" }, batch: [] },
|
||||
};
|
||||
assert.equal(hintFor(edit, PANE), null, "a later batch edits the doc");
|
||||
assert.equal(hintFor({ ...slides, tool_response: "no link" }, PANE), null);
|
||||
assert.equal(hintFor({ ...slides, tool_name: "ArtifactData" }, PANE), null);
|
||||
assert.equal(hintFor(null, PANE), null);
|
||||
});
|
||||
|
||||
it("prints Claude Code's PostToolUse context, and nothing on bad input", () => {
|
||||
const script = path.join(__dirname, "..", "..", "scripts", "artifact-export-hint.js");
|
||||
const run = (input, env = PANE) =>
|
||||
spawnSync(process.execPath, [script], {
|
||||
input,
|
||||
env: { PATH: process.env.PATH, ...env },
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const ok = run(JSON.stringify(slides));
|
||||
assert.equal(ok.status, 0);
|
||||
const out = JSON.parse(ok.stdout);
|
||||
assert.equal(out.hookSpecificOutput.hookEventName, "PostToolUse");
|
||||
assert.match(out.hookSpecificOutput.additionalContext, /deck-1/);
|
||||
const bad = run("{not json");
|
||||
assert.equal(bad.status, 0);
|
||||
assert.equal(bad.stdout, "");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,17 +247,48 @@ describe("shelves", () => {
|
||||
await assert.rejects(act("shelve", {}), { code: "EMERGING" });
|
||||
});
|
||||
|
||||
it("restores staged changes staged, and refuses a dirty tree", async () => {
|
||||
it("restores staged changes staged, and refuses one of its files changed", async () => {
|
||||
write("a.txt", "staged\n");
|
||||
g("add", "a.txt");
|
||||
const { shelf } = await act("shelve", {});
|
||||
write("sub/s.txt", "new work\n");
|
||||
write("a.txt", "new work\n");
|
||||
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
|
||||
g("checkout", "--", "sub/s.txt");
|
||||
g("checkout", "--", "a.txt");
|
||||
await act("unshelve", { sha: shelf.sha });
|
||||
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
|
||||
});
|
||||
|
||||
it("restores beside changes in other files, never over its own or a staged index", async () => {
|
||||
// A partial Shelve (n): the unticked files stay behind and must not block
|
||||
// the restore.
|
||||
write("a.txt", "wip\n");
|
||||
write("sub/new.txt", "new\n");
|
||||
write("left.txt", "left behind\n");
|
||||
const { shelf } = await act("shelve", { paths: ["a.txt", "sub/new.txt"] });
|
||||
assert.equal(read("left.txt"), "left behind\n");
|
||||
// Its own file changed since: refused, with the file named.
|
||||
write("a.txt", "other\n");
|
||||
await assert.rejects(act("unshelve", { sha: shelf.sha }), (e) => {
|
||||
assert.equal(e.code, "EDIRTY");
|
||||
assert.deepEqual(e.paths, ["a.txt"]);
|
||||
return true;
|
||||
});
|
||||
g("checkout", "--", "a.txt");
|
||||
// Anything staged: refused, nothing written.
|
||||
write("b.txt", "staged\n");
|
||||
g("add", "b.txt");
|
||||
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
|
||||
assert.equal(fs.existsSync(path.join(REPO, "sub/new.txt")), false);
|
||||
g("rm", "-q", "--cached", "b.txt");
|
||||
const r = await act("unshelve", { sha: shelf.sha });
|
||||
assert.equal(r.kept, false);
|
||||
assert.equal(read("a.txt"), "wip\n");
|
||||
assert.equal(read("sub/new.txt"), "new\n");
|
||||
assert.equal(read("left.txt"), "left behind\n");
|
||||
assert.equal(read("b.txt"), "staged\n");
|
||||
assert.equal((await listShelves(REPO)).length, 0);
|
||||
});
|
||||
|
||||
it("keeps a conflicted shelf and lets `resolve` settle its files", async () => {
|
||||
write("shared.txt", "mine\n");
|
||||
const { shelf } = await act("shelve", {});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file hub-boundary.test.js
|
||||
* @description `server/hub/` (the remote-clients hub, server mode only) is
|
||||
* loaded only behind `isServerMode()`: no file outside it may require it,
|
||||
* except `server/index.js` on lines that test the mode themselves. And a
|
||||
* local-mode app really leaves it out — no module under `server/hub/` is in
|
||||
* the require cache after `createApp()` in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const HUB = path.join(ROOT, "server", "hub");
|
||||
const SCAN = ["server", "bin", "scripts", "mcp", "statusline"];
|
||||
const REQUIRE_HUB = /require\(\s*["'`][^"'`]*\bhub(?:\/[^"'`]*)?["'`]\s*\)/;
|
||||
|
||||
function jsFiles(dir) {
|
||||
const out = [];
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (["node_modules", "__tests__", "dist"].includes(e.name) || full === HUB) continue;
|
||||
out.push(...jsFiles(full));
|
||||
} else if (/\.(c|m)?js$/.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("server/hub boundary", () => {
|
||||
it("nothing outside server/hub requires it, except index.js behind isServerMode()", () => {
|
||||
const offenders = [];
|
||||
for (const top of SCAN) {
|
||||
const dir = path.join(ROOT, top);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of jsFiles(dir)) {
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (!REQUIRE_HUB.test(line)) return;
|
||||
const rel = path.relative(ROOT, file);
|
||||
if (rel === path.join("server", "index.js") && /isServerMode\(\)/.test(line)) return;
|
||||
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
it("a local-mode app loads no module of server/hub", () => {
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "hub-boundary-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
require("../index").createApp();
|
||||
const loaded = Object.keys(require.cache).filter((k) => k.startsWith(HUB + path.sep));
|
||||
assert.deepEqual(loaded, []);
|
||||
} finally {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,17 @@ describe("install-hooks host-only guard (#193)", () => {
|
||||
JSON.stringify(e).includes("hook-handler.js")
|
||||
);
|
||||
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
|
||||
// The artifact export hint: one synchronous PostToolUse entry of its own,
|
||||
// next to the (async) handler, matching only the publishing tools.
|
||||
const post = settings.hooks.PostToolUse;
|
||||
const handler = post.filter((e) => JSON.stringify(e).includes("hook-handler.js"));
|
||||
const hint = post.filter((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
|
||||
assert.equal(handler.length, 1);
|
||||
assert.equal(hint.length, 1);
|
||||
assert.equal(hint[0].hooks[0].async, undefined);
|
||||
assert.match(hint[0].matcher, /Artifact/);
|
||||
const { isOurEntry } = require("../../scripts/install-hooks");
|
||||
assert.equal(isOurEntry(hint[0]), true, "uninstall takes it out too");
|
||||
});
|
||||
|
||||
it("with the agenthub plugin enabled, adds nothing and removes our duplicate entries", () => {
|
||||
|
||||
@@ -92,6 +92,18 @@ describe("local mode: one account", () => {
|
||||
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
|
||||
});
|
||||
|
||||
it("keeps the Artifacts folders, up to 64 KB", async () => {
|
||||
const folders = JSON.stringify({ folders: [{ id: "f1", name: "Slides" }], of: { 3: "f1" } });
|
||||
let r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { "agenthub.artifacts.folders": folders + "x".repeat(20 * 1024) } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { "agenthub.artifacts.folders": "x".repeat(65 * 1024) } },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
||||
let r = await call("GET", "/api/me/instructions");
|
||||
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* Path parameters are compared by position only (`:token` and `{ref}` match),
|
||||
* and the two alias mounts that the spec documents by their tag description
|
||||
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
|
||||
* are allowlisted. It also fails on a spec operation no route answers.
|
||||
* are allowlisted. It also fails on a spec operation no route answers. The
|
||||
* app is built twice — local and server mode — since only server mode mounts
|
||||
* the hub's routes (server/hub/).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -83,7 +85,16 @@ describe("OpenAPI coverage", () => {
|
||||
let documented;
|
||||
|
||||
before(() => {
|
||||
routes = collectRoutes(createApp());
|
||||
// Server mode mounts the hub's routes too (server/hub/): check both apps.
|
||||
const local = collectRoutes(createApp());
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
let hub;
|
||||
try {
|
||||
hub = collectRoutes(createApp());
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
routes = [...new Set([...local, ...hub])];
|
||||
documented = new Set();
|
||||
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
|
||||
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* @file remote-client.test.js
|
||||
* @description A LOCAL AgentHub's side of the remote-clients architecture
|
||||
* (server/lib/remote-client.js, /api/remote-client), against a fake hub:
|
||||
* enrollment keeps the device token in a 0600 file and never answers it; the
|
||||
* Company account is Claude-only, enrolled-only, and points a pane's
|
||||
* `--settings` at the hub's gateway; a resume stays on it; only sessions
|
||||
* started on it are uploaded — whole lines, secret-shaped strings replaced,
|
||||
* from where the hub stands (a 409 resyncs), the end sent once; a pane that
|
||||
* gets a new session id registers it; logout revokes on the hub.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "remote-client-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const remote = require("../lib/remote-client");
|
||||
const { resolveProxy, resumeProxy, proxyForSpawn } = require("../lib/proxy-apply");
|
||||
|
||||
const TOKEN = `ahdev_${"t".repeat(43)}`;
|
||||
let hub;
|
||||
let HUB;
|
||||
let server;
|
||||
let BASE;
|
||||
const hubSeen = { registered: [], uploads: [], logout: 0 };
|
||||
const consumed = new Map();
|
||||
let forceOffset = null;
|
||||
/** Sessions the hub answers ENOSESSION for until they are registered again. */
|
||||
const forgotten = new Set();
|
||||
/** Sessions the hub already purged. */
|
||||
const purged = new Set();
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((r) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => r(raw ? JSON.parse(raw) : {}));
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
hub = http.createServer(async (req, res) => {
|
||||
const body = await readBody(req);
|
||||
const json = (status, obj) => {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (req.url === "/api/remote/device/login") {
|
||||
if (body.password !== "right-pass") {
|
||||
return json(401, { error: { code: "EBADLOGIN", message: "wrong username or password" } });
|
||||
}
|
||||
return json(200, {
|
||||
token: TOKEN,
|
||||
device: {
|
||||
id: 7,
|
||||
name: body.deviceName,
|
||||
expiresAt: new Date(Date.now() + 90 * 864e5).toISOString(),
|
||||
},
|
||||
user: { id: 3, username: body.username, role: "developer" },
|
||||
retainDays: 30,
|
||||
});
|
||||
}
|
||||
if (req.headers.authorization !== `Bearer ${TOKEN}`) {
|
||||
return json(401, { error: { code: "EDEVICE", message: "no" } });
|
||||
}
|
||||
if (req.url === "/api/remote/device/me") return json(200, { device: { id: 7 } });
|
||||
if (req.url === "/api/remote/device/logout") {
|
||||
hubSeen.logout++;
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
if (req.url === "/api/remote/device/sessions") {
|
||||
hubSeen.registered.push(body);
|
||||
forgotten.delete(body.sessionId);
|
||||
return json(200, { ok: true, sessionId: body.sessionId });
|
||||
}
|
||||
if (req.url === "/api/remote/device/upload") {
|
||||
hubSeen.uploads.push(body);
|
||||
if (forgotten.has(body.sessionId)) {
|
||||
return json(404, { error: { code: "ENOSESSION", message: "not this device's" } });
|
||||
}
|
||||
if (purged.has(body.sessionId)) {
|
||||
return json(410, { error: { code: "EPURGED", message: "removed" } });
|
||||
}
|
||||
const files = [];
|
||||
for (const f of body.files) {
|
||||
const key = `${body.sessionId}/${f.path}`;
|
||||
const at = forceOffset ?? consumed.get(key) ?? 0;
|
||||
if (f.from !== at) {
|
||||
forceOffset = null;
|
||||
return json(409, {
|
||||
error: { code: "EOFFSET", message: "offset", expected: { path: f.path, consumed: at } },
|
||||
});
|
||||
}
|
||||
consumed.set(key, f.to);
|
||||
files.push({ path: f.path, consumed: f.to });
|
||||
}
|
||||
return json(200, { files, imported: true });
|
||||
}
|
||||
json(404, { error: { code: "ENOTFOUND", message: req.url } });
|
||||
});
|
||||
await new Promise((r) => hub.listen(0, "127.0.0.1", r));
|
||||
HUB = `http://127.0.0.1:${hub.address().port}`;
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
hub?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const local = (method, p, body) =>
|
||||
fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
/** A local session whose transcript is `lines`, optionally a Company-account one. */
|
||||
function makeSession({ company, lines, runStatus = "running" }) {
|
||||
const id = crypto.randomUUID();
|
||||
const dir = path.join(process.env.CLAUDE_HOME, "projects", "-w-p");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${id}.jsonl`);
|
||||
fs.writeFileSync(file, lines);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, transcript_path) VALUES (?, 'x', 'active', '/w/p', ?)"
|
||||
).run(id, file);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, cwd, mode, status, proxy) VALUES (?, ?, '/w/p', 'interactive', ?, ?)`
|
||||
).run(`run-${id}`, id, runStatus, company ? "agenthub.company" : null);
|
||||
if (company) remote.registerSoon({ sessionId: id, cwd: "/w/p" });
|
||||
return { id, file };
|
||||
}
|
||||
|
||||
describe("enrollment", () => {
|
||||
it("is off until login; a wrong password stores nothing", async () => {
|
||||
const st = await local("GET", "/api/remote-client");
|
||||
assert.equal(st.body.enrolled, false);
|
||||
const bad = await local("POST", "/api/remote-client/login", {
|
||||
hub: HUB,
|
||||
username: "mia",
|
||||
password: "wrong",
|
||||
});
|
||||
assert.equal(bad.status, 401);
|
||||
assert.equal(remote.readState(), null);
|
||||
});
|
||||
|
||||
it("refuses a hub address with a login in it or another scheme", () => {
|
||||
assert.throws(() => remote.normalizeHub("http://u:p@10.1.13.7:4820"), { code: "EBADHUB" });
|
||||
assert.throws(() => remote.normalizeHub("ftp://10.1.13.7"), { code: "EBADHUB" });
|
||||
assert.equal(remote.normalizeHub("http://10.1.13.7:4820/"), "http://10.1.13.7:4820");
|
||||
});
|
||||
|
||||
it("keeps the token in a 0600 file and never answers it", async () => {
|
||||
const r = await local("POST", "/api/remote-client/login", {
|
||||
hub: `${HUB}/`,
|
||||
username: "mia",
|
||||
password: "right-pass",
|
||||
deviceName: "mia-pc",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.enrolled, true);
|
||||
assert.equal(r.body.user.username, "mia");
|
||||
assert.ok(!JSON.stringify(r.body).includes(TOKEN));
|
||||
const file = path.join(process.env.DASHBOARD_DATA_DIR, "runtime", "remote-client.json");
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
assert.ok(!fs.readFileSync(file, "utf8").includes("right-pass"), "the password is not kept");
|
||||
const st = await local("GET", "/api/remote-client");
|
||||
assert.ok(!JSON.stringify(st.body).includes(TOKEN));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Company account", () => {
|
||||
it("is Claude-only and becomes a --settings file pointing at the gateway", () => {
|
||||
assert.throws(() => resolveProxy("codex", "agenthub.company"), { code: "EUNSUPPORTED" });
|
||||
const profile = resolveProxy("claude", "agenthub.company");
|
||||
assert.equal(profile.company, true);
|
||||
const { argv, env } = proxyForSpawn("claude", profile);
|
||||
assert.deepEqual(env, {}, "nothing secret goes through the pane env");
|
||||
const file = argv[0].replace("--settings=", "");
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
const s = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(s.env.ANTHROPIC_BASE_URL, `${HUB}/api/remote/gw`);
|
||||
assert.equal(s.env.ANTHROPIC_AUTH_TOKEN, TOKEN);
|
||||
assert.equal(s.env.ENABLE_PROMPT_CACHING_1H, "1");
|
||||
});
|
||||
|
||||
it("a resume stays on it", () => {
|
||||
const { id } = makeSession({ company: true, lines: "" });
|
||||
assert.equal(resumeProxy(id), "agenthub.company");
|
||||
});
|
||||
|
||||
it("no profile may take its name", () => {
|
||||
const proxies = require("../lib/ai-proxies");
|
||||
const p = proxies.createProxy({
|
||||
label: "Company",
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
});
|
||||
assert.notEqual(p.name, "agenthub.company");
|
||||
// A profile made before the Company account existed keeps working as itself.
|
||||
assert.equal(p.name, "company");
|
||||
const resolved = resolveProxy("claude", "company");
|
||||
assert.equal(resolved.company, undefined);
|
||||
assert.equal(resolved.name, "company");
|
||||
proxies.removeProxy(p.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the upload", () => {
|
||||
it("sends whole lines of Company sessions only, secrets replaced, then only what is new", async () => {
|
||||
hubSeen.uploads = [];
|
||||
const secret = `sk-ant-api03-${"s".repeat(40)}`;
|
||||
const line1 = JSON.stringify({ type: "user", message: { content: `my key is ${secret}` } });
|
||||
const line2 = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { usage: { input_tokens: 5, output_tokens: 6 } },
|
||||
});
|
||||
const company = makeSession({ company: true, lines: `${line1}\n${line2}\n{"partial":` });
|
||||
const own = makeSession({ company: false, lines: `${line1}\n` });
|
||||
const r = await remote.uploadAll();
|
||||
assert.deepEqual(r.errors, []);
|
||||
const mine = hubSeen.uploads.filter((u) => u.sessionId === company.id);
|
||||
const sent = mine.filter((u) => u.files.length);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].more, true, "a chunk leaves the import to the round's close");
|
||||
assert.equal(mine.length, 2);
|
||||
assert.deepEqual(mine[1], { sessionId: company.id, files: [] }, "the close: import now");
|
||||
const data = sent[0].files[0].data;
|
||||
assert.ok(!data.includes(secret), "the key never leaves the machine");
|
||||
assert.ok(data.includes("[redacted]"));
|
||||
assert.ok(data.includes('"output_tokens":6'), "usage numbers survive");
|
||||
assert.ok(!data.includes("partial"), "a line still being written waits");
|
||||
assert.equal(sent[0].files[0].to, Buffer.byteLength(`${line1}\n${line2}\n`));
|
||||
assert.equal(
|
||||
hubSeen.uploads.filter((u) => u.sessionId === own.id).length,
|
||||
0,
|
||||
"an own-login session is never sent"
|
||||
);
|
||||
|
||||
fs.appendFileSync(company.file, ' "done"}\n');
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll();
|
||||
const next = hubSeen.uploads.filter((u) => u.sessionId === company.id && u.files.length);
|
||||
assert.equal(next.length, 1);
|
||||
assert.equal(next[0].files[0].from, Buffer.byteLength(`${line1}\n${line2}\n`));
|
||||
assert.equal(next[0].files[0].data, '{"partial":"done"}\n');
|
||||
});
|
||||
|
||||
it("resyncs from where the hub stands after a 409", async () => {
|
||||
const line = JSON.stringify({ type: "user", message: { content: "hi" } });
|
||||
const s = makeSession({ company: true, lines: `${line}\n${line}\n` });
|
||||
// The hub already holds the first line (an earlier answer got lost).
|
||||
consumed.set(`${s.id}/${s.id}.jsonl`, Buffer.byteLength(`${line}\n`));
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll();
|
||||
const tries = hubSeen.uploads.filter((u) => u.sessionId === s.id && u.files.length);
|
||||
assert.equal(tries.length, 2);
|
||||
assert.equal(tries[1].files[0].from, Buffer.byteLength(`${line}\n`));
|
||||
assert.equal(consumed.get(`${s.id}/${s.id}.jsonl`), Buffer.byteLength(`${line}\n${line}\n`));
|
||||
});
|
||||
|
||||
it("sends the end once, when no pane holds the session any more", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n', runStatus: "exited" });
|
||||
db.prepare("UPDATE sessions SET updated_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(s.id);
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll();
|
||||
await remote.uploadAll();
|
||||
const ends = hubSeen.uploads.filter((u) => u.sessionId === s.id && u.ended === true);
|
||||
assert.equal(ends.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload edges", () => {
|
||||
it("sends a line too big for the hub as a placeholder and moves past it", async () => {
|
||||
const big = JSON.stringify({
|
||||
type: "user",
|
||||
message: { content: "x".repeat(remote.MAX_LINE_BYTES + 10) },
|
||||
});
|
||||
const small = JSON.stringify({ type: "user", message: { content: "after" } });
|
||||
const s = makeSession({ company: true, lines: `${big}\n${small}\n` });
|
||||
hubSeen.uploads = [];
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
const data = hubSeen.uploads
|
||||
.filter((u) => u.sessionId === s.id)
|
||||
.map((u) => u.files.map((f) => f.data).join(""))
|
||||
.join("");
|
||||
assert.ok(data.length < 1024, "the big line is not sent");
|
||||
assert.match(data, /"type":"agenthub-omitted","bytes":\d+/);
|
||||
assert.match(data, /after/);
|
||||
assert.equal(
|
||||
consumed.get(`${s.id}/${s.id}.jsonl`),
|
||||
Buffer.byteLength(`${big}\n${small}\n`),
|
||||
"the hub's offset still covers the whole file"
|
||||
);
|
||||
});
|
||||
|
||||
it("registers a session again after this machine logged in again (ENOSESSION)", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n' });
|
||||
await new Promise((r) => setTimeout(r, 100)); // registerSoon's own registration
|
||||
forgotten.add(s.id);
|
||||
hubSeen.registered = [];
|
||||
hubSeen.uploads = [];
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(hubSeen.registered.filter((b) => b.sessionId === s.id).length, 1);
|
||||
assert.equal(hubSeen.registered[0].cwd, "/w/p");
|
||||
assert.equal(consumed.get(`${s.id}/${s.id}.jsonl`), 8);
|
||||
});
|
||||
|
||||
it("also registers again when only the end is left to send", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n', runStatus: "exited" });
|
||||
db.prepare("UPDATE sessions SET updated_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(s.id);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
consumed.set(`${s.id}/${s.id}.jsonl`, 8);
|
||||
db.prepare("INSERT INTO remote_uploads (session_id, rel_path, sent) VALUES (?, ?, 8)").run(
|
||||
s.id,
|
||||
`${s.id}.jsonl`
|
||||
);
|
||||
forgotten.add(s.id);
|
||||
hubSeen.uploads = [];
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.ok(hubSeen.uploads.some((u) => u.sessionId === s.id && u.ended === true));
|
||||
assert.equal(forgotten.has(s.id), false, "registered again");
|
||||
});
|
||||
|
||||
it("stops sending a session the hub already purged", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n' });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
purged.add(s.id);
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(remote.isCompany(s.id), false);
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll({ onlySession: s.id });
|
||||
assert.equal(hubSeen.uploads.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a new session id in a Company pane", () => {
|
||||
it("is registered from its hook; another pane's is not", async () => {
|
||||
const { id } = makeSession({ company: true, lines: "" });
|
||||
const run = `run-${id}`;
|
||||
await new Promise((r) => setTimeout(r, 100)); // its own registration lands first
|
||||
hubSeen.registered = [];
|
||||
const fresh = crypto.randomUUID();
|
||||
remote.onHookSession({ sessionId: fresh, runId: run, cwd: "/w/p" });
|
||||
const other = makeSession({ company: false, lines: "" });
|
||||
remote.onHookSession({ sessionId: crypto.randomUUID(), runId: `run-${other.id}`, cwd: "/w/p" });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
assert.deepEqual(
|
||||
hubSeen.registered.map((x) => x.sessionId),
|
||||
[fresh]
|
||||
);
|
||||
assert.equal(remote.isCompany(fresh), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("revokes on the hub and forgets the token; the Company account is then refused", async () => {
|
||||
const r = await local("POST", "/api/remote-client/logout", {});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.revokedOnHub, true);
|
||||
assert.equal(hubSeen.logout, 1);
|
||||
assert.equal(remote.readState(), null);
|
||||
assert.throws(() => resolveProxy("claude", "agenthub.company"), { code: "ENOHUB" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,941 @@
|
||||
/**
|
||||
* @file remote-hub.test.js
|
||||
* @description The hub half of the remote-clients architecture (server/hub/),
|
||||
* in server mode: device enrollment by password (token shown once, stored as
|
||||
* a hash, 90 days, revocable), session registration (UUID, one device per
|
||||
* session, no hijack of a known session), the gateway (device token → pool
|
||||
* account's OAuth token, never the reverse; only registered sessions, `warn`
|
||||
* records an unmonitored one, `block` refuses; usage counted from SSE and
|
||||
* JSON answers; one account per session), the hourly upload (offset protocol,
|
||||
* fixed file shapes only, the SAME importer so tokens land, owner_id/source
|
||||
* stamped, scope: a developer sees only their own), retention purge, the
|
||||
* signed-in views (devices, revoke with the password again + audit, "who
|
||||
* opened my sessions").
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "remote-hub-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.AGENTHUB_REMOTE_REGISTER_GRACE_MS = "300";
|
||||
process.env.AGENTHUB_REMOTE_UNMONITORED_ALERT_MS = "250";
|
||||
process.env.AGENTHUB_REMOTE_FLUSH_MS = "250";
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
require("../lib/cc-usage").fetchOwner = async () => null;
|
||||
|
||||
let createApp;
|
||||
let startServer;
|
||||
let db;
|
||||
let auth;
|
||||
let server;
|
||||
let BASE;
|
||||
let upstream;
|
||||
let seen = [];
|
||||
const POOL_TOKEN = `sk-ant-oat01-${"p".repeat(40)}`;
|
||||
const users = {};
|
||||
|
||||
const sid = () => crypto.randomUUID();
|
||||
|
||||
async function call(method, p, { token, cookie, body, headers = {} } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
/* stream */
|
||||
}
|
||||
return { status: res.status, body: json, text, headers: res.headers };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
async function enroll(username, password, deviceName = "laptop") {
|
||||
return call("POST", "/api/remote/device/login", { body: { username, password, deviceName } });
|
||||
}
|
||||
|
||||
function transcript(sessionId, cwd, { input = 10, output = 5, text = "hello" } = {}) {
|
||||
return (
|
||||
[
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
timestamp: "2026-11-04T01:00:00.000Z",
|
||||
cwd,
|
||||
message: { content: "do the thing" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
timestamp: "2026-11-04T01:00:01.000Z",
|
||||
cwd,
|
||||
message: {
|
||||
id: `msg_${input}_${output}`,
|
||||
model: "claude-sonnet-4-5-20250929",
|
||||
content: [{ type: "text", text }],
|
||||
usage: {
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
.map((l) => JSON.stringify(l))
|
||||
.join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
upstream = http.createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => {
|
||||
seen.push({ url: req.url, headers: req.headers, body: raw });
|
||||
const asked = JSON.parse(raw || "{}");
|
||||
if (asked.gzip) {
|
||||
// As api.anthropic.com answers a client that accepts it: compressed.
|
||||
const zlib = require("node:zlib");
|
||||
const sse =
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":11,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":1}}}\n\n' +
|
||||
'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":22}}\n\n';
|
||||
res.writeHead(200, { "content-type": "text/event-stream", "content-encoding": "gzip" });
|
||||
res.end(zlib.gzipSync(sse));
|
||||
return;
|
||||
}
|
||||
if (asked.stream) {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.write(
|
||||
'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":100,"cache_read_input_tokens":40,"cache_creation_input_tokens":7,"output_tokens":1}}}\n\n'
|
||||
);
|
||||
setTimeout(
|
||||
() =>
|
||||
res.end(
|
||||
'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":25}}\n\n'
|
||||
),
|
||||
20
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, usage: { input_tokens: 3, output_tokens: 4 } }));
|
||||
});
|
||||
});
|
||||
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
||||
process.env.AGENTHUB_REMOTE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
|
||||
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
({ createApp, startServer } = require("../index"));
|
||||
({ db } = require("../db"));
|
||||
auth = require("../lib/auth");
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
users.boss = await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
users.mia = await auth.createUser("mia", "member-pass-1", "developer");
|
||||
users.tom = await auth.createUser("tom", "member-pass-2", "developer");
|
||||
require("../lib/claude-pool").addAccount("team", POOL_TOKEN);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_REMOTE_UPSTREAM;
|
||||
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||
server?.close();
|
||||
upstream?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("device enrollment", () => {
|
||||
it("gives a token once for the right password, stores only its hash, 90 days", async () => {
|
||||
const r = await enroll("mia", "member-pass-1", "mia-laptop");
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.body.token, /^ahdev_[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(r.body.user.username, "mia");
|
||||
assert.equal(r.body.device.name, "mia-laptop");
|
||||
const days = (Date.parse(r.body.device.expiresAt) - Date.now()) / 864e5;
|
||||
assert.ok(days > 89 && days <= 90, `expires in ${days} days`);
|
||||
const row = db.prepare("SELECT * FROM devices WHERE id = ?").get(r.body.device.id);
|
||||
assert.notEqual(row.token_hash, r.body.token);
|
||||
assert.ok(!JSON.stringify(row).includes(r.body.token));
|
||||
const me = await call("GET", "/api/remote/device/me", { token: r.body.token });
|
||||
assert.equal(me.status, 200);
|
||||
assert.equal(me.body.user.username, "mia");
|
||||
assert.ok(!JSON.stringify(me.body).includes("token_hash"));
|
||||
});
|
||||
|
||||
it("refuses a wrong password and a disabled user", async () => {
|
||||
assert.equal((await enroll("mia", "wrong-pass")).status, 401);
|
||||
await auth.createUser("gone", "gone-pass-1", "developer");
|
||||
auth.setDisabled("gone", true);
|
||||
assert.equal((await enroll("gone", "gone-pass-1")).status, 401);
|
||||
});
|
||||
|
||||
it("a revoked, expired or disabled-user token opens nothing", async () => {
|
||||
const r = await enroll("tom", "member-pass-2");
|
||||
const t = r.body.token;
|
||||
assert.equal((await call("GET", "/api/remote/device/me", { token: t })).status, 200);
|
||||
db.prepare("UPDATE devices SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(
|
||||
r.body.device.id
|
||||
);
|
||||
assert.equal((await call("GET", "/api/remote/device/me", { token: t })).status, 401);
|
||||
const r2 = await enroll("tom", "member-pass-2");
|
||||
assert.equal(
|
||||
(await call("POST", "/api/remote/device/logout", { token: r2.body.token, body: {} })).status,
|
||||
200
|
||||
);
|
||||
assert.equal(
|
||||
(await call("GET", "/api/remote/device/me", { token: r2.body.token })).status,
|
||||
401
|
||||
);
|
||||
assert.equal(
|
||||
(await call("GET", "/api/remote/device/me", { token: "ahdev_" + "x".repeat(43) })).status,
|
||||
401
|
||||
);
|
||||
});
|
||||
|
||||
it("a device token is no login: cookie routes refuse it", async () => {
|
||||
const r = await enroll("mia", "member-pass-1");
|
||||
const res = await call("GET", "/api/sessions", { token: r.body.token });
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session registration", () => {
|
||||
let mia;
|
||||
let tom;
|
||||
before(async () => {
|
||||
mia = (await enroll("mia", "member-pass-1")).body.token;
|
||||
tom = (await enroll("tom", "member-pass-2")).body.token;
|
||||
});
|
||||
|
||||
it("takes a UUID + absolute folder, refuses anything else", async () => {
|
||||
const id = sid();
|
||||
const ok = await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/home/mia/proj" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.body.sessionId, id);
|
||||
for (const body of [
|
||||
{ sessionId: "not-a-uuid", cwd: "/x" },
|
||||
{ sessionId: sid(), cwd: "relative/path" },
|
||||
{ sessionId: sid(), cwd: "/x", tool: "codex" },
|
||||
]) {
|
||||
const r = await call("POST", "/api/remote/device/sessions", { token: mia, body });
|
||||
assert.equal(r.status, 400, JSON.stringify(body));
|
||||
}
|
||||
});
|
||||
|
||||
it("one session belongs to one device; a known local session cannot be claimed", async () => {
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
const again = await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
assert.equal(again.status, 200, "re-registering (a resume) is fine");
|
||||
const theft = await call("POST", "/api/remote/device/sessions", {
|
||||
token: tom,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
assert.equal(theft.status, 409);
|
||||
// The same user enrolled again (a new device) resumes it: it moves along.
|
||||
const mia2 = (await enroll("mia", "member-pass-1", "mia-new")).body.token;
|
||||
const moved = await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia2,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
assert.equal(moved.status, 200);
|
||||
const back = await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
assert.equal(back.status, 200, "and back");
|
||||
const local = sid();
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, 'local', 'active', '/w')"
|
||||
).run(local);
|
||||
const claim = await call("POST", "/api/remote/device/sessions", {
|
||||
token: tom,
|
||||
body: { sessionId: local, cwd: "/w" },
|
||||
});
|
||||
assert.equal(claim.status, 409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enrollment and registration edges", () => {
|
||||
it("a temporary password (must change) cannot enroll a machine", async () => {
|
||||
db.prepare("UPDATE users SET must_change_password = 1 WHERE username = 'mia'").run();
|
||||
try {
|
||||
const r = await enroll("mia", "member-pass-1", "temp-box");
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EPWCHANGE");
|
||||
assert.equal(r.body.token, undefined);
|
||||
} finally {
|
||||
db.prepare("UPDATE users SET must_change_password = 0 WHERE username = 'mia'").run();
|
||||
}
|
||||
});
|
||||
|
||||
it("a session keeps the folder it was first registered with", async () => {
|
||||
const t = (await enroll("mia", "member-pass-1", "cwd-box")).body.token;
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: t,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: t,
|
||||
body: { sessionId: id, cwd: "/b" },
|
||||
});
|
||||
assert.equal(
|
||||
db.prepare("SELECT cwd FROM remote_sessions WHERE session_id = ?").get(id).cwd,
|
||||
"/a"
|
||||
);
|
||||
});
|
||||
|
||||
it("a purged session cannot be registered again", async () => {
|
||||
const t = (await enroll("mia", "member-pass-1", "purge-box")).body.token;
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: t,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
db.prepare("UPDATE remote_sessions SET purged_at = '2026-01-01' WHERE session_id = ?").run(id);
|
||||
const r = await call("POST", "/api/remote/device/sessions", {
|
||||
token: t,
|
||||
body: { sessionId: id, cwd: "/a" },
|
||||
});
|
||||
assert.equal(r.status, 410);
|
||||
assert.equal(r.body.error.code, "EPURGED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the gateway", () => {
|
||||
let token;
|
||||
let deviceId;
|
||||
before(async () => {
|
||||
const r = await enroll("mia", "member-pass-1", "gw-box");
|
||||
token = r.body.token;
|
||||
deviceId = r.body.device.id;
|
||||
});
|
||||
|
||||
const register = (id) =>
|
||||
call("POST", "/api/remote/device/sessions", {
|
||||
token,
|
||||
body: { sessionId: id, cwd: "/home/mia/p" },
|
||||
});
|
||||
|
||||
it("relays a registered session with the pool's OAuth token, never the device token", async () => {
|
||||
const id = sid();
|
||||
await register(id);
|
||||
seen = [];
|
||||
const r = await call("POST", "/api/remote/gw/v1/messages?beta=true", {
|
||||
token,
|
||||
headers: {
|
||||
"x-claude-code-session-id": id,
|
||||
"anthropic-beta": "claude-code-20250219",
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: { model: "m", messages: [] },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(seen.length, 1);
|
||||
const got = seen[0];
|
||||
assert.equal(got.url, "/v1/messages?beta=true");
|
||||
assert.equal(got.headers.authorization, `Bearer ${POOL_TOKEN}`);
|
||||
assert.equal(got.headers["x-api-key"], undefined);
|
||||
assert.equal(got.headers["anthropic-beta"], "claude-code-20250219,oauth-2025-04-20");
|
||||
assert.ok(!JSON.stringify(got.headers).includes("ahdev_"));
|
||||
assert.ok(!r.text.includes(POOL_TOKEN), "the pool token never reaches the client");
|
||||
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
assert.equal(row.account, "team");
|
||||
assert.equal(row.requests, 1);
|
||||
assert.equal(row.gw_input_tokens, 3);
|
||||
assert.equal(row.gw_output_tokens, 4);
|
||||
});
|
||||
|
||||
it("counts usage from a streamed answer and passes the stream through", async () => {
|
||||
const id = sid();
|
||||
await register(id);
|
||||
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: { stream: true },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.text, /message_start[\s\S]*message_delta/);
|
||||
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
assert.equal(row.gw_input_tokens, 100);
|
||||
assert.equal(row.gw_cache_read_tokens, 40);
|
||||
assert.equal(row.gw_cache_write_tokens, 7);
|
||||
assert.equal(row.gw_output_tokens, 25);
|
||||
});
|
||||
|
||||
it("counts usage from a compressed answer and relays the bytes as they came", async () => {
|
||||
const id = sid();
|
||||
await register(id);
|
||||
const res = await fetch(`${BASE}/api/remote/gw/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-claude-code-session-id": id,
|
||||
},
|
||||
body: JSON.stringify({ gzip: true }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("content-encoding"), "gzip");
|
||||
assert.match(await res.text(), /message_delta/); // fetch inflates it for us
|
||||
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
assert.equal(row.gw_input_tokens, 11);
|
||||
assert.equal(row.gw_output_tokens, 22);
|
||||
});
|
||||
|
||||
it("waits out a registration that is still on its way", async () => {
|
||||
const id = sid();
|
||||
const req = call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
setTimeout(() => register(id), 80);
|
||||
const r = await req;
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(
|
||||
db.prepare("SELECT unmonitored FROM remote_sessions WHERE session_id = ?").get(id)
|
||||
.unmonitored,
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("warn: an unregistered session is relayed and recorded as unmonitored", async () => {
|
||||
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||
const id = sid();
|
||||
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
assert.equal(row.unmonitored, 1);
|
||||
assert.equal(row.device_id, deviceId);
|
||||
});
|
||||
|
||||
it("warn: an unmonitored session raises the remote_unmonitored alert; a late registration clears it and alerts nobody", async () => {
|
||||
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||
const alerts = require("../lib/alerts");
|
||||
assert.deepEqual(alerts.validateRuleConfig("remote_unmonitored", {}), {
|
||||
ok: true,
|
||||
config: {},
|
||||
});
|
||||
db.prepare(
|
||||
"INSERT INTO alert_rules (id, name, rule_type, config) VALUES ('ru1', 'outside', 'remote_unmonitored', '{}')"
|
||||
).run();
|
||||
alerts.invalidateRuleCache();
|
||||
try {
|
||||
const gw = (id) =>
|
||||
call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
const outside = sid();
|
||||
const late = sid();
|
||||
assert.equal((await gw(outside)).status, 200);
|
||||
assert.equal((await gw(late)).status, 200);
|
||||
// `late` was only late: its registration arrives before the alert is due.
|
||||
assert.equal((await register(late)).status, 200);
|
||||
assert.equal(
|
||||
db.prepare("SELECT unmonitored FROM remote_sessions WHERE session_id = ?").get(late)
|
||||
.unmonitored,
|
||||
0
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 450));
|
||||
const fired = db
|
||||
.prepare("SELECT session_id, message FROM alert_events WHERE rule_id = 'ru1'")
|
||||
.all();
|
||||
const hit = fired.find((a) => a.session_id === outside);
|
||||
assert.ok(hit, "the session nobody registered alerts");
|
||||
assert.match(hit.message, /mia on gw-box/);
|
||||
assert.ok(!fired.some((a) => a.session_id === late), "a late registration alerts nobody");
|
||||
} finally {
|
||||
db.prepare("DELETE FROM alert_rules WHERE id = 'ru1'").run();
|
||||
alerts.invalidateRuleCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("warn: a message with no session id is still refused, and a known session is never claimed", async () => {
|
||||
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||
seen = [];
|
||||
const bare = await call("POST", "/api/remote/gw/v1/messages", { token, body: {} });
|
||||
assert.equal(bare.status, 403);
|
||||
assert.equal(bare.body.error.code, "EUNREGISTERED");
|
||||
const hubOwn = sid();
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd) VALUES (?, 'hub pane', 'active', '/w')"
|
||||
).run(hubOwn);
|
||||
const claim = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": hubOwn },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(claim.status, 409);
|
||||
assert.equal(
|
||||
db.prepare("SELECT 1 FROM remote_sessions WHERE session_id = ?").get(hubOwn),
|
||||
undefined
|
||||
);
|
||||
assert.equal(seen.length, 0, "nothing reached upstream");
|
||||
});
|
||||
|
||||
it("a user over their spend quota is refused (EQUOTA)", async () => {
|
||||
const quota = require("../lib/user-quota");
|
||||
const real = quota.assertSpawnAllowed;
|
||||
const gw = require("../hub/remote-gateway");
|
||||
quota.assertSpawnAllowed = () => {
|
||||
throw Object.assign(new Error("over the daily limit"), { code: "EQUOTA" });
|
||||
};
|
||||
gw.__resetQuota();
|
||||
try {
|
||||
const id = sid();
|
||||
await register(id);
|
||||
seen = [];
|
||||
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(r.status, 429);
|
||||
assert.equal(r.body.error.code, "EQUOTA");
|
||||
assert.equal(seen.length, 0);
|
||||
} finally {
|
||||
quota.assertSpawnAllowed = real;
|
||||
gw.__resetQuota();
|
||||
}
|
||||
});
|
||||
|
||||
it("a purged session is not relayed", async () => {
|
||||
const id = sid();
|
||||
await register(id);
|
||||
db.prepare("UPDATE remote_sessions SET purged_at = '2026-01-01' WHERE session_id = ?").run(id);
|
||||
seen = [];
|
||||
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(r.status, 410);
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
|
||||
it("block: an unregistered session or a message with no session id is refused", async () => {
|
||||
process.env.AGENTHUB_REMOTE_GATE = "block";
|
||||
try {
|
||||
seen = [];
|
||||
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": sid() },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EUNREGISTERED");
|
||||
const bare = await call("POST", "/api/remote/gw/v1/messages", { token, body: {} });
|
||||
assert.equal(bare.status, 403);
|
||||
assert.equal(seen.length, 0, "nothing reached upstream");
|
||||
const id = sid();
|
||||
await register(id);
|
||||
const ok = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_REMOTE_GATE;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a bad token, another device's session and a path outside /v1/", async () => {
|
||||
assert.equal(
|
||||
(await call("POST", "/api/remote/gw/v1/messages", { token: "nope", body: {} })).status,
|
||||
401
|
||||
);
|
||||
const id = sid();
|
||||
await register(id);
|
||||
const other = (await enroll("tom", "member-pass-2")).body.token;
|
||||
const r = await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token: other,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
seen = [];
|
||||
assert.equal((await call("GET", "/api/remote/gw/api/oauth/usage", { token })).status, 400);
|
||||
// fetch folds the `..` away: the request never reaches the gateway at all.
|
||||
assert.notEqual((await call("GET", "/api/remote/gw/v1/../../etc", { token })).status, 200);
|
||||
assert.equal(seen.length, 0, "nothing reached upstream");
|
||||
});
|
||||
|
||||
it("keeps a session on the account it started on", async () => {
|
||||
const id = sid();
|
||||
await register(id);
|
||||
db.prepare("UPDATE remote_sessions SET account = 'team' WHERE session_id = ?").run(id);
|
||||
require("../lib/claude-pool").addAccount("aaa-first", `sk-ant-oat01-${"q".repeat(40)}`);
|
||||
try {
|
||||
seen = [];
|
||||
await call("POST", "/api/remote/gw/v1/messages", {
|
||||
token,
|
||||
headers: { "x-claude-code-session-id": id },
|
||||
body: {},
|
||||
});
|
||||
assert.equal(seen[0].headers.authorization, `Bearer ${POOL_TOKEN}`);
|
||||
} finally {
|
||||
require("../lib/claude-pool").removeAccount("aaa-first");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hourly upload", () => {
|
||||
let mia;
|
||||
let tom;
|
||||
let miaCookie;
|
||||
let tomCookie;
|
||||
before(async () => {
|
||||
mia = (await enroll("mia", "member-pass-1", "mia-pc")).body.token;
|
||||
tom = (await enroll("tom", "member-pass-2", "tom-pc")).body.token;
|
||||
miaCookie = await login("mia", "member-pass-1");
|
||||
tomCookie = await login("tom", "member-pass-2");
|
||||
});
|
||||
|
||||
it("appends at the hub's offset, imports with the local importer, stamps owner and source", async () => {
|
||||
const id = sid();
|
||||
const cwd = "/home/mia/shop";
|
||||
await call("POST", "/api/remote/device/sessions", { token: mia, body: { sessionId: id, cwd } });
|
||||
const part1 = transcript(id, cwd);
|
||||
const r1 = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: {
|
||||
sessionId: id,
|
||||
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(part1), data: part1 }],
|
||||
},
|
||||
});
|
||||
assert.equal(r1.status, 200, JSON.stringify(r1.body));
|
||||
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||
assert.ok(s, "the importer made the session row");
|
||||
assert.equal(s.owner_id, users.mia.id);
|
||||
assert.match(s.source, /^device:\d+$/);
|
||||
const tok = db
|
||||
.prepare(
|
||||
"SELECT SUM(input_tokens) AS i, SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?"
|
||||
)
|
||||
.get(id);
|
||||
assert.equal(tok.i, 10);
|
||||
assert.equal(tok.o, 5);
|
||||
|
||||
// A wrong offset is told where the hub stands; nothing is written.
|
||||
const bad = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: { sessionId: id, files: [{ path: `${id}.jsonl`, from: 0, to: 10, data: "x\n" }] },
|
||||
});
|
||||
assert.equal(bad.status, 409);
|
||||
assert.equal(bad.body.error.expected.consumed, Buffer.byteLength(part1));
|
||||
|
||||
// Scope: mia sees it, tom does not, the admin does.
|
||||
const own = await call("GET", "/api/sessions?limit=100", { cookie: miaCookie });
|
||||
assert.ok(JSON.stringify(own.body).includes(id));
|
||||
const other = await call("GET", "/api/sessions?limit=100", { cookie: tomCookie });
|
||||
assert.ok(!JSON.stringify(other.body).includes(id));
|
||||
assert.equal((await call("GET", `/api/sessions/${id}`, { cookie: tomCookie })).status, 404);
|
||||
});
|
||||
|
||||
it("chunks marked `more` are imported once: by the round's closing request, or after FLUSH_MS", async () => {
|
||||
const tokens = (id) =>
|
||||
db.prepare("SELECT SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?").get(id).o;
|
||||
const cwd = "/home/mia/chunks";
|
||||
const up = (id, body) =>
|
||||
call("POST", "/api/remote/device/upload", { token: mia, body: { sessionId: id, ...body } });
|
||||
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", { token: mia, body: { sessionId: id, cwd } });
|
||||
const [l1, l2] = transcript(id, cwd).split(/(?<=\n)/);
|
||||
const a = await up(id, {
|
||||
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(l1), data: l1 }],
|
||||
more: true,
|
||||
});
|
||||
assert.equal(a.status, 200, JSON.stringify(a.body));
|
||||
const from = Buffer.byteLength(l1);
|
||||
await up(id, {
|
||||
files: [{ path: `${id}.jsonl`, from, to: from + Buffer.byteLength(l2), data: l2 }],
|
||||
more: true,
|
||||
});
|
||||
assert.equal(tokens(id), null, "nothing imported while the round goes on");
|
||||
const close = await up(id, { files: [] });
|
||||
assert.equal(close.status, 200);
|
||||
assert.equal(close.body.imported, true);
|
||||
assert.equal(tokens(id), 5);
|
||||
|
||||
// A round that stopped half-way is imported FLUSH_MS later.
|
||||
const id2 = sid();
|
||||
await call("POST", "/api/remote/device/sessions", { token: mia, body: { sessionId: id2, cwd } });
|
||||
const t2 = transcript(id2, cwd, { output: 7 });
|
||||
await up(id2, {
|
||||
files: [{ path: `${id2}.jsonl`, from: 0, to: Buffer.byteLength(t2), data: t2 }],
|
||||
more: true,
|
||||
});
|
||||
assert.equal(tokens(id2), null);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
assert.equal(tokens(id2), 7);
|
||||
assert.deepEqual(require("../hub/remote-ingest").__pending(), []);
|
||||
});
|
||||
|
||||
it("a session moved to the user's new device keeps its copy and offsets", async () => {
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/m" },
|
||||
});
|
||||
const t1 = transcript(id, "/m", { input: 1, output: 2 });
|
||||
const first = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: {
|
||||
sessionId: id,
|
||||
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(t1), data: t1 }],
|
||||
},
|
||||
});
|
||||
assert.equal(first.status, 200, JSON.stringify(first.body));
|
||||
const newer = (await enroll("mia", "member-pass-1", "mia-reinstalled")).body;
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: newer.token,
|
||||
body: { sessionId: id, cwd: "/m" },
|
||||
});
|
||||
const t2 = transcript(id, "/m", { input: 30, output: 40 }).replace(
|
||||
/2026-11-04T01:00/g,
|
||||
"2026-11-04T02:00"
|
||||
);
|
||||
const r = await call("POST", "/api/remote/device/upload", {
|
||||
token: newer.token,
|
||||
body: {
|
||||
sessionId: id,
|
||||
files: [
|
||||
{
|
||||
path: `${id}.jsonl`,
|
||||
from: Buffer.byteLength(t1),
|
||||
to: Buffer.byteLength(t1 + t2),
|
||||
data: t2,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
const old = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: { sessionId: id, files: [] },
|
||||
});
|
||||
assert.equal(old.status, 404, "the old device no longer holds it");
|
||||
const s = db.prepare("SELECT source, transcript_path FROM sessions WHERE id = ?").get(id);
|
||||
assert.equal(s.source, `device:${newer.device.id}`);
|
||||
assert.ok(s.transcript_path.includes(`/d${newer.device.id}/`), s.transcript_path);
|
||||
assert.equal(fs.readFileSync(s.transcript_path, "utf8"), t1 + t2, "one file, both parts");
|
||||
const tok = db
|
||||
.prepare(
|
||||
"SELECT SUM(input_tokens) AS i, SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?"
|
||||
)
|
||||
.get(id);
|
||||
assert.equal(tok.i, 31);
|
||||
assert.equal(tok.o, 42);
|
||||
});
|
||||
|
||||
it("takes only the session's own file shapes, from its own device", async () => {
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/m" },
|
||||
});
|
||||
for (const p of [
|
||||
"../../etc/passwd",
|
||||
`${id}/../x.jsonl`,
|
||||
`other.jsonl`,
|
||||
`${id}/subagents/agent-a/../../x.jsonl`,
|
||||
`${id}/notes.txt`,
|
||||
]) {
|
||||
const r = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: { sessionId: id, files: [{ path: p, from: 0, to: 2, data: "{}\n" }] },
|
||||
});
|
||||
assert.equal(r.status, 400, p);
|
||||
}
|
||||
const sub = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: {
|
||||
sessionId: id,
|
||||
files: [{ path: `${id}/subagents/agent-abc.jsonl`, from: 0, to: 3, data: "{}\n" }],
|
||||
},
|
||||
});
|
||||
assert.equal(sub.status, 200);
|
||||
const foreign = await call("POST", "/api/remote/device/upload", {
|
||||
token: tom,
|
||||
body: { sessionId: id, files: [] },
|
||||
});
|
||||
assert.equal(foreign.status, 404);
|
||||
const partial = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: { sessionId: id, files: [{ path: `${id}.jsonl`, from: 0, to: 3, data: "abc" }] },
|
||||
});
|
||||
assert.equal(partial.status, 400, "half a line is refused");
|
||||
});
|
||||
|
||||
it("purges conversations past retention and keeps the tokens", async () => {
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/m" },
|
||||
});
|
||||
const t = transcript(id, "/m", { input: 7, output: 9 });
|
||||
await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: {
|
||||
sessionId: id,
|
||||
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(t), data: t }],
|
||||
ended: true,
|
||||
},
|
||||
});
|
||||
assert.ok(db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n > 0);
|
||||
const ingest = require("../hub/remote-ingest");
|
||||
const n = ingest.purgeOld({ days: 30, now: Date.now() + 31 * 864e5 });
|
||||
assert.ok(n >= 1);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = ?").get(id).n, 0);
|
||||
assert.equal(
|
||||
db.prepare("SELECT SUM(output_tokens) AS o FROM token_usage WHERE session_id = ?").get(id).o,
|
||||
9
|
||||
);
|
||||
const row = db.prepare("SELECT * FROM remote_sessions WHERE session_id = ?").get(id);
|
||||
assert.ok(row.purged_at);
|
||||
assert.ok(row.ended_at);
|
||||
const r = await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: { sessionId: id, files: [] },
|
||||
});
|
||||
assert.equal(r.status, 410);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signed-in views", () => {
|
||||
it("a developer lists only their own devices and revokes one with their password", async () => {
|
||||
const d = (await enroll("mia", "member-pass-1", "to-revoke")).body;
|
||||
const miaCookie = await login("mia", "member-pass-1");
|
||||
const tomCookie = await login("tom", "member-pass-2");
|
||||
const mine = await call("GET", "/api/remote/devices", { cookie: miaCookie });
|
||||
assert.ok(mine.body.devices.every((x) => x.userId === users.mia.id));
|
||||
const peek = await call("GET", `/api/remote/devices?user=${users.mia.id}`, {
|
||||
cookie: tomCookie,
|
||||
});
|
||||
assert.ok(
|
||||
peek.body.devices.every((x) => x.userId === users.tom.id),
|
||||
"?user= is ignored for a developer"
|
||||
);
|
||||
const steal = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||
cookie: tomCookie,
|
||||
body: { password: "member-pass-2" },
|
||||
});
|
||||
assert.equal(steal.status, 404);
|
||||
const wrong = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||
cookie: miaCookie,
|
||||
body: { password: "nope" },
|
||||
});
|
||||
assert.equal(wrong.status, 403);
|
||||
const ok = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||
cookie: miaCookie,
|
||||
body: { password: "member-pass-1" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.ok(ok.body.device.revokedAt);
|
||||
assert.equal((await call("GET", "/api/remote/device/me", { token: d.token })).status, 401);
|
||||
});
|
||||
|
||||
it("an admin sees a user's devices and remote sessions and revokes with audit", async () => {
|
||||
const d = (await enroll("tom", "member-pass-2", "tom-revoked-by-admin")).body;
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const list = await call("GET", `/api/remote/devices?user=${users.tom.id}`, { cookie: boss });
|
||||
assert.ok(list.body.devices.some((x) => x.id === d.device.id));
|
||||
assert.ok(list.body.devices.every((x) => !("token_hash" in x)));
|
||||
const r = await call("POST", `/api/remote/devices/${d.device.id}/revoke`, {
|
||||
cookie: boss,
|
||||
body: { password: "admin-pass-1" },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
const a = db
|
||||
.prepare(
|
||||
"SELECT * FROM audit_log WHERE user_id = ? AND owner_id = ? AND path LIKE 'remote device revoked%'"
|
||||
)
|
||||
.get(users.boss.id, users.tom.id);
|
||||
assert.ok(a);
|
||||
const sessions = await call("GET", `/api/remote/sessions?user=${users.mia.id}`, {
|
||||
cookie: boss,
|
||||
});
|
||||
assert.equal(sessions.status, 200);
|
||||
assert.ok(sessions.body.sessions.length > 0);
|
||||
assert.ok(sessions.body.sessions.every((s) => s.userId === users.mia.id));
|
||||
});
|
||||
|
||||
it("a user sees who opened their sessions", async () => {
|
||||
const mia = (await enroll("mia", "member-pass-1")).body.token;
|
||||
const id = sid();
|
||||
await call("POST", "/api/remote/device/sessions", {
|
||||
token: mia,
|
||||
body: { sessionId: id, cwd: "/m" },
|
||||
});
|
||||
const t = transcript(id, "/m");
|
||||
await call("POST", "/api/remote/device/upload", {
|
||||
token: mia,
|
||||
body: {
|
||||
sessionId: id,
|
||||
files: [{ path: `${id}.jsonl`, from: 0, to: Buffer.byteLength(t), data: t }],
|
||||
},
|
||||
});
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
assert.equal((await call("GET", `/api/sessions/${id}`, { cookie: boss })).status, 200);
|
||||
const miaCookie = await login("mia", "member-pass-1");
|
||||
const log = await call("GET", "/api/remote/audit", { cookie: miaCookie });
|
||||
assert.equal(log.status, 200);
|
||||
assert.ok(log.body.entries.some((e) => e.sessionId === id && e.reader === "boss"));
|
||||
});
|
||||
|
||||
it("the users page counts a remote session's spend for its owner", async () => {
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const r = await call("GET", `/api/users/${users.mia.id}/details`, { cookie: boss });
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.body.sessions.total >= 2, JSON.stringify(r.body.sessions));
|
||||
});
|
||||
});
|
||||
@@ -247,6 +247,15 @@ describe("broadcastAudience", () => {
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
|
||||
});
|
||||
|
||||
it("tells an artifact's change to its owner and the people it is shared with only", () => {
|
||||
const d = { id: 5, ownerId: 1, shared: false, recipients: [2] };
|
||||
assert.equal(reaches("artifact_changed", d, m1), true);
|
||||
assert.equal(reaches("artifact_changed", d, m2), true);
|
||||
assert.equal(reaches("artifact_changed", { ...d, recipients: [] }, m2), false);
|
||||
assert.equal(reaches("artifact_changed", { ...d, recipients: undefined }, m2), false);
|
||||
assert.equal(reaches("artifact_changed", { ...d, shared: true, recipients: [] }, m2), true);
|
||||
});
|
||||
|
||||
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
||||
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
|
||||
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
|
||||
|
||||
@@ -408,6 +408,17 @@ describe("our own tmux server: bounded and version-matched", () => {
|
||||
assert.equal(calls, after);
|
||||
});
|
||||
|
||||
it(
|
||||
"forgets a cached server binary that is gone and runs PATH's tmux",
|
||||
{ skip: process.platform !== "linux" || !tmux.isTmuxAvailable() },
|
||||
() => {
|
||||
// The last pane stopped: the cached /proc/<pid>/exe no longer exists.
|
||||
tmux.__setHostBin("/proc/999999999/exe");
|
||||
tmux.serverVersion(); // no server in the test's TMUX_TMPDIR: null, not ENOENT
|
||||
assert.equal(tmux.__hostBin(), "tmux");
|
||||
}
|
||||
);
|
||||
|
||||
it("finds the binary of the server listening on our socket from /proc alone", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tmux-proc-"));
|
||||
const sock = "/tmp/tmux-1000/default";
|
||||
|
||||
Reference in New Issue
Block a user