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:
2026-11-04 15:16:49 +07:00
parent 3daf7ed4cb
commit c946b73d11
95 changed files with 7918 additions and 340 deletions
+582
View File
@@ -0,0 +1,582 @@
/**
* @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 <hub>` → `login()` posts the user's
* hub password to the hub ONCE and keeps the device token it answers in
* `<data dir>/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ĩ <vinnt@smartgift.vn>
*/
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,
};