/** * @file remote-client.js * @description A LOCAL AgentHub's side of the remote-clients architecture * (docs/plans/remote-clients/README.md): this machine enrolled with the * company hub, its "Company account" sessions, and their hourly upload. * * - Enrollment: `agenthub remote login ` → `login()` posts the user's * hub password to the hub ONCE and keeps the device token it answers in * `/runtime/remote-client.json` (0600). The password is never * stored. `logout()` revokes the token on the hub and deletes the file. * - A session started on the Company account (`proxy: "agenthub.company"`, * proxy-apply.js) runs Claude Code against the hub's gateway with the * device token as its key; `registerSession` tells the hub its id at start * (no content). Every other session — the machine's own login, the user's * own AI Proxies — is never registered and never uploaded. * - Upload: every `DASHBOARD_REMOTE_UPLOAD_MS` (an hour) and when such a * session ends, the NEW whole lines of its transcript and its sub-agents' * are sent, each line's secret-shaped strings replaced first * (privacy.js `redactValue`). How far each file was sent is kept in * `remote_uploads`; the transcript files themselves are the queue, so an * offline machine simply sends the rest next time. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { getDataDir } = require("./claude-home"); /** = ai-proxies.js `COMPANY_NAME`, the Company account's proxy name. */ const HUB_PROXY = "agenthub.company"; /** One upload request carries at most this many bytes of a file. */ const CHUNK_BYTES = 4 * 1024 * 1024; /** * A transcript line longer than this (a big paste, an image) is sent as a * short placeholder instead: JSON escaping can double it, and the hub's * upload parser takes 12 MB — one such line would stop the session's uploads * for good. The hub's offset still moves past it. */ const MAX_LINE_BYTES = 5 * 1024 * 1024; /** One upload round sends at most this much in all (the rest next round). */ const ROUND_BYTES = 64 * 1024 * 1024; const TIMEOUT_MS = 15_000; const err = (code, message, status = 400) => Object.assign(new Error(message), { code, status }); const sql = (q) => require("../db").db.prepare(q); const stateFile = () => path.join(getDataDir(), "runtime", "remote-client.json"); /** The enrollment, or null. */ function readState() { try { const s = JSON.parse(fs.readFileSync(stateFile(), "utf8")); return s && typeof s.hub === "string" && typeof s.token === "string" ? s : null; } catch { return null; } } function writeState(s) { const file = stateFile(); fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const tmp = `${file}.tmp`; fs.writeFileSync(tmp, JSON.stringify(s, null, 2), { mode: 0o600 }); fs.chmodSync(tmp, 0o600); fs.renameSync(tmp, file); } /** Whether this machine holds a device token that has not expired. */ function enrolled() { const s = readState(); return Boolean(s && !(Date.parse(s.device?.expiresAt) <= Date.now())); } /** What the UI and CLI show — never the token. */ function status() { const s = readState(); if (!s) return { enrolled: false }; const expired = Date.parse(s.device?.expiresAt) <= Date.now(); return { enrolled: !expired, expired, hub: s.hub, user: s.user || null, device: s.device || null, enrolledAt: s.enrolledAt || null, retainDays: s.retainDays || null, }; } /** The hub's base URL: http(s), no credentials, no query — its origin + path. */ function normalizeHub(url) { let u; try { u = new URL(String(url || "").trim()); } catch { throw err("EBADHUB", "the hub address must be a URL like http://10.1.13.7:4820"); } if (!/^https?:$/.test(u.protocol)) throw err("EBADHUB", "the hub address must be http or https"); if (u.username || u.password) throw err("EBADHUB", "the hub address may not carry a login"); return `${u.origin}${u.pathname.replace(/\/+$/, "")}`; } /** A JSON call to the hub; throws a coded error (EHUBDOWN when it cannot be reached). */ async function hubFetch( hub, apiPath, { method = "GET", token, body, timeoutMs = TIMEOUT_MS } = {} ) { let res; try { res = await fetch(`${hub}${apiPath}`, { method, headers: { ...(body !== undefined ? { "content-type": "application/json" } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs), }); } catch (e) { throw err( "EHUBDOWN", `cannot reach the company hub at ${hub} — company accounts work only on the company network (${e.cause?.code || e.name})`, 503 ); } let data = null; try { data = await res.json(); } catch { /* not JSON */ } if (!res.ok) { const e = err( data?.error?.code || `EHUB${res.status}`, data?.error?.message || `the hub answered ${res.status}`, res.status === 401 ? 401 : res.status >= 500 ? 502 : res.status ); if (data?.error?.expected) e.expected = data.error.expected; throw e; } return data; } /** Enroll this machine; the password goes to the hub once and is not kept. */ async function login({ hub, username, password, deviceName }) { const base = normalizeHub(hub); if (!username || !password) throw err("EBADLOGIN", "username and password are required"); const r = await hubFetch(base, "/api/remote/device/login", { method: "POST", body: { username: String(username), password: String(password), deviceName: String(deviceName || os.hostname()), }, }); if (!r || typeof r.token !== "string") throw err("EHUB", "the hub gave no device token", 502); writeState({ hub: base, token: r.token, device: r.device, user: r.user, retainDays: r.retainDays, enrolledAt: new Date().toISOString(), }); return status(); } /** Revoke this machine's token on the hub (best effort) and forget it here. */ async function logout() { const s = readState(); if (!s) return { ok: true, wasEnrolled: false }; let revoked = true; try { await hubFetch(s.hub, "/api/remote/device/logout", { method: "POST", token: s.token, body: {}, }); } catch { revoked = false; // offline: the token still expires or an admin revokes it } fs.rmSync(stateFile(), { force: true }); return { ok: true, wasEnrolled: true, revokedOnHub: revoked }; } function requireState() { const s = readState(); if (!s) throw err("ENOHUB", "this machine is not enrolled with a company hub (agenthub remote login)"); if (Date.parse(s.device?.expiresAt) <= Date.now()) { throw err("EDEVICE", "this machine's hub login expired — run agenthub remote login again", 401); } return s; } /** Check the hub answers and still accepts this device (before a session starts). */ async function ping() { const s = requireState(); return hubFetch(s.hub, "/api/remote/device/me", { token: s.token, timeoutMs: 3000 }); } /** Tell the hub a Company-account session starts (its id and folder — no content). */ async function registerSession({ sessionId, cwd }) { const s = requireState(); markCompany(sessionId); return hubFetch(s.hub, "/api/remote/device/sessions", { method: "POST", token: s.token, body: { sessionId, cwd, tool: "claude" }, timeoutMs: 5000, }); } /** * Register in the background; a failure is only logged (the gateway then * refuses or flags the session — a hub that cannot be reached cannot serve it * anyway). Used where the caller cannot wait: a spawn, a hook. */ function registerSoon({ sessionId, cwd }) { try { markCompany(sessionId); } catch { /* the DB write is retried by the next hook */ } registerSession({ sessionId, cwd }).catch((e) => console.warn(`[remote] could not register session ${sessionId}: ${e.message}`) ); } /** * A hook from a pane that runs on the Company account, for a session not * registered yet (`/clear` and a compaction give the pane a NEW session id): * register it. A cheap no-op for every other session. */ function onHookSession({ sessionId, runId, cwd }) { if (!sessionId || !runId) return; const run = sql("SELECT proxy, cwd FROM dashboard_runs WHERE id = ?").get(String(runId)); if (!run || run.proxy !== HUB_PROXY || isCompany(sessionId) || !readState()) return; registerSoon({ sessionId: String(sessionId), cwd: cwd || run.cwd }); } /** * What a Claude pane on the Company account gets in its `--settings` file: * the hub's gateway as its endpoint and the device token as its key. The * command-line layer outranks the user's and the project's settings. */ function claudeEnv() { const s = requireState(); return { ANTHROPIC_BASE_URL: `${s.hub}/api/remote/gw`, ANTHROPIC_AUTH_TOKEN: s.token, // Through a base URL Claude Code writes 5-minute cache entries; the // subscription's 1-hour ones are kept by asking (verified 2026-10-09). ENABLE_PROMPT_CACHING_1H: "1", }; } // ── Upload ───────────────────────────────────────────────────────────────── /** One transcript line with its secret-shaped strings replaced. */ function redactLine(line) { const { redactValue, redactText } = require("./privacy"); try { return JSON.stringify(redactValue(JSON.parse(line))); } catch { return redactText(line); } } const REGISTERED = "#registered"; const ENDED = "#ended"; /** Remember `sessionId` as a Company-account session (the uploader's list). */ function markCompany(sessionId) { sql("INSERT OR IGNORE INTO remote_uploads (session_id, rel_path, sent) VALUES (?, ?, 0)").run( String(sessionId), REGISTERED ); // A resume opens it again: its end is sent again when it ends again. sql("DELETE FROM remote_uploads WHERE session_id = ? AND rel_path = ?").run( String(sessionId), ENDED ); } const isCompany = (sessionId) => Boolean( sql("SELECT 1 FROM remote_uploads WHERE session_id = ? AND rel_path = ?").get( String(sessionId), REGISTERED ) ); /** * The Company-account sessions this machine ran: `[{sessionId, live}]` — * live while a pane still holds it, or it wrote in the last 10 minutes. */ function companySessions() { return sql( `SELECT u.session_id AS sessionId, (EXISTS (SELECT 1 FROM dashboard_runs r WHERE r.session_id = u.session_id AND r.status IN ('running', 'spawning')) OR COALESCE((SELECT s.updated_at FROM sessions s WHERE s.id = u.session_id), '') >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 minutes')) AS live FROM remote_uploads u WHERE u.rel_path = ?` ).all(REGISTERED); } /** A session's files the hub takes: `[{rel, abs}]` — its JSONL and its sub-agents'. */ function sessionFiles(sessionId) { const row = sql("SELECT transcript_path FROM sessions WHERE id = ?").get(sessionId); const main = row?.transcript_path; if (!main || !fs.existsSync(main)) return []; const out = [{ rel: `${sessionId}.jsonl`, abs: main }]; const subDir = path.join(path.dirname(main), sessionId, "subagents"); const walk = (dir, relDir) => { let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of ents) { const abs = path.join(dir, e.name); const rel = `${relDir}/${e.name}`; if (e.isFile() && /^agent-[A-Za-z0-9_-]{1,100}\.jsonl$/.test(e.name)) out.push({ rel, abs }); else if (e.isDirectory() && relDir === `${sessionId}/subagents` && e.name === "workflows") { walk(abs, rel); } else if (e.isDirectory() && relDir === `${sessionId}/subagents/workflows`) { if (/^[A-Za-z0-9_-]{1,100}$/.test(e.name)) walk(abs, rel); } } }; walk(subDir, `${sessionId}/subagents`); return out; } const sentOf = (sid, rel) => sql("SELECT sent FROM remote_uploads WHERE session_id = ? AND rel_path = ?").get(sid, rel) ?.sent ?? 0; function setSent(sid, rel, sent) { sql( `INSERT INTO remote_uploads (session_id, rel_path, sent, sent_at) VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(session_id, rel_path) DO UPDATE SET sent = excluded.sent, sent_at = excluded.sent_at` ).run(sid, rel, sent); } /** * The next chunk of `abs` from byte `from`: whole lines only, at most * CHUNK_BYTES (a single longer line goes alone). Null when nothing complete is new. */ function readChunk(abs, from) { let size; try { size = fs.statSync(abs).size; } catch { return null; } if (size <= from) return null; const want = Math.min(size - from, CHUNK_BYTES); const fd = fs.openSync(abs, "r"); try { let buf = Buffer.alloc(want); let n = fs.readSync(fd, buf, 0, want, from); buf = buf.subarray(0, n); let cut = buf.lastIndexOf(0x0a); if (cut < 0 && size - from > want) { // One line longer than a chunk: read it whole, up to its newline. let more = buf; let pos = from + n; while (cut < 0 && pos < size) { const extra = Buffer.alloc(Math.min(CHUNK_BYTES, size - pos)); const m = fs.readSync(fd, extra, 0, extra.length, pos); if (m <= 0) break; pos += m; more = Buffer.concat([more, extra.subarray(0, m)]); cut = more.lastIndexOf(0x0a); } buf = more; } if (cut < 0) return null; // the last line is still being written const raw = buf.subarray(0, cut + 1).toString("utf8"); const lines = raw.split("\n"); lines.pop(); // the "" after the final newline const data = lines .map((l) => Buffer.byteLength(l) > MAX_LINE_BYTES ? JSON.stringify({ type: "agenthub-omitted", bytes: Buffer.byteLength(l) }) : l.trim() ? redactLine(l) : l ) .join("\n") + "\n"; return { from, to: from + cut + 1, data }; } finally { fs.closeSync(fd); } } /** Send what is new of one session. Returns bytes sent; throws on a hub error. */ async function uploadSession(s, { sessionId, live }, budget) { let sent = 0; let registeredAgain = false; /** One upload request, registering the session again once if the hub asks. */ const post = async (body, timeoutMs) => { try { return await hubFetch(s.hub, "/api/remote/device/upload", { method: "POST", token: s.token, body: { sessionId, ...body }, timeoutMs, }); } catch (e) { // Registered under this machine's previous device token (it logged in // again since): register it with this one — the hub moves the same // user's session over — and send again. const cwd = e.code === "ENOSESSION" && !registeredAgain ? sql("SELECT cwd FROM sessions WHERE id = ?").get(sessionId)?.cwd : null; if (!cwd) throw e; registeredAgain = true; await registerSession({ sessionId, cwd }); return post(body, timeoutMs); } }; try { const files = sessionFiles(sessionId); // Every chunk says `more: true`; the round's closing request (below) tells // the hub to import, so a long session is parsed once per round, not once // per chunk. let chunks = 0; files: for (const f of files) { for (;;) { if (sent >= budget) break files; const chunk = readChunk(f.abs, sentOf(sessionId, f.rel)); if (!chunk) break; try { const r = await post( { files: [{ path: f.rel, from: chunk.from, to: chunk.to, data: chunk.data }], more: true, }, 60_000 ); chunks++; setSent(sessionId, f.rel, r.files?.[0]?.consumed ?? chunk.to); sent += chunk.to - chunk.from; } catch (e) { // The hub holds a different offset (it lost a chunk, or we did): // continue from where IT stands. if (e.code === "EOFFSET" && Number.isSafeInteger(e.expected?.consumed)) { setSent(sessionId, f.rel, e.expected.consumed); continue; } throw e; } } } const ending = !live && files.length && sentOf(sessionId, ENDED) === 0 && sent < budget; if (ending) { await post({ files: [], ended: true }); setSent(sessionId, ENDED, 1); } else if (chunks > 0) { await post({ files: [] }, 60_000); } } catch (e) { // The hub already removed it (retention): nothing more of it is sent. if (e.code !== "EPURGED") throw e; sql("DELETE FROM remote_uploads WHERE session_id = ?").run(sessionId); } return sent; } let running = null; /** * One upload round over every Company-account session (or only `onlySession`). * Returns `{sessions, bytes, errors}`. Rounds never overlap. */ async function uploadAll({ onlySession = null } = {}) { if (running) return running; running = (async () => { const out = { sessions: 0, bytes: 0, errors: [] }; const s = readState(); if (!s || !enrolled()) return out; let budget = ROUND_BYTES; for (const row of companySessions()) { if (onlySession && row.sessionId !== onlySession) continue; if (!row.live && sentOf(row.sessionId, ENDED) === 1) continue; try { const n = await uploadSession(s, row, budget); out.sessions++; out.bytes += n; budget -= n; } catch (e) { out.errors.push({ sessionId: row.sessionId, code: e.code, message: e.message }); if (e.code === "EHUBDOWN" || e.code === "EDEVICE" || e.status === 401) break; } if (budget <= 0) break; } return out; })(); try { return await running; } finally { running = null; } } /** What was sent, per session, for the "Sent to the hub" view. */ function uploads() { return sql( `SELECT u.session_id AS sessionId, SUM(CASE WHEN u.rel_path LIKE '#%' THEN 0 ELSE u.sent END) AS bytes, MAX(CASE WHEN u.rel_path LIKE '#%' THEN NULL ELSE u.sent_at END) AS lastSentAt, MAX(u.rel_path = '${ENDED}') AS ended, s.name AS title, s.cwd, s.started_at AS startedAt FROM remote_uploads u LEFT JOIN sessions s ON s.id = u.session_id GROUP BY u.session_id ORDER BY COALESCE(lastSentAt, s.started_at) DESC LIMIT 200` ).all(); } let timer = null; const endTimers = new Map(); /** The hourly round, while this machine is enrolled (checked at every tick). */ function startUploader() { if (timer) return; const every = Number(process.env.DASHBOARD_REMOTE_UPLOAD_MS ?? 60 * 60 * 1000); if (!Number.isFinite(every) || every <= 0) return; timer = setInterval(() => { if (!readState()) return; uploadAll().catch((e) => console.warn(`[remote] upload round failed: ${e.message}`)); }, every); timer.unref(); } /** A session ended: send its rest soon (debounced; only a Company-account one). */ function onSessionEnd(sessionId) { if (!sessionId || !isCompany(sessionId) || !readState()) return; clearTimeout(endTimers.get(sessionId)); const t = setTimeout( () => { endTimers.delete(sessionId); uploadAll({ onlySession: sessionId }).catch(() => {}); }, Number(process.env.DASHBOARD_REMOTE_END_DELAY_MS ?? 15_000) ); t.unref(); endTimers.set(sessionId, t); } module.exports = { HUB_PROXY, readState, status, enrolled, normalizeHub, login, logout, ping, registerSession, registerSoon, onHookSession, isCompany, claudeEnv, redactLine, companySessions, sessionFiles, readChunk, MAX_LINE_BYTES, uploadAll, uploads, startUploader, onSessionEnd, };