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.
942 lines
35 KiB
JavaScript
942 lines
35 KiB
JavaScript
/**
|
|
* @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));
|
|
});
|
|
});
|