Files
AgentHub/server/hub/remote-ingest.js
T
nntrivi2001 c946b73d11 chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
2026-11-04 15:16:49 +07:00

301 lines
11 KiB
JavaScript

/**
* @file remote-ingest.js
* @description The hub's side of a remote client's hourly upload (server mode
* only). A client sends, for each company-account session it registered, the
* NEW part of each transcript file — the session's JSONL and its sub-agents'
* — already stripped of secret-shaped strings on the client. The hub appends
* it to its own copy and imports that copy with the SAME importer it uses for
* local history (`importFromDirectory`), so the session's tokens and cost are
* computed exactly like a local one's; the session is then marked as the
* device's (`source = device:<id>`) and its user's (`owner_id`).
*
* Every path is built from ids the hub checked — user id, device id, a
* registered session UUID, and one of two fixed file-name shapes — never from
* request text. A chunk is accepted only at the offset the hub already holds
* (`from` = consumed): a client that lost its place is told where to resume
* (409 with `expected`), so nothing is written twice or skipped.
*
* Importing parses the whole copy, so a long session sent as many chunks
* would be parsed once per chunk. A client marks every chunk but the last of
* a round `more: true`; the hub imports once when the round's last request
* arrives (or FLUSH_MS after a round that stopped half-way).
*
* Conversations are kept `RETAIN_DAYS` (30) after a session's last upload:
* `purgeOld` deletes the copy and the session's events, and keeps its token
* rows, so spend and cost history stay whole.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getDataDir } = require("../lib/claude-home");
const sessions = require("./remote-sessions");
const err = (code, message, status, extra = {}) =>
Object.assign(new Error(message), { code, status, ...extra });
const RETAIN_DAYS = Number(process.env.AGENTHUB_REMOTE_RETAIN_DAYS) || 30;
/** One file's copy may not grow past this. */
const MAX_FILE_BYTES = 512 * 1024 * 1024;
const MAX_FILES_PER_UPLOAD = 200;
/** A round that stopped before its last request is imported this long after. */
const FLUSH_MS = () => Number(process.env.AGENTHUB_REMOTE_FLUSH_MS) || 2 * 60 * 1000;
const SUBAGENT_RE =
/^subagents\/(?:workflows\/[A-Za-z0-9_-]{1,100}\/)?agent-[A-Za-z0-9_-]{1,100}\.jsonl$/;
/** `<data dir>/remote/u<user>/d<device>/<session>` — the session's import root. */
function sessionRoot(userId, deviceId, sessionId) {
return path.join(
getDataDir(),
"remote",
`u${Number(userId)}`,
`d${Number(deviceId)}`,
String(sessionId)
);
}
/** The folder a session's files sit in, named like Claude Code names a project. */
function projectDir(row) {
const slug = (row.cwd || "unknown").replace(/[^A-Za-z0-9]/g, "-").slice(0, 200) || "unknown";
return path.join(sessionRoot(row.user_id, row.device_id, row.session_id), slug);
}
/**
* Where `rel` of session `row` is kept: `<sid>.jsonl` (the session itself) or
* `<sid>/subagents/agent-*.jsonl` (a sub-agent's) — anything else is refused.
*/
function fileFor(row, rel) {
const sid = row.session_id;
if (rel === `${sid}.jsonl`) return path.join(projectDir(row), rel);
const prefix = `${sid}/`;
if (
typeof rel === "string" &&
rel.startsWith(prefix) &&
SUBAGENT_RE.test(rel.slice(prefix.length))
) {
return path.join(projectDir(row), ...rel.split("/"));
}
throw err("EBADPATH", `not a transcript file of this session: ${String(rel).slice(0, 120)}`, 400);
}
const sql = (q) => require("../db").db.prepare(q);
function consumedOf(sessionId, rel) {
const r = sql("SELECT consumed FROM remote_files WHERE session_id = ? AND rel_path = ?").get(
sessionId,
rel
);
return r ? r.consumed : 0;
}
/**
* Take one upload of `device`: `{sessionId, files: [{path, from, to, data}], ended}`.
* Returns `{files: [{path, consumed}], imported}`; throws a coded error
* (409 EOFFSET carries `expected`).
*/
async function takeUpload(device, body = {}) {
const row = sessions.sessionOfDevice(body.sessionId, device.id);
if (!row) throw err("ENOSESSION", "this device registered no such session", 404);
if (row.purged_at) throw err("EPURGED", "this session's conversation was already removed", 410);
const files = Array.isArray(body.files) ? body.files : [];
if (files.length > MAX_FILES_PER_UPLOAD)
throw err("ETOOMANY", "too many files in one upload", 400);
// Check every chunk before writing any, so one bad entry writes nothing.
const plan = files.map((f) => {
const rel = String(f?.path || "");
const file = fileFor(row, rel);
const from = Number(f.from);
const to = Number(f.to);
const data = typeof f.data === "string" ? f.data : null;
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 0 || to < from) {
throw err("EBADRANGE", "from/to must be offsets with from ≤ to", 400);
}
if (data == null || (data && !data.endsWith("\n"))) {
throw err("EBADDATA", "data must be whole lines", 400);
}
const consumed = consumedOf(row.session_id, rel);
if (from !== consumed) {
throw err("EOFFSET", "upload does not continue where the hub stopped", 409, {
expected: { path: rel, consumed },
});
}
let size = 0;
try {
size = fs.statSync(file).size;
} catch {
/* new file */
}
if (size + Buffer.byteLength(data) > MAX_FILE_BYTES) {
throw err("ETOOBIG", "this transcript is over the hub's size limit", 413);
}
return { rel, file, to, data };
});
for (const p of plan) {
fs.mkdirSync(path.dirname(p.file), { recursive: true, mode: 0o700 });
if (p.data) fs.appendFileSync(p.file, p.data, { mode: 0o600 });
sql(
`INSERT INTO remote_files (session_id, rel_path, consumed, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(session_id, rel_path) DO UPDATE SET consumed = excluded.consumed,
updated_at = excluded.updated_at`
).run(row.session_id, p.rel, p.to);
}
let imported = false;
const wrote = plan.some((p) => p.data);
if (wrote && body.more === true) {
pending.add(row.session_id);
scheduleFlush(row.session_id);
} else if (wrote || pending.has(row.session_id)) {
imported = await importSession(row.session_id);
}
sql(
"UPDATE remote_sessions SET last_upload_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE session_id = ?"
).run(row.session_id);
if (body.ended === true) sessions.markEnded(row.session_id, device.id);
return {
files: plan.map((p) => ({ path: p.rel, consumed: p.to })),
imported,
};
}
// Sessions with chunks written but not imported yet, and their fallback timers.
const pending = new Set();
const flushTimers = new Map();
// One import at a time per session: two would parse the same copy twice.
const importing = new Map();
function scheduleFlush(sessionId) {
clearTimeout(flushTimers.get(sessionId));
const t = setTimeout(() => {
flushTimers.delete(sessionId);
if (pending.has(sessionId)) {
importSession(sessionId).catch((e) =>
console.warn(`[remote] import of ${sessionId} failed: ${e.message}`)
);
}
}, FLUSH_MS());
t.unref();
flushTimers.set(sessionId, t);
}
/** Import the hub's copy of `sessionId` and mark the row its user's. */
function importSession(sessionId) {
const prev = importing.get(sessionId) || Promise.resolve();
const run = prev
.catch(() => {})
.then(async () => {
pending.delete(sessionId);
clearTimeout(flushTimers.get(sessionId));
flushTimers.delete(sessionId);
// Re-read: the session may have moved device, or been purged, meanwhile.
const row = sessions.getSession(sessionId);
if (!row || row.purged_at) return false;
const dbModule = require("../db");
const { importFromDirectory } = require("../../scripts/import-history");
const counters = await importFromDirectory(
dbModule,
sessionRoot(row.user_id, row.device_id, row.session_id)
);
// The import made (or grew) the session row: it is this device's user's.
// Never over a session someone else owns (defence in depth: the
// registration already refuses a session id the hub knew).
sql(
"UPDATE sessions SET source = ?, owner_id = ? WHERE id = ? AND (owner_id IS NULL OR owner_id = ?) AND lane_id IS NULL"
).run(`device:${row.device_id}`, row.user_id, row.session_id, row.user_id);
return counters.imported > 0 || counters.backfilled > 0;
});
importing.set(sessionId, run);
run
.finally(() => {
if (importing.get(sessionId) === run) importing.delete(sessionId);
})
.catch(() => {});
return run;
}
/**
* A session moves to another device of the SAME user (registerSession): its
* copy moves with it, so the next upload continues the same files.
*/
function moveSession(sessionId, fromDevice, toDevice, userId) {
const from = sessionRoot(userId, fromDevice, sessionId);
const to = sessionRoot(userId, toDevice, sessionId);
if (fs.existsSync(from)) {
fs.mkdirSync(path.dirname(to), { recursive: true, mode: 0o700 });
fs.renameSync(from, to);
}
const { db } = require("../db");
db.transaction(() => {
db.prepare("UPDATE remote_sessions SET device_id = ? WHERE session_id = ?").run(
toDevice,
sessionId
);
db.prepare(
"UPDATE sessions SET source = ?, transcript_path = replace(transcript_path, ?, ?) WHERE id = ?"
).run(`device:${toDevice}`, from, to, sessionId);
})();
}
/** Where the hub stands on every file of a session (a client re-syncs from it). */
function fileState(device, sessionId) {
const row = sessions.sessionOfDevice(sessionId, device.id);
if (!row) throw err("ENOSESSION", "this device registered no such session", 404);
return sql("SELECT rel_path AS path, consumed FROM remote_files WHERE session_id = ?").all(
row.session_id
);
}
/**
* Remove the conversation of every remote session whose last upload (or
* registration) is older than `days`: the copied transcripts and the
* session's events go; the session row and its token rows stay.
*/
function purgeOld({ days = RETAIN_DAYS, now = Date.now() } = {}) {
const cutoff = new Date(now - days * 864e5).toISOString();
const { db } = require("../db");
const old = db
.prepare(
`SELECT * FROM remote_sessions
WHERE purged_at IS NULL AND COALESCE(last_upload_at, registered_at) < ?`
)
.all(cutoff);
for (const row of old) {
try {
fs.rmSync(sessionRoot(row.user_id, row.device_id, row.session_id), {
recursive: true,
force: true,
});
} catch (e) {
console.warn(`[remote] could not remove ${row.session_id}: ${e.message}`);
continue;
}
pending.delete(row.session_id);
clearTimeout(flushTimers.get(row.session_id));
flushTimers.delete(row.session_id);
db.transaction(() => {
db.prepare("DELETE FROM events WHERE session_id = ?").run(row.session_id);
db.prepare("UPDATE sessions SET transcript_path = NULL WHERE id = ?").run(row.session_id);
db.prepare("DELETE FROM remote_files WHERE session_id = ?").run(row.session_id);
db.prepare(
"UPDATE remote_sessions SET purged_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE session_id = ?"
).run(row.session_id);
})();
}
return old.length;
}
module.exports = {
RETAIN_DAYS,
takeUpload,
fileState,
moveSession,
purgeOld,
sessionRoot,
__pending: () => [...pending],
__fileFor: fileFor,
};