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:
+1
-1
@@ -564,7 +564,7 @@ Request body shape:
|
||||
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
|
||||
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
|
||||
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: clean tree only, `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: nothing staged and none of its own files changed — `400 EDIRTY` with `paths` —, other files' changes stay; `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
an existing absolute git repository. The response workspace starts as
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file Tests for scripts/artifact-export-hint.js: when Claude is asked to save
|
||||
* a viewable copy of a published Doc / typed artifact, and when it is not.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const { hintFor } = require("../../scripts/artifact-export-hint");
|
||||
|
||||
const PANE = { AGENTHUB_LANE_ID: "3" };
|
||||
const docBirth = {
|
||||
tool_name: "mcp__claude_ai_Claude_Docs__batch",
|
||||
tool_input: { container: { kind: "project", create: { name: "Plan" } } },
|
||||
tool_response: [{ type: "text", text: '{"frame":{"url":"https://claude.ai/artifact/doc-9"}}' }],
|
||||
};
|
||||
const slides = {
|
||||
tool_name: "Artifact",
|
||||
tool_input: { type_url: "https://claude.ai/types/slides", title: "Deck" },
|
||||
tool_response: "Created https://claude.ai/artifact/deck-1",
|
||||
};
|
||||
|
||||
describe("artifact export hint", () => {
|
||||
it("asks for a Markdown copy of a new doc and a PDF/HTML copy of a typed artifact", () => {
|
||||
assert.match(hintFor(docBirth, PANE), /artifacts-export\/doc-9\.md/);
|
||||
const s = hintFor(slides, PANE);
|
||||
assert.match(s, /artifacts-export\/deck-1\.pdf/);
|
||||
assert.match(s, /artifacts-export\/deck-1\.html/);
|
||||
});
|
||||
|
||||
it("stays silent everywhere else", () => {
|
||||
assert.equal(hintFor(slides, {}), null, "not an AgentHub pane");
|
||||
assert.equal(hintFor(slides, { ...PANE, AGENTHUB_ARTIFACT_EXPORT: "0" }), null, "turned off");
|
||||
const html = { ...slides, tool_input: { file_path: "/w/page.html" } };
|
||||
assert.equal(hintFor(html, PANE), null, "an HTML page is copied by AgentHub itself");
|
||||
const edit = {
|
||||
...docBirth,
|
||||
tool_input: { container: { kind: "project", id: "p1" }, batch: [] },
|
||||
};
|
||||
assert.equal(hintFor(edit, PANE), null, "a later batch edits the doc");
|
||||
assert.equal(hintFor({ ...slides, tool_response: "no link" }, PANE), null);
|
||||
assert.equal(hintFor({ ...slides, tool_name: "ArtifactData" }, PANE), null);
|
||||
assert.equal(hintFor(null, PANE), null);
|
||||
});
|
||||
|
||||
it("prints Claude Code's PostToolUse context, and nothing on bad input", () => {
|
||||
const script = path.join(__dirname, "..", "..", "scripts", "artifact-export-hint.js");
|
||||
const run = (input, env = PANE) =>
|
||||
spawnSync(process.execPath, [script], {
|
||||
input,
|
||||
env: { PATH: process.env.PATH, ...env },
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const ok = run(JSON.stringify(slides));
|
||||
assert.equal(ok.status, 0);
|
||||
const out = JSON.parse(ok.stdout);
|
||||
assert.equal(out.hookSpecificOutput.hookEventName, "PostToolUse");
|
||||
assert.match(out.hookSpecificOutput.additionalContext, /deck-1/);
|
||||
const bad = run("{not json");
|
||||
assert.equal(bad.status, 0);
|
||||
assert.equal(bad.stdout, "");
|
||||
});
|
||||
});
|
||||
@@ -874,6 +874,101 @@ describe("sharing and visibility", () => {
|
||||
assert.equal(seen.unrecorded, 1);
|
||||
});
|
||||
|
||||
it("shares with chosen people only: they see and list it, nobody else does", () => {
|
||||
const addUser = db.prepare(
|
||||
"INSERT OR IGNORE INTO users (id, username, role, password_hash, disabled) VALUES (?, ?, 'developer', 'x', ?)"
|
||||
);
|
||||
addUser.run(701, "owner701", 0);
|
||||
addUser.run(702, "ann702", 0);
|
||||
addUser.run(703, "bob703", 0);
|
||||
addUser.run(704, "gone704", 1);
|
||||
const as = (id) => ({ method: "GET", user: { id, role: "developer", username: `u${id}` } });
|
||||
const id = Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'for-ann', 701, 'https://claude.ai/ann')"
|
||||
)
|
||||
.run().lastInsertRowid
|
||||
);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
try {
|
||||
// Only enabled, existing users; the owner is dropped from the list.
|
||||
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [704] }), {
|
||||
code: "EBADFIELD",
|
||||
});
|
||||
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [99999] }), {
|
||||
code: "EBADFIELD",
|
||||
});
|
||||
assert.throws(() => artifacts.update(as(701), id, { sharedWith: "702" }), {
|
||||
code: "EBADFIELD",
|
||||
});
|
||||
// Nobody but its owner may choose who sees it.
|
||||
assert.throws(() => artifacts.update(as(702), id, { sharedWith: [702] }), {
|
||||
code: "ENOTFOUND",
|
||||
});
|
||||
const v = artifacts.update(as(701), id, { sharedWith: [702, 701, 702] });
|
||||
assert.equal(v.shared, false, "never to everyone");
|
||||
assert.deepEqual(
|
||||
v.sharedWith.map((p) => p.id),
|
||||
[702]
|
||||
);
|
||||
|
||||
assert.equal(artifacts.canSeeId(as(702), id), true);
|
||||
assert.equal(artifacts.canSeeId(as(703), id), false);
|
||||
const ann = artifacts.list(as(702), { scope: "withme" });
|
||||
assert.deepEqual(
|
||||
ann.map((a) => a.title),
|
||||
["for-ann"]
|
||||
);
|
||||
assert.equal(ann[0].sharedWithMe, true);
|
||||
assert.deepEqual(ann[0].sharedWith, [], "a recipient does not see who else got it");
|
||||
assert.ok(!artifacts.list(as(702), { scope: "hub" }).some((a) => a.id === id));
|
||||
assert.ok(!artifacts.list(as(703)).some((a) => a.id === id));
|
||||
assert.deepEqual(artifacts.list(as(703), { scope: "withme" }), []);
|
||||
assert.deepEqual(artifacts.shareRecipients(id), [702]);
|
||||
|
||||
// People to pick from: enabled accounts but the caller, names only.
|
||||
const people = artifacts.people(as(701));
|
||||
assert.ok(people.some((p) => p.id === 702 && p.name === "ann702"));
|
||||
assert.ok(!people.some((p) => p.id === 701 || p.id === 704));
|
||||
assert.deepEqual(Object.keys(people[0]).sort(), ["id", "name", "username"]);
|
||||
|
||||
// A recipient disabled since is not listed, so it never blocks an edit.
|
||||
artifacts.update(as(701), id, { sharedWith: [702, 703] });
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE id = 703").run();
|
||||
const listed = artifacts.view(
|
||||
db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id),
|
||||
as(701)
|
||||
);
|
||||
assert.deepEqual(
|
||||
listed.sharedWith.map((p) => p.id),
|
||||
[702]
|
||||
);
|
||||
artifacts.update(as(701), id, { sharedWith: [702] });
|
||||
db.prepare("UPDATE users SET disabled = 0 WHERE id = 703").run();
|
||||
|
||||
// Out of its owner's library it stays with Ann; taking Ann off deletes it.
|
||||
assert.equal(artifacts.remove(as(701), id).kept, true);
|
||||
assert.equal(artifacts.canSeeId(as(702), id), true);
|
||||
const gone = artifacts.update(as(701), id, { sharedWith: [] });
|
||||
assert.equal(gone.removed, true);
|
||||
assert.equal(
|
||||
db.prepare("SELECT 1 FROM artifact_shares WHERE artifact_id = ?").get(id),
|
||||
undefined
|
||||
);
|
||||
const audited = db
|
||||
.prepare("SELECT path FROM audit_log WHERE path LIKE '%for-ann%' ORDER BY id")
|
||||
.all()
|
||||
.map((r) => r.path);
|
||||
assert.ok(
|
||||
audited.some((a) => /shared with ann702/.test(a)),
|
||||
audited.join(" | ")
|
||||
);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
@@ -1063,3 +1158,123 @@ describe("unowned rows", () => {
|
||||
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a page's own copy, viewable without its claude.ai account", () => {
|
||||
it("copies the HTML file a page was published from, with the files it names", async () => {
|
||||
const dir = path.join(ws, "pub");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "deck.html"), '<link href="style.css"><h1>v1</h1>');
|
||||
fs.writeFileSync(path.join(dir, "style.css"), "h1{color:red}");
|
||||
fs.writeFileSync(path.join(dir, ".env"), "SECRET=1");
|
||||
const row = artifacts.captureFromHook("s1", {
|
||||
cwd: ws,
|
||||
tool_input: {
|
||||
action: "publish",
|
||||
file_path: "pub/deck.html",
|
||||
files: { "style.css": "pub/style.css", ".env": "pub/.env", "x.txt": "/etc/hostname" },
|
||||
},
|
||||
tool_response: "Published: https://claude.ai/artifact/copy-1",
|
||||
});
|
||||
await Promise.all(artifacts.__pageCopies());
|
||||
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
const files = JSON.parse(kept.files);
|
||||
assert.equal(files[0].path, "deck.html", "the page itself first");
|
||||
// A secret-looking file and one outside the workspace are never copied.
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.path),
|
||||
["deck.html", "style.css"]
|
||||
);
|
||||
const shot = artifacts.snapshotFile(null, row.id, "deck.html");
|
||||
assert.match(fs.readFileSync(shot.abs, "utf8"), /v1/);
|
||||
|
||||
// A republish of the same link takes the new version, same row.
|
||||
fs.writeFileSync(path.join(dir, "deck.html"), "<h1>v2</h1>");
|
||||
const again = artifacts.captureFromHook("s1", {
|
||||
cwd: ws,
|
||||
tool_input: { action: "publish", file_path: path.join(dir, "deck.html") },
|
||||
tool_response: "https://claude.ai/artifact/copy-1",
|
||||
});
|
||||
assert.equal(again.id, row.id);
|
||||
await Promise.all(artifacts.__pageCopies());
|
||||
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /v2/);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", kept.snapshot)),
|
||||
"the old copy is dropped"
|
||||
);
|
||||
});
|
||||
|
||||
it("never copies a file outside the workspace or one that is not HTML", async () => {
|
||||
const outside = artifacts.captureFromHook("s1", {
|
||||
cwd: ws,
|
||||
tool_input: { action: "publish", file_path: "/etc/hosts.html" },
|
||||
tool_response: "https://claude.ai/artifact/copy-out",
|
||||
});
|
||||
const typed = artifacts.captureFromHook("s1", {
|
||||
cwd: ws,
|
||||
tool_input: { action: "publish", file_path: "main.js", type_url: "https://claude.ai/t" },
|
||||
tool_response: "https://claude.ai/artifact/copy-typed",
|
||||
});
|
||||
await Promise.all(artifacts.__pageCopies());
|
||||
for (const r of [outside, typed]) {
|
||||
assert.equal(
|
||||
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
|
||||
null
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes an older page's copy on first view, once", async () => {
|
||||
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
|
||||
const id = Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO artifacts (kind, title, lane_id, source, url) VALUES ('page', 'old', ?, ?, 'https://claude.ai/artifact/old-1')"
|
||||
)
|
||||
.run(lane.id, path.join(ws, "pub", "old.html")).lastInsertRowid
|
||||
);
|
||||
const r = await request("POST", `/api/artifacts/${id}/copy`, {});
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.deepEqual(
|
||||
r.body.item.files.map((f) => f.path),
|
||||
["old.html"]
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches an exported copy of a Doc or Slides to its page, not as a second card", async () => {
|
||||
const page = artifacts.captureFromHook("s1", {
|
||||
tool_input: { action: "publish", type_url: "https://claude.ai/types/slides" },
|
||||
tool_response: "https://claude.ai/artifact/deck-77",
|
||||
});
|
||||
fs.mkdirSync(path.join(ws, "artifacts-export"), { recursive: true });
|
||||
fs.writeFileSync(path.join(ws, "artifacts-export", "deck-77.pdf"), "%PDF-1.4 deck");
|
||||
fs.writeFileSync(path.join(ws, "artifacts-export", "nobody-1.md"), "# not ours");
|
||||
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n;
|
||||
const done = await artifacts.captureOutputs("s1");
|
||||
assert.ok(done.some((r) => r.id === page.id));
|
||||
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(page.id);
|
||||
assert.deepEqual(
|
||||
JSON.parse(kept.files).map((f) => f.path),
|
||||
["deck-77.pdf"]
|
||||
);
|
||||
assert.ok(
|
||||
!done.some((r) => /artifacts-export/.test(r.source || "")),
|
||||
"an export never becomes a file card"
|
||||
);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n, before);
|
||||
// Unchanged: not taken again.
|
||||
assert.ok(!(await artifacts.captureOutputs("s1")).some((r) => r.id === page.id));
|
||||
});
|
||||
|
||||
it("turns the copy hint off for new panes", async () => {
|
||||
assert.equal(artifacts.exportsEnabled(), true);
|
||||
const r = await request("PUT", "/api/artifacts/settings", { exportsEnabled: false });
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.equal(r.body.exportsEnabled, false);
|
||||
assert.equal(r.body.installsEnabled, true, "installs untouched");
|
||||
assert.equal(
|
||||
(await request("PUT", "/api/artifacts/settings", { exportsEnabled: "no" })).status,
|
||||
400
|
||||
);
|
||||
artifacts.setExportsEnabled(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -247,17 +247,48 @@ describe("shelves", () => {
|
||||
await assert.rejects(act("shelve", {}), { code: "EMERGING" });
|
||||
});
|
||||
|
||||
it("restores staged changes staged, and refuses a dirty tree", async () => {
|
||||
it("restores staged changes staged, and refuses one of its files changed", async () => {
|
||||
write("a.txt", "staged\n");
|
||||
g("add", "a.txt");
|
||||
const { shelf } = await act("shelve", {});
|
||||
write("sub/s.txt", "new work\n");
|
||||
write("a.txt", "new work\n");
|
||||
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
|
||||
g("checkout", "--", "sub/s.txt");
|
||||
g("checkout", "--", "a.txt");
|
||||
await act("unshelve", { sha: shelf.sha });
|
||||
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
|
||||
});
|
||||
|
||||
it("restores beside changes in other files, never over its own or a staged index", async () => {
|
||||
// A partial Shelve (n): the unticked files stay behind and must not block
|
||||
// the restore.
|
||||
write("a.txt", "wip\n");
|
||||
write("sub/new.txt", "new\n");
|
||||
write("left.txt", "left behind\n");
|
||||
const { shelf } = await act("shelve", { paths: ["a.txt", "sub/new.txt"] });
|
||||
assert.equal(read("left.txt"), "left behind\n");
|
||||
// Its own file changed since: refused, with the file named.
|
||||
write("a.txt", "other\n");
|
||||
await assert.rejects(act("unshelve", { sha: shelf.sha }), (e) => {
|
||||
assert.equal(e.code, "EDIRTY");
|
||||
assert.deepEqual(e.paths, ["a.txt"]);
|
||||
return true;
|
||||
});
|
||||
g("checkout", "--", "a.txt");
|
||||
// Anything staged: refused, nothing written.
|
||||
write("b.txt", "staged\n");
|
||||
g("add", "b.txt");
|
||||
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
|
||||
assert.equal(fs.existsSync(path.join(REPO, "sub/new.txt")), false);
|
||||
g("rm", "-q", "--cached", "b.txt");
|
||||
const r = await act("unshelve", { sha: shelf.sha });
|
||||
assert.equal(r.kept, false);
|
||||
assert.equal(read("a.txt"), "wip\n");
|
||||
assert.equal(read("sub/new.txt"), "new\n");
|
||||
assert.equal(read("left.txt"), "left behind\n");
|
||||
assert.equal(read("b.txt"), "staged\n");
|
||||
assert.equal((await listShelves(REPO)).length, 0);
|
||||
});
|
||||
|
||||
it("keeps a conflicted shelf and lets `resolve` settle its files", async () => {
|
||||
write("shared.txt", "mine\n");
|
||||
const { shelf } = await act("shelve", {});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file hub-boundary.test.js
|
||||
* @description `server/hub/` (the remote-clients hub, server mode only) is
|
||||
* loaded only behind `isServerMode()`: no file outside it may require it,
|
||||
* except `server/index.js` on lines that test the mode themselves. And a
|
||||
* local-mode app really leaves it out — no module under `server/hub/` is in
|
||||
* the require cache after `createApp()` in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const HUB = path.join(ROOT, "server", "hub");
|
||||
const SCAN = ["server", "bin", "scripts", "mcp", "statusline"];
|
||||
const REQUIRE_HUB = /require\(\s*["'`][^"'`]*\bhub(?:\/[^"'`]*)?["'`]\s*\)/;
|
||||
|
||||
function jsFiles(dir) {
|
||||
const out = [];
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (["node_modules", "__tests__", "dist"].includes(e.name) || full === HUB) continue;
|
||||
out.push(...jsFiles(full));
|
||||
} else if (/\.(c|m)?js$/.test(e.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("server/hub boundary", () => {
|
||||
it("nothing outside server/hub requires it, except index.js behind isServerMode()", () => {
|
||||
const offenders = [];
|
||||
for (const top of SCAN) {
|
||||
const dir = path.join(ROOT, top);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const file of jsFiles(dir)) {
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (!REQUIRE_HUB.test(line)) return;
|
||||
const rel = path.relative(ROOT, file);
|
||||
if (rel === path.join("server", "index.js") && /isServerMode\(\)/.test(line)) return;
|
||||
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
|
||||
it("a local-mode app loads no module of server/hub", () => {
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "hub-boundary-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
require("../index").createApp();
|
||||
const loaded = Object.keys(require.cache).filter((k) => k.startsWith(HUB + path.sep));
|
||||
assert.deepEqual(loaded, []);
|
||||
} finally {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -102,6 +102,17 @@ describe("install-hooks host-only guard (#193)", () => {
|
||||
JSON.stringify(e).includes("hook-handler.js")
|
||||
);
|
||||
assert.equal(ours.length, 1, "must not duplicate our hook entry on re-run");
|
||||
// The artifact export hint: one synchronous PostToolUse entry of its own,
|
||||
// next to the (async) handler, matching only the publishing tools.
|
||||
const post = settings.hooks.PostToolUse;
|
||||
const handler = post.filter((e) => JSON.stringify(e).includes("hook-handler.js"));
|
||||
const hint = post.filter((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
|
||||
assert.equal(handler.length, 1);
|
||||
assert.equal(hint.length, 1);
|
||||
assert.equal(hint[0].hooks[0].async, undefined);
|
||||
assert.match(hint[0].matcher, /Artifact/);
|
||||
const { isOurEntry } = require("../../scripts/install-hooks");
|
||||
assert.equal(isOurEntry(hint[0]), true, "uninstall takes it out too");
|
||||
});
|
||||
|
||||
it("with the agenthub plugin enabled, adds nothing and removes our duplicate entries", () => {
|
||||
|
||||
@@ -92,6 +92,18 @@ describe("local mode: one account", () => {
|
||||
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
|
||||
});
|
||||
|
||||
it("keeps the Artifacts folders, up to 64 KB", async () => {
|
||||
const folders = JSON.stringify({ folders: [{ id: "f1", name: "Slides" }], of: { 3: "f1" } });
|
||||
let r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { "agenthub.artifacts.folders": folders + "x".repeat(20 * 1024) } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { "agenthub.artifacts.folders": "x".repeat(65 * 1024) } },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("keeps the account's own agent instructions, capped, blank clears", async () => {
|
||||
let r = await call("GET", "/api/me/instructions");
|
||||
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* Path parameters are compared by position only (`:token` and `{ref}` match),
|
||||
* and the two alias mounts that the spec documents by their tag description
|
||||
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
|
||||
* are allowlisted. It also fails on a spec operation no route answers.
|
||||
* are allowlisted. It also fails on a spec operation no route answers. The
|
||||
* app is built twice — local and server mode — since only server mode mounts
|
||||
* the hub's routes (server/hub/).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -83,7 +85,16 @@ describe("OpenAPI coverage", () => {
|
||||
let documented;
|
||||
|
||||
before(() => {
|
||||
routes = collectRoutes(createApp());
|
||||
// Server mode mounts the hub's routes too (server/hub/): check both apps.
|
||||
const local = collectRoutes(createApp());
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
let hub;
|
||||
try {
|
||||
hub = collectRoutes(createApp());
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
routes = [...new Set([...local, ...hub])];
|
||||
documented = new Set();
|
||||
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
|
||||
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* @file remote-client.test.js
|
||||
* @description A LOCAL AgentHub's side of the remote-clients architecture
|
||||
* (server/lib/remote-client.js, /api/remote-client), against a fake hub:
|
||||
* enrollment keeps the device token in a 0600 file and never answers it; the
|
||||
* Company account is Claude-only, enrolled-only, and points a pane's
|
||||
* `--settings` at the hub's gateway; a resume stays on it; only sessions
|
||||
* started on it are uploaded — whole lines, secret-shaped strings replaced,
|
||||
* from where the hub stands (a 409 resyncs), the end sent once; a pane that
|
||||
* gets a new session id registers it; logout revokes on the hub.
|
||||
* @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-client-"));
|
||||
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";
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const remote = require("../lib/remote-client");
|
||||
const { resolveProxy, resumeProxy, proxyForSpawn } = require("../lib/proxy-apply");
|
||||
|
||||
const TOKEN = `ahdev_${"t".repeat(43)}`;
|
||||
let hub;
|
||||
let HUB;
|
||||
let server;
|
||||
let BASE;
|
||||
const hubSeen = { registered: [], uploads: [], logout: 0 };
|
||||
const consumed = new Map();
|
||||
let forceOffset = null;
|
||||
/** Sessions the hub answers ENOSESSION for until they are registered again. */
|
||||
const forgotten = new Set();
|
||||
/** Sessions the hub already purged. */
|
||||
const purged = new Set();
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((r) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => r(raw ? JSON.parse(raw) : {}));
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
hub = http.createServer(async (req, res) => {
|
||||
const body = await readBody(req);
|
||||
const json = (status, obj) => {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (req.url === "/api/remote/device/login") {
|
||||
if (body.password !== "right-pass") {
|
||||
return json(401, { error: { code: "EBADLOGIN", message: "wrong username or password" } });
|
||||
}
|
||||
return json(200, {
|
||||
token: TOKEN,
|
||||
device: {
|
||||
id: 7,
|
||||
name: body.deviceName,
|
||||
expiresAt: new Date(Date.now() + 90 * 864e5).toISOString(),
|
||||
},
|
||||
user: { id: 3, username: body.username, role: "developer" },
|
||||
retainDays: 30,
|
||||
});
|
||||
}
|
||||
if (req.headers.authorization !== `Bearer ${TOKEN}`) {
|
||||
return json(401, { error: { code: "EDEVICE", message: "no" } });
|
||||
}
|
||||
if (req.url === "/api/remote/device/me") return json(200, { device: { id: 7 } });
|
||||
if (req.url === "/api/remote/device/logout") {
|
||||
hubSeen.logout++;
|
||||
return json(200, { ok: true });
|
||||
}
|
||||
if (req.url === "/api/remote/device/sessions") {
|
||||
hubSeen.registered.push(body);
|
||||
forgotten.delete(body.sessionId);
|
||||
return json(200, { ok: true, sessionId: body.sessionId });
|
||||
}
|
||||
if (req.url === "/api/remote/device/upload") {
|
||||
hubSeen.uploads.push(body);
|
||||
if (forgotten.has(body.sessionId)) {
|
||||
return json(404, { error: { code: "ENOSESSION", message: "not this device's" } });
|
||||
}
|
||||
if (purged.has(body.sessionId)) {
|
||||
return json(410, { error: { code: "EPURGED", message: "removed" } });
|
||||
}
|
||||
const files = [];
|
||||
for (const f of body.files) {
|
||||
const key = `${body.sessionId}/${f.path}`;
|
||||
const at = forceOffset ?? consumed.get(key) ?? 0;
|
||||
if (f.from !== at) {
|
||||
forceOffset = null;
|
||||
return json(409, {
|
||||
error: { code: "EOFFSET", message: "offset", expected: { path: f.path, consumed: at } },
|
||||
});
|
||||
}
|
||||
consumed.set(key, f.to);
|
||||
files.push({ path: f.path, consumed: f.to });
|
||||
}
|
||||
return json(200, { files, imported: true });
|
||||
}
|
||||
json(404, { error: { code: "ENOTFOUND", message: req.url } });
|
||||
});
|
||||
await new Promise((r) => hub.listen(0, "127.0.0.1", r));
|
||||
HUB = `http://127.0.0.1:${hub.address().port}`;
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
hub?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const local = (method, p, body) =>
|
||||
fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
/** A local session whose transcript is `lines`, optionally a Company-account one. */
|
||||
function makeSession({ company, lines, runStatus = "running" }) {
|
||||
const id = crypto.randomUUID();
|
||||
const dir = path.join(process.env.CLAUDE_HOME, "projects", "-w-p");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${id}.jsonl`);
|
||||
fs.writeFileSync(file, lines);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, transcript_path) VALUES (?, 'x', 'active', '/w/p', ?)"
|
||||
).run(id, file);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, cwd, mode, status, proxy) VALUES (?, ?, '/w/p', 'interactive', ?, ?)`
|
||||
).run(`run-${id}`, id, runStatus, company ? "agenthub.company" : null);
|
||||
if (company) remote.registerSoon({ sessionId: id, cwd: "/w/p" });
|
||||
return { id, file };
|
||||
}
|
||||
|
||||
describe("enrollment", () => {
|
||||
it("is off until login; a wrong password stores nothing", async () => {
|
||||
const st = await local("GET", "/api/remote-client");
|
||||
assert.equal(st.body.enrolled, false);
|
||||
const bad = await local("POST", "/api/remote-client/login", {
|
||||
hub: HUB,
|
||||
username: "mia",
|
||||
password: "wrong",
|
||||
});
|
||||
assert.equal(bad.status, 401);
|
||||
assert.equal(remote.readState(), null);
|
||||
});
|
||||
|
||||
it("refuses a hub address with a login in it or another scheme", () => {
|
||||
assert.throws(() => remote.normalizeHub("http://u:p@10.1.13.7:4820"), { code: "EBADHUB" });
|
||||
assert.throws(() => remote.normalizeHub("ftp://10.1.13.7"), { code: "EBADHUB" });
|
||||
assert.equal(remote.normalizeHub("http://10.1.13.7:4820/"), "http://10.1.13.7:4820");
|
||||
});
|
||||
|
||||
it("keeps the token in a 0600 file and never answers it", async () => {
|
||||
const r = await local("POST", "/api/remote-client/login", {
|
||||
hub: `${HUB}/`,
|
||||
username: "mia",
|
||||
password: "right-pass",
|
||||
deviceName: "mia-pc",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.enrolled, true);
|
||||
assert.equal(r.body.user.username, "mia");
|
||||
assert.ok(!JSON.stringify(r.body).includes(TOKEN));
|
||||
const file = path.join(process.env.DASHBOARD_DATA_DIR, "runtime", "remote-client.json");
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
assert.ok(!fs.readFileSync(file, "utf8").includes("right-pass"), "the password is not kept");
|
||||
const st = await local("GET", "/api/remote-client");
|
||||
assert.ok(!JSON.stringify(st.body).includes(TOKEN));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Company account", () => {
|
||||
it("is Claude-only and becomes a --settings file pointing at the gateway", () => {
|
||||
assert.throws(() => resolveProxy("codex", "agenthub.company"), { code: "EUNSUPPORTED" });
|
||||
const profile = resolveProxy("claude", "agenthub.company");
|
||||
assert.equal(profile.company, true);
|
||||
const { argv, env } = proxyForSpawn("claude", profile);
|
||||
assert.deepEqual(env, {}, "nothing secret goes through the pane env");
|
||||
const file = argv[0].replace("--settings=", "");
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
const s = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
assert.equal(s.env.ANTHROPIC_BASE_URL, `${HUB}/api/remote/gw`);
|
||||
assert.equal(s.env.ANTHROPIC_AUTH_TOKEN, TOKEN);
|
||||
assert.equal(s.env.ENABLE_PROMPT_CACHING_1H, "1");
|
||||
});
|
||||
|
||||
it("a resume stays on it", () => {
|
||||
const { id } = makeSession({ company: true, lines: "" });
|
||||
assert.equal(resumeProxy(id), "agenthub.company");
|
||||
});
|
||||
|
||||
it("no profile may take its name", () => {
|
||||
const proxies = require("../lib/ai-proxies");
|
||||
const p = proxies.createProxy({
|
||||
label: "Company",
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
});
|
||||
assert.notEqual(p.name, "agenthub.company");
|
||||
// A profile made before the Company account existed keeps working as itself.
|
||||
assert.equal(p.name, "company");
|
||||
const resolved = resolveProxy("claude", "company");
|
||||
assert.equal(resolved.company, undefined);
|
||||
assert.equal(resolved.name, "company");
|
||||
proxies.removeProxy(p.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the upload", () => {
|
||||
it("sends whole lines of Company sessions only, secrets replaced, then only what is new", async () => {
|
||||
hubSeen.uploads = [];
|
||||
const secret = `sk-ant-api03-${"s".repeat(40)}`;
|
||||
const line1 = JSON.stringify({ type: "user", message: { content: `my key is ${secret}` } });
|
||||
const line2 = JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { usage: { input_tokens: 5, output_tokens: 6 } },
|
||||
});
|
||||
const company = makeSession({ company: true, lines: `${line1}\n${line2}\n{"partial":` });
|
||||
const own = makeSession({ company: false, lines: `${line1}\n` });
|
||||
const r = await remote.uploadAll();
|
||||
assert.deepEqual(r.errors, []);
|
||||
const mine = hubSeen.uploads.filter((u) => u.sessionId === company.id);
|
||||
const sent = mine.filter((u) => u.files.length);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].more, true, "a chunk leaves the import to the round's close");
|
||||
assert.equal(mine.length, 2);
|
||||
assert.deepEqual(mine[1], { sessionId: company.id, files: [] }, "the close: import now");
|
||||
const data = sent[0].files[0].data;
|
||||
assert.ok(!data.includes(secret), "the key never leaves the machine");
|
||||
assert.ok(data.includes("[redacted]"));
|
||||
assert.ok(data.includes('"output_tokens":6'), "usage numbers survive");
|
||||
assert.ok(!data.includes("partial"), "a line still being written waits");
|
||||
assert.equal(sent[0].files[0].to, Buffer.byteLength(`${line1}\n${line2}\n`));
|
||||
assert.equal(
|
||||
hubSeen.uploads.filter((u) => u.sessionId === own.id).length,
|
||||
0,
|
||||
"an own-login session is never sent"
|
||||
);
|
||||
|
||||
fs.appendFileSync(company.file, ' "done"}\n');
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll();
|
||||
const next = hubSeen.uploads.filter((u) => u.sessionId === company.id && u.files.length);
|
||||
assert.equal(next.length, 1);
|
||||
assert.equal(next[0].files[0].from, Buffer.byteLength(`${line1}\n${line2}\n`));
|
||||
assert.equal(next[0].files[0].data, '{"partial":"done"}\n');
|
||||
});
|
||||
|
||||
it("resyncs from where the hub stands after a 409", async () => {
|
||||
const line = JSON.stringify({ type: "user", message: { content: "hi" } });
|
||||
const s = makeSession({ company: true, lines: `${line}\n${line}\n` });
|
||||
// The hub already holds the first line (an earlier answer got lost).
|
||||
consumed.set(`${s.id}/${s.id}.jsonl`, Buffer.byteLength(`${line}\n`));
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll();
|
||||
const tries = hubSeen.uploads.filter((u) => u.sessionId === s.id && u.files.length);
|
||||
assert.equal(tries.length, 2);
|
||||
assert.equal(tries[1].files[0].from, Buffer.byteLength(`${line}\n`));
|
||||
assert.equal(consumed.get(`${s.id}/${s.id}.jsonl`), Buffer.byteLength(`${line}\n${line}\n`));
|
||||
});
|
||||
|
||||
it("sends the end once, when no pane holds the session any more", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n', runStatus: "exited" });
|
||||
db.prepare("UPDATE sessions SET updated_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(s.id);
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll();
|
||||
await remote.uploadAll();
|
||||
const ends = hubSeen.uploads.filter((u) => u.sessionId === s.id && u.ended === true);
|
||||
assert.equal(ends.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload edges", () => {
|
||||
it("sends a line too big for the hub as a placeholder and moves past it", async () => {
|
||||
const big = JSON.stringify({
|
||||
type: "user",
|
||||
message: { content: "x".repeat(remote.MAX_LINE_BYTES + 10) },
|
||||
});
|
||||
const small = JSON.stringify({ type: "user", message: { content: "after" } });
|
||||
const s = makeSession({ company: true, lines: `${big}\n${small}\n` });
|
||||
hubSeen.uploads = [];
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
const data = hubSeen.uploads
|
||||
.filter((u) => u.sessionId === s.id)
|
||||
.map((u) => u.files.map((f) => f.data).join(""))
|
||||
.join("");
|
||||
assert.ok(data.length < 1024, "the big line is not sent");
|
||||
assert.match(data, /"type":"agenthub-omitted","bytes":\d+/);
|
||||
assert.match(data, /after/);
|
||||
assert.equal(
|
||||
consumed.get(`${s.id}/${s.id}.jsonl`),
|
||||
Buffer.byteLength(`${big}\n${small}\n`),
|
||||
"the hub's offset still covers the whole file"
|
||||
);
|
||||
});
|
||||
|
||||
it("registers a session again after this machine logged in again (ENOSESSION)", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n' });
|
||||
await new Promise((r) => setTimeout(r, 100)); // registerSoon's own registration
|
||||
forgotten.add(s.id);
|
||||
hubSeen.registered = [];
|
||||
hubSeen.uploads = [];
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(hubSeen.registered.filter((b) => b.sessionId === s.id).length, 1);
|
||||
assert.equal(hubSeen.registered[0].cwd, "/w/p");
|
||||
assert.equal(consumed.get(`${s.id}/${s.id}.jsonl`), 8);
|
||||
});
|
||||
|
||||
it("also registers again when only the end is left to send", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n', runStatus: "exited" });
|
||||
db.prepare("UPDATE sessions SET updated_at = '2000-01-01T00:00:00Z' WHERE id = ?").run(s.id);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
consumed.set(`${s.id}/${s.id}.jsonl`, 8);
|
||||
db.prepare("INSERT INTO remote_uploads (session_id, rel_path, sent) VALUES (?, ?, 8)").run(
|
||||
s.id,
|
||||
`${s.id}.jsonl`
|
||||
);
|
||||
forgotten.add(s.id);
|
||||
hubSeen.uploads = [];
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.ok(hubSeen.uploads.some((u) => u.sessionId === s.id && u.ended === true));
|
||||
assert.equal(forgotten.has(s.id), false, "registered again");
|
||||
});
|
||||
|
||||
it("stops sending a session the hub already purged", async () => {
|
||||
const s = makeSession({ company: true, lines: '{"a":1}\n' });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
purged.add(s.id);
|
||||
const r = await remote.uploadAll({ onlySession: s.id });
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.equal(remote.isCompany(s.id), false);
|
||||
hubSeen.uploads = [];
|
||||
await remote.uploadAll({ onlySession: s.id });
|
||||
assert.equal(hubSeen.uploads.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a new session id in a Company pane", () => {
|
||||
it("is registered from its hook; another pane's is not", async () => {
|
||||
const { id } = makeSession({ company: true, lines: "" });
|
||||
const run = `run-${id}`;
|
||||
await new Promise((r) => setTimeout(r, 100)); // its own registration lands first
|
||||
hubSeen.registered = [];
|
||||
const fresh = crypto.randomUUID();
|
||||
remote.onHookSession({ sessionId: fresh, runId: run, cwd: "/w/p" });
|
||||
const other = makeSession({ company: false, lines: "" });
|
||||
remote.onHookSession({ sessionId: crypto.randomUUID(), runId: `run-${other.id}`, cwd: "/w/p" });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
assert.deepEqual(
|
||||
hubSeen.registered.map((x) => x.sessionId),
|
||||
[fresh]
|
||||
);
|
||||
assert.equal(remote.isCompany(fresh), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
it("revokes on the hub and forgets the token; the Company account is then refused", async () => {
|
||||
const r = await local("POST", "/api/remote-client/logout", {});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.revokedOnHub, true);
|
||||
assert.equal(hubSeen.logout, 1);
|
||||
assert.equal(remote.readState(), null);
|
||||
assert.throws(() => resolveProxy("claude", "agenthub.company"), { code: "ENOHUB" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,941 @@
|
||||
/**
|
||||
* @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));
|
||||
});
|
||||
});
|
||||
@@ -247,6 +247,15 @@ describe("broadcastAudience", () => {
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
|
||||
});
|
||||
|
||||
it("tells an artifact's change to its owner and the people it is shared with only", () => {
|
||||
const d = { id: 5, ownerId: 1, shared: false, recipients: [2] };
|
||||
assert.equal(reaches("artifact_changed", d, m1), true);
|
||||
assert.equal(reaches("artifact_changed", d, m2), true);
|
||||
assert.equal(reaches("artifact_changed", { ...d, recipients: [] }, m2), false);
|
||||
assert.equal(reaches("artifact_changed", { ...d, recipients: undefined }, m2), false);
|
||||
assert.equal(reaches("artifact_changed", { ...d, shared: true, recipients: [] }, m2), true);
|
||||
});
|
||||
|
||||
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
||||
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m1), true);
|
||||
assert.equal(reaches("lane_update", { lane: { id: 1 } }, m2), false);
|
||||
|
||||
@@ -408,6 +408,17 @@ describe("our own tmux server: bounded and version-matched", () => {
|
||||
assert.equal(calls, after);
|
||||
});
|
||||
|
||||
it(
|
||||
"forgets a cached server binary that is gone and runs PATH's tmux",
|
||||
{ skip: process.platform !== "linux" || !tmux.isTmuxAvailable() },
|
||||
() => {
|
||||
// The last pane stopped: the cached /proc/<pid>/exe no longer exists.
|
||||
tmux.__setHostBin("/proc/999999999/exe");
|
||||
tmux.serverVersion(); // no server in the test's TMUX_TMPDIR: null, not ENOENT
|
||||
assert.equal(tmux.__hostBin(), "tmux");
|
||||
}
|
||||
);
|
||||
|
||||
it("finds the binary of the server listening on our socket from /proc alone", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "tmux-proc-"));
|
||||
const sock = "/tmp/tmux-1000/default";
|
||||
|
||||
@@ -986,6 +986,18 @@ for (const [col, ddl] of [
|
||||
}
|
||||
}
|
||||
|
||||
// An artifact shared with chosen people (server mode): one row per person.
|
||||
// `artifacts.shared = 1` stays "everyone" (the Hub); these rows are on top.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS artifact_shares (
|
||||
artifact_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
shared_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (artifact_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_artifact_shares_user ON artifact_shares(user_id);
|
||||
`);
|
||||
|
||||
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
||||
// who, when, and the folders it was written to — `artifacts.installs` only
|
||||
// counts. Installs before this table existed stay counted, not listed.
|
||||
@@ -2006,6 +2018,76 @@ db.exec(`
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// Remote clients (docs/plans/remote-clients/README.md). On the HUB (server
|
||||
// mode): `devices` — a user's enrolled machines, each with one device token
|
||||
// stored as its SHA-256 only (server/hub/devices.js); `remote_sessions` — the
|
||||
// company-account sessions a device registered, the pool account the gateway
|
||||
// keeps each one on, and what the gateway counted (server/hub/remote-sessions.js);
|
||||
// `remote_files` — how far each uploaded transcript file has been taken
|
||||
// (server/hub/remote-ingest.js). On a LOCAL install: `remote_uploads` — how far
|
||||
// each local transcript file has been sent (server/lib/remote-client.js).
|
||||
// Created on every install, empty and unread until used. Additive.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT,
|
||||
last_ip TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id);
|
||||
CREATE TABLE IF NOT EXISTS remote_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL,
|
||||
cwd TEXT NOT NULL DEFAULT '',
|
||||
tool TEXT NOT NULL DEFAULT 'claude',
|
||||
account TEXT,
|
||||
registered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
ended_at TEXT,
|
||||
last_request_at TEXT,
|
||||
requests INTEGER NOT NULL DEFAULT 0,
|
||||
gw_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
gw_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
gw_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
gw_cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
last_upload_at TEXT,
|
||||
purged_at TEXT,
|
||||
-- 1 = never registered by its device: the gateway saw it (remote_gate=warn)
|
||||
unmonitored INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_sessions_user ON remote_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_sessions_device ON remote_sessions(device_id);
|
||||
CREATE TABLE IF NOT EXISTS remote_files (
|
||||
session_id TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
consumed INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (session_id, rel_path)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS remote_uploads (
|
||||
session_id TEXT NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
sent INTEGER NOT NULL DEFAULT 0,
|
||||
sent_at TEXT,
|
||||
PRIMARY KEY (session_id, rel_path)
|
||||
);
|
||||
`);
|
||||
// Migrate: add `owner_id` to sessions — the user a session belongs to when no
|
||||
// workspace on this machine owns it: a remote client's company-account session
|
||||
// is owned by the device's user (server/hub/remote-ingest.js). Read by
|
||||
// scope.js before the account fallback. NULL everywhere else, so every
|
||||
// existing row reads exactly as before.
|
||||
try {
|
||||
db.prepare("SELECT owner_id FROM sessions LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE sessions ADD COLUMN owner_id INTEGER").run();
|
||||
}
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_id)`);
|
||||
// A user's OWN choice of one pool account per tool (Settings → AI tools →
|
||||
// Pool account, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// balancer, and only among the accounts the user's group may use.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @file devices.js
|
||||
* @description The hub's enrolled remote-client machines (server mode only;
|
||||
* docs/plans/remote-clients/README.md). A user enrolls a machine running
|
||||
* AgentHub LOCAL with their hub password (`POST /api/remote/device/login`,
|
||||
* throttled like a login — `auth.checkLogin`) and gets ONE device token,
|
||||
* `ahdev_<32 random bytes>`, shown once; the hub keeps only its SHA-256.
|
||||
*
|
||||
* A token is good for `TOKEN_DAYS` (90) and until it is revoked — by its own
|
||||
* machine (logout), by its user, or by an admin (both behind the step-up
|
||||
* password, written to `audit_log`) — or its user is disabled. It is the only
|
||||
* thing a client holds: it opens the device routes and the gateway, never a
|
||||
* cookie route, and never carries a pool credential.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
||||
|
||||
const TOKEN_DAYS = Number(process.env.AGENTHUB_DEVICE_TOKEN_DAYS) || 90;
|
||||
const TOKEN_RE = /^ahdev_[A-Za-z0-9_-]{43}$/;
|
||||
const MAX_NAME = 80;
|
||||
/** last_seen_at is rewritten at most this often per device (every gateway call reads it). */
|
||||
const SEEN_EVERY_MS = 60_000;
|
||||
|
||||
/** A device name as shown: printable, trimmed, capped; "" → "device". */
|
||||
function cleanName(name) {
|
||||
const s = String(name || "")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.trim()
|
||||
.slice(0, MAX_NAME);
|
||||
return s || "device";
|
||||
}
|
||||
|
||||
/** Mint a device for `userId`: `{token, device}` — the token exists only in this answer. */
|
||||
function createDevice(userId, name) {
|
||||
const token = `ahdev_${crypto.randomBytes(32).toString("base64url")}`;
|
||||
const expires = new Date(Date.now() + TOKEN_DAYS * 864e5).toISOString();
|
||||
const r = sql(
|
||||
"INSERT INTO devices (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
|
||||
).run(userId, cleanName(name), sha256(token), expires);
|
||||
return { token, device: getDevice(Number(r.lastInsertRowid)) };
|
||||
}
|
||||
|
||||
/** A device row as the API shows it — never the hash. */
|
||||
function shape(row) {
|
||||
if (!row) return null;
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
lastIp: row.last_ip,
|
||||
revokedAt: row.revoked_at,
|
||||
active: !row.revoked_at && Date.parse(row.expires_at) > now,
|
||||
};
|
||||
}
|
||||
|
||||
function getDevice(id) {
|
||||
return shape(sql("SELECT * FROM devices WHERE id = ?").get(Number(id)));
|
||||
}
|
||||
|
||||
/** Every device of `userId` (null = every user's), newest first. */
|
||||
function listDevices(userId = null) {
|
||||
const rows =
|
||||
userId == null
|
||||
? sql("SELECT * FROM devices ORDER BY id DESC").all()
|
||||
: sql("SELECT * FROM devices WHERE user_id = ? ORDER BY id DESC").all(Number(userId));
|
||||
return rows.map(shape);
|
||||
}
|
||||
|
||||
/** Revoke one device (idempotent); false when there is no such device. */
|
||||
function revokeDevice(id) {
|
||||
const r = sql(
|
||||
"UPDATE devices SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%fZ','now')) WHERE id = ?"
|
||||
).run(Number(id));
|
||||
return r.changes > 0;
|
||||
}
|
||||
|
||||
/** The bearer token of a request (Authorization: Bearer, else x-api-key). */
|
||||
function bearerOf(req) {
|
||||
const h = String(req.headers.authorization || "");
|
||||
const m = /^Bearer\s+(\S+)$/i.exec(h);
|
||||
if (m) return m[1];
|
||||
const k = req.headers["x-api-key"];
|
||||
return typeof k === "string" ? k : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* `{device, user}` for a request carrying a live device token of an enabled
|
||||
* user, else null. A malformed token is not even hashed against the table.
|
||||
*/
|
||||
function deviceFromRequest(req) {
|
||||
const token = bearerOf(req);
|
||||
if (!TOKEN_RE.test(token)) return null;
|
||||
const row = sql(
|
||||
`SELECT d.*, u.username, u.role, u.disabled
|
||||
FROM devices d JOIN users u ON u.id = d.user_id
|
||||
WHERE d.token_hash = ?`
|
||||
).get(sha256(token));
|
||||
if (!row || row.revoked_at || row.disabled) return null;
|
||||
if (!(Date.parse(row.expires_at) > Date.now())) return null;
|
||||
const seen = row.last_seen_at ? Date.parse(row.last_seen_at) : 0;
|
||||
const ip = req.socket?.remoteAddress || null;
|
||||
if (Date.now() - seen > SEEN_EVERY_MS || ip !== row.last_ip) {
|
||||
sql("UPDATE devices SET last_seen_at = ?, last_ip = ? WHERE id = ?").run(
|
||||
new Date().toISOString(),
|
||||
ip,
|
||||
row.id
|
||||
);
|
||||
}
|
||||
return {
|
||||
device: shape(row),
|
||||
user: { id: row.user_id, username: row.username, role: row.role },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TOKEN_DAYS,
|
||||
TOKEN_RE,
|
||||
cleanName,
|
||||
createDevice,
|
||||
getDevice,
|
||||
listDevices,
|
||||
revokeDevice,
|
||||
deviceFromRequest,
|
||||
bearerOf,
|
||||
__sha256: sha256,
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @file server/hub/index.js
|
||||
* @description The hub half of the remote-clients architecture
|
||||
* (docs/plans/remote-clients/README.md), loaded ONLY in server mode:
|
||||
* `server/index.js` requires this folder inside its `isServerMode()` branch,
|
||||
* and nothing outside `server/hub/` may require it at load time (the boundary
|
||||
* test in server/__tests__/hub-boundary.test.js). A local install never loads
|
||||
* a line of it.
|
||||
*
|
||||
* - `mountEarly(app)` — before the body parsers and the cookie gate: the
|
||||
* gateway (`/api/remote/gw`, a raw stream) and the device-token routes
|
||||
* (`/api/remote/device/*`, their own JSON parser with room for an upload).
|
||||
* - `mountRoutes(app)` — with the other routers, behind authGuard/scopeGuard:
|
||||
* the signed-in views (devices, remote sessions, "who opened my sessions").
|
||||
* - `startTimers()` — the retention sweep.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const devices = require("./devices");
|
||||
const sessions = require("./remote-sessions");
|
||||
const ingest = require("./remote-ingest");
|
||||
const { remoteGateway } = require("./remote-gateway");
|
||||
|
||||
/** An upload chunk is at most 4 MB of text; JSON escaping can grow it. */
|
||||
const UPLOAD_LIMIT = "12mb";
|
||||
|
||||
const send = (res, e) => {
|
||||
const status = e.status || (e.code === "EBADLOGIN" ? 401 : e.code === "ETOOMANY" ? 429 : 500);
|
||||
if (status === 500) console.error("[remote]", e);
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: e.code || "EINTERNAL",
|
||||
message: e.message,
|
||||
...(e.expected ? { expected: e.expected } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** Device-token middleware: sets `req.device` / `req.deviceUser`, else 401. */
|
||||
function requireDevice(req, res, next) {
|
||||
const who = devices.deviceFromRequest(req);
|
||||
if (!who) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: { code: "EDEVICE", message: "device token missing, expired or revoked" } });
|
||||
}
|
||||
req.device = { id: who.device.id, userId: who.user.id, name: who.device.name };
|
||||
req.deviceInfo = who;
|
||||
next();
|
||||
}
|
||||
|
||||
function deviceRouter() {
|
||||
const r = express.Router();
|
||||
r.use(express.json({ limit: UPLOAD_LIMIT }));
|
||||
|
||||
// Enroll this machine: the user's own hub password, throttled like a login.
|
||||
r.post("/login", async (req, res) => {
|
||||
try {
|
||||
const { username, password, deviceName } = req.body || {};
|
||||
const u = await require("../lib/auth").checkLogin(req, username, password);
|
||||
if (u.must_change_password) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
code: "EPWCHANGE",
|
||||
message: "choose your own password on the hub first, then enroll this machine",
|
||||
},
|
||||
});
|
||||
}
|
||||
const { token, device } = devices.createDevice(u.id, deviceName);
|
||||
require("../lib/audit").recordAccountChange(
|
||||
u.id,
|
||||
u.id,
|
||||
"POST",
|
||||
`remote device enrolled: #${device.id} ${device.name}`
|
||||
);
|
||||
res.json({
|
||||
token,
|
||||
device,
|
||||
user: { id: u.id, username: u.username, role: u.role },
|
||||
retainDays: ingest.RETAIN_DAYS,
|
||||
});
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
r.use(requireDevice);
|
||||
|
||||
r.get("/me", (req, res) => {
|
||||
res.json({ ...req.deviceInfo, retainDays: ingest.RETAIN_DAYS });
|
||||
});
|
||||
|
||||
// This machine logs itself out: its token stops working at once.
|
||||
r.post("/logout", (req, res) => {
|
||||
devices.revokeDevice(req.device.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
r.post("/sessions", (req, res) => {
|
||||
try {
|
||||
const row = sessions.registerSession(req.device, req.body || {});
|
||||
res.json({ ok: true, sessionId: row.session_id, account: row.account });
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
r.post("/sessions/:id/end", (req, res) => {
|
||||
res.json({ ok: sessions.markEnded(req.params.id, req.device.id) });
|
||||
});
|
||||
|
||||
r.get("/sessions/:id/files", (req, res) => {
|
||||
try {
|
||||
res.json({ files: ingest.fileState(req.device, req.params.id) });
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
r.post("/upload", async (req, res) => {
|
||||
try {
|
||||
res.json(await ingest.takeUpload(req.device, req.body || {}));
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
const isStaff = (u) => u && (u.role === "admin" || u.role === "analyst");
|
||||
|
||||
/** Whose rows a signed-in request asks for: their own, or (staff) `?user=`, or all. */
|
||||
function targetUser(req) {
|
||||
if (!isStaff(req.user)) return req.user.id;
|
||||
if (req.query.user == null || req.query.user === "") return null;
|
||||
const n = Number(req.query.user);
|
||||
return Number.isInteger(n) && n > 0 ? n : -1;
|
||||
}
|
||||
|
||||
function signedInRouter() {
|
||||
const r = express.Router();
|
||||
r.use((req, res, next) =>
|
||||
req.user ? next() : res.status(401).json({ error: { code: "EAUTH", message: "log in first" } })
|
||||
);
|
||||
|
||||
r.get("/devices", (req, res) => {
|
||||
res.json({ devices: devices.listDevices(targetUser(req)) });
|
||||
});
|
||||
|
||||
// Revoke a device: its owner or an admin, with their own password again.
|
||||
r.post("/devices/:id/revoke", async (req, res) => {
|
||||
try {
|
||||
const d = devices.getDevice(req.params.id);
|
||||
const mine = d && d.userId === req.user.id;
|
||||
if (!d || (!mine && req.user.role !== "admin")) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such device" } });
|
||||
}
|
||||
await require("../lib/auth").confirmPassword(req.user, req.body?.password);
|
||||
devices.revokeDevice(d.id);
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
d.userId,
|
||||
"POST",
|
||||
`remote device revoked: #${d.id} ${d.name}`
|
||||
);
|
||||
res.json({ ok: true, device: devices.getDevice(d.id) });
|
||||
} catch (e) {
|
||||
send(res, e.code === "EREAUTH" ? Object.assign(e, { status: 403 }) : e);
|
||||
}
|
||||
});
|
||||
|
||||
r.get("/sessions", (req, res) => {
|
||||
res.json({ sessions: sessions.listSessions(targetUser(req)) });
|
||||
});
|
||||
|
||||
// Who opened MY sessions (admin/analyst reads, audit.js) — for every user.
|
||||
r.get("/audit", (req, res) => {
|
||||
const rows = require("../db")
|
||||
.db.prepare(
|
||||
`SELECT a.id, a.at, a.method, a.path, a.session_id AS sessionId,
|
||||
r.username AS reader, r.display_name AS readerDisplay
|
||||
FROM audit_log a LEFT JOIN users r ON r.id = a.user_id
|
||||
WHERE a.owner_id = ? AND a.user_id != ? AND a.session_id IS NOT NULL
|
||||
ORDER BY a.id DESC LIMIT 100`
|
||||
)
|
||||
.all(req.user.id, req.user.id);
|
||||
res.json({ entries: rows });
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Before the body parsers and the cookie gate. */
|
||||
function mountEarly(app) {
|
||||
app.use("/api/remote/gw", remoteGateway);
|
||||
app.use("/api/remote/device", deviceRouter());
|
||||
}
|
||||
|
||||
/** With the other routers, behind authGuard/scopeGuard/auditGuard. */
|
||||
function mountRoutes(app) {
|
||||
app.use("/api/remote", signedInRouter());
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
function startTimers() {
|
||||
if (timer) return;
|
||||
const every = Number(process.env.AGENTHUB_REMOTE_PURGE_MS) || 60 * 60 * 1000;
|
||||
const run = () => {
|
||||
try {
|
||||
const n = ingest.purgeOld();
|
||||
if (n) console.log(`[remote] removed ${n} conversation(s) past ${ingest.RETAIN_DAYS} days`);
|
||||
} catch (e) {
|
||||
console.warn(`[remote] purge failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
setTimeout(run, 30_000).unref();
|
||||
timer = setInterval(run, every);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
module.exports = { mountEarly, mountRoutes, startTimers };
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* @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(),
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @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,
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file remote-sessions.js
|
||||
* @description The company-account sessions remote clients registered with the
|
||||
* hub (server mode only). A client registers a session ONCE, when it starts it
|
||||
* on the "Company account" (`POST /api/remote/device/sessions`), with its id,
|
||||
* folder and tool — no content. The gateway (remote-gateway.js) relays a
|
||||
* request only for a session THAT device registered; everything a session
|
||||
* says arrives later in the hourly upload (remote-ingest.js).
|
||||
*
|
||||
* A session id is claimed by its first device: another device (or a session id
|
||||
* the hub already holds from anywhere else) is refused with EFOREIGN, so one
|
||||
* client can never write into another user's session. The pool account the
|
||||
* gateway picks is kept on the row for the session's whole life: the prompt
|
||||
* cache belongs to one account, and switching would re-write it all.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
const err = (code, message, status) => Object.assign(new Error(message), { code, status });
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const TOOLS = new Set(["claude"]);
|
||||
const MAX_CWD = 1024;
|
||||
|
||||
/** A client-sent folder, as text only: absolute-looking, printable, capped. */
|
||||
function cleanCwd(cwd) {
|
||||
const s = String(cwd || "").replace(/[\u0000-\u001f\u007f]/g, "");
|
||||
if (!s || s.length > MAX_CWD) throw err("EBADCWD", "cwd must be a folder path", 400);
|
||||
if (!s.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(s)) {
|
||||
throw err("EBADCWD", "cwd must be absolute", 400);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or re-register, on a resume) `sessionId` for `device`. Refuses a
|
||||
* session another device registered, and one the hub already knows that did
|
||||
* not come from this device (a local session, another user's upload).
|
||||
*/
|
||||
function registerSession(device, { sessionId, cwd, tool = "claude" } = {}) {
|
||||
if (!UUID_RE.test(String(sessionId || ""))) {
|
||||
throw err("EBADSESSION", "sessionId must be a UUID", 400);
|
||||
}
|
||||
if (!TOOLS.has(tool)) throw err("EBADTOOL", "only claude sessions use the company account", 400);
|
||||
const folder = cleanCwd(cwd);
|
||||
const id = String(sessionId).toLowerCase();
|
||||
let row = sql(
|
||||
"SELECT device_id, user_id, cwd, purged_at FROM remote_sessions WHERE session_id = ?"
|
||||
).get(id);
|
||||
if (row?.purged_at) {
|
||||
throw err(
|
||||
"EPURGED",
|
||||
"this session's conversation was removed after the retention period; start a new session",
|
||||
410
|
||||
);
|
||||
}
|
||||
if (row && row.device_id !== device.id) {
|
||||
// The same user on a new device (enrolled again, a new machine) resumes
|
||||
// their own session: it moves to that device. Anyone else's never does.
|
||||
if (row.user_id !== device.userId) {
|
||||
throw err("EFOREIGN", "this session belongs to another device", 409);
|
||||
}
|
||||
require("./remote-ingest").moveSession(id, row.device_id, device.id, device.userId);
|
||||
row = { ...row, device_id: device.id, user_id: device.userId };
|
||||
}
|
||||
if (!row) {
|
||||
const known = sql("SELECT source FROM sessions WHERE id = ?").get(id);
|
||||
if (known && known.source !== `device:${device.id}`) {
|
||||
throw err("EFOREIGN", "this session id is already taken", 409);
|
||||
}
|
||||
sql(
|
||||
"INSERT INTO remote_sessions (session_id, device_id, user_id, cwd, tool) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(id, device.id, device.userId, folder, tool);
|
||||
} else {
|
||||
// The folder is kept from the first registration: the hub's copy lives
|
||||
// under it, and a later one (a resume, a hook) would split that copy.
|
||||
// A registration that lost the race to its first request (the gateway
|
||||
// waited REGISTER_GRACE_MS and recorded it unmonitored) clears the flag:
|
||||
// its device did register it, which is all "monitored" means.
|
||||
sql(
|
||||
"UPDATE remote_sessions SET ended_at = NULL, unmonitored = 0, cwd = COALESCE(NULLIF(cwd, ''), ?) WHERE session_id = ?"
|
||||
).run(folder, id);
|
||||
}
|
||||
return getSession(id);
|
||||
}
|
||||
|
||||
function getSession(sessionId) {
|
||||
return (
|
||||
sql("SELECT * FROM remote_sessions WHERE session_id = ?").get(
|
||||
String(sessionId || "").toLowerCase()
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
/** The registered session `sessionId` of `deviceId`, or null. */
|
||||
function sessionOfDevice(sessionId, deviceId) {
|
||||
const row = getSession(sessionId);
|
||||
return row && row.device_id === deviceId ? row : null;
|
||||
}
|
||||
|
||||
function markEnded(sessionId, deviceId) {
|
||||
return (
|
||||
sql(
|
||||
"UPDATE remote_sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE session_id = ? AND device_id = ?"
|
||||
).run(String(sessionId).toLowerCase(), deviceId).changes > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session the gateway relayed for although its device never registered it
|
||||
* (remote_gate = warn): kept as UNMONITORED so an admin sees it. A session id
|
||||
* already held by another device stays refused (null).
|
||||
*/
|
||||
function recordUnmonitored(device, sessionId) {
|
||||
const id = String(sessionId || "").toLowerCase();
|
||||
if (!UUID_RE.test(id)) return null;
|
||||
const row = getSession(id);
|
||||
if (row) return row.device_id === device.id ? row : null;
|
||||
// Never for a session the hub already knows from elsewhere (a hub pane's,
|
||||
// another device's upload): that would hand it to this device's user.
|
||||
const known = sql("SELECT source FROM sessions WHERE id = ?").get(id);
|
||||
if (known && known.source !== `device:${device.id}`) return null;
|
||||
sql(
|
||||
"INSERT INTO remote_sessions (session_id, device_id, user_id, unmonitored) VALUES (?, ?, ?, 1)"
|
||||
).run(id, device.id, device.userId);
|
||||
scheduleUnmonitoredAlert(id);
|
||||
return getSession(id);
|
||||
}
|
||||
|
||||
/** How long a session may stay unregistered before the alert fires. */
|
||||
const ALERT_AFTER_MS = () => Number(process.env.AGENTHUB_REMOTE_UNMONITORED_ALERT_MS) || 60_000;
|
||||
|
||||
/**
|
||||
* Fire the `remote_unmonitored` alert rules for `sessionId` once it has stayed
|
||||
* unregistered for ALERT_AFTER_MS — a registration that was only late (the
|
||||
* hub was briefly unreachable) clears the flag first and alerts nobody.
|
||||
*/
|
||||
function scheduleUnmonitoredAlert(sessionId) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const row = sql(
|
||||
`SELECT r.*, d.name AS device_name, u.username
|
||||
FROM remote_sessions r JOIN devices d ON d.id = r.device_id
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE r.session_id = ?`
|
||||
).get(sessionId);
|
||||
if (!row || !row.unmonitored) return;
|
||||
require("../lib/alerts").fireRemoteUnmonitored({
|
||||
sessionId,
|
||||
username: row.username || `#${row.user_id}`,
|
||||
device: row.device_name,
|
||||
deviceId: row.device_id,
|
||||
userId: row.user_id,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`[remote] unmonitored alert failed: ${e.message}`);
|
||||
}
|
||||
}, ALERT_AFTER_MS()).unref();
|
||||
}
|
||||
|
||||
/** Keep the pool account the gateway picked for this session. */
|
||||
function setAccount(sessionId, account) {
|
||||
sql("UPDATE remote_sessions SET account = ? WHERE session_id = ?").run(account, sessionId);
|
||||
}
|
||||
|
||||
/** Count one relayed request and the usage its answer reported. */
|
||||
function countRequest(sessionId, usage = {}) {
|
||||
const n = (v) => (Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
|
||||
sql(
|
||||
`UPDATE remote_sessions SET requests = requests + 1,
|
||||
last_request_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
gw_input_tokens = gw_input_tokens + ?, gw_output_tokens = gw_output_tokens + ?,
|
||||
gw_cache_read_tokens = gw_cache_read_tokens + ?,
|
||||
gw_cache_write_tokens = gw_cache_write_tokens + ?
|
||||
WHERE session_id = ?`
|
||||
).run(
|
||||
n(usage.input_tokens),
|
||||
n(usage.output_tokens),
|
||||
n(usage.cache_read_input_tokens),
|
||||
n(usage.cache_creation_input_tokens),
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered sessions a user (or every user, `userId` null) has, newest
|
||||
* first, with their device's name — the "device › folder" view's rows.
|
||||
*/
|
||||
function listSessions(userId = null, limit = 200) {
|
||||
const where = userId == null ? "" : "WHERE r.user_id = ?";
|
||||
const args = userId == null ? [] : [Number(userId)];
|
||||
return sql(
|
||||
`SELECT r.session_id AS sessionId, r.device_id AS deviceId, d.name AS deviceName,
|
||||
r.user_id AS userId, r.cwd, r.tool, r.account, r.registered_at AS registeredAt,
|
||||
r.ended_at AS endedAt, r.last_request_at AS lastRequestAt, r.requests,
|
||||
r.gw_input_tokens AS inputTokens, r.gw_output_tokens AS outputTokens,
|
||||
r.gw_cache_read_tokens AS cacheReadTokens, r.gw_cache_write_tokens AS cacheWriteTokens,
|
||||
r.last_upload_at AS lastUploadAt, r.purged_at AS purgedAt,
|
||||
r.unmonitored,
|
||||
s.name AS title
|
||||
FROM remote_sessions r
|
||||
JOIN devices d ON d.id = r.device_id
|
||||
LEFT JOIN sessions s ON s.id = r.session_id
|
||||
${where}
|
||||
ORDER BY COALESCE(r.last_request_at, r.registered_at) DESC
|
||||
LIMIT ?`
|
||||
).all(...args, limit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UUID_RE,
|
||||
registerSession,
|
||||
getSession,
|
||||
sessionOfDevice,
|
||||
markEnded,
|
||||
recordUnmonitored,
|
||||
setAccount,
|
||||
countRequest,
|
||||
listSessions,
|
||||
};
|
||||
@@ -184,6 +184,11 @@ function createApp() {
|
||||
: next()
|
||||
);
|
||||
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
|
||||
// Server mode only: the hub half of the remote-clients architecture
|
||||
// (server/hub/) — its gateway and device-token routes come before the body
|
||||
// parsers and the cookie gate. A local install never loads that folder.
|
||||
const hub = isServerMode() ? require("./hub") : null;
|
||||
if (hub) hub.mountEarly(app);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
// Express 4's parser left `req.body` as `{}` when there was no JSON body;
|
||||
// Express 5's leaves it undefined, and handlers destructure it.
|
||||
@@ -245,6 +250,9 @@ function createApp() {
|
||||
app.use("/api/reports", reportsRouter);
|
||||
app.use("/api/webhooks", webhooksRouter);
|
||||
app.use("/api/remote-sources", remoteSourcesRouter);
|
||||
// A local AgentHub's link to the company hub (lib/remote-client.js).
|
||||
app.use("/api/remote-client", require("./routes/remote-client"));
|
||||
if (hub) hub.mountRoutes(app);
|
||||
app.use("/api/metrics", metricsRouter);
|
||||
app.get("/api/openapi.json", (_req, res) => {
|
||||
res.json(openApiSpec);
|
||||
@@ -903,6 +911,11 @@ function startBackgroundServices() {
|
||||
if (quotaTimer.unref) quotaTimer.unref();
|
||||
setImmediate(quotaSweep);
|
||||
}
|
||||
// The hub's remote-client conversations past their retention (server/hub/);
|
||||
// on a local install enrolled with a hub, the hourly upload of its
|
||||
// Company-account sessions (lib/remote-client.js — idle when not enrolled).
|
||||
if (require("./lib/auth").isServerMode()) require("./hub").startTimers();
|
||||
else require("./lib/remote-client").startUploader();
|
||||
// Another dashboard on the same database (hooks reach only one of them)
|
||||
// moving a lane: re-broadcast it here. One pragma per beat when nothing
|
||||
// changed — and only while such a dashboard runs at all: alone on its data
|
||||
|
||||
@@ -61,6 +61,12 @@ const LABEL_MAX = 60;
|
||||
const MODEL_SLOTS = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
const BUILTIN_NAME = "native";
|
||||
/**
|
||||
* Reserved for the remote client's Company account (server/lib/remote-client.js).
|
||||
* It has a dot because `slugify` never makes one, so no profile created
|
||||
* before it existed can carry this name.
|
||||
*/
|
||||
const COMPANY_NAME = "agenthub.company";
|
||||
|
||||
/**
|
||||
* The vendor's own API. Never written anywhere (`resolveProxy` answers null for
|
||||
@@ -169,7 +175,12 @@ function freeName(base, owner = null) {
|
||||
const prefix = owner == null ? "" : `u${owner}_`;
|
||||
const stem = base.slice(0, 40 - prefix.length - 4).replace(/-+$/, "") || "proxy";
|
||||
let name = prefix + stem;
|
||||
for (let n = 2; name === BUILTIN_NAME || fs.existsSync(proxyFile(name)); n += 1) {
|
||||
// COMPANY_NAME is the remote client's "Company account" (remote-client.js), never a profile.
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || name === COMPANY_NAME || fs.existsSync(proxyFile(name));
|
||||
n += 1
|
||||
) {
|
||||
name = `${prefix}${stem}-${n}`;
|
||||
}
|
||||
return name;
|
||||
@@ -503,6 +514,7 @@ module.exports = {
|
||||
testProxy,
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
COMPANY_NAME,
|
||||
NATIVE,
|
||||
ownerOfName,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* to alert_events with per-scope cooldown dedup — a budget instead fires once
|
||||
* per threshold per period — and broadcast to clients as `alert_triggered`.
|
||||
* `previewRule` replays a rule over recorded history without firing anything.
|
||||
* `remote_unmonitored` (server mode, server/hub/) fires when a user's machine
|
||||
* uses the company account for a session it never registered with the hub.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,6 +21,7 @@ const RULE_TYPES = [
|
||||
"token_threshold",
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
"remote_unmonitored",
|
||||
];
|
||||
const TOOL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const AGENT_STATUSES = ["working", "waiting"];
|
||||
@@ -149,6 +152,8 @@ function validateRuleConfig(ruleType, config) {
|
||||
out.tz_offset = tz;
|
||||
return { ok: true, config: out };
|
||||
}
|
||||
case "remote_unmonitored":
|
||||
return { ok: true, config: {} };
|
||||
default:
|
||||
return { ok: false, error: "unsupported rule_type" };
|
||||
}
|
||||
@@ -513,6 +518,22 @@ function previewRule(ruleType, config, { days = 7, cooldownSeconds = 300, now =
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote client's company-account session that its device never registered
|
||||
* (server/hub/remote-sessions.js, after a grace period): every enabled
|
||||
* `remote_unmonitored` rule fires once for it (cooldown per session).
|
||||
*/
|
||||
function fireRemoteUnmonitored({ sessionId, username, device, deviceId, userId }) {
|
||||
for (const rule of loadEnabledRules()) {
|
||||
if (rule.rule_type !== "remote_unmonitored") continue;
|
||||
fireAlert(rule, {
|
||||
sessionId,
|
||||
message: `Company account used outside AgentHub: ${username} on ${device} (session ${String(sessionId).slice(0, 8)})`,
|
||||
details: { device_id: deviceId, device, user_id: userId, username },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic sweep for the time-based rules. unref'd so it never keeps the
|
||||
// process (or the test runner) alive — same pattern as the hooks watchdog.
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
@@ -528,4 +549,5 @@ module.exports = {
|
||||
invalidateRuleCache,
|
||||
previewRule,
|
||||
sweepBudget,
|
||||
fireRemoteUnmonitored,
|
||||
};
|
||||
|
||||
+389
-21
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file artifacts.js
|
||||
* @description The Artifacts hub (docs/plans/artifacts/README.md): what agents
|
||||
* produce, kept per OWNER and — in server mode — shared to every user.
|
||||
* produce, kept per OWNER and — in server mode — shared to every user (the
|
||||
* Hub, `shared = 1`) or to chosen people (`artifact_shares`).
|
||||
*
|
||||
* Four kinds:
|
||||
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
||||
@@ -24,7 +25,7 @@
|
||||
* them — and a path from a request only ever picks one of the snapshot's
|
||||
* own recorded files (`snapshotFile`).
|
||||
* - Visibility: in local mode every row; in server mode a developer sees
|
||||
* their own rows plus shared ones, admin and analyst see every row (like
|
||||
* their own rows plus shared ones (to everyone, or to them by name), admin and analyst see every row (like
|
||||
* sessions), and only the owner (or an admin) changes or deletes one.
|
||||
* - Installing a shared skill or plugin is the viewer's click, into the
|
||||
* VIEWER's own accounts only (`installAccounts`): a member's home under S3
|
||||
@@ -94,13 +95,23 @@ const installsOffFlag = () => path.join(root(), "installs-off");
|
||||
|
||||
// ── Visibility ────────────────────────────────────────────────────────────
|
||||
|
||||
/** SQL over alias `a`, one `?` = a user id: the row was shared with that user by name. */
|
||||
const SHARED_TO_ME =
|
||||
"EXISTS (SELECT 1 FROM artifact_shares sh WHERE sh.artifact_id = a.id AND sh.user_id = ?)";
|
||||
|
||||
const sharedTo = (artifactId, userId) =>
|
||||
!!sql("SELECT 1 FROM artifact_shares WHERE artifact_id = ? AND user_id = ?").get(
|
||||
Number(artifactId),
|
||||
Number(userId)
|
||||
);
|
||||
|
||||
/** `{where, params}` over an `artifacts` alias `a`: the rows this request sees. */
|
||||
function visibleWhere(req) {
|
||||
const u = req?.user;
|
||||
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
||||
return {
|
||||
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
|
||||
params: [u.id, u.id],
|
||||
where: `(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?) OR ${SHARED_TO_ME})`,
|
||||
params: [u.id, u.id, u.id],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +122,8 @@ const canSee = (req, row) => {
|
||||
return (
|
||||
row.owner_id === u.id ||
|
||||
row.shared === 1 ||
|
||||
(row.owner_id == null && row.suggested_owner_id === u.id)
|
||||
(row.owner_id == null && row.suggested_owner_id === u.id) ||
|
||||
(row.id != null && sharedTo(row.id, u.id))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,7 +135,9 @@ const canChange = (req, row) =>
|
||||
function canSeeId(req, id) {
|
||||
return canSee(
|
||||
req,
|
||||
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
|
||||
sql("SELECT id, owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(
|
||||
Number(id)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,6 +149,42 @@ function load(req, id) {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** `[{id, name}]` of the people `artifactId` is shared with by name. */
|
||||
function sharedWithOf(artifactId) {
|
||||
return sql(
|
||||
`SELECT u.id, COALESCE(NULLIF(u.display_name, ''), u.username) AS name
|
||||
FROM artifact_shares sh JOIN users u ON u.id = sh.user_id
|
||||
WHERE sh.artifact_id = ? AND u.disabled = 0 ORDER BY name`
|
||||
).all(Number(artifactId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every recipient of `artifactId` by name that is still an account (the
|
||||
* broadcast audience, and whether anyone keeps a Hub-only row alive). A
|
||||
* disabled one keeps their row (enabling them again restores it) but is not
|
||||
* listed to the owner, so it never blocks an edit of the list.
|
||||
*/
|
||||
function shareRecipients(artifactId) {
|
||||
return sql(
|
||||
`SELECT sh.user_id FROM artifact_shares sh JOIN users u ON u.id = sh.user_id
|
||||
WHERE sh.artifact_id = ?`
|
||||
)
|
||||
.all(Number(artifactId))
|
||||
.map((r) => r.user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a user may share with: every enabled account but themselves,
|
||||
* name only (server mode). Nothing else about them is returned.
|
||||
*/
|
||||
function people(req) {
|
||||
if (!isServer() || !req?.user) return [];
|
||||
return sql(
|
||||
`SELECT id, username, COALESCE(NULLIF(display_name, ''), username) AS name
|
||||
FROM users WHERE disabled = 0 AND id != ? ORDER BY name COLLATE NOCASE`
|
||||
).all(req.user.id);
|
||||
}
|
||||
|
||||
function view(row, req, owners = null) {
|
||||
if (!row) return null;
|
||||
let owner = null;
|
||||
@@ -165,6 +215,10 @@ function view(row, req, owners = null) {
|
||||
size: row.size,
|
||||
shared: row.shared === 1,
|
||||
sharedAt: row.shared_at,
|
||||
// The people it is shared with by name: only its owner (or an admin) sees
|
||||
// the list; anyone else learns only that it reached them.
|
||||
sharedWith: canChange(req, row) ? sharedWithOf(row.id) : [],
|
||||
sharedWithMe: !!req?.user && row.owner_id !== req.user.id && sharedTo(row.id, req.user.id),
|
||||
inLibrary: row.in_library !== 0,
|
||||
installs: row.installs,
|
||||
auto: row.auto === 1,
|
||||
@@ -194,11 +248,16 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
// Their own library, plus what others shared that they installed (kept
|
||||
// below only when `installed` — a disk read, so it cannot be SQL).
|
||||
where.push(
|
||||
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
|
||||
`((a.owner_id = ? AND a.in_library = 1) OR ((a.shared = 1 OR ${SHARED_TO_ME}) AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))`
|
||||
);
|
||||
params.push(req.user.id, req.user.id);
|
||||
params.push(req.user.id, req.user.id, req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
} else if (scope === "withme") {
|
||||
// Shared with the caller by name (server mode).
|
||||
if (!isServer() || !req?.user) return [];
|
||||
where.push(SHARED_TO_ME, "a.owner_id IS NOT ?");
|
||||
params.push(req.user.id, req.user.id);
|
||||
} else if (scope === "unowned") {
|
||||
if (!isServer() || !req?.user) return [];
|
||||
where.push("a.owner_id IS NULL");
|
||||
@@ -432,9 +491,15 @@ function captureFromHook(sessionId, data, peer = null) {
|
||||
url,
|
||||
ownerId
|
||||
);
|
||||
if (same) return same;
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
return insert({
|
||||
// A publish of a page from a file: keep a copy anyone it is shared with can
|
||||
// view here, without a claude.ai login (a republish takes the new version).
|
||||
const copy = laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files) : null;
|
||||
if (same) {
|
||||
if (copy) schedulePageSnapshot(same.id, copy);
|
||||
return same;
|
||||
}
|
||||
const row = insert({
|
||||
kind: "page",
|
||||
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
|
||||
description: cleanDescription(input.description),
|
||||
@@ -445,6 +510,186 @@ function captureFromHook(sessionId, data, peer = null) {
|
||||
url,
|
||||
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
|
||||
});
|
||||
if (copy) schedulePageSnapshot(row.id, copy);
|
||||
return row;
|
||||
}
|
||||
|
||||
// ── A page's own copy ─────────────────────────────────────────────────────
|
||||
// A claude.ai page is private to the account that published it, so a viewer
|
||||
// without it saw nothing. A page published FROM a file keeps a snapshot of
|
||||
// that file (and the files it names) — viewed here in the sandbox, like a
|
||||
// pinned HTML file. A Docs doc or a typed artifact (Slides, Design) has no
|
||||
// such file: Claude is asked (scripts/artifact-export-hint.js) to export a
|
||||
// copy to `artifacts-export/<artifact id>.(md|pdf)`, which the Stop capture
|
||||
// attaches to the row instead (`captureOutputsNow`).
|
||||
|
||||
const PAGE_FILE_EXT = /\.html?$/i;
|
||||
const PAGE_FILES_MAX = 50;
|
||||
const PUBLISHED_RE =
|
||||
/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._ ()@+-][A-Za-z0-9._ ()@+/-]{0,299}$/;
|
||||
/** Where Claude is asked to export a typed artifact's copy. */
|
||||
const EXPORT_DIR = "artifacts-export";
|
||||
|
||||
/**
|
||||
* What to copy for a page: `{laneId, main, extra: [{rel, as}]}` with every
|
||||
* path workspace-relative, or null when the main file is not an HTML file
|
||||
* inside the workspace. `cwd` is the session's folder (a relative path in
|
||||
* the hook is relative to it); `files` is the Artifact tool's supporting
|
||||
* files map (`{published: source | {from}}`) or list (`[{path}]`).
|
||||
*/
|
||||
function pageCopy(laneId, cwd, file, files) {
|
||||
const lane = require("./lanes").getLane(Number(laneId));
|
||||
if (!lane || !PAGE_FILE_EXT.test(file)) return null;
|
||||
const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd;
|
||||
const inLane = (p) => {
|
||||
const rel = path.relative(lane.cwd, path.resolve(base, p));
|
||||
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join("/")
|
||||
: null;
|
||||
};
|
||||
const main = inLane(file);
|
||||
if (!main) return null;
|
||||
const extra = [];
|
||||
const add = (as, src) => {
|
||||
if (typeof as !== "string" || !PUBLISHED_RE.test(as) || typeof src !== "string") return;
|
||||
const rel = inLane(src);
|
||||
if (rel && extra.length < PAGE_FILES_MAX) extra.push({ rel, as });
|
||||
};
|
||||
if (Array.isArray(files)) {
|
||||
for (const f of files) add(f?.path, f?.path);
|
||||
} else if (files && typeof files === "object") {
|
||||
for (const [as, v] of Object.entries(files)) {
|
||||
add(as, typeof v === "string" ? v : v && typeof v.from === "string" ? v.from : null);
|
||||
}
|
||||
}
|
||||
return { laneId: lane.id, main, extra };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy several workspace files into ONE new snapshot: `entries` =
|
||||
* `[{rel, as}]`, each read under the same guards as a pinned file and
|
||||
* written at its published path `as`. A file that is a secret, gone or
|
||||
* outside the workspace is skipped; the total stays under FILE_MAX_BYTES.
|
||||
* Returns `{snap, files: [{path, size}]}`, or null when nothing was copied.
|
||||
*/
|
||||
async function snapshotLaneFiles(lane, entries) {
|
||||
const { openDownload } = require("./lane-file-transfer");
|
||||
const snap = newSnapshotDir();
|
||||
const files = [];
|
||||
let total = 0;
|
||||
try {
|
||||
for (const { rel, as } of entries) {
|
||||
// A key or a login is never copied, under either name.
|
||||
if (
|
||||
SECRET_NAME_RE.test(path.posix.basename(rel)) ||
|
||||
SECRET_NAME_RE.test(path.posix.basename(as))
|
||||
)
|
||||
continue;
|
||||
let file;
|
||||
try {
|
||||
file = await openDownload(lane.cwd, rel);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (total + file.size > FILE_MAX_BYTES || files.some((f) => f.path === as)) {
|
||||
file.stream.destroy();
|
||||
continue;
|
||||
}
|
||||
const dest = path.join(snap.dir, ...as.split("/"));
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
|
||||
await new Promise((resolve, reject) => {
|
||||
const out = fs.createWriteStream(dest, { mode: 0o600 });
|
||||
file.stream.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("finish", resolve);
|
||||
file.stream.pipe(out);
|
||||
});
|
||||
total += file.size;
|
||||
files.push({ path: as, size: file.size });
|
||||
}
|
||||
} catch (err) {
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
if (!files.length) {
|
||||
dropSnapshot(snap.id);
|
||||
return null;
|
||||
}
|
||||
return { snap, files, size: total };
|
||||
}
|
||||
|
||||
/** Put a new snapshot on a row, dropping the one it replaces. */
|
||||
function setSnapshot(rowId, shot) {
|
||||
const old = sql("SELECT snapshot FROM artifacts WHERE id = ?").get(rowId);
|
||||
sql(
|
||||
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(shot.snap.id, JSON.stringify(shot.files), shot.size, rowId);
|
||||
if (old?.snapshot && old.snapshot !== shot.snap.id) dropSnapshot(old.snapshot);
|
||||
}
|
||||
|
||||
/** Snapshot a page's copy; the main file goes first, under its own name. */
|
||||
async function snapshotPage(rowId, copy) {
|
||||
const lane = require("./lanes").getLane(Number(copy.laneId));
|
||||
if (!lane) return null;
|
||||
const mainAs = path.posix.basename(copy.main);
|
||||
const shot = await snapshotLaneFiles(lane, [
|
||||
{ rel: copy.main, as: mainAs },
|
||||
...copy.extra.filter((e) => e.as !== mainAs),
|
||||
]);
|
||||
if (!shot || shot.files[0].path !== mainAs) {
|
||||
if (shot) dropSnapshot(shot.snap.id);
|
||||
return null;
|
||||
}
|
||||
if (!sql("SELECT 1 FROM artifacts WHERE id = ?").get(rowId)) {
|
||||
dropSnapshot(shot.snap.id); // deleted meanwhile
|
||||
return null;
|
||||
}
|
||||
setSnapshot(rowId, shot);
|
||||
return sql("SELECT * FROM artifacts WHERE id = ?").get(rowId);
|
||||
}
|
||||
|
||||
// One copy at a time per row: a republish waits for the copy before it.
|
||||
const pageCopies = new Map();
|
||||
function schedulePageSnapshot(rowId, copy) {
|
||||
const prev = pageCopies.get(rowId) || Promise.resolve();
|
||||
const run = prev
|
||||
.catch(() => null)
|
||||
.then(() => snapshotPage(rowId, copy))
|
||||
.catch((err) => {
|
||||
console.warn(`[artifacts] page #${rowId} not copied: ${err.message}`);
|
||||
return null;
|
||||
});
|
||||
pageCopies.set(rowId, run);
|
||||
run.finally(() => {
|
||||
if (pageCopies.get(rowId) === run) pageCopies.delete(rowId);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page captured before pages kept a copy: take it now from the file it was
|
||||
* published from, if that file is still there (once per row per process).
|
||||
* Any viewer may ask; what they get is the copy, never the workspace file.
|
||||
*/
|
||||
const backfillTried = new Set();
|
||||
async function ensurePageCopy(req, id) {
|
||||
const row = load(req, id);
|
||||
if (row.kind !== "page" || row.snapshot || !row.source || row.lane_id == null) {
|
||||
return view(row, req);
|
||||
}
|
||||
if (backfillTried.has(row.id)) return view(row, req);
|
||||
backfillTried.add(row.id);
|
||||
const copy = pageCopy(row.lane_id, null, row.source, null);
|
||||
const next = copy ? await schedulePageSnapshot(row.id, copy) : null;
|
||||
return view(next || row, req);
|
||||
}
|
||||
|
||||
/** The artifact id a claude.ai page link ends in (`…/artifact/<id>`). */
|
||||
function pageIdOf(url) {
|
||||
const m = /\/artifact\/([A-Za-z0-9_-]+)/.exec(String(url || ""));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// ── file ──────────────────────────────────────────────────────────────────
|
||||
@@ -549,6 +794,8 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
|
||||
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
|
||||
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
||||
/** A typed artifact's exported copy: `artifacts-export/<artifact id>.<ext>`. */
|
||||
const EXPORT_RE = /^artifacts-export\/([A-Za-z0-9_-]+)\.(md|markdown|pdf|html?|png)$/i;
|
||||
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
||||
const OUTPUT_MAX = 20;
|
||||
/** Folders that hold a site's own pages, never a report someone reads. */
|
||||
@@ -596,10 +843,25 @@ async function outputCandidates(lane, since) {
|
||||
} else {
|
||||
paths = (await gd.walkFolder(lane.cwd)).files;
|
||||
}
|
||||
// The export folder is read directly: once AgentHub has excluded it from
|
||||
// git, `ls-files --others --exclude-standard` no longer lists it.
|
||||
try {
|
||||
for (const name of await fs.promises.readdir(path.join(lane.cwd, EXPORT_DIR))) {
|
||||
paths.push(`${EXPORT_DIR}/${name}`);
|
||||
}
|
||||
} catch {
|
||||
/* no export folder */
|
||||
}
|
||||
const out = [];
|
||||
for (const rel of new Set(paths)) {
|
||||
if (!OUTPUT_EXT.test(rel)) continue;
|
||||
if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
|
||||
const exported = EXPORT_RE.test(rel);
|
||||
if (!OUTPUT_EXT.test(rel) && !exported) continue;
|
||||
if (
|
||||
!exported &&
|
||||
/\.html?$/i.test(rel) &&
|
||||
(tracked.has(rel) || (await isSitePage(lane.cwd, rel)))
|
||||
)
|
||||
continue;
|
||||
let st;
|
||||
try {
|
||||
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||
@@ -638,6 +900,31 @@ async function captureOutputsNow(laneId, sessionId) {
|
||||
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
|
||||
const done = [];
|
||||
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
|
||||
const exp = EXPORT_RE.exec(rel);
|
||||
if (exp) {
|
||||
// An exported copy of a page of this workspace: it becomes that page's
|
||||
// copy (no second card). One naming no page of ours is left alone.
|
||||
const page = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'page' AND lane_id = ? AND url LIKE ? ORDER BY id DESC"
|
||||
)
|
||||
.all(lane.id, `%/artifact/${exp[1]}%`)
|
||||
.find((r) => pageIdOf(r.url) === exp[1]);
|
||||
if (!page) continue;
|
||||
const kept = page.files ? JSON.parse(page.files)[0] : null;
|
||||
if (kept && kept.mtime === mtimeMs && kept.path === path.posix.basename(rel)) continue;
|
||||
try {
|
||||
const shot = await snapshotLaneFiles(lane, [{ rel, as: path.posix.basename(rel) }]);
|
||||
if (!shot) continue;
|
||||
shot.files[0].mtime = mtimeMs;
|
||||
setSnapshot(page.id, shot);
|
||||
// AgentHub's folder, not the user's work: kept out of their git status.
|
||||
require("./git-exclude").excludeFromGit(lane.cwd, [`${EXPORT_DIR}/`]);
|
||||
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(page.id));
|
||||
} catch {
|
||||
/* a secret name, a vanished file */
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const rows = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
|
||||
).all(lane.id, rel);
|
||||
@@ -1543,7 +1830,12 @@ function update(req, id, body = {}) {
|
||||
if (body.skill && typeof body.skill === "object") {
|
||||
editSkill(req, row, body.skill);
|
||||
row = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
if (!("title" in body) && !("shared" in body) && !("description" in body))
|
||||
if (
|
||||
!("title" in body) &&
|
||||
!("shared" in body) &&
|
||||
!("sharedWith" in body) &&
|
||||
!("description" in body)
|
||||
)
|
||||
return view(row, req);
|
||||
}
|
||||
const sets = [];
|
||||
@@ -1576,22 +1868,77 @@ function update(req, id, body = {}) {
|
||||
params.push(body.shared ? 1 : 0, body.shared ? new Date().toISOString() : null);
|
||||
}
|
||||
}
|
||||
if (!sets.length) return view(row, req);
|
||||
if (shareChange === "unshared" && row.in_library === 0) {
|
||||
let people = null;
|
||||
if ("sharedWith" in body) {
|
||||
if (!isServer()) throw fail("ELOCALMODE", "sharing needs server mode");
|
||||
people = cleanPeople(body.sharedWith, row.owner_id);
|
||||
}
|
||||
if (!sets.length && !people) return view(row, req);
|
||||
const nowShared = "shared" in body ? body.shared : row.shared === 1;
|
||||
const keepsReaders = nowShared || (people ? people.length > 0 : shareRecipients(row.id).length);
|
||||
if (row.in_library === 0 && !keepsReaders) {
|
||||
// Off the Hub and out of its owner's library: nothing keeps it.
|
||||
deleteRow(row);
|
||||
audit(req, row, "unshared");
|
||||
return { ...view(row, req), shared: false, removed: true };
|
||||
}
|
||||
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||
let peopleChange = null;
|
||||
require("../db").db.transaction(() => {
|
||||
sets.push("updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')");
|
||||
sql(`UPDATE artifacts SET ${sets.join(", ")} WHERE id = ?`).run(...params, row.id);
|
||||
if (people) peopleChange = setSharedWith(row.id, people);
|
||||
})();
|
||||
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
if (shareChange) audit(req, next, shareChange);
|
||||
if (peopleChange) audit(req, next, peopleChange);
|
||||
return view(next, req);
|
||||
}
|
||||
|
||||
const MAX_PEOPLE = 100;
|
||||
|
||||
/** A `sharedWith` body value → distinct ids of enabled users, never the owner. */
|
||||
function cleanPeople(v, ownerId) {
|
||||
if (!Array.isArray(v) || v.length > MAX_PEOPLE) {
|
||||
throw fail("EBADFIELD", `sharedWith: up to ${MAX_PEOPLE} user ids`);
|
||||
}
|
||||
const ids = [...new Set(v)];
|
||||
if (!ids.every((n) => Number.isInteger(n) && n > 0)) {
|
||||
throw fail("EBADFIELD", "sharedWith: user ids");
|
||||
}
|
||||
const known = new Set(
|
||||
ids.length
|
||||
? sql(`SELECT id FROM users WHERE disabled = 0 AND id IN (${ids.map(() => "?").join(",")})`)
|
||||
.all(...ids)
|
||||
.map((r) => r.id)
|
||||
: []
|
||||
);
|
||||
const bad = ids.find((n) => !known.has(n));
|
||||
if (bad != null) throw fail("EBADFIELD", `sharedWith: no such user ${bad}`);
|
||||
return ids.filter((n) => n !== ownerId);
|
||||
}
|
||||
|
||||
/** Replace who `artifactId` is shared with by name; the audit line, or null if unchanged. */
|
||||
function setSharedWith(artifactId, ids) {
|
||||
const before = new Set(shareRecipients(artifactId));
|
||||
const added = ids.filter((n) => !before.has(n));
|
||||
const removed = [...before].filter((n) => !ids.includes(n));
|
||||
if (!added.length && !removed.length) return null;
|
||||
const del = sql("DELETE FROM artifact_shares WHERE artifact_id = ? AND user_id = ?");
|
||||
for (const n of removed) del.run(artifactId, n);
|
||||
const ins = sql("INSERT OR IGNORE INTO artifact_shares (artifact_id, user_id) VALUES (?, ?)");
|
||||
for (const n of added) ins.run(artifactId, n);
|
||||
const name = (n) => sql("SELECT username FROM users WHERE id = ?").get(n)?.username || `#${n}`;
|
||||
return [
|
||||
added.length ? `shared with ${added.map(name).join(", ")}` : "",
|
||||
removed.length ? `unshared from ${removed.map(name).join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function deleteRow(row) {
|
||||
sql("DELETE FROM artifact_installs WHERE artifact_id = ?").run(row.id);
|
||||
sql("DELETE FROM artifact_shares WHERE artifact_id = ?").run(row.id);
|
||||
sql("DELETE FROM artifacts WHERE id = ?").run(row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
}
|
||||
@@ -1606,14 +1953,15 @@ function remove(req, id) {
|
||||
const row = load(req, id);
|
||||
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner deletes an artifact");
|
||||
const owner = !req?.user || row.owner_id === req.user.id;
|
||||
if (owner && row.shared === 1 && row.in_library !== 0) {
|
||||
const readers = row.shared === 1 || shareRecipients(row.id).length > 0;
|
||||
if (owner && readers && row.in_library !== 0) {
|
||||
sql(
|
||||
"UPDATE artifacts SET in_library = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(row.id);
|
||||
return { ...row, in_library: 0, kept: true };
|
||||
}
|
||||
deleteRow(row);
|
||||
if (row.shared === 1) audit(req, row, "deleted");
|
||||
if (readers) audit(req, row, "deleted");
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -1703,6 +2051,16 @@ function snapshotFile(req, id, rel) {
|
||||
|
||||
const installsEnabled = () => !fs.existsSync(installsOffFlag());
|
||||
|
||||
/** Ask Claude to export a copy of a Doc / Slides / Design (the pane env decides). */
|
||||
const exportsOffFlag = () => path.join(root(), "exports-off");
|
||||
const exportsEnabled = () => !fs.existsSync(exportsOffFlag());
|
||||
function setExportsEnabled(on) {
|
||||
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
|
||||
if (on) fs.rmSync(exportsOffFlag(), { force: true });
|
||||
else fs.writeFileSync(exportsOffFlag(), "", { mode: 0o600 });
|
||||
return exportsEnabled();
|
||||
}
|
||||
|
||||
function setInstallsEnabled(on) {
|
||||
fs.mkdirSync(root(), { recursive: true, mode: 0o700 });
|
||||
if (on) fs.rmSync(installsOffFlag(), { force: true });
|
||||
@@ -2137,13 +2495,16 @@ function reshareSkill(req, id) {
|
||||
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
|
||||
function shareNow(req, id, changed) {
|
||||
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
|
||||
if (isServer() && row.shared !== 1) {
|
||||
// A row shared with chosen people stays with them: Share again only
|
||||
// takes the new version, it never widens who sees it.
|
||||
const byName = shareRecipients(id).length > 0;
|
||||
if (isServer() && row.shared !== 1 && !byName) {
|
||||
sql(
|
||||
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||
).run(id);
|
||||
audit(req, row, "shared");
|
||||
} else if (changed && row.shared === 1) {
|
||||
} else if (changed && (row.shared === 1 || byName)) {
|
||||
audit(req, row, "updated");
|
||||
}
|
||||
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
|
||||
@@ -2430,12 +2791,19 @@ module.exports = {
|
||||
addPlugin,
|
||||
update,
|
||||
remove,
|
||||
people,
|
||||
shareRecipients,
|
||||
snapshotFile,
|
||||
syncSkillSources,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
exportsEnabled,
|
||||
setExportsEnabled,
|
||||
ensurePageCopy,
|
||||
pageIdOf,
|
||||
__pageCopies: () => [...pageCopies.values()],
|
||||
__setExecImpl,
|
||||
__setInspectImpl,
|
||||
};
|
||||
|
||||
+18
-2
@@ -664,6 +664,19 @@ const DUMMY_HASH = `scrypt$${"A".repeat(22)}==$${"A".repeat(86)}==`;
|
||||
|
||||
/** `{user}` for a right username + password, else throws EBADLOGIN/ETOOMANY. */
|
||||
async function login(req, username, password) {
|
||||
const u = await checkLogin(req, username, password);
|
||||
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The enabled user row a username + password names, throttled exactly like a
|
||||
* login (the same per-address and per-username counters) but starting no
|
||||
* session — a remote client's device enrollment (server/hub/devices.js) is a
|
||||
* login that yields a device token instead of a cookie. Throws
|
||||
* EBADLOGIN/ETOOMANY.
|
||||
*/
|
||||
async function checkLogin(req, username, password) {
|
||||
const name = String(username || "").trim();
|
||||
const ipKey = clientKey(req);
|
||||
const userKey = `user:${name.toLowerCase()}`;
|
||||
@@ -678,8 +691,7 @@ async function login(req, username, password) {
|
||||
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
|
||||
failures.delete(ipKey);
|
||||
failures.delete(userKey);
|
||||
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
return u;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -776,6 +788,9 @@ function authGuard(req, res, next) {
|
||||
const lp = p.toLowerCase();
|
||||
if (lp === "/health" || lp.startsWith("/auth/")) return next();
|
||||
if (lp === "/hooks/ingest-batch") return next(); // its own bearer token
|
||||
// A remote client's enrollment and its device-token calls (server/hub/):
|
||||
// each checks its own password or device token, never a cookie.
|
||||
if (underPrefix(p, "/remote/device") || underPrefix(p, "/remote/gw")) return next();
|
||||
if (underPrefix(p, "/hooks")) {
|
||||
return isLocalRequest(req)
|
||||
? next()
|
||||
@@ -880,6 +895,7 @@ module.exports = {
|
||||
hasAdmin,
|
||||
userFromRequest,
|
||||
login,
|
||||
checkLogin,
|
||||
confirmPassword,
|
||||
endSession,
|
||||
cookieToken,
|
||||
|
||||
@@ -520,6 +520,15 @@ function pick(member, ownerId = null) {
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
|
||||
if (live) return live;
|
||||
return rankAccounts(usable);
|
||||
}
|
||||
|
||||
/**
|
||||
* The best of `usable` by the balancer's order: not full, fewest live users
|
||||
* (panes here plus remote clients' sessions the gateway relayed for in the
|
||||
* last `REMOTE_LIVE_MS`), least spent in 5 h then 7 d. Null when empty.
|
||||
*/
|
||||
function rankAccounts(usable) {
|
||||
if (usable.length === 0) return null;
|
||||
refreshQuotas().catch(() => {});
|
||||
const ranked = usable
|
||||
@@ -530,7 +539,7 @@ function pick(member, ownerId = null) {
|
||||
a,
|
||||
key: [
|
||||
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
|
||||
livePanes(a.name),
|
||||
livePanes(a.name) + liveRemote(a.name),
|
||||
u.spend5h,
|
||||
u.spend7d,
|
||||
],
|
||||
@@ -545,6 +554,37 @@ function pick(member, ownerId = null) {
|
||||
return ranked[0].a;
|
||||
}
|
||||
|
||||
/** Remote clients' sessions on `account` the gateway relayed for lately (server/hub/). */
|
||||
const REMOTE_LIVE_MS = 10 * 60 * 1000;
|
||||
function liveRemote(account) {
|
||||
const { db } = require("../db");
|
||||
const since = new Date(Date.now() - REMOTE_LIVE_MS).toISOString();
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS n FROM remote_sessions WHERE account = ? AND ended_at IS NULL AND last_request_at >= ?"
|
||||
)
|
||||
.get(account, since).n;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pool account a remote client's company-account session runs on through
|
||||
* the hub's gateway (server/hub/remote-gateway.js): `{name, bearer}` or null.
|
||||
* `sticky` — the account the session already ran on — is kept while it is
|
||||
* still usable by this owner (one session, one account: the prompt cache is
|
||||
* per account). Both kinds work: a setup-token as it is, a login by its
|
||||
* access token, which this server keeps fresh (`refreshLogins`). Nothing is
|
||||
* written into any home. The bearer never leaves the hub.
|
||||
*/
|
||||
function forRemote(ownerId, sticky = null) {
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "claude");
|
||||
const usable = allAccounts().filter((a) => !expired(a) && allowed(a.name));
|
||||
if (usable.length === 0) return null;
|
||||
const keep = sticky ? usable.find((a) => a.name === sticky) : null;
|
||||
const pin = keep ? null : require("./account-pins").preferredFor(ownerId, "claude");
|
||||
const a = keep || (pin && usable.find((x) => x.name === pin)) || rankAccounts(usable);
|
||||
return a ? { name: a.name, bearer: bearerOf(a), kind: a.kind } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a new Claude pane should run on — `{name, kind, token?}` — or
|
||||
* null when the pool has nothing this pane may use. Synchronous (a spawn
|
||||
@@ -600,6 +640,7 @@ module.exports = {
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
forRemote,
|
||||
switchHome,
|
||||
refreshQuotas,
|
||||
refreshLogins,
|
||||
|
||||
+39
-8
@@ -21,7 +21,8 @@
|
||||
* stash would shift), and nothing drops one except a clean restore or a
|
||||
* Delete with `confirm: true`. They are repo-wide, so repo root only
|
||||
* (ESUBDIR), never during a merge/cherry-pick/revert/rebase (EMERGING), and
|
||||
* a restore needs a clean tree (EDIRTY). A conflicted restore keeps its
|
||||
* a restore needs an empty index and none of its own files changed (EDIRTY;
|
||||
* changes elsewhere stay). A conflicted restore keeps its
|
||||
* shelf and leaves the unmerged files to `resolve`. A write that moves
|
||||
* files under an agent the route reports as working asks for
|
||||
* `confirm: true` first (EAGENTRUNNING). A merge is the
|
||||
@@ -617,11 +618,9 @@ async function runGitAction(dir, action, body = {}, opts = {}) {
|
||||
return { ...done(action, await dropShelf(dir, entry.sha)), sha: entry.sha };
|
||||
}
|
||||
const entry = await findShelf(dir, body.sha);
|
||||
// A restore never mixes with new work: on a clean tree its result is
|
||||
// exactly the shelf, and a conflict can only be between it and HEAD.
|
||||
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
const before = await assertRestorable(dir, entry);
|
||||
assertAgentConfirmed(opts, body, "restoring a shelf");
|
||||
return applyShelf(dir, entry);
|
||||
return applyShelf(dir, entry, before);
|
||||
}
|
||||
|
||||
if (action === "ignore") return ignorePaths(dir, body);
|
||||
@@ -914,13 +913,44 @@ async function dropShelf(dir, sha) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a shelf back on a clean tree. `--index` first, so what was staged comes
|
||||
* Put a shelf back onto a tree `assertRestorable` let through. `--index` first, so what was staged comes
|
||||
* back staged; when only the index cannot be rebuilt git refuses before it
|
||||
* touches the tree, and the shelf is applied without it. A clean apply drops
|
||||
* the entry; a conflicted one KEEPS it and leaves the unmerged files to the
|
||||
* `resolve` flow (ours = the branch, theirs = the shelf).
|
||||
*/
|
||||
async function applyShelf(dir, entry) {
|
||||
/**
|
||||
* A restore never mixes with new work in the files it touches: nothing may be
|
||||
* staged (`stash apply --index` against a changed index stops halfway, its
|
||||
* untracked files already written) and no change may be in one of the shelf's
|
||||
* own files. Changes elsewhere stay where they are — a partial Shelve (n)
|
||||
* leaves the unticked files behind, and refusing them made such a shelf
|
||||
* impossible to restore. So a conflict is still only between the shelf and
|
||||
* HEAD. Answers the tree's status before the restore, for `applyShelf`.
|
||||
*/
|
||||
async function assertRestorable(dir, entry) {
|
||||
const status = await laneStatus(dir);
|
||||
if (status.some((e) => e.code[0] !== " " && e.code[0] !== "?"))
|
||||
throw bad("EDIRTY", "the index has staged changes: commit or unstage them first");
|
||||
const mine = new Set(entry.files.map((f) => f.path));
|
||||
const overlap = status
|
||||
.flatMap((e) => [e.path, e.from].filter(Boolean))
|
||||
.filter((p) => mine.has(p));
|
||||
if (overlap.length)
|
||||
throw Object.assign(
|
||||
bad("EDIRTY", `files in this shelf have changed since: ${overlap.join(", ")}`),
|
||||
{ paths: [...new Set(overlap)] }
|
||||
);
|
||||
return status;
|
||||
}
|
||||
|
||||
const statusKey = (status) =>
|
||||
status
|
||||
.map((e) => `${e.code}\0${e.path}\0${e.from || ""}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
|
||||
async function applyShelf(dir, entry, before = []) {
|
||||
const kept = (e, conflicts) => ({
|
||||
...done("unshelve", e.git || { stdout: "", stderr: "" }),
|
||||
sha: entry.sha,
|
||||
@@ -933,7 +963,8 @@ async function applyShelf(dir, entry) {
|
||||
} catch (e) {
|
||||
const conflicts = await unmergedPaths(dir);
|
||||
if (conflicts.length) return kept(e, conflicts);
|
||||
if (await isDirty(dir)) throw e;
|
||||
// Only when git refused before touching the tree is a retry safe.
|
||||
if (statusKey(await laneStatus(dir)) !== statusKey(before)) throw e;
|
||||
try {
|
||||
result = await git(dir, ["stash", "apply", entry.sha]);
|
||||
} catch (e2) {
|
||||
|
||||
+16
-11
@@ -10,7 +10,9 @@
|
||||
* not booked twice; Codex has no hourly rows, so its session counts whole
|
||||
* once a run of the account started in the window, as before. With `ownerId`
|
||||
* only the runs of that user's workspaces count — one user's share of the
|
||||
* account, for the Administration page's user detail.
|
||||
* account, for the Administration page's user detail. A remote client's
|
||||
* company-account session (`remote_sessions`, server/hub/) holds its account
|
||||
* like a run does, from its registration to its end.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -34,25 +36,28 @@ function accountUsage(
|
||||
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
|
||||
const owned = ownerId == null ? "" : "AND lane_id IN (SELECT id FROM lanes WHERE owner_id = ?)";
|
||||
const ownedArgs = ownerId == null ? [] : [ownerId];
|
||||
const remoteOwned = ownerId == null ? "" : "AND user_id = ?";
|
||||
// Every holder of the account: the dashboard's runs, then remote clients'
|
||||
// sessions (a run's columns under the same names).
|
||||
const HOLDERS = `SELECT session_id, started_at, ended_at FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ? ${owned}
|
||||
UNION ALL
|
||||
SELECT session_id, registered_at, ended_at FROM remote_sessions
|
||||
WHERE tool = ? AND account = ? AND COALESCE(ended_at, '9999') >= ? ${remoteOwned}`;
|
||||
const holderArgs = [tool, account, since7d, ...ownedArgs, tool, account, since7d, ...ownedArgs];
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
|
||||
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
|
||||
FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ? ${owned}`
|
||||
FROM (${HOLDERS})`
|
||||
)
|
||||
.all(tool, account, since7d, ...ownedArgs);
|
||||
.all(...holderArgs);
|
||||
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
|
||||
if (runs.length === 0) return out;
|
||||
const runsOf = new Map();
|
||||
for (const r of runs) runsOf.set(r.session_id, [...(runsOf.get(r.session_id) || []), r]);
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ? ${owned})`,
|
||||
[tool, account, since7d, ...ownedArgs]
|
||||
);
|
||||
const rows = hourlyTokenRows(`WHERE s.id IN (SELECT session_id FROM (${HOLDERS}))`, holderArgs);
|
||||
const rules = stmts.listPricing.all();
|
||||
for (const [label, hours] of Object.entries(WINDOWS)) {
|
||||
const sinceHour = hourAgo(hours, now);
|
||||
|
||||
@@ -49,6 +49,52 @@ const { getCodexHome } = require("./codex-home");
|
||||
const { PROXY_CAPABLE } = require("./ai-tools");
|
||||
const { gatewayBase } = require("./proxy-gateway");
|
||||
|
||||
/** The remote client's Company account (server/lib/remote-client.js `HUB_PROXY`). */
|
||||
const COMPANY = proxies.COMPANY_NAME;
|
||||
|
||||
/**
|
||||
* The Company account as a profile `proxyForSpawn` understands. Local mode on
|
||||
* an enrolled machine only (a hub never enrolls itself), Claude only.
|
||||
*/
|
||||
function companyProfile(tool) {
|
||||
if (require("./auth").isServerMode()) {
|
||||
throw Object.assign(new Error("the Company account is for a local AgentHub"), {
|
||||
code: "EBADPROXY",
|
||||
});
|
||||
}
|
||||
if (tool !== "claude") {
|
||||
throw Object.assign(new Error("only Claude sessions can use the Company account"), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
const st = require("./remote-client").status();
|
||||
if (!st.enrolled) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
st.expired
|
||||
? "this machine's hub login expired — run agenthub remote login again"
|
||||
: "this machine is not enrolled with a company hub — run agenthub remote login"
|
||||
),
|
||||
{ code: "ENOHUB" }
|
||||
);
|
||||
}
|
||||
return { name: COMPANY, label: "Company account", company: true, builtin: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Company account's `--settings` file: the hub's gateway and this
|
||||
* machine's device token (its own, in a 0600 file of ours — the pool
|
||||
* credential stays on the hub). Rewritten at every spawn. A pane keeps the
|
||||
* token it started with (Claude Code reads it once — checked 2026-11-04), so
|
||||
* after a new `agenthub remote login` an open pane is resumed, not refreshed.
|
||||
*/
|
||||
function writeCompanySettings() {
|
||||
const env = require("./remote-client").claudeEnv();
|
||||
const file = claudeSettingsPath(COMPANY);
|
||||
writeSecret(file, `${JSON.stringify({ env }, null, 2)}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Server mode: panes reach a proxy only through the key gateway. */
|
||||
const viaGateway = () => require("./auth").isServerMode();
|
||||
|
||||
@@ -191,6 +237,9 @@ function codexOverrides(profile) {
|
||||
*/
|
||||
function resolveProxy(tool, name, ownerId = null) {
|
||||
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
|
||||
// A remote client's Company account: the hub's gateway, Claude only, and
|
||||
// only on a machine enrolled with a hub (server/lib/remote-client.js).
|
||||
if (name === COMPANY) return companyProfile(tool);
|
||||
if (typeof name !== "string") {
|
||||
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
|
||||
}
|
||||
@@ -225,6 +274,9 @@ function resumeProxy(sessionId, ownerId = null) {
|
||||
if (!sessionId) return null;
|
||||
const name = require("./dashboard-runs").proxyForSession(sessionId);
|
||||
if (!name) return null;
|
||||
// A Company-account session resumes on it (refused at spawn if this machine
|
||||
// is no longer enrolled — never silently moved to the user's own login).
|
||||
if (name === COMPANY) return name;
|
||||
const owner = proxies.ownerOfName(name);
|
||||
if (owner != null && owner !== ownerId) return null;
|
||||
try {
|
||||
@@ -244,6 +296,7 @@ function resumeProxy(sessionId, ownerId = null) {
|
||||
*/
|
||||
function proxyForSpawn(tool, profile, paneToken = null, member = null, runId = null) {
|
||||
if (!profile) return { argv: [], env: {} };
|
||||
if (profile.company) return { argv: [`--settings=${writeCompanySettings()}`], env: {} };
|
||||
const gw = viaGateway();
|
||||
if (gw && !paneToken) {
|
||||
// Server mode always mints one (auth.newPaneToken); without it the pane
|
||||
|
||||
@@ -577,6 +577,13 @@ function spawnRun(args) {
|
||||
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||||
const newSessionId =
|
||||
entry.launcher === "claude" && !resumeSessionId ? require("crypto").randomUUID() : null;
|
||||
// A Company-account session (a remote client's, remote-client.js) is told
|
||||
// to the hub before its first request: the gateway serves only a session
|
||||
// this machine registered. A fork's new id arrives with its SessionStart
|
||||
// hook instead (routes/hooks.js → onHookSession).
|
||||
if (proxyProfile?.company && (newSessionId || (resumeSessionId && forkSession !== true))) {
|
||||
require("./remote-client").registerSoon({ sessionId: newSessionId || resumeSessionId, cwd });
|
||||
}
|
||||
const argv = buildArgv(
|
||||
{
|
||||
laneId,
|
||||
@@ -690,6 +697,11 @@ function spawnRun(args) {
|
||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||
// wins, so `"0"` there turns them back off.
|
||||
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
||||
// An admin turned off the export hint for Docs / Slides / Design
|
||||
// (scripts/artifact-export-hint.js reads it; on by default).
|
||||
...(entry.launcher === "claude" && !require("./artifacts").exportsEnabled()
|
||||
? { AGENTHUB_ARTIFACT_EXPORT: "0" }
|
||||
: {}),
|
||||
// Fullscreen claude scrolls by itself, and through tmux it cannot tell it
|
||||
// runs in xterm.js (XTVERSION answers tmux), so it takes ONE line per wheel
|
||||
// report — while xterm.js sends one report per notch (and damps a trackpad
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+19
-5
@@ -7,7 +7,9 @@
|
||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||||
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||||
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
|
||||
* server/lib/session-account.js) — a script's `claude -p` is its runner's. A
|
||||
* session a remote client uploaded is its device user's (`sessions.owner_id`,
|
||||
* server/hub/remote-ingest.js). Agents, events and
|
||||
* token rows belong to their session. A developer sees and changes only what
|
||||
* their own workspaces own; an analyst SEES everything but changes only their
|
||||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||||
@@ -57,8 +59,9 @@ function ownedSessionWhere(uid, col, laneCol) {
|
||||
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
|
||||
OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||
params: [uid, uid, uid],
|
||||
params: [uid, uid, uid, uid],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,8 +279,10 @@ function sessionOwners(sessionId) {
|
||||
UNION
|
||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
|
||||
UNION
|
||||
SELECT owner_id FROM sessions WHERE id = ? AND owner_id IS NOT NULL
|
||||
UNION
|
||||
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
|
||||
).all(String(sessionId), String(sessionId), String(sessionId));
|
||||
).all(String(sessionId), String(sessionId), String(sessionId), String(sessionId));
|
||||
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
|
||||
}
|
||||
|
||||
@@ -317,9 +322,18 @@ function broadcastAudience(type, data) {
|
||||
case "lane_hook_result":
|
||||
case "lane_runtime":
|
||||
return { owners: laneOwners(d.laneId) };
|
||||
// A shared artifact is everyone's news; a private one, its owner's.
|
||||
// A shared artifact is everyone's news; a private one, its owner's and
|
||||
// the people it is shared with by name.
|
||||
case "artifact_changed":
|
||||
return d.shared ? null : { owners: new Set(d.ownerId != null ? [d.ownerId] : []) };
|
||||
return d.shared
|
||||
? null
|
||||
: {
|
||||
owners: new Set(
|
||||
[d.ownerId, ...(Array.isArray(d.recipients) ? d.recipients : [])].filter(
|
||||
(x) => x != null
|
||||
)
|
||||
),
|
||||
};
|
||||
case "update_status":
|
||||
// No payload: each client refetches its own `GET /api/run/interrupted`.
|
||||
case "runs_interrupted":
|
||||
|
||||
@@ -27,8 +27,10 @@ const HOST_OWNER = `COALESCE(
|
||||
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
|
||||
|
||||
// A session no workspace owns: neither stamped onto an owned lane nor held by
|
||||
// a run of one — exactly the complement of scope.js's ownership.
|
||||
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
|
||||
// a run of one, nor owned outright (`owner_id`, a remote client's upload) —
|
||||
// exactly the complement of scope.js's ownership.
|
||||
const UNOWNED = `s.owner_id IS NULL
|
||||
AND (s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
|
||||
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
|
||||
|
||||
|
||||
+26
-5
@@ -126,8 +126,20 @@ function hostTmuxBin() {
|
||||
hostBin = { at: Date.now(), bin };
|
||||
return bin;
|
||||
}
|
||||
let execImpl = (args, opts = EXEC_OPTS) =>
|
||||
execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts);
|
||||
// A cached `/proc/<pid>/exe` is gone once that server exits (the last pane
|
||||
// stopped): ENOENT means "no such server any more", so forget it and run
|
||||
// PATH's tmux, which starts a new one.
|
||||
function runHost(args, opts = EXEC_OPTS) {
|
||||
const bin = args[0] === "-V" ? "tmux" : hostTmuxBin();
|
||||
try {
|
||||
return execFileSync(bin, args, opts);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT" || bin === "tmux") throw err;
|
||||
hostBin = { at: Date.now(), bin: "tmux" };
|
||||
return execFileSync("tmux", args, opts);
|
||||
}
|
||||
}
|
||||
let execImpl = runHost;
|
||||
|
||||
// Linux: the tmux server lives in the cgroup of whatever started it — the
|
||||
// dashboard's systemd unit, or the terminal/IDE scope a dev server runs in —
|
||||
@@ -281,8 +293,7 @@ function __setScopeImpl(fn) {
|
||||
scopeImpl = fn;
|
||||
}
|
||||
function __reset() {
|
||||
execImpl = (args, opts = EXEC_OPTS) =>
|
||||
execFileSync(args[0] === "-V" ? "tmux" : hostTmuxBin(), args, opts);
|
||||
execImpl = runHost;
|
||||
hostBin = { at: 0, bin: "tmux" };
|
||||
scopeImpl = realScopeImpl;
|
||||
accountImpl = (name) => require("./run-as").accountForSession(name);
|
||||
@@ -731,7 +742,13 @@ function leaveCopyMode(name) {
|
||||
function windowSize(name) {
|
||||
try {
|
||||
const [cols, rows] = String(
|
||||
onServerOf(name, ["display-message", "-p", "-t", pane(name), "#{window_width} #{window_height}"])
|
||||
onServerOf(name, [
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
pane(name),
|
||||
"#{window_width} #{window_height}",
|
||||
])
|
||||
)
|
||||
.trim()
|
||||
.split(" ")
|
||||
@@ -1054,4 +1071,8 @@ module.exports = {
|
||||
__setAccountImpl,
|
||||
__settle,
|
||||
__reset,
|
||||
__setHostBin: (bin) => {
|
||||
hostBin = { at: Date.now(), bin };
|
||||
},
|
||||
__hostBin: () => hostBin.bin,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,8 @@ const toolsMore = require("./openapi-extra/tools-more");
|
||||
const opsMore = require("./openapi-extra/ops-more");
|
||||
// The git bar's Branches panel (GET /git/refs + the branch & tag writes).
|
||||
const gitRefs = require("./openapi-extra/git-refs");
|
||||
// The remote-clients architecture: the hub's /api/remote, the local /api/remote-client.
|
||||
const remote = require("./openapi-extra/remote");
|
||||
// Enriched overrides of already-documented endpoints — same operationId, tags,
|
||||
// and request/response `$ref` schemas as the base, with added examples and
|
||||
// richer descriptions. Listed last so they win the merge.
|
||||
@@ -76,7 +78,8 @@ module.exports = combine(
|
||||
overrideCore,
|
||||
overridePricingAlerts,
|
||||
overrideOps,
|
||||
gitRefs
|
||||
gitRefs,
|
||||
remote
|
||||
);
|
||||
// The branch & tag writes join the git action entry in place.
|
||||
gitRefs.extend(module.exports);
|
||||
|
||||
@@ -50,6 +50,16 @@ const schemas = {
|
||||
size: { type: "integer", nullable: true },
|
||||
shared: { type: "boolean" },
|
||||
sharedAt: { type: "string", nullable: true },
|
||||
sharedWith: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { id: { type: "integer" }, name: { type: "string" } },
|
||||
},
|
||||
description:
|
||||
"The people it is shared with by name — listed only to its owner or an admin, else empty.",
|
||||
},
|
||||
sharedWithMe: { type: "boolean", description: "Shared with the caller by name." },
|
||||
installs: { type: "integer" },
|
||||
createdAt: { type: "string" },
|
||||
updatedAt: { type: "string" },
|
||||
@@ -88,12 +98,12 @@ const paths = {
|
||||
summary: "List artifacts",
|
||||
operationId: "listArtifacts",
|
||||
description:
|
||||
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
|
||||
"A developer sees their own rows plus shared ones (to everyone, or to them by name); staff see every row in `hub` scope. `withme`: the rows shared with the caller by name (server mode).",
|
||||
parameters: [
|
||||
{
|
||||
name: "scope",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
|
||||
schema: { type: "string", enum: ["all", "mine", "hub", "withme", "unowned"] },
|
||||
},
|
||||
{
|
||||
name: "kind",
|
||||
@@ -116,7 +126,7 @@ const paths = {
|
||||
operationId: "getArtifactSettings",
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{serverMode, canShare, installsEnabled, canManage}`.",
|
||||
description: "`{serverMode, canShare, installsEnabled, exportsEnabled, canManage}`.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
@@ -125,6 +135,11 @@ const paths = {
|
||||
serverMode: { type: "boolean" },
|
||||
canShare: { type: "boolean" },
|
||||
installsEnabled: { type: "boolean" },
|
||||
exportsEnabled: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Claude panes are asked to save a viewable copy of a Doc / Slides / Design they publish (scripts/artifact-export-hint.js).",
|
||||
},
|
||||
canManage: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
@@ -135,17 +150,46 @@ const paths = {
|
||||
},
|
||||
put: {
|
||||
tags: ["Artifacts"],
|
||||
summary: "Turn installs on or off (admin; any caller in local mode)",
|
||||
summary:
|
||||
"Turn installs, or the copy hint for Docs/Slides, on or off (admin; any caller in local mode)",
|
||||
operationId: "setArtifactSettings",
|
||||
description: "Audited in server mode (Settings → AI tools → Artifacts Hub).",
|
||||
requestBody: json({ installsEnabled: { type: "boolean" } }, ["installsEnabled"]),
|
||||
description:
|
||||
"Audited in server mode (Settings → AI tools → Artifacts Hub). Send either or both; the export switch reaches panes started afterwards.",
|
||||
requestBody: json({
|
||||
installsEnabled: { type: "boolean" },
|
||||
exportsEnabled: { type: "boolean" },
|
||||
}),
|
||||
responses: {
|
||||
200: { description: "`{installsEnabled}`." },
|
||||
200: { description: "`{installsEnabled, exportsEnabled}`." },
|
||||
400: { description: "EBADFIELD" },
|
||||
403: { description: "EADMIN / EBADORIGIN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/artifacts/{id}/copy": {
|
||||
post: {
|
||||
tags: ["Artifacts"],
|
||||
summary: "Take a page's viewable copy from the file it was published from",
|
||||
operationId: "copyArtifactPage",
|
||||
description:
|
||||
"A `page` captured before pages kept a copy: if the HTML file it was published from is still in its workspace, it is snapshotted (read under the pinned-file guards) and the row's `files` name it. Once per row per server run; any viewer may ask and gets only the copy. A row that already has one, or is not a page, is answered as it is.",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
|
||||
404: errs,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/artifacts/people": {
|
||||
get: {
|
||||
tags: ["Artifacts"],
|
||||
summary: "The people the caller may share an artifact with",
|
||||
operationId: "listArtifactPeople",
|
||||
description:
|
||||
"Server mode: every enabled account but the caller, by name only (`{people: [{id, username, name}]}`); empty in local mode.",
|
||||
responses: { 200: { description: "`{people: [{id, username, name}]}`." } },
|
||||
},
|
||||
},
|
||||
"/api/artifacts/candidates": {
|
||||
get: {
|
||||
tags: ["Artifacts"],
|
||||
@@ -326,7 +370,13 @@ const paths = {
|
||||
requestBody: json({
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
shared: { type: "boolean" },
|
||||
shared: { type: "boolean", description: "Share with everyone (the Hub)." },
|
||||
sharedWith: {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
description:
|
||||
"Server mode: the user ids it is shared with by name (replaces the list; up to 100 enabled users; the owner is dropped). Out of its owner's library and shared with nobody, the row is deleted (`removed`).",
|
||||
},
|
||||
source: { type: "string", description: "Plugin only." },
|
||||
market: { type: "string", description: "Plugin only." },
|
||||
}),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* @file OpenAPI fragments for the remote-clients architecture
|
||||
* (docs/plans/remote-clients/README.md): the HUB's routes (server mode only,
|
||||
* server/hub/ — a device's enrollment, session registration and hourly upload
|
||||
* under `/api/remote/device`, and the signed-in views under `/api/remote`) and
|
||||
* a LOCAL AgentHub's `/api/remote-client` (server/routes/remote-client.js).
|
||||
* The gateway `/api/remote/gw/v1/*` is a raw relay of Anthropic's Messages API
|
||||
* and is described in the tag, not as operations. Exports `{ tags, schemas,
|
||||
* paths }` for server/openapi-extra.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "Remote clients",
|
||||
description:
|
||||
"Company hub ↔ local AgentHub. On the hub (server mode): a machine enrolls with its user's password and gets ONE device token (shown once, 90 days, revocable); it registers each Company-account session at start (id + folder, no content) and uploads the new lines of its transcripts hourly. Its Claude Code reaches Anthropic only through `/api/remote/gw/v1/*`, which checks the device token and the registered session id (`x-claude-code-session-id`), swaps in a pool account's OAuth token and relays the answer — the pool credential never leaves the hub. On a local install, `/api/remote-client` shows and manages this machine's enrollment; the device token is never returned.",
|
||||
},
|
||||
];
|
||||
|
||||
const err = { $ref: "#/components/schemas/ErrorResponse" };
|
||||
const json = (schema) => ({ "application/json": { schema } });
|
||||
const ok = (description, schema = { type: "object" }) => ({ description, content: json(schema) });
|
||||
const fail = (description) => ({ description, content: json(err) });
|
||||
|
||||
const schemas = {
|
||||
RemoteDevice: {
|
||||
type: "object",
|
||||
description: "An enrolled machine. Never carries its token or token hash.",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
userId: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
expiresAt: { type: "string", format: "date-time" },
|
||||
lastSeenAt: { type: "string", format: "date-time", nullable: true },
|
||||
lastIp: { type: "string", nullable: true },
|
||||
revokedAt: { type: "string", format: "date-time", nullable: true },
|
||||
active: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
RemoteUploadFile: {
|
||||
type: "object",
|
||||
required: ["path", "from", "to", "data"],
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"`<sessionId>.jsonl` or `<sessionId>/subagents/agent-<id>.jsonl` — nothing else.",
|
||||
},
|
||||
from: {
|
||||
type: "integer",
|
||||
description: "Local byte offset this chunk starts at; must equal the hub's `consumed`.",
|
||||
},
|
||||
to: { type: "integer", description: "Local byte offset after the chunk." },
|
||||
data: {
|
||||
type: "string",
|
||||
description: "Whole JSONL lines, secret-shaped strings already replaced.",
|
||||
},
|
||||
},
|
||||
},
|
||||
RemoteClientStatus: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enrolled: { type: "boolean" },
|
||||
expired: { type: "boolean" },
|
||||
hub: { type: "string" },
|
||||
user: { type: "object", nullable: true },
|
||||
device: { $ref: "#/components/schemas/RemoteDevice" },
|
||||
enrolledAt: { type: "string", nullable: true },
|
||||
retainDays: { type: "integer", nullable: true },
|
||||
uploads: { type: "array", items: { type: "object" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const T = ["Remote clients"];
|
||||
const sid = { name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } };
|
||||
const did = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||||
|
||||
const paths = {
|
||||
"/api/remote/device/login": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteDeviceLogin",
|
||||
summary: "Enroll a machine (hub, server mode)",
|
||||
description:
|
||||
"The user's hub password, throttled like a login. Answers the device token ONCE.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: json({
|
||||
type: "object",
|
||||
required: ["username", "password"],
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string" },
|
||||
deviceName: { type: "string" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: ok("Enrolled", {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string", example: "ahdev_…" },
|
||||
device: { $ref: "#/components/schemas/RemoteDevice" },
|
||||
user: { type: "object" },
|
||||
retainDays: { type: "integer" },
|
||||
},
|
||||
}),
|
||||
401: fail("Wrong username or password"),
|
||||
429: fail("Too many failed logins"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote/device/me": {
|
||||
get: {
|
||||
tags: T,
|
||||
operationId: "remoteDeviceMe",
|
||||
summary: "This device and its user (device token)",
|
||||
|
||||
responses: { 200: ok("The device"), 401: fail("Token missing, expired or revoked") },
|
||||
},
|
||||
},
|
||||
"/api/remote/device/logout": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteDeviceLogout",
|
||||
summary: "Revoke this device's own token",
|
||||
|
||||
responses: { 200: ok("Revoked"), 401: fail("Token missing, expired or revoked") },
|
||||
},
|
||||
},
|
||||
"/api/remote/device/sessions": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteDeviceRegisterSession",
|
||||
summary: "Register a Company-account session at its start (id + folder, no content)",
|
||||
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: json({
|
||||
type: "object",
|
||||
required: ["sessionId", "cwd"],
|
||||
properties: {
|
||||
sessionId: { type: "string", format: "uuid" },
|
||||
cwd: { type: "string" },
|
||||
tool: { type: "string", enum: ["claude"] },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: ok("Registered (again, on a resume)"),
|
||||
400: fail("Not a UUID, not an absolute folder, not Claude"),
|
||||
409: fail("EFOREIGN — the session belongs to another device or is already known"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote/device/sessions/{id}/end": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteDeviceEndSession",
|
||||
summary: "Mark a registered session ended",
|
||||
|
||||
parameters: [sid],
|
||||
responses: { 200: ok("Done") },
|
||||
},
|
||||
},
|
||||
"/api/remote/device/sessions/{id}/files": {
|
||||
get: {
|
||||
tags: T,
|
||||
operationId: "remoteDeviceSessionFiles",
|
||||
summary: "How far the hub holds each file of a registered session",
|
||||
|
||||
parameters: [sid],
|
||||
responses: { 200: ok("Files and offsets"), 404: fail("Not this device's session") },
|
||||
},
|
||||
},
|
||||
"/api/remote/device/upload": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteDeviceUpload",
|
||||
summary: "Upload new transcript lines of a registered session (hourly)",
|
||||
description:
|
||||
"Each chunk must start where the hub stopped; the hub then imports its copy with the local-history importer and stamps the session as this device's user's.",
|
||||
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: json({
|
||||
type: "object",
|
||||
required: ["sessionId"],
|
||||
properties: {
|
||||
sessionId: { type: "string", format: "uuid" },
|
||||
files: { type: "array", items: { $ref: "#/components/schemas/RemoteUploadFile" } },
|
||||
ended: { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: ok("Taken"),
|
||||
400: fail("A path outside the two file shapes, a bad range, half a line"),
|
||||
404: fail("Not this device's session"),
|
||||
409: fail("EOFFSET — `error.expected.consumed` says where to resume"),
|
||||
410: fail("EPURGED — the conversation passed its retention"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote/devices": {
|
||||
get: {
|
||||
tags: T,
|
||||
operationId: "remoteListDevices",
|
||||
summary: "Enrolled machines — your own; staff may pass ?user=",
|
||||
parameters: [{ name: "user", in: "query", schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: ok("Devices", {
|
||||
type: "object",
|
||||
properties: {
|
||||
devices: { type: "array", items: { $ref: "#/components/schemas/RemoteDevice" } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote/devices/{id}/revoke": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteRevokeDevice",
|
||||
summary: "Revoke a device — its owner or an admin, with their own password again (audited)",
|
||||
parameters: [did],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: json({
|
||||
type: "object",
|
||||
required: ["password"],
|
||||
properties: { password: { type: "string" } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: ok("Revoked"),
|
||||
403: fail("Wrong password"),
|
||||
404: fail("No such device of yours"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote/sessions": {
|
||||
get: {
|
||||
tags: T,
|
||||
operationId: "remoteListSessions",
|
||||
summary:
|
||||
"Registered Company-account sessions (device › folder) — your own; staff may pass ?user=",
|
||||
parameters: [{ name: "user", in: "query", schema: { type: "integer" } }],
|
||||
responses: { 200: ok("Sessions") },
|
||||
},
|
||||
},
|
||||
"/api/remote/audit": {
|
||||
get: {
|
||||
tags: T,
|
||||
operationId: "remoteMyAudit",
|
||||
summary: "Who (admin/analyst) opened your sessions",
|
||||
responses: { 200: ok("Audit entries") },
|
||||
},
|
||||
},
|
||||
"/api/remote-client": {
|
||||
get: {
|
||||
tags: T,
|
||||
operationId: "remoteClientStatus",
|
||||
summary: "This local machine's enrollment and what it sent (local AgentHub)",
|
||||
responses: { 200: ok("Status", { $ref: "#/components/schemas/RemoteClientStatus" }) },
|
||||
},
|
||||
},
|
||||
"/api/remote-client/login": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteClientLogin",
|
||||
summary: "Enroll this machine with a hub — the password goes to the hub once and is not kept",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: json({
|
||||
type: "object",
|
||||
required: ["hub", "username", "password"],
|
||||
properties: {
|
||||
hub: { type: "string", example: "http://10.1.13.7:4820" },
|
||||
username: { type: "string" },
|
||||
password: { type: "string" },
|
||||
deviceName: { type: "string" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: ok("Enrolled", { $ref: "#/components/schemas/RemoteClientStatus" }),
|
||||
400: fail("Bad hub address"),
|
||||
401: fail("Wrong username or password"),
|
||||
503: fail("EHUBDOWN — the hub cannot be reached"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/remote-client/logout": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteClientLogout",
|
||||
summary: "Revoke this machine on the hub (best effort) and forget its token",
|
||||
responses: { 200: ok("Logged out") },
|
||||
},
|
||||
},
|
||||
"/api/remote-client/upload": {
|
||||
post: {
|
||||
tags: T,
|
||||
operationId: "remoteClientUpload",
|
||||
summary: "Send the Company-account sessions' new lines now",
|
||||
responses: { 200: ok("Round result"), 400: fail("ENOHUB — not enrolled") },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
+53
-11
@@ -54,16 +54,24 @@ function sendError(res, err) {
|
||||
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
}
|
||||
|
||||
/** Tell clients a row changed: everyone when shared, else its owner (+ staff). */
|
||||
function changed(row) {
|
||||
/**
|
||||
* Tell clients a row changed: everyone when shared, else its owner and the
|
||||
* people it is shared with by name (+ staff).
|
||||
*/
|
||||
function changed(row, before = []) {
|
||||
if (!row) return;
|
||||
broadcast("artifact_changed", {
|
||||
id: row.id,
|
||||
ownerId: row.ownerId ?? row.owner_id ?? null,
|
||||
shared: !!(row.shared === true || row.shared === 1),
|
||||
// `before`: who had it until this change — taken off it, they still hear.
|
||||
recipients: [...new Set([...before, ...artifacts.shareRecipients(row.id)])],
|
||||
});
|
||||
}
|
||||
|
||||
/** Recipients of the artifact a request names, before it changes. */
|
||||
const recipientsOf = (id) => (/^\d+$/.test(String(id)) ? artifacts.shareRecipients(id) : []);
|
||||
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
try {
|
||||
await fn(req, res);
|
||||
@@ -99,6 +107,7 @@ router.get(
|
||||
serverMode: server,
|
||||
canShare: server,
|
||||
installsEnabled: artifacts.installsEnabled(),
|
||||
exportsEnabled: artifacts.exportsEnabled(),
|
||||
canManage: !req.user || req.user.role === "admin",
|
||||
});
|
||||
})
|
||||
@@ -112,24 +121,44 @@ router.put(
|
||||
return res.status(403).json({ error: { code: "EADMIN", message: "admin only" } });
|
||||
}
|
||||
const on = req.body?.installsEnabled;
|
||||
if (typeof on !== "boolean") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADFIELD", message: "installsEnabled: boolean" } });
|
||||
const ex = req.body?.exportsEnabled;
|
||||
if (
|
||||
(on !== undefined && typeof on !== "boolean") ||
|
||||
(ex !== undefined && typeof ex !== "boolean") ||
|
||||
(on === undefined && ex === undefined)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADFIELD", message: "installsEnabled / exportsEnabled: boolean" },
|
||||
});
|
||||
}
|
||||
artifacts.setInstallsEnabled(on);
|
||||
if (on !== undefined) artifacts.setInstallsEnabled(on);
|
||||
if (ex !== undefined) artifacts.setExportsEnabled(ex);
|
||||
if (req.user) {
|
||||
require("../lib/audit").recordAccountChange(
|
||||
req.user.id,
|
||||
null,
|
||||
"PUT",
|
||||
`artifact hub installs ${on ? "on" : "off"}`
|
||||
[
|
||||
on !== undefined ? `artifact hub installs ${on ? "on" : "off"}` : "",
|
||||
ex !== undefined ? `artifact copies of docs/slides ${ex ? "on" : "off"}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ")
|
||||
);
|
||||
}
|
||||
res.json({ installsEnabled: artifacts.installsEnabled() });
|
||||
res.json({
|
||||
installsEnabled: artifacts.installsEnabled(),
|
||||
exportsEnabled: artifacts.exportsEnabled(),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// The people the caller may share an artifact with (server mode): names only.
|
||||
router.get(
|
||||
"/people",
|
||||
wrap((req, res) => res.json({ people: artifacts.people(req) }))
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/candidates",
|
||||
wrap((req, res) => res.json({ items: artifacts.fileCandidates(req.query.laneId) }))
|
||||
@@ -279,13 +308,25 @@ router.patch(
|
||||
"/:id",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const before = recipientsOf(req.params.id);
|
||||
const item = artifacts.update(req, req.params.id, req.body || {});
|
||||
changed(item);
|
||||
changed(item, before);
|
||||
// `removed`: unsharing a Hub-only row deleted it.
|
||||
res.json(item.removed ? { item, removed: true } : { item });
|
||||
})
|
||||
);
|
||||
|
||||
// A page captured before pages kept a copy: take it now from the file it was
|
||||
// published from, if still there. Any viewer may ask; they get the copy.
|
||||
router.post(
|
||||
"/:id/copy",
|
||||
sameOriginGuard,
|
||||
wrap(async (req, res) => {
|
||||
const item = await artifacts.ensurePageCopy(req, req.params.id);
|
||||
res.json({ item });
|
||||
})
|
||||
);
|
||||
|
||||
// Share again: the owner's skill folder on this server becomes the new
|
||||
// version (installers see Update); 409 ENOSOURCE when the server has none —
|
||||
// the client then uploads the folder onto this row (`/skill/upload` + `id`).
|
||||
@@ -303,8 +344,9 @@ router.delete(
|
||||
"/:id",
|
||||
sameOriginGuard,
|
||||
wrap((req, res) => {
|
||||
const before = recipientsOf(req.params.id);
|
||||
const row = artifacts.remove(req, req.params.id);
|
||||
changed(row);
|
||||
changed(row, before);
|
||||
// `kept`: a shared row left the owner's library but stays in the Hub.
|
||||
res.json({ ok: true, kept: !!row.kept });
|
||||
})
|
||||
|
||||
@@ -1675,6 +1675,21 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
} else {
|
||||
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
|
||||
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
// A Company-account pane (a remote client's, lib/remote-client.js)
|
||||
// that got a NEW session id (/clear, a fork) registers it with the
|
||||
// hub before its first request; its end sends the rest of it.
|
||||
if (
|
||||
tool === "claude" &&
|
||||
(hook_type === "SessionStart" || hook_type === "UserPromptSubmit")
|
||||
) {
|
||||
require("../lib/remote-client").onHookSession({
|
||||
sessionId: data.session_id,
|
||||
runId: body.run_id,
|
||||
cwd: data.cwd,
|
||||
});
|
||||
} else if (hook_type === "SessionEnd") {
|
||||
require("../lib/remote-client").onSessionEnd(data.session_id);
|
||||
}
|
||||
// The Linux account it ran as: whose bill it is when no workspace owns it.
|
||||
require("../lib/session-account").stampFromPeer(data.session_id, peer);
|
||||
}
|
||||
|
||||
+21
-1
@@ -33,6 +33,10 @@ const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
const { resumeProxy } = require("../lib/proxy-apply");
|
||||
|
||||
/** HTTP status for a Company-account check that failed (remote-client.js). */
|
||||
const remoteStatus = (err) =>
|
||||
err.code === "EHUBDOWN" ? 503 : err.code === "EDEVICE" || err.status === 401 ? 401 : 400;
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { isLocalRequest } = require("../lib/same-origin");
|
||||
const scope = require("../lib/scope");
|
||||
@@ -3437,6 +3441,22 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
sessionTool = body.tool;
|
||||
}
|
||||
// The Company account (a remote client's, server/lib/remote-client.js)
|
||||
// runs only while the company hub answers and still takes this
|
||||
// machine: ask it now, so a pane never opens onto a dead endpoint.
|
||||
const wantProxy =
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(resumeId, lane.owner_id ?? null);
|
||||
if (wantProxy === require("../lib/ai-proxies").COMPANY_NAME) {
|
||||
try {
|
||||
await require("../lib/remote-client").ping();
|
||||
} catch (err) {
|
||||
return res
|
||||
.status(remoteStatus(err))
|
||||
.json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
}
|
||||
// Overwriting run_id while its child is alive orphans that child: a later
|
||||
// reset would kill and await only the RECORDED run, then `git clean -fd`
|
||||
// the directory the orphan is still writing into. Stop the first run before starting a
|
||||
@@ -3565,7 +3585,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
||||
// EQUOTA (server mode): the workspace owner is over their spend quota.
|
||||
// ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed.
|
||||
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
||||
const status = /^(EBAD|EUNSUPPORTED$|ENOHUB$)/.test(err.code || "")
|
||||
? 400
|
||||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||||
? 409
|
||||
|
||||
+7
-3
@@ -78,11 +78,15 @@ const PREF_KEYS = [
|
||||
"agenthub.markdown.view",
|
||||
"agenthub.workspace.groups",
|
||||
"agenthub.changes.innerRepos",
|
||||
"agenthub.artifacts.folders",
|
||||
];
|
||||
const MAX_VALUE = 8 * 1024;
|
||||
// The sidebar's workspace groups (lane id → group, JSON) outgrow 8 KB long
|
||||
// before any other key would.
|
||||
const MAX_FOR = { "agenthub.workspace.groups": 64 * 1024 };
|
||||
// The sidebar's workspace groups and the Artifacts folders (id → group,
|
||||
// JSON) outgrow 8 KB long before any other key would.
|
||||
const MAX_FOR = {
|
||||
"agenthub.workspace.groups": 64 * 1024,
|
||||
"agenthub.artifacts.folders": 64 * 1024,
|
||||
};
|
||||
|
||||
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
|
||||
function userKey(req) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file remote-client.js (routes)
|
||||
* @description `/api/remote-client` — a LOCAL AgentHub's link to the company
|
||||
* hub (server/lib/remote-client.js): whether this machine is enrolled, which
|
||||
* Company-account sessions it sent and when, and enroll / log out / send now.
|
||||
* Every write is same-origin; the password in `login` goes on to the hub once
|
||||
* and is never stored; no answer ever carries the device token. A hub (server
|
||||
* mode) has none of this: it answers 404 ENOTLOCAL there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const remote = require("../lib/remote-client");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) =>
|
||||
require("../lib/auth").isServerMode()
|
||||
? res
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOTLOCAL", message: "a hub does not enroll with another hub" } })
|
||||
: next()
|
||||
);
|
||||
|
||||
const statusOf = (e) =>
|
||||
e.code === "EHUBDOWN"
|
||||
? 503
|
||||
: e.code === "ETOOMANY"
|
||||
? 429
|
||||
: e.status && e.status >= 400 && e.status < 600
|
||||
? e.status
|
||||
: 400;
|
||||
const send = (res, e) =>
|
||||
res.status(statusOf(e)).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ ...remote.status(), uploads: remote.uploads() });
|
||||
});
|
||||
|
||||
router.post("/login", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const { hub, username, password, deviceName } = req.body || {};
|
||||
res.json(await remote.login({ hub, username, password, deviceName }));
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/logout", sameOriginGuard, async (_req, res) => {
|
||||
try {
|
||||
res.json(await remote.logout());
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/upload", sameOriginGuard, async (_req, res) => {
|
||||
try {
|
||||
if (!remote.status().enrolled) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOHUB", message: "this machine is not enrolled with a hub" } });
|
||||
}
|
||||
res.json(await remote.uploadAll());
|
||||
} catch (e) {
|
||||
send(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+18
-1
@@ -612,7 +612,7 @@ router.get("/tmux", (_req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
router.post("/", async (req, res) => {
|
||||
const body = req.body || {};
|
||||
const laneId = Number.parseInt(String(body.laneId ?? ""), 10);
|
||||
if (!Number.isInteger(laneId)) {
|
||||
@@ -629,6 +629,23 @@ router.post("/", (req, res) => {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
|
||||
// The Company account (remote-client.js) runs only while the hub answers.
|
||||
const wantProxy =
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(
|
||||
typeof body.resumeSessionId === "string" ? body.resumeSessionId : null,
|
||||
lanesLib.getLane(laneId)?.owner_id ?? null
|
||||
);
|
||||
if (wantProxy === require("../lib/ai-proxies").COMPANY_NAME) {
|
||||
try {
|
||||
await require("../lib/remote-client").ping();
|
||||
} catch (err) {
|
||||
const status =
|
||||
err.code === "EHUBDOWN" ? 503 : err.code === "EDEVICE" || err.status === 401 ? 401 : 400;
|
||||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const lane = lanesLib.getLane(laneId);
|
||||
const handle = runs.spawnRun({
|
||||
|
||||
@@ -67,6 +67,8 @@ const OWNED_SESSIONS = `
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||
WHERE l.owner_id IS NOT NULL
|
||||
UNION
|
||||
SELECT s.id, s.owner_id FROM sessions s WHERE s.owner_id IS NOT NULL
|
||||
UNION
|
||||
${require("../lib/session-account").ACCOUNT_OWNED}`;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user