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.
283 lines
9.9 KiB
JavaScript
283 lines
9.9 KiB
JavaScript
/**
|
|
* @file remote-gateway.js
|
|
* @description The hub's gateway for remote clients' company-account sessions
|
|
* (server mode only; docs/plans/remote-clients/README.md). A client's Claude
|
|
* Code runs with `ANTHROPIC_BASE_URL = <hub>/api/remote/gw` and its device
|
|
* token as `ANTHROPIC_AUTH_TOKEN`; this handler checks the token, checks that
|
|
* the request's `x-claude-code-session-id` is a session THAT device
|
|
* registered, swaps in the pool account's OAuth token and relays the request
|
|
* to Anthropic, streaming the answer back untouched.
|
|
*
|
|
* The pool credential never leaves the hub: the client sends a device token
|
|
* and gets back only Anthropic's answer. An unknown session id is a device
|
|
* token used outside AgentHub — `AGENTHUB_REMOTE_GATE` decides: `warn` (the
|
|
* default) relays it and records the session as unmonitored, `block` answers
|
|
* 403. A message with no session id at all is always refused (Claude Code
|
|
* always sends one), and so is a user over their spend quota (EQUOTA, the
|
|
* same rule `spawnRun` applies). Each answer's `usage` is counted onto the
|
|
* session as it streams past.
|
|
*
|
|
* Only `/v1/…` is relayed, to `AGENTHUB_REMOTE_UPSTREAM` (default
|
|
* https://api.anthropic.com — set only from the server's own environment,
|
|
* never from a request).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const http = require("node:http");
|
|
const https = require("node:https");
|
|
const zlib = require("node:zlib");
|
|
|
|
const devices = require("./devices");
|
|
const sessions = require("./remote-sessions");
|
|
const { GATEWAY_MARK } = require("../lib/proxy-gateway");
|
|
|
|
const IDLE_MS = 10 * 60 * 1000;
|
|
/** A request may race its session's registration by this long. */
|
|
const REGISTER_GRACE_MS = Number(process.env.AGENTHUB_REMOTE_REGISTER_GRACE_MS) || 5000;
|
|
const OAUTH_BETA = "oauth-2025-04-20";
|
|
/** How much of a non-streamed answer is kept to read its `usage`. */
|
|
const MAX_TAP = 2 * 1024 * 1024;
|
|
|
|
const DROP_REQUEST = new Set([
|
|
GATEWAY_MARK,
|
|
"host",
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authorization",
|
|
"proxy-connection",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
"authorization",
|
|
"x-api-key",
|
|
"cookie",
|
|
"x-agenthub-pane-token",
|
|
"x-forwarded-for",
|
|
"x-forwarded-host",
|
|
"x-forwarded-proto",
|
|
"forwarded",
|
|
]);
|
|
const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding"]);
|
|
|
|
const fail = (res, status, code, message) =>
|
|
res.status(status).json({ type: "error", error: { type: code, code, message } });
|
|
|
|
const gate = () => (process.env.AGENTHUB_REMOTE_GATE === "block" ? "block" : "warn");
|
|
const upstream = () => new URL(process.env.AGENTHUB_REMOTE_UPSTREAM || "https://api.anthropic.com");
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// A user's quota status, re-read at most every QUOTA_TTL_MS: computing spend
|
|
// on every relayed request would be an aggregate per request.
|
|
const QUOTA_TTL_MS = 60_000;
|
|
const quotaSeen = new Map();
|
|
function overQuota(userId) {
|
|
const hit = quotaSeen.get(userId);
|
|
if (hit && Date.now() - hit.at < QUOTA_TTL_MS) return hit.error;
|
|
let error = null;
|
|
try {
|
|
require("../lib/user-quota").assertSpawnAllowed(userId);
|
|
} catch (e) {
|
|
if (e.code === "EQUOTA") error = e.message;
|
|
}
|
|
quotaSeen.set(userId, { at: Date.now(), error });
|
|
return error;
|
|
}
|
|
|
|
/** The registered session, waiting out a registration still on its way. */
|
|
async function waitForSession(sessionId, deviceId) {
|
|
const until = Date.now() + REGISTER_GRACE_MS;
|
|
for (;;) {
|
|
const row = sessions.sessionOfDevice(sessionId, deviceId);
|
|
if (row || Date.now() >= until) return row;
|
|
await sleep(200);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads `usage` out of an answer as it streams past, without holding it up:
|
|
* SSE line by line (`message_start` carries input + cache, `message_delta`
|
|
* the output), or a JSON body read whole up to `MAX_TAP`.
|
|
*/
|
|
function usageTap(contentType) {
|
|
const usage = {};
|
|
const add = (u) => {
|
|
if (!u || typeof u !== "object") return;
|
|
for (const [k, v] of Object.entries(u)) {
|
|
if (typeof v === "number") usage[k] = v; // later events carry the running total
|
|
}
|
|
};
|
|
const sse = /text\/event-stream/i.test(contentType || "");
|
|
let carry = "";
|
|
let size = 0;
|
|
const chunks = [];
|
|
return {
|
|
write(buf) {
|
|
if (sse) {
|
|
const text = carry + buf.toString("utf8");
|
|
const lines = text.split("\n");
|
|
carry = lines.pop() || "";
|
|
if (carry.length > 1024 * 1024) carry = "";
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data:") || !line.includes('"usage"')) continue;
|
|
try {
|
|
const ev = JSON.parse(line.slice(5));
|
|
add(ev.message?.usage);
|
|
add(ev.usage);
|
|
} catch {
|
|
/* not JSON: skip */
|
|
}
|
|
}
|
|
} else if (size < MAX_TAP) {
|
|
chunks.push(buf);
|
|
size += buf.length;
|
|
}
|
|
},
|
|
end() {
|
|
if (!sse && size > 0 && size <= MAX_TAP) {
|
|
try {
|
|
add(JSON.parse(Buffer.concat(chunks).toString("utf8")).usage);
|
|
} catch {
|
|
/* not JSON */
|
|
}
|
|
}
|
|
return usage;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** A decompressor for a `content-encoding`, or null for an uncompressed answer. */
|
|
function decoderFor(encoding) {
|
|
const e = String(encoding || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (e === "gzip" || e === "x-gzip") return zlib.createGunzip();
|
|
if (e === "br") return zlib.createBrotliDecompress();
|
|
if (e === "deflate") return zlib.createInflate();
|
|
if (e === "zstd" && typeof zlib.createZstdDecompress === "function") {
|
|
return zlib.createZstdDecompress();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Express handler, mounted at `/api/remote/gw` BEFORE the body parsers. */
|
|
async function remoteGateway(req, res) {
|
|
const who = devices.deviceFromRequest(req);
|
|
if (!who) return fail(res, 401, "EAUTH", "not a valid AgentHub device token");
|
|
const m = /^(\/v1\/[^?]*)(\?.*)?$/.exec(req.url);
|
|
if (!m) return fail(res, 400, "EBADPATH", "expected /api/remote/gw/v1/…");
|
|
|
|
const deviceRow = { id: who.device.id, userId: who.user.id };
|
|
const quota = overQuota(who.user.id);
|
|
if (quota) return fail(res, 429, "EQUOTA", quota);
|
|
const sid = String(req.headers["x-claude-code-session-id"] || "").toLowerCase();
|
|
let session = null;
|
|
if (sid) {
|
|
session = await waitForSession(sid, deviceRow.id);
|
|
if (!session) {
|
|
if (gate() === "block") {
|
|
return fail(
|
|
res,
|
|
403,
|
|
"EUNREGISTERED",
|
|
"this session was not started from AgentHub; company accounts are used only through AgentHub"
|
|
);
|
|
}
|
|
session = sessions.recordUnmonitored(deviceRow, sid);
|
|
if (!session) return fail(res, 409, "EFOREIGN", "this session belongs to another device");
|
|
console.warn(
|
|
`[remote-gw] unmonitored session ${sid} from device ${deviceRow.id} (user ${who.user.username})`
|
|
);
|
|
}
|
|
} else if (m[1].startsWith("/v1/messages") && !m[1].startsWith("/v1/messages/count_tokens")) {
|
|
// A message without a session id did not come from Claude Code, and could
|
|
// be neither counted nor shown: refused whatever the gate.
|
|
return fail(res, 403, "EUNREGISTERED", "a message needs its Claude Code session id");
|
|
}
|
|
if (session?.purged_at) {
|
|
return fail(
|
|
res,
|
|
410,
|
|
"EPURGED",
|
|
"this session is older than the hub keeps conversations; start a new session"
|
|
);
|
|
}
|
|
|
|
const acct = require("../lib/claude-pool").forRemote(
|
|
who.user.id,
|
|
session ? session.account : null
|
|
);
|
|
if (!acct) return fail(res, 503, "ENOACCOUNT", "no company account is available right now");
|
|
if (session && session.account !== acct.name) sessions.setAccount(session.session_id, acct.name);
|
|
|
|
const base = upstream();
|
|
const target = new URL(`${base.origin}${m[1]}${m[2] || ""}`);
|
|
if (target.origin !== base.origin || !target.pathname.startsWith("/v1/")) {
|
|
return fail(res, 400, "EBADPATH", "path leaves /v1/");
|
|
}
|
|
const headers = {};
|
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
if (!DROP_REQUEST.has(k.toLowerCase())) headers[k] = v;
|
|
}
|
|
headers.authorization = `Bearer ${acct.bearer}`;
|
|
const betas = String(headers["anthropic-beta"] || "")
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (!betas.includes(OAUTH_BETA)) betas.push(OAUTH_BETA);
|
|
headers["anthropic-beta"] = betas.join(",");
|
|
headers[GATEWAY_MARK] = "1";
|
|
|
|
const mod = target.protocol === "https:" ? https : http;
|
|
const up = mod.request(target, { method: req.method, headers }, (answer) => {
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(answer.headers)) {
|
|
if (!DROP_RESPONSE.has(k.toLowerCase())) out[k] = v;
|
|
}
|
|
res.writeHead(answer.statusCode || 502, out);
|
|
const tap = session ? usageTap(answer.headers["content-type"]) : null;
|
|
// The answer is relayed exactly as it came (compressed, if it was); the
|
|
// usage is read from a decompressed COPY of it.
|
|
const inflate = tap ? decoderFor(answer.headers["content-encoding"]) : null;
|
|
const count = () => {
|
|
try {
|
|
sessions.countRequest(session.session_id, tap.end());
|
|
} catch (e) {
|
|
console.warn(`[remote-gw] usage not counted: ${e.message}`);
|
|
}
|
|
};
|
|
if (inflate) {
|
|
inflate.on("data", (buf) => tap.write(buf));
|
|
inflate.on("end", count);
|
|
inflate.on("error", count); // a broken copy only costs the count's detail
|
|
}
|
|
answer.on("data", (buf) => {
|
|
if (inflate) inflate.write(buf);
|
|
else if (tap) tap.write(buf);
|
|
res.write(buf);
|
|
});
|
|
answer.on("end", () => {
|
|
res.end();
|
|
if (inflate) inflate.end();
|
|
else if (tap) count();
|
|
});
|
|
answer.on("error", () => res.destroy());
|
|
});
|
|
up.setTimeout(IDLE_MS, () => up.destroy(new Error("upstream went quiet")));
|
|
up.on("error", (e) => {
|
|
if (!res.headersSent) fail(res, 502, "EUPSTREAM", e.message);
|
|
else res.destroy();
|
|
});
|
|
res.on("close", () => {
|
|
if (!res.writableFinished) up.destroy();
|
|
});
|
|
req.pipe(up);
|
|
}
|
|
|
|
module.exports = {
|
|
remoteGateway,
|
|
usageTap,
|
|
__gate: gate,
|
|
__resetQuota: () => quotaSeen.clear(),
|
|
};
|