c946b73d11
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.
389 lines
16 KiB
JavaScript
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" });
|
|
});
|
|
});
|