Files
AgentHub/server/__tests__/remote-client.test.js
T
nntrivi2001 c946b73d11 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.
2026-11-04 15:16:49 +07:00

389 lines
16 KiB
JavaScript

/**
* @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" });
});
});