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:
@@ -39,6 +39,16 @@
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Artifact|mcp__claude_ai_.*__batch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/artifact-export-hint.js\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
|
||||
+116
@@ -1133,6 +1133,107 @@ function renderPricingTable(rules) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub remote login|status|logout|upload` — this LOCAL AgentHub's link
|
||||
* to the company hub (server/lib/remote-client.js, through the running
|
||||
* dashboard's /api/remote-client). The password goes to the hub once and is
|
||||
* never stored; only a device token is, which no command prints.
|
||||
*/
|
||||
async function cmdRemote(flags, positional) {
|
||||
const sub = positional[0] || "status";
|
||||
const show = (st) => {
|
||||
if (!st.enrolled) {
|
||||
console.log(
|
||||
st.expired
|
||||
? c.yellow(
|
||||
`This machine's hub login expired (${st.hub}) — run: agenthub remote login ${st.hub}`
|
||||
)
|
||||
: "Not enrolled with a company hub — run: agenthub remote login <hub url>"
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`${c.green("●")} Enrolled with ${c.bold(st.hub)} as ${c.bold(st.user?.username || "?")}`
|
||||
);
|
||||
console.log(
|
||||
c.dim(
|
||||
` device ${st.device?.name} (#${st.device?.id}), valid until ${new Date(st.device?.expiresAt).toLocaleString()}`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
c.dim(
|
||||
" Only sessions started on the Company account send their conversation and tokens to the hub (hourly)."
|
||||
)
|
||||
);
|
||||
};
|
||||
if (sub === "login") {
|
||||
const hub = positional[1];
|
||||
if (!hub) {
|
||||
console.error(
|
||||
c.red("✖ usage: agenthub remote login <hub url> [--user NAME] [--name DEVICE]")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
let username = flags.user ? String(flags.user) : "";
|
||||
if (!username) {
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error(c.red("✖ --user is required when the password comes from stdin"));
|
||||
process.exit(1);
|
||||
}
|
||||
const rl = require("node:readline").createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
username = await new Promise((r) =>
|
||||
rl.question("Hub username: ", (a) => (rl.close(), r(a.trim())))
|
||||
);
|
||||
}
|
||||
const password = await askPassword(`Hub password for ${username}: `);
|
||||
const r = await api(
|
||||
"POST",
|
||||
"/api/remote-client/login",
|
||||
{ hub, username, password, deviceName: flags.name ? String(flags.name) : undefined },
|
||||
{ allowError: true, timeoutMs: 30_000 }
|
||||
);
|
||||
if (r && r.status) {
|
||||
console.error(c.red(`✖ ${r.data?.error?.message || `HTTP ${r.status}`}`));
|
||||
process.exit(1);
|
||||
}
|
||||
show(r);
|
||||
return;
|
||||
}
|
||||
if (sub === "logout") {
|
||||
const r = await post("/api/remote-client/logout", {});
|
||||
console.log(
|
||||
r.wasEnrolled
|
||||
? `${c.green("✔")} Logged out${r.revokedOnHub ? " — the hub revoked this device" : c.yellow(" — the hub could not be reached; the token stops working when it expires or an admin revokes it")}`
|
||||
: "This machine was not enrolled."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "upload") {
|
||||
const r = await post("/api/remote-client/upload", {}, { timeoutMs: 600_000 });
|
||||
console.log(`${c.green("✔")} Sent ${r.bytes} bytes of ${r.sessions} session(s)`);
|
||||
for (const e of r.errors || []) console.log(c.yellow(` ${e.sessionId}: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
if (sub === "status") {
|
||||
const st = await get("/api/remote-client");
|
||||
if (flags.json) return console.log(JSON.stringify(st, null, 2));
|
||||
show(st);
|
||||
for (const u of (st.uploads || []).slice(0, 10)) {
|
||||
console.log(
|
||||
c.dim(
|
||||
` ${u.sessionId.slice(0, 8)} ${String(u.bytes || 0).padStart(9)} B ${u.lastSentAt || "not sent yet"} ${u.cwd || ""}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(c.red(`✖ unknown: agenthub remote ${sub} (login | status | logout | upload)`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function cmdPricing(flags, positional) {
|
||||
const sub = positional[0];
|
||||
if (sub === "set" && positional[1]) {
|
||||
@@ -2934,6 +3035,19 @@ const COMMAND_GROUPS = [
|
||||
["remote-sources rm <id>", "[--purge]", "Remove a source (--purge also deletes its data)"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Company hub",
|
||||
[
|
||||
[
|
||||
"remote login <hub url>",
|
||||
"[--user NAME --name DEVICE]",
|
||||
"Enroll this machine (Company account)",
|
||||
],
|
||||
["remote status", "[--json]", "Enrollment and what was sent to the hub"],
|
||||
["remote upload", "", "Send the Company-account sessions' new lines now"],
|
||||
["remote logout", "", "Revoke this machine on the hub and forget it"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Workspaces",
|
||||
[
|
||||
@@ -4549,6 +4663,8 @@ async function runCommand(argv) {
|
||||
case "remote-sources":
|
||||
case "remotes":
|
||||
return cmdRemoteSources(flags, positional);
|
||||
case "remote":
|
||||
return cmdRemote(flags, positional);
|
||||
case "pricing":
|
||||
return cmdPricing(flags, positional);
|
||||
case "import":
|
||||
|
||||
Generated
+247
@@ -25,6 +25,7 @@
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
@@ -1194,6 +1195,241 @@
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz",
|
||||
"integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "1.1.0",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.1.0",
|
||||
"@napi-rs/canvas-darwin-x64": "1.1.0",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.1.0",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.1.0",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.1.0",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.1.0",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.1.0",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.1.0",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz",
|
||||
"integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz",
|
||||
"integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz",
|
||||
"integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz",
|
||||
"integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz",
|
||||
"integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz",
|
||||
"integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz",
|
||||
"integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz",
|
||||
"integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz",
|
||||
"integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz",
|
||||
"integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -5406,6 +5642,17 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.4.299",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
|
||||
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
|
||||
"engines": {
|
||||
"node": ">=22.13.0 || >=24"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
|
||||
@@ -132,6 +132,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
];
|
||||
/** Only a hub (server mode) has remote clients to alert about. */
|
||||
const SERVER_RULE_TYPES: AlertRuleType[] = ["remote_unmonitored"];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
|
||||
@@ -243,6 +245,8 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
if (form.budget_tool) config.tool = form.budget_tool;
|
||||
return config;
|
||||
}
|
||||
case "remote_unmonitored":
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +282,8 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
|
||||
period: t(`periods.${c.period ?? "month"}`),
|
||||
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||
});
|
||||
case "remote_unmonitored":
|
||||
return t("ruleDesc.remoteUnmonitored");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +292,8 @@ export function AlertsNotifications() {
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const ruleTypes = local ? RULE_TYPES : [...RULE_TYPES, ...SERVER_RULE_TYPES];
|
||||
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||
|
||||
// Rules
|
||||
@@ -613,7 +620,7 @@ export function AlertsNotifications() {
|
||||
title={editingId ? t("rules.typeFixed") : undefined}
|
||||
className="select w-full"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
{ruleTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{t(`ruleTypes.${type}`)}
|
||||
</option>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file PdfView.tsx
|
||||
* @description A PDF drawn by pdf.js onto canvases, page after page — the
|
||||
* Artifacts preview's PDF viewer. The browser's own viewer cannot run inside
|
||||
* the sandbox the preview keeps (it blocks plugins), and opening a shared PDF
|
||||
* on the dashboard's origin would be the risk the sandbox exists for; pdf.js
|
||||
* only DRAWS the file (no scripting sandbox loaded, no XFA, no links
|
||||
* followed), so nothing in it runs. pdf.js and its worker are loaded the
|
||||
* first time a PDF is opened (a separate chunk), never with the dashboard.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
/** Pages drawn at most: a 500-page export would otherwise hold every canvas. */
|
||||
const MAX_PAGES = 200;
|
||||
const MAX_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
async function loadPdfJs() {
|
||||
// The legacy build: the modern one needs `Promise.try` and friends, which a
|
||||
// browser a year old lacks — the PDF would then just fail to open.
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
import.meta.url
|
||||
).toString();
|
||||
}
|
||||
return pdfjs;
|
||||
}
|
||||
|
||||
export function PdfView({ url, title }: { url: string; title: string }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "failed">("loading");
|
||||
const [pages, setPages] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let destroy: (() => void) | null = null;
|
||||
setState("loading");
|
||||
(async () => {
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
if (data.byteLength > MAX_BYTES) throw new Error("too big");
|
||||
const pdfjs = await loadPdfJs();
|
||||
const task = pdfjs.getDocument({
|
||||
data,
|
||||
enableXfa: false,
|
||||
disableAutoFetch: true,
|
||||
});
|
||||
destroy = () => void task.destroy();
|
||||
const doc = await task.promise;
|
||||
if (!live || !box.current) return;
|
||||
setPages(doc.numPages);
|
||||
const width = Math.max(320, box.current.clientWidth - 32);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
box.current.replaceChildren();
|
||||
for (let n = 1; n <= Math.min(doc.numPages, MAX_PAGES); n++) {
|
||||
const page = await doc.getPage(n);
|
||||
if (!live || !box.current) return;
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const viewport = page.getViewport({ scale: (width / base.width) * ratio });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
|
||||
canvas.className = "mx-auto mb-3 block bg-white shadow";
|
||||
canvas.setAttribute("aria-label", t("pdf.page", { n }));
|
||||
box.current.appendChild(canvas);
|
||||
await page.render({ canvas, viewport }).promise;
|
||||
if (n === 1 && live) setState("ready");
|
||||
}
|
||||
})().catch(() => live && setState("failed"));
|
||||
return () => {
|
||||
live = false;
|
||||
destroy?.();
|
||||
};
|
||||
}, [url, t]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto bg-surface-2 p-4" data-testid="pdf-view">
|
||||
{state === "loading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-fg-muted">
|
||||
<Loader2 className="h-5 w-5 animate-spin" aria-label={t("loading")} />
|
||||
</div>
|
||||
)}
|
||||
{state === "failed" && (
|
||||
<p className="p-6 text-center text-sm text-fg-secondary">{t("pdf.failed")}</p>
|
||||
)}
|
||||
<div ref={box} role="document" aria-label={title} />
|
||||
{pages > MAX_PAGES && (
|
||||
<p className="py-2 text-center text-xs text-fg-muted">
|
||||
{t("pdf.more", { shown: MAX_PAGES, total: pages })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file Tests for PdfView: pdf.js (mocked — jsdom has no canvas) draws one
|
||||
* canvas per page, scripting off, and a PDF that cannot be read says so.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../i18n";
|
||||
|
||||
const getDocument = vi.fn();
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
GlobalWorkerOptions: { workerSrc: "" },
|
||||
getDocument: (...a: unknown[]) => getDocument(...a),
|
||||
}));
|
||||
|
||||
import { PdfView } from "../PdfView";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
beforeEach(() => {
|
||||
getDocument.mockReset();
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response(new Uint8Array([37, 80, 68, 70]))
|
||||
) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("PdfView", () => {
|
||||
it("draws every page on its own canvas, with XFA off", async () => {
|
||||
const render1 = vi.fn(() => ({ promise: Promise.resolve() }));
|
||||
getDocument.mockReturnValue({
|
||||
destroy: vi.fn(),
|
||||
promise: Promise.resolve({
|
||||
numPages: 3,
|
||||
getPage: async () => ({
|
||||
getViewport: ({ scale }: { scale: number }) => ({
|
||||
width: 600 * scale,
|
||||
height: 800 * scale,
|
||||
}),
|
||||
render: render1,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const { container } = render(<PdfView url="/api/artifacts/1/raw?path=a.pdf" title="a.pdf" />);
|
||||
await waitFor(() => expect(container.querySelectorAll("canvas")).toHaveLength(3));
|
||||
expect(render1).toHaveBeenCalledTimes(3);
|
||||
expect(getDocument.mock.calls[0]![0]).toMatchObject({ enableXfa: false });
|
||||
expect(screen.getByLabelText("Page 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when the file cannot be read", async () => {
|
||||
getDocument.mockReturnValue({ destroy: vi.fn(), promise: Promise.reject(new Error("bad")) });
|
||||
render(<PdfView url="/x" title="x.pdf" />);
|
||||
expect(await screen.findByText(/could not be shown/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,7 @@ function HomeBody({
|
||||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||
const profile = proxyCapable
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin && !r.company) ?? null)
|
||||
: null;
|
||||
const proxyModels: ModelChoice[] | undefined = profile
|
||||
? [
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @file RemoteDevices.tsx
|
||||
* @description The hub's view of remote clients (server mode, server/hub/):
|
||||
* a user's enrolled machines with Revoke (the viewer's own password again —
|
||||
* the server checks it and writes the audit entry), and their Company-account
|
||||
* sessions grouped device › folder with what the gateway counted. Shown in
|
||||
* Settings for the signed-in user's own machines and on the Users page's user
|
||||
* detail for an admin/analyst (`userId`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Laptop, ShieldAlert } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
const fmt = (n: number) =>
|
||||
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
|
||||
|
||||
export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevoke: boolean }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [devices, setDevices] = useState<RemoteDevice[] | null>(null);
|
||||
const [sessions, setSessions] = useState<RemoteSessionRow[]>([]);
|
||||
const [revoking, setRevoking] = useState<RemoteDevice | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.remoteHub
|
||||
.devices(userId)
|
||||
.then((r) => setDevices(r.devices))
|
||||
.catch(() => setDevices([]));
|
||||
api.remoteHub
|
||||
.sessions(userId)
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [userId]);
|
||||
useEffect(load, [load]);
|
||||
|
||||
// device › folder › sessions, the newest first (the API's order).
|
||||
const groups = useMemo(() => {
|
||||
const byDevice = new Map<string, Map<string, RemoteSessionRow[]>>();
|
||||
for (const s of sessions) {
|
||||
const d = s.deviceName;
|
||||
if (!byDevice.has(d)) byDevice.set(d, new Map());
|
||||
const folders = byDevice.get(d)!;
|
||||
const f = s.cwd || "—";
|
||||
if (!folders.has(f)) folders.set(f, []);
|
||||
folders.get(f)!.push(s);
|
||||
}
|
||||
return [...byDevice.entries()];
|
||||
}, [sessions]);
|
||||
|
||||
if (devices === null) return null;
|
||||
return (
|
||||
<div className="space-y-4" data-testid="remote-devices">
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.noDevices")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs">
|
||||
<Laptop className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="font-medium text-fg-primary">{d.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{d.revokedAt
|
||||
? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) })
|
||||
: d.active
|
||||
? t("companyHub.validUntil", { time: formatDateTime(d.expiresAt) })
|
||||
: t("companyHub.expired")}
|
||||
</span>
|
||||
{d.lastSeenAt && (
|
||||
<span className="text-fg-muted">
|
||||
·{" "}
|
||||
{t("companyHub.lastSeen", {
|
||||
time: formatDateTime(d.lastSeenAt),
|
||||
ip: d.lastIp || "?",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{canRevoke && d.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost ml-auto h-7 px-2 text-xs text-status-danger"
|
||||
onClick={() => setRevoking(d)}
|
||||
>
|
||||
{t("companyHub.revoke")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-fg-secondary">{t("companyHub.sessions")}</h4>
|
||||
{groups.map(([device, folders]) => (
|
||||
<div key={device} className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-fg-primary">{device}</div>
|
||||
{[...folders.entries()].map(([folder, rows]) => (
|
||||
<div key={folder} className="ml-3 space-y-1">
|
||||
<div className="truncate font-mono text-[11px] text-fg-muted" title={folder}>
|
||||
{folder}
|
||||
</div>
|
||||
<ul className="ml-3 space-y-0.5">
|
||||
{rows.map((s) => (
|
||||
<li
|
||||
key={s.sessionId}
|
||||
className="flex flex-wrap items-center gap-2 text-[11px]"
|
||||
>
|
||||
{s.purgedAt ? (
|
||||
<span className="text-fg-muted">
|
||||
{s.title || s.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={`/sessions/${s.sessionId}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{s.title || s.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
{s.unmonitored ? (
|
||||
<span className="inline-flex items-center gap-1 text-status-warning">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
{t("companyHub.unmonitored")}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-fg-muted">
|
||||
{t("companyHub.sessionLine", {
|
||||
requests: s.requests,
|
||||
input: fmt(s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens),
|
||||
output: fmt(s.outputTokens),
|
||||
account: s.account || "—",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
· {formatDateTime(s.lastRequestAt || s.registeredAt)}
|
||||
</span>
|
||||
{s.purgedAt && (
|
||||
<span className="text-fg-muted">· {t("companyHub.purged")}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revoking && (
|
||||
<RevokeDialog
|
||||
device={revoking}
|
||||
onClose={() => setRevoking(null)}
|
||||
onDone={() => {
|
||||
setRevoking(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RevokeDialog({
|
||||
device,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
device: RemoteDevice;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async () => {
|
||||
if (!password || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.remoteHub.revoke(device.id, password);
|
||||
toast.success(t("companyHub.revoked", { name: device.name }));
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("companyHub.revokeTitle", { name: device.name })}
|
||||
message={t("companyHub.revokeHint")}
|
||||
confirmLabel={t("companyHub.revoke")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!password}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-2"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("companyHub.yourPassword")}
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("companyHub.yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowUp,
|
||||
@@ -145,6 +145,20 @@ const storedSide = () => {
|
||||
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
/** When the conversation's last turn ended — its newest reply's timestamp
|
||||
* when that reply finished (`end_turn`, the "Done in …" line) and no prompt
|
||||
* came after it — else null. */
|
||||
export function endedTurnAt(messages: readonly TranscriptMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.type === "session_event") continue;
|
||||
const sender = m.sender ?? (m.type === "assistant" ? "assistant" : "user");
|
||||
if (sender === "assistant") return m.stop_reason === "end_turn" ? m.timestamp : null;
|
||||
if (sender === "user" && m.content.some((c) => c.type === "text")) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `value`, except that a drop to false shows only once it has lasted
|
||||
* `holdMs`; a change of `release` (Stop pressed) lets it drop at once. */
|
||||
export function useHeldTrue(value: boolean, holdMs: number, release: unknown): boolean {
|
||||
@@ -172,13 +186,7 @@ export function contextWindow(model: string | null): number | null {
|
||||
|
||||
/** How full the context is: a ring and a percentage, amber then red as
|
||||
* Claude Code's auto-compact comes near. */
|
||||
function ContextMeter({
|
||||
tokens,
|
||||
model,
|
||||
}: {
|
||||
tokens: number;
|
||||
model: string | null;
|
||||
}) {
|
||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||
const { t } = useTranslation("run");
|
||||
const size = contextWindow(model);
|
||||
if (!size || !tokens) return null;
|
||||
@@ -638,6 +646,9 @@ export const PaneChat = memo(function PaneChat({
|
||||
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it,
|
||||
// and its finished reply ends the turn (below).
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// The main agent is "working" from a prompt's submit to its Stop hook, and
|
||||
// waits on the user from a Notification (a permission prompt) to the next hook.
|
||||
const [working, setWorking] = useState(false);
|
||||
@@ -741,7 +752,20 @@ export const PaneChat = memo(function PaneChat({
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId, onScreen]);
|
||||
const busy = useHeldTrue(working || screenBusy, BUSY_HOLD_MS, stops);
|
||||
// The reply the chat already shows as "Done in …" ends the turn, hook or
|
||||
// not: a turn may end with no hook at all (no Stop, no SubagentStop) until
|
||||
// the idle Notification a minute later. Only a reply that is not the one
|
||||
// already there when `working` began counts, so a new prompt whose line the
|
||||
// transcript has not caught up with still shows Stop.
|
||||
const endedAt = useMemo(() => endedTurnAt(convMessages), [convMessages]);
|
||||
const endedRef = useRef(endedAt);
|
||||
endedRef.current = endedAt;
|
||||
const [staleEnd, setStaleEnd] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (working) setStaleEnd(endedRef.current);
|
||||
}, [working]);
|
||||
const turnOver = endedAt !== null && endedAt !== staleEnd;
|
||||
const busy = useHeldTrue((working && !turnOver) || screenBusy, BUSY_HOLD_MS, stops);
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
@@ -789,8 +813,6 @@ export const PaneChat = memo(function PaneChat({
|
||||
() => showTerminal(runId, sessionId, run.tool),
|
||||
[runId, sessionId, run.tool]
|
||||
);
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it.
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// An artifact card opens in this chat's own panel, like Claude Desktop's —
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
@@ -839,6 +861,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
if (!text || uploading) return;
|
||||
const out = promptForPane(prompt, images, claude);
|
||||
if (!(await command(out.text, out.files))) return;
|
||||
// A new turn: the reply now on screen is the previous one's.
|
||||
setStaleEnd(endedRef.current);
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||
* for the next session (`agenthub.session.proxy`; the pre-rename
|
||||
* `agenthub.session.router` is still read when that is unset).
|
||||
* `agenthub.session.router` is still read when that is unset). On a local
|
||||
* machine enrolled with the company hub (`/api/remote-client`) the list ends
|
||||
* with the Company account (`agenthub.company`): a Claude session there runs through
|
||||
* the hub's gateway, and only such sessions are reported to the hub.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -39,14 +42,30 @@ export function useSessionProxy(): [string, (v: string) => void] {
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** The saved profiles (Native included), null until they arrive. */
|
||||
/** The remote client's Company account, as the list shows it. */
|
||||
const COMPANY: AiProxy = {
|
||||
name: "agenthub.company",
|
||||
label: "Company account",
|
||||
builtin: false,
|
||||
company: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
model: "",
|
||||
models: {},
|
||||
};
|
||||
|
||||
/** The saved profiles (Native included, the Company account last when enrolled), null until they arrive. */
|
||||
export function useAiProxies(): AiProxy[] | null {
|
||||
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiProxies
|
||||
.list()
|
||||
.then((r) => alive && setProxies(r.items))
|
||||
// A hub (server mode) answers 404 here, and a failure only hides the entry.
|
||||
const enrolled = Promise.resolve()
|
||||
.then(() => api.remoteClient.status())
|
||||
.then((r) => r.enrolled)
|
||||
.catch(() => false);
|
||||
Promise.all([api.aiProxies.list().then((r) => r.items), enrolled])
|
||||
.then(([items, company]) => alive && setProxies(company ? [...items, COMPANY] : items))
|
||||
.catch(() => alive && setProxies([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -82,12 +101,19 @@ export function ProxyPicker({
|
||||
// A remembered profile that has since been deleted falls back to native,
|
||||
// or the start would be refused for a proxy the user cannot see.
|
||||
useEffect(() => {
|
||||
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
|
||||
}, [proxies, value, onChange]);
|
||||
if (!proxies || value === "native") return;
|
||||
const pick = proxies.find((r) => r.name === value);
|
||||
if (!pick || (pick.company && tool !== "claude")) onChange("native");
|
||||
}, [proxies, value, onChange, tool]);
|
||||
|
||||
const options =
|
||||
capable && proxies?.length
|
||||
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||
? proxies
|
||||
.filter((r) => !r.company || tool === "claude")
|
||||
.map((r) => ({
|
||||
value: r.name,
|
||||
label: r.builtin ? native : r.company ? t("proxy.company") : r.label,
|
||||
}))
|
||||
: [{ value: "native", label: native }];
|
||||
return (
|
||||
<div data-testid="proxy-picker">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneChat, contextWindow } from "../PaneChat";
|
||||
import { PaneChat, contextWindow, endedTurnAt } from "../PaneChat";
|
||||
import { api } from "../../../lib/api";
|
||||
import {
|
||||
answerRunMenu,
|
||||
@@ -33,16 +33,21 @@ import {
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
// What the stand-in conversation reports as on screen when asked.
|
||||
const conv = vi.hoisted(() => ({ messages: [] as unknown[] }));
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
ConversationView: ({
|
||||
onOpenFile,
|
||||
onNotStarted,
|
||||
onMessages,
|
||||
}: {
|
||||
onOpenFile?: (path: string) => void;
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
onMessages?: (messages: never[]) => void;
|
||||
}) => (
|
||||
<div data-testid="conversation">
|
||||
<button data-testid="conv-messages" onClick={() => onMessages?.(conv.messages as never[])} />
|
||||
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||
{/* The session has no row yet: the CLI has not started it. */}
|
||||
@@ -225,6 +230,41 @@ describe("PaneChat — Stop", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("drops Stop once the conversation shows the finished reply, with no hook at all", async () => {
|
||||
// Claude Code may end a turn with no Stop and no SubagentStop: main stays
|
||||
// "working" until the idle Notification a minute later.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(api.agents.list).mockResolvedValue({
|
||||
agents: [{ type: "main", status: "working" }],
|
||||
} as never);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
conv.messages = [
|
||||
{
|
||||
type: "user",
|
||||
timestamp: "2026-11-04T05:00:32Z",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-11-04T05:01:25Z",
|
||||
stop_reason: "end_turn",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
];
|
||||
fireEvent.click(screen.getByTestId("conv-messages"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2600);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
conv.messages = [];
|
||||
vi.mocked(api.agents.list).mockResolvedValue({ agents: [] } as never);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
|
||||
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
@@ -748,3 +788,24 @@ describe("PaneChat — a Codex terminal", () => {
|
||||
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("endedTurnAt", () => {
|
||||
const user = (text: string) => ({
|
||||
type: "user",
|
||||
timestamp: "u",
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
const reply = (stop_reason: string, timestamp = "a") => ({
|
||||
type: "assistant",
|
||||
timestamp,
|
||||
stop_reason,
|
||||
content: [{ type: "text", text: "x" }],
|
||||
});
|
||||
const toolResult = { type: "user", timestamp: "r", content: [{ type: "tool_result" }] };
|
||||
it("is the finished reply's time, and null mid-turn or after a new prompt", () => {
|
||||
expect(endedTurnAt([user("hi"), reply("end_turn", "t1")] as never)).toBe("t1");
|
||||
expect(endedTurnAt([user("hi"), reply("tool_use"), toolResult] as never)).toBeNull();
|
||||
expect(endedTurnAt([user("hi"), reply("end_turn"), user("again")] as never)).toBeNull();
|
||||
expect(endedTurnAt([] as never)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
},
|
||||
aiProxies: { list: vi.fn() },
|
||||
remoteClient: { status: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -40,6 +41,55 @@ beforeEach(() => {
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native, nine] } as never);
|
||||
vi.mocked(api.remoteClient.status).mockReset().mockResolvedValue({ enrolled: false });
|
||||
});
|
||||
|
||||
describe("the Company account", () => {
|
||||
it("is listed last only on a machine enrolled with the hub", async () => {
|
||||
vi.mocked(api.remoteClient.status).mockResolvedValue({ enrolled: true });
|
||||
const { result } = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
expect(result.current!.map((p) => p.name)).toEqual(["native", "9router", "agenthub.company"]);
|
||||
expect(result.current![2]!.company).toBe(true);
|
||||
});
|
||||
|
||||
it("is absent when not enrolled or when the status call fails (a hub answers 404)", async () => {
|
||||
const off = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(off.result.current).not.toBeNull());
|
||||
expect(off.result.current!.map((p) => p.name)).toEqual(["native", "9router"]);
|
||||
vi.mocked(api.remoteClient.status).mockRejectedValue(new Error("404"));
|
||||
const err = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(err.result.current).not.toBeNull());
|
||||
expect(err.result.current!.map((p) => p.name)).toEqual(["native", "9router"]);
|
||||
});
|
||||
|
||||
it("is offered for Claude only; a remembered pick on another tool falls back to Native", async () => {
|
||||
const company = {
|
||||
name: "agenthub.company",
|
||||
label: "Company account",
|
||||
builtin: false,
|
||||
company: true,
|
||||
} as AiProxy;
|
||||
render(
|
||||
<ProxyPicker
|
||||
tool="claude"
|
||||
proxies={[native, company]}
|
||||
value="agenthub.company"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("Company account")).toBeInTheDocument();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ProxyPicker
|
||||
tool="codex"
|
||||
proxies={[native, company]}
|
||||
value="agenthub.company"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProxyPicker", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
|
||||
* admin's server-wide switch that allows or blocks installing a shared skill
|
||||
* or plugin from the Hub into a user's own account (`GET/PUT
|
||||
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* admin's server-wide switches: installing a shared skill or plugin from the
|
||||
* Hub into a user's own account, and asking Claude to save a viewable copy of
|
||||
* a Doc / Slides / Design it publishes (`GET/PUT /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* or for anyone the server does not let manage it (`canManage`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -24,9 +24,10 @@ export function ArtifactsHubSettings() {
|
||||
.catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
if (!settings?.serverMode || !settings.canManage) return null;
|
||||
if (!settings?.canManage) return null;
|
||||
return (
|
||||
<SettingsSection id="artifacts-hub" title={t("title")}>
|
||||
{settings.serverMode && (
|
||||
<SettingsSwitchRow
|
||||
label={t("installsSwitch")}
|
||||
description={t("installsSwitchHint")}
|
||||
@@ -40,6 +41,20 @@ export function ArtifactsHubSettings() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingsSwitchRow
|
||||
label={t("exportsSwitch")}
|
||||
description={t("exportsSwitchHint")}
|
||||
checked={settings.exportsEnabled !== false}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setExportsEnabled(v);
|
||||
setSettings({ ...settings, exportsEnabled: r.exportsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* @file CompanyHubSettings.tsx
|
||||
* @description Settings → AI tools → Company hub (docs/plans/remote-clients).
|
||||
* On a LOCAL install: enroll this machine with the company hub (hub address +
|
||||
* the user's hub password, which goes to the hub once and is not kept — the
|
||||
* same as `agenthub remote login`), what is and is not sent, the
|
||||
* Company-account sessions sent and when, Send now and Log out
|
||||
* (`/api/remote-client`). On the HUB (server mode, signed in): the user's own
|
||||
* enrolled machines with Revoke, their Company-account sessions, and who
|
||||
* (admin/analyst) opened their sessions (`/api/remote/*`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Building2, LogOut, Upload } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type RemoteClientStatus } from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function CompanyHubSettings() {
|
||||
const { local } = usePermissions();
|
||||
return local ? <LocalHub /> : <HubSide />;
|
||||
}
|
||||
|
||||
function LocalHub() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [st, setSt] = useState<RemoteClientStatus | null>(null);
|
||||
const [hub, setHub] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.remoteClient
|
||||
.status()
|
||||
.then(setSt)
|
||||
.catch(() => setSt(null));
|
||||
}, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const enroll = () =>
|
||||
run(async () => {
|
||||
setSt(await api.remoteClient.login({ hub, username, password }));
|
||||
setPassword("");
|
||||
toast.success(t("companyHub.enrolledToast"));
|
||||
load();
|
||||
});
|
||||
const logout = async () => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("companyHub.logoutTitle"),
|
||||
message: t("companyHub.logoutHint"),
|
||||
confirmLabel: t("companyHub.logout"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await run(async () => {
|
||||
await api.remoteClient.logout();
|
||||
load();
|
||||
});
|
||||
};
|
||||
const sendNow = () =>
|
||||
run(async () => {
|
||||
const r = await api.remoteClient.upload();
|
||||
toast.success(
|
||||
t("companyHub.sentToast", { bytes: formatBytes(r.bytes), sessions: r.sessions })
|
||||
);
|
||||
if (r.errors.length) setError(r.errors.map((e) => e.message).join("\n"));
|
||||
load();
|
||||
});
|
||||
|
||||
if (!st) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="company-hub"
|
||||
title={t("companyHub.title")}
|
||||
description={t("companyHub.description")}
|
||||
>
|
||||
<ul className="mb-3 list-disc space-y-0.5 pl-5 text-[11px] text-fg-muted">
|
||||
<li>{t("companyHub.ruleSent")}</li>
|
||||
<li>{t("companyHub.ruleNotSent")}</li>
|
||||
<li>{t("companyHub.ruleKey")}</li>
|
||||
<li>{t("companyHub.ruleOffice")}</li>
|
||||
</ul>
|
||||
{st.enrolled ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Building2 className="h-4 w-4 text-accent" />
|
||||
<span className="text-fg-primary">
|
||||
{t("companyHub.enrolledAs", { hub: st.hub, user: st.user?.username || "?" })}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
· {st.device?.name} ·{" "}
|
||||
{t("companyHub.validUntil", { time: formatDateTime(st.device?.expiresAt || "") })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary h-8 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => void sendNow()}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("companyHub.sendNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost h-8 text-xs"
|
||||
disabled={busy}
|
||||
onClick={() => void logout()}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t("companyHub.logout")}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">
|
||||
{t("companyHub.sentTitle")}
|
||||
</h4>
|
||||
{st.uploads && st.uploads.length > 0 ? (
|
||||
<ul
|
||||
className="divide-y divide-border rounded-lg border border-border"
|
||||
data-testid="company-uploads"
|
||||
>
|
||||
{st.uploads.map((u) => (
|
||||
<li
|
||||
key={u.sessionId}
|
||||
className="flex flex-wrap items-center gap-2 px-3 py-1.5 text-[11px]"
|
||||
>
|
||||
<Link to={`/sessions/${u.sessionId}`} className="text-accent hover:underline">
|
||||
{u.title || u.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="truncate font-mono text-fg-muted">{u.cwd}</span>
|
||||
<span className="ml-auto text-fg-muted">
|
||||
{u.lastSentAt
|
||||
? t("companyHub.sentLine", {
|
||||
bytes: formatBytes(u.bytes || 0),
|
||||
time: formatDateTime(u.lastSentAt),
|
||||
})
|
||||
: t("companyHub.notSentYet")}
|
||||
{u.ended ? ` · ${t("companyHub.ended")}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.nothingSent")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid max-w-md gap-2"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && hub && username && password) {
|
||||
e.preventDefault();
|
||||
void enroll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{st.expired && (
|
||||
<p className="text-xs text-status-warning">{t("companyHub.expiredHint")}</p>
|
||||
)}
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder="http://10.1.13.7:4820"
|
||||
value={hub}
|
||||
onChange={(e) => setHub(e.target.value)}
|
||||
aria-label={t("companyHub.hubAddress")}
|
||||
/>
|
||||
<input
|
||||
className="input text-sm"
|
||||
placeholder={t("companyHub.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
aria-label={t("companyHub.username")}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input text-sm"
|
||||
placeholder={t("companyHub.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("companyHub.password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary h-8 w-fit text-xs"
|
||||
disabled={busy || !hub || !username || !password}
|
||||
onClick={() => void enroll()}
|
||||
>
|
||||
{t("companyHub.enroll")}
|
||||
</button>
|
||||
<p className="text-[11px] text-fg-muted">{t("companyHub.cliHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function HubSide() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [audit, setAudit] = useState<
|
||||
| {
|
||||
id: number;
|
||||
at: string;
|
||||
sessionId: string;
|
||||
reader: string | null;
|
||||
readerDisplay: string | null;
|
||||
}[]
|
||||
| null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
api.remoteHub
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit(null));
|
||||
}, []);
|
||||
// A hub without the remote routes (an older server) answers 404: show nothing.
|
||||
if (audit === null) return null;
|
||||
return (
|
||||
<SettingsSection
|
||||
id="company-hub"
|
||||
title={t("companyHub.hubTitle")}
|
||||
description={t("companyHub.hubDescription")}
|
||||
>
|
||||
<RemoteDevices canRevoke />
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">{t("companyHub.whoRead")}</h4>
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("companyHub.nobodyRead")}</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-[11px]" data-testid="company-audit">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="text-fg-muted">
|
||||
{formatDateTime(a.at)} ·{" "}
|
||||
<span className="text-fg-primary">{a.readerDisplay || a.reader || "?"}</span> ·{" "}
|
||||
<Link to={`/sessions/${a.sessionId}`} className="text-accent hover:underline">
|
||||
{a.sessionId.slice(0, 8)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
@@ -38,6 +38,12 @@ import {
|
||||
type ToolProfilesTool,
|
||||
} from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
profileKey,
|
||||
readHiddenProfiles,
|
||||
toggleHidden,
|
||||
writeHiddenProfiles,
|
||||
} from "../../lib/hiddenProfilesStorage";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
@@ -129,7 +135,7 @@ export function ToolProfilesSettings() {
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
@@ -475,7 +481,7 @@ function ActiveBadge() {
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
@@ -492,6 +498,34 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
/** Local mode only: profiles this browser leaves out (Alt+Shift+click a row);
|
||||
* Alt+Shift+H shows them, dimmed, until pressed again. */
|
||||
const [hidden, setHidden] = useState<string[]>(() => (local ? readHiddenProfiles() : []));
|
||||
const [reveal, setReveal] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!local) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.altKey && e.shiftKey && e.code === "KeyH") {
|
||||
e.preventDefault();
|
||||
setReveal((r) => !r);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [local]);
|
||||
const isHidden = useCallback(
|
||||
(p: { accountUuid: string | null; name?: string }) => local && hidden.includes(profileKey(p)),
|
||||
[local, hidden]
|
||||
);
|
||||
const toggleRow = (e: MouseEvent, p: CcProfile) => {
|
||||
if (!local || !e.altKey || !e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const next = toggleHidden(hidden, profileKey(p));
|
||||
setHidden(next);
|
||||
writeHiddenProfiles(next);
|
||||
};
|
||||
const activeHidden = !!active && !reveal && isHidden(active);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -540,10 +574,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
if (!reveal && isHidden(p)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
}, [items, checkUsage, isHidden, reveal]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -587,7 +622,7 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
@@ -603,7 +638,9 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
{active && activeHidden ? (
|
||||
<div className="text-sm text-fg-muted">—</div>
|
||||
) : active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
@@ -631,17 +668,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items?.map((p) => (
|
||||
{items?.filter((p) => reveal || !isHidden(p)).map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className={rowCls}
|
||||
onClickCapture={(e) => toggleRow(e, p)}
|
||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
|
||||
@@ -12,7 +12,9 @@ import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
|
||||
api: {
|
||||
artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn(), setExportsEnabled: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const settings = vi.mocked(api.artifacts.settings);
|
||||
@@ -32,15 +34,23 @@ describe("ArtifactsHubSettings", () => {
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
|
||||
});
|
||||
|
||||
it("renders nothing for a non-manager or in local mode", async () => {
|
||||
it("renders nothing for a non-manager, and only the copy switch in local mode", async () => {
|
||||
settings.mockResolvedValue({ ...base, canManage: false });
|
||||
const { container, unmount } = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
settings.mockResolvedValue({ ...base, serverMode: false });
|
||||
const r = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
|
||||
expect(r.container).toBeEmptyDOMElement();
|
||||
settings.mockResolvedValue({ ...base, serverMode: false, exportsEnabled: true });
|
||||
vi.mocked(api.artifacts.setExportsEnabled).mockResolvedValue({
|
||||
installsEnabled: true,
|
||||
exportsEnabled: false,
|
||||
});
|
||||
render(<ArtifactsHubSettings />);
|
||||
const sw = await screen.findByRole("switch", {
|
||||
name: "Keep a viewable copy of Docs and Slides",
|
||||
});
|
||||
expect(screen.queryByRole("switch", { name: "Allow installing from the Hub" })).toBeNull();
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(api.artifacts.setExportsEnabled).toHaveBeenCalledWith(false));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @file CompanyHubSettings.test.tsx
|
||||
* @description Settings → Company hub. Local: the enroll form sends the hub
|
||||
* address, username and password once and clears the password; enrolled, it
|
||||
* lists what was sent and never shows a token; Log out asks first. Hub
|
||||
* (server mode): the user's own machines with Revoke (their password again)
|
||||
* and who opened their sessions.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { CompanyHubSettings } from "../CompanyHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: true, isAdmin: true, seesAll: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
remoteClient: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), upload: vi.fn() },
|
||||
remoteHub: { devices: vi.fn(), sessions: vi.fn(), revoke: vi.fn(), audit: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const enrolled = {
|
||||
enrolled: true,
|
||||
hub: "http://10.1.13.7:4820",
|
||||
user: { id: 3, username: "mia", role: "developer" },
|
||||
device: { id: 7, name: "mia-pc", expiresAt: "2027-02-01T00:00:00.000Z" },
|
||||
uploads: [
|
||||
{
|
||||
sessionId: "11111111-2222-3333-4444-555555555555",
|
||||
bytes: 2048,
|
||||
lastSentAt: "2026-11-04T02:00:00.000Z",
|
||||
ended: 0,
|
||||
title: "fix the cart",
|
||||
cwd: "/home/mia/shop",
|
||||
startedAt: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const show = async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CompanyHubSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.local = true;
|
||||
});
|
||||
|
||||
describe("CompanyHubSettings — local", () => {
|
||||
it("enrolls with the hub address, username and password, then clears the password", async () => {
|
||||
vi.mocked(api.remoteClient.status)
|
||||
.mockResolvedValueOnce({ enrolled: false })
|
||||
.mockResolvedValue(enrolled);
|
||||
vi.mocked(api.remoteClient.login).mockResolvedValue(enrolled);
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.type(screen.getByLabelText(/hub address|địa chỉ hub/i), "http://10.1.13.7:4820");
|
||||
await user.type(screen.getByLabelText(/hub username|tên đăng nhập hub/i), "mia");
|
||||
const pw = screen.getByLabelText(/^(hub password|mật khẩu hub)$/i);
|
||||
await user.type(pw, "secret-1");
|
||||
await user.click(screen.getByRole("button", { name: /enroll this machine|đăng ký máy này/i }));
|
||||
expect(api.remoteClient.login).toHaveBeenCalledWith({
|
||||
hub: "http://10.1.13.7:4820",
|
||||
username: "mia",
|
||||
password: "secret-1",
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId("company-uploads")).toBeInTheDocument());
|
||||
expect(pw).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists what was sent and logs out only after confirming", async () => {
|
||||
vi.mocked(api.remoteClient.status).mockResolvedValue(enrolled);
|
||||
vi.mocked(api.remoteClient.logout).mockResolvedValue({ ok: true, wasEnrolled: true });
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
expect(screen.getByText("fix the cart")).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toMatch(/ahdev_/);
|
||||
await user.click(screen.getByRole("button", { name: /log out|đăng xuất/i }));
|
||||
expect(api.remoteClient.logout).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await waitFor(() => expect(api.remoteClient.logout).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyHubSettings — hub", () => {
|
||||
it("shows the user's machines, revokes with their password, and who read their sessions", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.remoteHub.audit).mockResolvedValue({
|
||||
entries: [
|
||||
{
|
||||
id: 1,
|
||||
at: "2026-11-04T03:00:00.000Z",
|
||||
sessionId: "abcdef12-0000",
|
||||
reader: "boss",
|
||||
readerDisplay: "Boss",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.remoteHub.devices).mockResolvedValue({
|
||||
devices: [
|
||||
{
|
||||
id: 7,
|
||||
userId: 3,
|
||||
name: "mia-pc",
|
||||
createdAt: "2026-11-01T00:00:00.000Z",
|
||||
expiresAt: "2027-02-01T00:00:00.000Z",
|
||||
lastSeenAt: null,
|
||||
lastIp: null,
|
||||
revokedAt: null,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
|
||||
vi.mocked(api.remoteHub.revoke).mockResolvedValue({ ok: true } as never);
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await waitFor(() => expect(screen.getByText("mia-pc")).toBeInTheDocument());
|
||||
expect(screen.getByTestId("company-audit")).toHaveTextContent("Boss");
|
||||
await user.click(screen.getByRole("button", { name: /^(revoke|thu hồi)$/i }));
|
||||
await user.type(screen.getByLabelText(/your password|mật khẩu của bạn/i), "my-pass-1");
|
||||
const buttons = screen.getAllByRole("button", { name: /^(revoke|thu hồi)$/i });
|
||||
await user.click(buttons[buttons.length - 1]!);
|
||||
await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7, "my-pass-1"));
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ async function show() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
perms.local = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
@@ -137,6 +138,34 @@ describe("ToolProfilesSettings — Claude", () => {
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
it("leaves a profile this browser hid out of the list and asks nothing about it", async () => {
|
||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||
await show();
|
||||
await settle();
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(screen.queryByText("d@e.f")).toBeNull();
|
||||
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
|
||||
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides a row on Alt+Shift+click and shows it again with Alt+Shift+H", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.keyboard("{Alt>}{Shift>}");
|
||||
await user.click(screen.getByTestId("profile-row-spare"));
|
||||
await user.keyboard("{/Shift}{/Alt}");
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.cc.hiddenProfiles")!)).toEqual(["u2"]);
|
||||
await user.keyboard("{Alt>}{Shift>}H{/Shift}{/Alt}");
|
||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not hide anything in server mode", async () => {
|
||||
perms.local = false;
|
||||
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
|
||||
await show();
|
||||
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — other tools", () => {
|
||||
|
||||
@@ -25,6 +25,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SessionStatusBadge } from "../StatusBadge";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
||||
import { RemoteDevices } from "../remote/RemoteDevices";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
const POOL_LABEL: Record<PoolTool, string> = {
|
||||
claude: "Claude Code",
|
||||
@@ -35,6 +37,7 @@ const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
export function UserDetail({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("users");
|
||||
const { isAdmin } = usePermissions();
|
||||
const { label: toolLabel } = useAiTools();
|
||||
const [data, setData] = useState<UserDetails | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -289,6 +292,12 @@ export function UserDetail({ userId }: { userId: number }) {
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Their own machines (AgentHub local) on the company hub, and those
|
||||
machines' Company-account sessions — revocable by an admin. */}
|
||||
<Panel title={t("detail.devicesTitle")}>
|
||||
<RemoteDevices userId={userId} canRevoke={isAdmin} />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"status_duration": "Stuck agent",
|
||||
"token_threshold": "Token threshold",
|
||||
"cost_threshold": "Cost threshold",
|
||||
"budget": "Spend budget"
|
||||
"budget": "Spend budget",
|
||||
"remote_unmonitored": "Company account outside AgentHub"
|
||||
},
|
||||
"ruleTypeHints": {
|
||||
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
|
||||
@@ -17,7 +18,8 @@
|
||||
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
|
||||
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
|
||||
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
|
||||
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
|
||||
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool.",
|
||||
"remote_unmonitored": "Fires when a user's machine uses the company account for a session it did not start from AgentHub (nothing of it is sent to the hub). Server mode only."
|
||||
},
|
||||
"ruleDesc": {
|
||||
"eventPattern": "Matches {{pattern}}",
|
||||
@@ -26,7 +28,8 @@
|
||||
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
|
||||
"tokenThreshold": "Session tokens exceed {{tokens}}",
|
||||
"costThreshold": "Session cost reaches {{usd}}",
|
||||
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
|
||||
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}",
|
||||
"remoteUnmonitored": "session not started from AgentHub"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Alert rules",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"share": "Share",
|
||||
"unshare": "Unshare",
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"unshareDone": "No longer shared",
|
||||
"reshare": "Share again",
|
||||
"reshareDone": "New version shared — people who installed it are offered Update",
|
||||
"reshareSame": "Already the newest version — shared",
|
||||
@@ -234,5 +234,46 @@
|
||||
"pack.updateN": "Update {{count}}",
|
||||
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
|
||||
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
|
||||
"pack.updated": "Updated {{ok}} · failed {{failed}}"
|
||||
"pack.updated": "Updated {{ok}} · failed {{failed}}",
|
||||
"tab.withme": "Shared with you",
|
||||
"sharing": "Sharing…",
|
||||
"shareTitle": "Share “{{title}}”",
|
||||
"shareMode.people": "Only the people I pick",
|
||||
"shareMode.peopleHint": "Only them (and admins) see it; nobody else finds it in the Hub.",
|
||||
"shareMode.everyone": "Everyone (the Hub)",
|
||||
"shareMode.everyoneHint": "Every user sees it in the Hub.",
|
||||
"shareMode.nobody": "Stop sharing",
|
||||
"shareMode.nobodyHint": "Only you (and admins) see it again.",
|
||||
"sharePeople.search": "Find a person",
|
||||
"sharePeople.none": "Nobody matches.",
|
||||
"sharePeople.picked_one": "{{count}} person picked",
|
||||
"sharePeople.picked_other": "{{count}} people picked",
|
||||
"shareSave": "Save",
|
||||
"sharePeopleDone_one": "Shared with {{count}} person",
|
||||
"sharePeopleDone_other": "Shared with {{count}} people",
|
||||
"sharedWithCount_one": "Shared with {{count}} person",
|
||||
"sharedWithCount_other": "Shared with {{count}} people",
|
||||
"sharedWithMe": "Shared with you",
|
||||
"withme.empty": "Nothing has been shared with you by name yet.",
|
||||
"folders.all": "All folders",
|
||||
"folders.new": "New folder",
|
||||
"folders.newName": "Folder name",
|
||||
"folders.rename": "Rename",
|
||||
"folders.delete": "Delete folder",
|
||||
"folders.deleteTitle": "Delete the folder “{{name}}”?",
|
||||
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
|
||||
"folders.empty": "This folder is empty. Put an artifact here with ⋯ → Move to folder.",
|
||||
"folders.move": "Move to folder…",
|
||||
"folders.moveTitle": "Move “{{title}}” to a folder",
|
||||
"folders.private": "Your folders are yours alone: nobody else sees them.",
|
||||
"folders.createHere": "New folder",
|
||||
"folders.takeOut": "Take it out of its folder",
|
||||
"page.view": "View",
|
||||
"page.viewHint": "Its copy, viewable here without the claude.ai account that published it",
|
||||
"page.noCopy": "No viewable copy: the file this page was published from is gone. Open it on claude.ai instead.",
|
||||
"pdf.page": "Page {{n}}",
|
||||
"pdf.failed": "This PDF could not be shown. Download it instead.",
|
||||
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
|
||||
"exportsSwitch": "Keep a viewable copy of Docs and Slides",
|
||||
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards."
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
"renameLabel": "Rename this workspace",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"proxy.company": "Company account",
|
||||
"runtime.down": "down",
|
||||
"runtime.healthy": "healthy",
|
||||
"runtime.partial": "partial",
|
||||
@@ -749,7 +750,7 @@
|
||||
"shelves.deleted": "Shelf deleted",
|
||||
"shelves.restored": "Shelved changes restored",
|
||||
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
|
||||
"shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf",
|
||||
"shelves.restoreDirty": "Commit, discard or unstage the changes in this shelf's files (and anything staged) before restoring it",
|
||||
"shelves.shelveTicked": "Shelve ({{count}})",
|
||||
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
|
||||
"shelves.shelveApply": "Shelve {{count}} file(s)",
|
||||
|
||||
@@ -1450,5 +1450,49 @@
|
||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||
"nextSession": "{{account}} is used from your next session"
|
||||
},
|
||||
"companyHub": {
|
||||
"title": "Company hub",
|
||||
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
|
||||
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.",
|
||||
"ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.",
|
||||
"ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).",
|
||||
"ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.",
|
||||
"enrolledAs": "Enrolled with {{hub}} as {{user}}",
|
||||
"validUntil": "valid until {{time}}",
|
||||
"expired": "expired",
|
||||
"expiredHint": "This machine's hub login expired — enroll again.",
|
||||
"sendNow": "Send now",
|
||||
"logout": "Log out",
|
||||
"logoutTitle": "Log this machine out of the hub?",
|
||||
"logoutHint": "Its device token is revoked on the hub. Company-account sessions stop working here until you enroll again.",
|
||||
"sentTitle": "Sent to the hub",
|
||||
"sentLine": "{{bytes}} · {{time}}",
|
||||
"notSentYet": "not sent yet",
|
||||
"ended": "ended",
|
||||
"nothingSent": "No Company-account session yet.",
|
||||
"sentToast": "Sent {{bytes}} of {{sessions}} session(s)",
|
||||
"enrolledToast": "This machine is enrolled with the hub",
|
||||
"hubAddress": "Hub address",
|
||||
"username": "Hub username",
|
||||
"password": "Hub password",
|
||||
"enroll": "Enroll this machine",
|
||||
"cliHint": "Or in a terminal: agenthub remote login <hub address>",
|
||||
"hubTitle": "Your machines",
|
||||
"hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.",
|
||||
"noDevices": "No machine enrolled.",
|
||||
"revokedAt": "revoked {{time}}",
|
||||
"lastSeen": "seen {{time}} from {{ip}}",
|
||||
"revoke": "Revoke",
|
||||
"revokeTitle": "Revoke {{name}}?",
|
||||
"revokeHint": "Its device token stops working at once; the machine must enroll again.",
|
||||
"revoked": "{{name}} revoked",
|
||||
"yourPassword": "Your password",
|
||||
"sessions": "Company-account sessions",
|
||||
"unmonitored": "not started from AgentHub",
|
||||
"sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}",
|
||||
"purged": "conversation removed (retention)",
|
||||
"whoRead": "Who opened your sessions",
|
||||
"nobodyRead": "Nobody has opened your sessions."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +409,8 @@
|
||||
"activeLabel": "Running now",
|
||||
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
||||
"nowTitle": "Running now",
|
||||
"poolTheirs7d": "theirs 7 d {{cost}}"
|
||||
"poolTheirs7d": "theirs 7 d {{cost}}",
|
||||
"devicesTitle": "Machines (AgentHub local)"
|
||||
},
|
||||
"activity": "Activity",
|
||||
"spend": "Spend today",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"status_duration": "Agent bị kẹt",
|
||||
"token_threshold": "Ngưỡng token",
|
||||
"cost_threshold": "Ngưỡng chi phí",
|
||||
"budget": "Ngân sách chi tiêu"
|
||||
"budget": "Ngân sách chi tiêu",
|
||||
"remote_unmonitored": "Tài khoản công ty dùng ngoài AgentHub"
|
||||
},
|
||||
"ruleTypeHints": {
|
||||
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
|
||||
@@ -17,7 +18,8 @@
|
||||
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
|
||||
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
|
||||
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
|
||||
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
|
||||
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI.",
|
||||
"remote_unmonitored": "Kích hoạt khi máy của một người dùng tài khoản công ty cho một phiên không mở từ AgentHub (phiên đó không được gửi về hub). Chỉ có ở chế độ server."
|
||||
},
|
||||
"ruleDesc": {
|
||||
"eventPattern": "Khớp {{pattern}}",
|
||||
@@ -26,7 +28,8 @@
|
||||
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
|
||||
"tokenThreshold": "Token của phiên vượt {{tokens}}",
|
||||
"costThreshold": "Chi phí phiên chạm {{usd}}",
|
||||
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
|
||||
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}",
|
||||
"remoteUnmonitored": "phiên không mở từ AgentHub"
|
||||
},
|
||||
"rules": {
|
||||
"title": "Quy tắc cảnh báo",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"share": "Chia sẻ",
|
||||
"unshare": "Bỏ chia sẻ",
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"unshareDone": "Đã ngừng chia sẻ",
|
||||
"reshare": "Share lại",
|
||||
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
||||
"reshareSame": "Đã là bản mới nhất — đã share",
|
||||
@@ -232,5 +232,46 @@
|
||||
"pack.updateN": "Cập nhật {{count}}",
|
||||
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
|
||||
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
|
||||
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
|
||||
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}",
|
||||
"tab.withme": "Chia sẻ với bạn",
|
||||
"sharing": "Chia sẻ…",
|
||||
"shareTitle": "Chia sẻ “{{title}}”",
|
||||
"shareMode.people": "Chỉ những người tôi chọn",
|
||||
"shareMode.peopleHint": "Chỉ họ (và admin) thấy; người khác không thấy trên Hub.",
|
||||
"shareMode.everyone": "Mọi người (Hub)",
|
||||
"shareMode.everyoneHint": "Mọi người dùng đều thấy trên Hub.",
|
||||
"shareMode.nobody": "Ngừng chia sẻ",
|
||||
"shareMode.nobodyHint": "Chỉ bạn (và admin) thấy như trước.",
|
||||
"sharePeople.search": "Tìm người",
|
||||
"sharePeople.none": "Không có ai khớp.",
|
||||
"sharePeople.picked_one": "Đã chọn {{count}} người",
|
||||
"sharePeople.picked_other": "Đã chọn {{count}} người",
|
||||
"shareSave": "Lưu",
|
||||
"sharePeopleDone_one": "Đã chia sẻ với {{count}} người",
|
||||
"sharePeopleDone_other": "Đã chia sẻ với {{count}} người",
|
||||
"sharedWithCount_one": "Chia sẻ với {{count}} người",
|
||||
"sharedWithCount_other": "Chia sẻ với {{count}} người",
|
||||
"sharedWithMe": "Chia sẻ với bạn",
|
||||
"withme.empty": "Chưa có ai chia sẻ riêng với bạn.",
|
||||
"folders.all": "Tất cả thư mục",
|
||||
"folders.new": "Thư mục mới",
|
||||
"folders.newName": "Tên thư mục",
|
||||
"folders.rename": "Đổi tên",
|
||||
"folders.delete": "Xóa thư mục",
|
||||
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
|
||||
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
|
||||
"folders.empty": "Thư mục này trống. Đưa artifact vào bằng ⋯ → Chuyển vào thư mục.",
|
||||
"folders.move": "Chuyển vào thư mục…",
|
||||
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
|
||||
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
|
||||
"folders.createHere": "Thư mục mới",
|
||||
"folders.takeOut": "Bỏ khỏi thư mục",
|
||||
"page.view": "Xem",
|
||||
"page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang",
|
||||
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn. Hãy mở trên claude.ai.",
|
||||
"pdf.page": "Trang {{n}}",
|
||||
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
|
||||
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
|
||||
"exportsSwitch": "Giữ bản xem được của Docs và Slides",
|
||||
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó."
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
"renameLabel": "Đổi tên workspace này",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"proxy.company": "Tài khoản công ty",
|
||||
"runtime.down": "tắt",
|
||||
"runtime.healthy": "khoẻ",
|
||||
"runtime.partial": "một phần",
|
||||
@@ -749,7 +750,7 @@
|
||||
"shelves.deleted": "Đã xoá shelf",
|
||||
"shelves.restored": "Đã khôi phục thay đổi đã cất",
|
||||
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
|
||||
"shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf",
|
||||
"shelves.restoreDirty": "Hãy commit, bỏ hoặc bỏ stage thay đổi trên các file của shelf này (và mọi thứ đã stage) trước khi khôi phục",
|
||||
"shelves.shelveTicked": "Cất ({{count}})",
|
||||
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
|
||||
"shelves.shelveApply": "Cất {{count}} file",
|
||||
|
||||
@@ -1438,5 +1438,49 @@
|
||||
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
||||
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||
},
|
||||
"companyHub": {
|
||||
"title": "Hub công ty",
|
||||
"description": "Dùng tài khoản Claude chung của công ty từ máy này. Chỉ phần tốn tiền công ty mới được ghi nhận.",
|
||||
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi.",
|
||||
"ruleNotSent": "Session dùng tài khoản riêng hoặc AI Proxy riêng của bạn không gửi gì cả — kể cả tên thư mục. Hub không đọc file của bạn và không điều khiển máy này.",
|
||||
"ruleKey": "Key của tài khoản công ty nằm ở hub; máy này chỉ giữ một device token (90 ngày, thu hồi được).",
|
||||
"ruleOffice": "Hub nằm trong mạng công ty: ở ngoài, session tài khoản công ty không mở được; tài khoản riêng vẫn dùng bình thường.",
|
||||
"enrolledAs": "Đã đăng ký với {{hub}} bằng {{user}}",
|
||||
"validUntil": "hiệu lực đến {{time}}",
|
||||
"expired": "đã hết hạn",
|
||||
"expiredHint": "Đăng nhập hub của máy này đã hết hạn — đăng ký lại.",
|
||||
"sendNow": "Gửi ngay",
|
||||
"logout": "Đăng xuất",
|
||||
"logoutTitle": "Đăng xuất máy này khỏi hub?",
|
||||
"logoutHint": "Device token bị thu hồi trên hub. Session tài khoản công ty không dùng được ở máy này cho đến khi đăng ký lại.",
|
||||
"sentTitle": "Đã gửi lên hub",
|
||||
"sentLine": "{{bytes}} · {{time}}",
|
||||
"notSentYet": "chưa gửi",
|
||||
"ended": "đã kết thúc",
|
||||
"nothingSent": "Chưa có session tài khoản công ty nào.",
|
||||
"sentToast": "Đã gửi {{bytes}} của {{sessions}} session",
|
||||
"enrolledToast": "Máy này đã đăng ký với hub",
|
||||
"hubAddress": "Địa chỉ hub",
|
||||
"username": "Tên đăng nhập hub",
|
||||
"password": "Mật khẩu hub",
|
||||
"enroll": "Đăng ký máy này",
|
||||
"cliHint": "Hoặc trong terminal: agenthub remote login <địa chỉ hub>",
|
||||
"hubTitle": "Máy của bạn",
|
||||
"hubDescription": "Các máy chạy AgentHub local bạn đã đăng ký với hub này, và các session tài khoản công ty của chúng.",
|
||||
"noDevices": "Chưa có máy nào đăng ký.",
|
||||
"revokedAt": "đã thu hồi {{time}}",
|
||||
"lastSeen": "thấy lần cuối {{time}} từ {{ip}}",
|
||||
"revoke": "Thu hồi",
|
||||
"revokeTitle": "Thu hồi {{name}}?",
|
||||
"revokeHint": "Device token ngừng hoạt động ngay; máy đó phải đăng ký lại.",
|
||||
"revoked": "Đã thu hồi {{name}}",
|
||||
"yourPassword": "Mật khẩu của bạn",
|
||||
"sessions": "Session tài khoản công ty",
|
||||
"unmonitored": "không mở từ AgentHub",
|
||||
"sessionLine": "{{requests}} request · {{input}} vào · {{output}} ra · {{account}}",
|
||||
"purged": "đã xoá hội thoại (quá hạn lưu)",
|
||||
"whoRead": "Ai đã xem session của bạn",
|
||||
"nobodyRead": "Chưa ai xem session của bạn."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,7 +409,8 @@
|
||||
"activeLabel": "Đang chạy",
|
||||
"activeValue": "{{sessions}} session · {{panes}} pane",
|
||||
"nowTitle": "Đang chạy",
|
||||
"poolTheirs7d": "của họ 7 ngày {{cost}}"
|
||||
"poolTheirs7d": "của họ 7 ngày {{cost}}",
|
||||
"devicesTitle": "Máy cá nhân (AgentHub local)"
|
||||
},
|
||||
"activity": "Hoạt động",
|
||||
"spend": "Chi phí hôm nay",
|
||||
|
||||
+132
-1
@@ -3601,7 +3601,11 @@ export const api = {
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (
|
||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||
params: {
|
||||
scope?: "mine" | "hub" | "withme" | "all" | "unowned";
|
||||
kind?: ArtifactKind;
|
||||
q?: string;
|
||||
} = {}
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
@@ -3611,6 +3615,17 @@ export const api = {
|
||||
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
||||
},
|
||||
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
||||
setExportsEnabled: (exportsEnabled: boolean) =>
|
||||
request<{ installsEnabled: boolean; exportsEnabled: boolean }>("/artifacts/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ exportsEnabled }),
|
||||
}),
|
||||
/** A page's viewable copy, taken now from the file it was published from (if still there). */
|
||||
copy: (id: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/copy`, { method: "POST", body: "{}" }),
|
||||
/** Server mode: who the caller may share with — names only. */
|
||||
people: () =>
|
||||
request<{ people: { id: number; username: string; name: string }[] }>("/artifacts/people"),
|
||||
setInstallsEnabled: (installsEnabled: boolean) =>
|
||||
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
||||
method: "PUT",
|
||||
@@ -3697,6 +3712,8 @@ export const api = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
shared?: boolean;
|
||||
/** Server mode: the user ids it is shared with by name (replaces the list). */
|
||||
sharedWith?: number[];
|
||||
/** plugin only: `name@marketplace`. */
|
||||
source?: string;
|
||||
/** plugin only: the marketplace source. */
|
||||
@@ -3846,6 +3863,55 @@ export const api = {
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
/** A LOCAL AgentHub's link to the company hub (server/routes/remote-client.js). */
|
||||
remoteClient: {
|
||||
/** GET /api/remote-client - enrollment and what was sent; never the token. */
|
||||
status: () => request<RemoteClientStatus>("/remote-client"),
|
||||
/** POST /api/remote-client/login - the password goes to the hub once, not kept. */
|
||||
login: (body: { hub: string; username: string; password: string; deviceName?: string }) =>
|
||||
request<RemoteClientStatus>("/remote-client/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/remote-client/logout - revoke on the hub, forget the token here. */
|
||||
logout: () =>
|
||||
request<{ ok: true; wasEnrolled: boolean; revokedOnHub?: boolean }>("/remote-client/logout", {
|
||||
method: "POST",
|
||||
}),
|
||||
/** POST /api/remote-client/upload - send the Company-account sessions' new lines now. */
|
||||
upload: () =>
|
||||
request<{
|
||||
sessions: number;
|
||||
bytes: number;
|
||||
errors: { sessionId: string; message: string }[];
|
||||
}>("/remote-client/upload", { method: "POST" }),
|
||||
},
|
||||
/** The hub's remote-client views (server mode, server/hub/). */
|
||||
remoteHub: {
|
||||
/** GET /api/remote/devices - your machines; staff may name `user`. */
|
||||
devices: (user?: number) =>
|
||||
request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`),
|
||||
/** POST /api/remote/devices/:id/revoke - your own password again. */
|
||||
revoke: (id: number, password: string) =>
|
||||
request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
/** GET /api/remote/sessions - Company-account sessions (device › folder). */
|
||||
sessions: (user?: number) =>
|
||||
request<{ sessions: RemoteSessionRow[] }>(`/remote/sessions${user ? `?user=${user}` : ""}`),
|
||||
/** GET /api/remote/audit - who opened your sessions. */
|
||||
audit: () =>
|
||||
request<{
|
||||
entries: {
|
||||
id: number;
|
||||
at: string;
|
||||
sessionId: string;
|
||||
reader: string | null;
|
||||
readerDisplay: string | null;
|
||||
}[];
|
||||
}>("/remote/audit"),
|
||||
},
|
||||
aiProxies: {
|
||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
@@ -5222,6 +5288,10 @@ export interface Artifact {
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
/** Server mode: the people it is shared with by name (only its owner or an admin gets the list). */
|
||||
sharedWith?: { id: number; name: string }[];
|
||||
/** Server mode: shared with the caller by name. */
|
||||
sharedWithMe?: boolean;
|
||||
installs: number;
|
||||
/** False once its owner removed it from their library while shared: Hub only. */
|
||||
inLibrary: boolean;
|
||||
@@ -5302,6 +5372,8 @@ export interface ArtifactSettings {
|
||||
serverMode: boolean;
|
||||
canShare: boolean;
|
||||
installsEnabled: boolean;
|
||||
/** Claude panes are asked to save a viewable copy of a Doc / Slides / Design they publish. */
|
||||
exportsEnabled?: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
@@ -5614,6 +5686,9 @@ export interface AiProxy {
|
||||
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** The remote client's Company account (`agenthub.company`) — the hub's gateway,
|
||||
* added by the client on an enrolled machine; never a saved profile. */
|
||||
company?: boolean;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
||||
@@ -6805,3 +6880,59 @@ export interface PetList {
|
||||
featured: FeaturedPet[];
|
||||
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
|
||||
}
|
||||
|
||||
/** An enrolled machine on the hub (server/hub/devices.js) — never its token. */
|
||||
export interface RemoteDevice {
|
||||
id: number;
|
||||
userId: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastSeenAt: string | null;
|
||||
lastIp: string | null;
|
||||
revokedAt: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** A Company-account session a device registered (server/hub/remote-sessions.js). */
|
||||
export interface RemoteSessionRow {
|
||||
sessionId: string;
|
||||
deviceId: number;
|
||||
deviceName: string;
|
||||
userId: number;
|
||||
cwd: string;
|
||||
tool: string;
|
||||
account: string | null;
|
||||
registeredAt: string;
|
||||
endedAt: string | null;
|
||||
lastRequestAt: string | null;
|
||||
requests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
lastUploadAt: string | null;
|
||||
purgedAt: string | null;
|
||||
unmonitored: number;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/** GET /api/remote-client — this local machine's link to the hub. */
|
||||
export interface RemoteClientStatus {
|
||||
enrolled: boolean;
|
||||
expired?: boolean;
|
||||
hub?: string;
|
||||
user?: { id: number; username: string; role: string } | null;
|
||||
device?: { id: number; name: string; expiresAt: string } | null;
|
||||
enrolledAt?: string | null;
|
||||
retainDays?: number | null;
|
||||
uploads?: {
|
||||
sessionId: string;
|
||||
bytes: number;
|
||||
lastSentAt: string | null;
|
||||
ended: number;
|
||||
title: string | null;
|
||||
cwd: string | null;
|
||||
startedAt: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file artifactFolders — the Artifacts page's user-made folders ("Slides",
|
||||
* "Client A", …): their names and order, and which folder each artifact sits
|
||||
* in. One JSON value under `agenthub.artifacts.folders`, an account preference
|
||||
* key (`lib/userPrefs.ts`, `server/routes/me.js`), so the folders are kept per
|
||||
* ACCOUNT on the server and follow it to any browser. A viewer's arrangement,
|
||||
* not an artifact fact: nothing about an artifact changes, other users never
|
||||
* see these folders, and deleting a folder only takes its artifacts out of it.
|
||||
* Same store shape as `workspaceGroups.ts`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { PREFS_EVENT } from "./workspaceGroups";
|
||||
|
||||
export const ARTIFACT_FOLDERS_KEY = "agenthub.artifacts.folders";
|
||||
export const MAX_FOLDER_NAME = 60;
|
||||
|
||||
export interface ArtifactFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ArtifactFolders {
|
||||
/** In display order. */
|
||||
folders: ArtifactFolder[];
|
||||
/** Artifact id → folder id. An artifact absent here is in no folder. */
|
||||
of: Record<string, string>;
|
||||
}
|
||||
|
||||
const EMPTY: ArtifactFolders = { folders: [], of: {} };
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: { raw: string | null; value: ArtifactFolders } = { raw: null, value: EMPTY };
|
||||
|
||||
function readRaw(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ARTIFACT_FOLDERS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parse(raw: string | null): ArtifactFolders {
|
||||
if (!raw) return EMPTY;
|
||||
try {
|
||||
const v = JSON.parse(raw) as Partial<ArtifactFolders>;
|
||||
const folders = (Array.isArray(v.folders) ? v.folders : []).filter(
|
||||
(f): f is ArtifactFolder =>
|
||||
!!f && typeof f.id === "string" && !!f.id && typeof f.name === "string"
|
||||
);
|
||||
const ids = new Set(folders.map((f) => f.id));
|
||||
const of: Record<string, string> = {};
|
||||
if (v.of && typeof v.of === "object") {
|
||||
for (const [item, fid] of Object.entries(v.of)) {
|
||||
if (typeof fid === "string" && ids.has(fid)) of[item] = fid;
|
||||
}
|
||||
}
|
||||
return { folders, of };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export function readArtifactFolders(): ArtifactFolders {
|
||||
const raw = readRaw();
|
||||
if (raw === cache.raw) return cache.value;
|
||||
cache = { raw, value: parse(raw) };
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
function write(next: ArtifactFolders): void {
|
||||
try {
|
||||
localStorage.setItem(ARTIFACT_FOLDERS_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* disabled storage: the folders live until the page closes */
|
||||
cache = { raw: JSON.stringify(next), value: next };
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function update(fn: (cur: ArtifactFolders) => ArtifactFolders): void {
|
||||
write(fn(readArtifactFolders()));
|
||||
}
|
||||
|
||||
const cleanName = (name: string) => name.trim().slice(0, MAX_FOLDER_NAME);
|
||||
|
||||
/** A new folder at the end, holding `itemIds` (moved out of their old folder). Returns its id, or null for a blank name. */
|
||||
export function createFolder(name: string, itemIds: number[] = []): string | null {
|
||||
const clean = cleanName(name);
|
||||
if (!clean) return null;
|
||||
const id = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
update((cur) => {
|
||||
const of = { ...cur.of };
|
||||
for (const item of itemIds) of[item] = id;
|
||||
return { folders: [...cur.folders, { id, name: clean }], of };
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Rename; a blank name keeps the old one. */
|
||||
export function renameFolder(id: string, name: string): void {
|
||||
const clean = cleanName(name);
|
||||
if (!clean) return;
|
||||
update((cur) => ({
|
||||
...cur,
|
||||
folders: cur.folders.map((f) => (f.id === id ? { ...f, name: clean } : f)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Delete the folder; its artifacts are only taken out of it. */
|
||||
export function deleteFolder(id: string): void {
|
||||
update((cur) => ({
|
||||
folders: cur.folders.filter((f) => f.id !== id),
|
||||
of: Object.fromEntries(Object.entries(cur.of).filter(([, fid]) => fid !== id)),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Put an artifact in a folder, or (null) take it out of every folder. */
|
||||
export function moveToFolder(itemId: number, folderId: string | null): void {
|
||||
update((cur) => {
|
||||
const of = { ...cur.of };
|
||||
if (folderId && cur.folders.some((f) => f.id === folderId)) of[itemId] = folderId;
|
||||
else delete of[itemId];
|
||||
return { ...cur, of };
|
||||
});
|
||||
}
|
||||
|
||||
/** The folder an artifact is in, or null. */
|
||||
export function folderOf(value: ArtifactFolders, itemId: number): ArtifactFolder | null {
|
||||
const fid = value.of[itemId];
|
||||
return (fid && value.folders.find((f) => f.id === fid)) || null;
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === ARTIFACT_FOLDERS_KEY || e.key === null) l();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PREFS_EVENT, l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PREFS_EVENT, l);
|
||||
};
|
||||
}
|
||||
|
||||
export function useArtifactFolders(): ArtifactFolders {
|
||||
return useSyncExternalStore(subscribe, readArtifactFolders);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file hiddenProfilesStorage.ts
|
||||
* @description Claude login profiles this browser leaves out of Settings →
|
||||
* AI tools → Profiles, kept in localStorage with the same read/validate/
|
||||
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
|
||||
* profile files, the API and the database are untouched, and another browser
|
||||
* still lists every profile.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
|
||||
|
||||
/** A profile's stable key: its account (survives a rename), else its file name. */
|
||||
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
|
||||
return p.accountUuid || p.name || "";
|
||||
}
|
||||
|
||||
export function readHiddenProfiles(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((k): k is string => typeof k === "string" && k.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHiddenProfiles(keys: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
/** `keys` with `key` added when absent, removed when present. */
|
||||
export function toggleHidden(keys: string[], key: string): string[] {
|
||||
return keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key];
|
||||
}
|
||||
@@ -1573,7 +1573,10 @@ export type AlertRuleType =
|
||||
| "status_duration"
|
||||
| "token_threshold"
|
||||
| "cost_threshold"
|
||||
| "budget";
|
||||
| "budget"
|
||||
/** Server mode: a user's machine used the company account for a session it
|
||||
* never registered with the hub (no config). */
|
||||
| "remote_unmonitored";
|
||||
|
||||
/**
|
||||
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
|
||||
|
||||
@@ -47,6 +47,7 @@ export const PREF_KEYS = [
|
||||
"agenthub.markdown.view",
|
||||
"agenthub.workspace.groups",
|
||||
"agenthub.changes.innerRepos",
|
||||
"agenthub.artifacts.folders",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
|
||||
+538
-24
@@ -53,12 +53,14 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -83,6 +85,8 @@ import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { Checkbox } from "../components/Checkbox";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
@@ -95,6 +99,17 @@ import {
|
||||
splitSkillMd,
|
||||
} from "../components/artifacts/ArtifactAddPanels";
|
||||
import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview";
|
||||
import { HtmlFrame } from "../components/HtmlFrame";
|
||||
import { PdfView } from "../components/PdfView";
|
||||
import {
|
||||
MAX_FOLDER_NAME,
|
||||
createFolder,
|
||||
deleteFolder,
|
||||
folderOf,
|
||||
moveToFolder,
|
||||
renameFolder,
|
||||
useArtifactFolders,
|
||||
} from "../lib/artifactFolders";
|
||||
|
||||
// The storefront's shelf order: what installs first, what is read after.
|
||||
const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"];
|
||||
@@ -110,22 +125,30 @@ const fmtDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||
// What the sandboxed preview can show: the server's typed list minus PDF.
|
||||
// What the preview can show: the sandboxed types, plus PDF drawn by pdf.js.
|
||||
const PREVIEWABLE =
|
||||
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
|
||||
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav|pdf)$/i;
|
||||
const PDF = /\.pdf$/i;
|
||||
|
||||
/** What a row can do, worked out once for its card, menu and detail sheet. */
|
||||
/** Shared at all: with everyone (the Hub) or with chosen people. */
|
||||
const isShared = (a: Artifact) => a.shared || (a.sharedWith?.length ?? 0) > 0;
|
||||
|
||||
function capabilities(a: Artifact) {
|
||||
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
||||
return {
|
||||
previewPath,
|
||||
canPreview:
|
||||
a.files.length > 0 &&
|
||||
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))),
|
||||
(a.kind === "skill" ||
|
||||
((a.kind === "file" || a.kind === "page") && PREVIEWABLE.test(previewPath))),
|
||||
// A page with no copy yet that was published from a file: one may still
|
||||
// be taken (`POST /:id/copy`) when someone asks to view it.
|
||||
mayCopy: a.kind === "page" && a.files.length === 0 && !!a.source,
|
||||
canDownload: a.kind === "file" && a.files.length > 0,
|
||||
installable: a.kind === "skill" || a.kind === "plugin",
|
||||
// Its owner removed it from their library; it lives on in the Hub only.
|
||||
hubOnly: a.mine && a.shared && !a.inLibrary,
|
||||
hubOnly: a.mine && isShared(a) && !a.inLibrary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,14 +173,25 @@ const footAction =
|
||||
export function Artifacts() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
|
||||
const [tab, setTab] = useState<"all" | "mine" | "hub" | "withme" | "unowned">("all");
|
||||
const { isAdmin } = usePermissions();
|
||||
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
||||
const [unownedCount, setUnownedCount] = useState(0);
|
||||
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
||||
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||
const [loaded, setItems] = useState<Artifact[] | null>(null);
|
||||
// Folders are this account's own arrangement (`lib/artifactFolders.ts`).
|
||||
const folders = useArtifactFolders();
|
||||
const [folder, setFolder] = useState<string>("");
|
||||
const [sharing, setSharing] = useState<Artifact | null>(null);
|
||||
const [moving, setMoving] = useState<Artifact | null>(null);
|
||||
const activeFolder = folders.folders.some((f) => f.id === folder) ? folder : "";
|
||||
const items = useMemo(
|
||||
() =>
|
||||
loaded && activeFolder ? loaded.filter((a) => folders.of[a.id] === activeFolder) : loaded,
|
||||
[loaded, activeFolder, folders]
|
||||
);
|
||||
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
||||
const [addMenu, setAddMenu] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -261,12 +295,16 @@ export function Artifacts() {
|
||||
|
||||
const server = !!settings?.serverMode;
|
||||
|
||||
// Share opens the dialog: everyone (the Hub), chosen people, or nobody. A
|
||||
// Hub-only row (its owner already removed it from their library) is
|
||||
// deleted by unsharing it, so its one action is that, behind a confirm.
|
||||
const share = async (a: Artifact) => {
|
||||
// A Hub-only row (its owner already removed it from their library) is
|
||||
// deleted by unsharing it, so that asks first.
|
||||
if (!capabilities(a).hubOnly) {
|
||||
setDetailId(null);
|
||||
setSharing(a);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
a.shared &&
|
||||
!a.inLibrary &&
|
||||
!(await confirmDialog({
|
||||
title: t("hubRemoveTitle", { title: a.title }),
|
||||
message: t("hubRemoveBody"),
|
||||
@@ -275,8 +313,8 @@ export function Artifacts() {
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.artifacts.update(a.id, { shared: !a.shared });
|
||||
toast.success(t(a.shared ? "unshareDone" : "shareDone"));
|
||||
await api.artifacts.update(a.id, { shared: false, sharedWith: [] });
|
||||
toast.success(t("unshareDone"));
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
@@ -495,6 +533,10 @@ export function Artifacts() {
|
||||
const actionsFor = (a: Artifact): CardActions => ({
|
||||
onOpen: () => setDetailId(a.id),
|
||||
onShare: () => void share(a),
|
||||
onMove: () => {
|
||||
setDetailId(null);
|
||||
setMoving(a);
|
||||
},
|
||||
onReshare: () => void reshare(a),
|
||||
onRename: () => {
|
||||
setDetailId(null);
|
||||
@@ -506,7 +548,20 @@ export function Artifacts() {
|
||||
onUninstall: () => void uninstall(a),
|
||||
onPreview: (path) => {
|
||||
setDetailId(null);
|
||||
if (path || !capabilities(a).mayCopy) {
|
||||
setPreview({ item: a, path });
|
||||
return;
|
||||
}
|
||||
// An older page: take its copy now, then show it.
|
||||
api.artifacts
|
||||
.copy(a.id)
|
||||
.then(({ item }) => {
|
||||
const c = capabilities(item);
|
||||
if (c.canPreview) setPreview({ item, path: c.previewPath });
|
||||
else toast.error(t("page.noCopy"));
|
||||
load();
|
||||
})
|
||||
.catch((err) => toast.error(err));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -609,20 +664,21 @@ export function Artifacts() {
|
||||
// shared (only an admin is shown those) gets a section of its own, so
|
||||
// "Shared by others" never holds a private row.
|
||||
const halves =
|
||||
server && items && tab !== "unowned"
|
||||
server && items && tab !== "unowned" && tab !== "withme"
|
||||
? [
|
||||
{
|
||||
key: `${tab}.others`,
|
||||
rows: items.filter((a) => !a.mine && (a.shared || tab === "mine")),
|
||||
rows: items.filter((a) => !a.mine && (a.shared || a.sharedWithMe || tab === "mine")),
|
||||
},
|
||||
{
|
||||
key: "unshared",
|
||||
rows: tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared),
|
||||
rows:
|
||||
tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared && !a.sharedWithMe),
|
||||
},
|
||||
{ key: `${tab}.own`, rows: items.filter((a) => a.mine) },
|
||||
].filter((h) => h.rows.length > 0 || h.key !== "unshared")
|
||||
: null;
|
||||
const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null);
|
||||
const detail = detailId === null ? null : (loaded?.find((a) => a.id === detailId) ?? null);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
|
||||
@@ -682,7 +738,7 @@ export function Artifacts() {
|
||||
className="-mb-1 flex gap-1 overflow-x-auto border-b border-border"
|
||||
data-testid="artifacts-scope"
|
||||
>
|
||||
{(["all", "mine", "hub"] as const).map((v) => (
|
||||
{(["all", "mine", "hub", "withme"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
@@ -773,12 +829,26 @@ export function Artifacts() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FolderBar
|
||||
folders={folders.folders}
|
||||
active={activeFolder}
|
||||
counts={(id) => (loaded ?? []).filter((a) => folders.of[a.id] === id).length}
|
||||
onPick={setFolder}
|
||||
/>
|
||||
|
||||
{items === null ? (
|
||||
<SkeletonGrid label={t("loading")} />
|
||||
) : items.length === 0 && tab === "unowned" ? (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
||||
{t("unowned.empty")}
|
||||
</p>
|
||||
) : items.length === 0 && (tab === "withme" || activeFolder) ? (
|
||||
<p
|
||||
className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted"
|
||||
data-testid="artifacts-empty-filtered"
|
||||
>
|
||||
{t(activeFolder ? "folders.empty" : "withme.empty")}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
hub={tab === "hub"}
|
||||
@@ -846,6 +916,24 @@ export function Artifacts() {
|
||||
)}
|
||||
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
|
||||
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
||||
{sharing && (
|
||||
<ShareDialog
|
||||
item={sharing}
|
||||
onClose={() => setSharing(null)}
|
||||
onSaved={() => {
|
||||
setSharing(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{moving && (
|
||||
<MoveDialog
|
||||
item={moving}
|
||||
current={folderOf(folders, moving.id)?.id ?? null}
|
||||
folders={folders.folders}
|
||||
onClose={() => setMoving(null)}
|
||||
/>
|
||||
)}
|
||||
{renaming && (
|
||||
<RenameDialog
|
||||
item={renaming}
|
||||
@@ -1131,6 +1219,8 @@ function EmptyState({
|
||||
type CardActions = {
|
||||
onOpen: () => void;
|
||||
onShare: () => void;
|
||||
/** Put it in one of the viewer's own folders. */
|
||||
onMove: () => void;
|
||||
/** A skill of your own: its newest version, shared (installers get Update). */
|
||||
onReshare: () => void;
|
||||
onRename: () => void;
|
||||
@@ -1191,6 +1281,20 @@ function PrimaryAction({
|
||||
{t("install")}
|
||||
</button>
|
||||
);
|
||||
// A page with a copy is viewed here, by anyone it is shared with; the
|
||||
// claude.ai link (⋯ → Open) needs the publishing account.
|
||||
if (a.kind === "page" && (c.canPreview || c.mayCopy))
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={smallPrimary}
|
||||
onClick={() => onPreview(c.canPreview ? c.previewPath : "")}
|
||||
title={t("page.viewHint")}
|
||||
data-testid="artifact-view"
|
||||
>
|
||||
{t("page.view")}
|
||||
</button>
|
||||
);
|
||||
if (a.kind === "page" && a.url)
|
||||
return (
|
||||
<a href={a.url} target="_blank" rel="noopener noreferrer" className={smallPrimary}>
|
||||
@@ -1251,11 +1355,25 @@ function Badges({ item: a, server }: { item: Artifact; server: boolean }) {
|
||||
{t("hubOnly")}
|
||||
</span>
|
||||
)}
|
||||
{server && !a.shared && (
|
||||
{server && !isShared(a) && !a.sharedWithMe && (
|
||||
<span className="badge shrink-0" title={t("privateHint")}>
|
||||
{t("private")}
|
||||
</span>
|
||||
)}
|
||||
{server && !a.shared && (a.sharedWith?.length ?? 0) > 0 && (
|
||||
<span
|
||||
className="badge shrink-0 border-accent/30 bg-accent-muted text-accent"
|
||||
title={a.sharedWith!.map((p) => p.name).join(", ")}
|
||||
data-testid={`shared-people-${a.id}`}
|
||||
>
|
||||
{t("sharedWithCount", { count: a.sharedWith!.length })}
|
||||
</span>
|
||||
)}
|
||||
{server && !a.shared && a.sharedWithMe && (
|
||||
<span className="badge shrink-0 border-accent/30 bg-accent-muted text-accent">
|
||||
{t("sharedWithMe")}
|
||||
</span>
|
||||
)}
|
||||
{a.auto && (
|
||||
<span className="badge shrink-0" title={t("autoHint")}>
|
||||
{t("auto")}
|
||||
@@ -1316,6 +1434,7 @@ function ArtifactMenu({
|
||||
menu,
|
||||
onOpen,
|
||||
onShare,
|
||||
onMove,
|
||||
onReshare,
|
||||
onRename,
|
||||
onDelete,
|
||||
@@ -1369,12 +1488,13 @@ function ArtifactMenu({
|
||||
{t("uninstall")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(onMove)}>{t("folders.move")}</MenuItem>
|
||||
{a.canChange && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{server && (
|
||||
<MenuItem danger={hubOnly} onSelect={pick(onShare)}>
|
||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||
{t(hubOnly ? "hubRemove" : isShared(a) ? "sharing" : "share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{server && a.kind === "skill" && (
|
||||
@@ -1567,9 +1687,18 @@ function DetailDialog({
|
||||
{server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)}
|
||||
{a.source ? ` · ${a.source}` : ""}
|
||||
</p>
|
||||
{a.shared && (
|
||||
<span className="badge mt-2 border-accent/30 bg-accent-muted text-accent">
|
||||
{t(hubOnly ? "hubOnly" : "shared")}
|
||||
{(isShared(a) || a.sharedWithMe) && (
|
||||
<span
|
||||
className="badge mt-2 border-accent/30 bg-accent-muted text-accent"
|
||||
title={a.sharedWith?.map((p) => p.name).join(", ") || undefined}
|
||||
>
|
||||
{hubOnly
|
||||
? t("hubOnly")
|
||||
: a.shared
|
||||
? t("shared")
|
||||
: a.sharedWithMe
|
||||
? t("sharedWithMe")
|
||||
: t("sharedWithCount", { count: a.sharedWith?.length ?? 0 })}
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
@@ -1695,7 +1824,7 @@ function DetailDialog({
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
{server && (
|
||||
<button type="button" className={secondary} onClick={onShare}>
|
||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||
{t(hubOnly ? "hubRemove" : isShared(a) ? "sharing" : "share")}
|
||||
</button>
|
||||
)}
|
||||
{server && a.kind === "skill" && (
|
||||
@@ -2107,6 +2236,374 @@ function RenameDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/** Who an artifact is shared with: everyone (the Hub), chosen people, or nobody. */
|
||||
function ShareDialog({
|
||||
item,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
item: Artifact;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const initialPeople = (item.sharedWith ?? []).map((p) => p.id);
|
||||
const [mode, setMode] = useState<"nobody" | "everyone" | "people">(
|
||||
item.shared ? "everyone" : "people"
|
||||
);
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set(initialPeople));
|
||||
const [people, setPeople] = useState<{ id: number; username: string; name: string }[] | null>(
|
||||
null
|
||||
);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
api.artifacts
|
||||
.people()
|
||||
.then((r) => setPeople(r.people))
|
||||
.catch((err) => {
|
||||
setPeople([]);
|
||||
toast.error(err);
|
||||
});
|
||||
}, []);
|
||||
const shown = (people ?? []).filter((p) => {
|
||||
const f = filter.trim().toLowerCase();
|
||||
return !f || p.name.toLowerCase().includes(f) || p.username.toLowerCase().includes(f);
|
||||
});
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setPicked((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const target =
|
||||
mode === "everyone"
|
||||
? { shared: true, sharedWith: [] as number[] }
|
||||
: mode === "people"
|
||||
? { shared: false, sharedWith: [...picked] }
|
||||
: { shared: false, sharedWith: [] as number[] };
|
||||
const blocked = busy || (mode === "people" && picked.size === 0);
|
||||
const save = async () => {
|
||||
if (blocked) return;
|
||||
// Out of its owner's library, a row that nobody can see any more is
|
||||
// deleted: that asks first.
|
||||
const goneForGood =
|
||||
item.mine && !item.inLibrary && !target.shared && target.sharedWith.length === 0;
|
||||
if (
|
||||
goneForGood &&
|
||||
!(await confirmDialog({
|
||||
title: t("hubRemoveTitle", { title: item.title }),
|
||||
message: t("hubRemoveBody"),
|
||||
confirmLabel: t("hubRemove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.artifacts.update(item.id, target);
|
||||
toast.success(
|
||||
target.shared
|
||||
? t("shareDone")
|
||||
: target.sharedWith.length
|
||||
? t("sharePeopleDone", { count: target.sharedWith.length })
|
||||
: t("unshareDone")
|
||||
);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const radio = (v: typeof mode, label: string, hint: string) => (
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md p-2 hover:bg-surface-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="share-mode"
|
||||
className="mt-1"
|
||||
checked={mode === v}
|
||||
onChange={() => setMode(v)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm text-fg-primary">{label}</span>
|
||||
<span className="block text-[11px] text-fg-muted">{hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<Modal title={t("shareTitle", { title: item.title })} onClose={onClose}>
|
||||
<div className="space-y-3" data-testid="share-dialog">
|
||||
<div role="radiogroup" aria-label={t("shareTitle", { title: item.title })}>
|
||||
{radio("people", t("shareMode.people"), t("shareMode.peopleHint"))}
|
||||
{radio("everyone", t("shareMode.everyone"), t("shareMode.everyoneHint"))}
|
||||
{(item.shared || initialPeople.length > 0) &&
|
||||
radio("nobody", t("shareMode.nobody"), t("shareMode.nobodyHint"))}
|
||||
</div>
|
||||
{mode === "people" && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
placeholder={t("sharePeople.search")}
|
||||
aria-label={t("sharePeople.search")}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
{people === null ? (
|
||||
<p className="text-xs text-fg-muted">{t("loading")}</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("sharePeople.none")}</p>
|
||||
) : (
|
||||
<ul
|
||||
className="max-h-64 space-y-0.5 overflow-y-auto rounded-lg border border-border p-1"
|
||||
data-testid="share-people"
|
||||
>
|
||||
{shown.map((p) => (
|
||||
<li key={p.id} className="rounded-md px-2 py-1 hover:bg-surface-2">
|
||||
<Checkbox
|
||||
checked={picked.has(p.id)}
|
||||
onChange={(v) => toggle(p.id, v)}
|
||||
label={
|
||||
<span className="text-sm text-fg-primary">
|
||||
{p.name}
|
||||
{p.name !== p.username && (
|
||||
<span className="ml-1 text-xs text-fg-muted">@{p.username}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t("sharePeople.picked", { count: picked.size })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
disabled={blocked}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("shareSave")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Put one artifact in one of the viewer's own folders (or a new one), or take it out. */
|
||||
function MoveDialog({
|
||||
item,
|
||||
current,
|
||||
folders,
|
||||
onClose,
|
||||
}: {
|
||||
item: Artifact;
|
||||
current: string | null;
|
||||
folders: { id: string; name: string }[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [name, setName] = useState("");
|
||||
const pick = (id: string | null) => {
|
||||
moveToFolder(item.id, id);
|
||||
onClose();
|
||||
};
|
||||
const create = () => {
|
||||
const id = createFolder(name, [item.id]);
|
||||
if (id) onClose();
|
||||
};
|
||||
return (
|
||||
<Modal title={t("folders.moveTitle", { title: item.title })} onClose={onClose}>
|
||||
<div className="space-y-3" data-testid="move-dialog">
|
||||
<p className="text-[11px] text-fg-muted">{t("folders.private")}</p>
|
||||
{folders.length > 0 && (
|
||||
<ul className="space-y-0.5">
|
||||
{folders.map((f) => (
|
||||
<li key={f.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={current === f.id}
|
||||
onClick={() => pick(f.id)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-surface-2 ${
|
||||
current === f.id ? "text-accent" : "text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{f.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input min-w-0 flex-1 text-sm"
|
||||
placeholder={t("folders.newName")}
|
||||
aria-label={t("folders.newName")}
|
||||
maxLength={MAX_FOLDER_NAME}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
create();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary text-sm"
|
||||
disabled={!name.trim()}
|
||||
onClick={create}
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
{t("folders.createHere")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{current ? (
|
||||
<button type="button" className="btn-ghost text-sm" onClick={() => pick(null)}>
|
||||
{t("folders.takeOut")}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The viewer's folders as filter chips, with new / rename / delete. */
|
||||
function FolderBar({
|
||||
folders,
|
||||
active,
|
||||
counts,
|
||||
onPick,
|
||||
}: {
|
||||
folders: { id: string; name: string }[];
|
||||
active: string;
|
||||
counts: (id: string) => number;
|
||||
onPick: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
// `new`: naming a new folder; a folder id: renaming that one.
|
||||
const [editing, setEditing] = useState<"new" | string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const begin = (what: "new" | string, start = "") => {
|
||||
setEditing(what);
|
||||
setName(start);
|
||||
};
|
||||
const commit = () => {
|
||||
if (editing === "new") {
|
||||
const id = createFolder(name);
|
||||
if (id) onPick(id);
|
||||
} else if (editing) renameFolder(editing, name);
|
||||
setEditing(null);
|
||||
};
|
||||
const remove = async (f: { id: string; name: string }) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("folders.deleteTitle", { name: f.name }),
|
||||
message: t("folders.deleteBody"),
|
||||
confirmLabel: t("folders.delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
deleteFolder(f.id);
|
||||
if (active === f.id) onPick("");
|
||||
};
|
||||
const chip = (on: boolean) =>
|
||||
`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap transition-colors ${
|
||||
on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary"
|
||||
}`;
|
||||
const activeFolder = folders.find((f) => f.id === active);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1" data-testid="artifact-folders">
|
||||
<Folder className="mr-1 h-3.5 w-3.5 text-fg-muted" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!active}
|
||||
className={chip(!active)}
|
||||
onClick={() => onPick("")}
|
||||
>
|
||||
{t("folders.all")}
|
||||
</button>
|
||||
{folders.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
aria-pressed={active === f.id}
|
||||
className={chip(active === f.id)}
|
||||
onClick={() => onPick(f.id)}
|
||||
>
|
||||
{f.name}
|
||||
<span className="text-fg-muted">{counts(f.id)}</span>
|
||||
</button>
|
||||
))}
|
||||
{editing ? (
|
||||
<input
|
||||
className="input h-7 w-40 text-xs"
|
||||
autoFocus
|
||||
maxLength={MAX_FOLDER_NAME}
|
||||
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
|
||||
placeholder={t("folders.newName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={chip(false)}
|
||||
onClick={() => begin("new")}
|
||||
data-testid="artifact-folder-new"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
{t("folders.new")}
|
||||
</button>
|
||||
)}
|
||||
{activeFolder && !editing && (
|
||||
<span className="ml-1 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost px-2 py-1 text-xs"
|
||||
onClick={() => begin(activeFolder.id, activeFolder.name)}
|
||||
>
|
||||
{t("folders.rename")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost px-2 py-1 text-xs text-status-danger"
|
||||
onClick={() => void remove(activeFolder)}
|
||||
>
|
||||
{t("folders.delete")}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Text the dashboard renders itself: Markdown through `MarkdownPreview`, the
|
||||
// rest as code. A sandboxed iframe would draw a text/plain body with the
|
||||
// browser's own colours — light text on our white frame in a dark theme.
|
||||
@@ -2287,6 +2784,23 @@ function PreviewDialog({
|
||||
<video src={url} controls className="max-h-full max-w-full" />
|
||||
)}
|
||||
</div>
|
||||
) : PDF.test(current) ? (
|
||||
<PdfView url={url} title={current} />
|
||||
) : html && item.kind === "page" && !/\.svg$/i.test(current) ? (
|
||||
// A page's copy is an app: it runs, in the chat artifacts'
|
||||
// sandbox (scripts, never the dashboard's origin), with its
|
||||
// own files read from the copy.
|
||||
<HtmlFrame
|
||||
path={current}
|
||||
title={item.title}
|
||||
className="h-full w-full"
|
||||
testId="artifact-page-frame"
|
||||
fetchFile={(rel) =>
|
||||
fetch(api.artifacts.rawUrl(item.id, rel), { credentials: "same-origin" }).then(
|
||||
(r) => (r.ok ? r.blob() : Promise.reject(new Error(String(r.status))))
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : IMAGE.test(current) && !html ? (
|
||||
<img src={url} alt={current} className="h-full w-full object-contain p-4" />
|
||||
) : (
|
||||
|
||||
@@ -75,6 +75,7 @@ import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSetting
|
||||
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -320,6 +321,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<OmpSharingSettings />
|
||||
{/* Members' test runs on this box: server mode + S3, an admin's. */}
|
||||
<MemberPolicySettings />
|
||||
{/* The company hub: enroll this machine (local) / your machines (hub). */}
|
||||
<CompanyHubSettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -28,6 +28,10 @@ import { api, type Artifact } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
|
||||
vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||
// pdf.js draws on a canvas jsdom does not have; the viewer is its own unit.
|
||||
vi.mock("../../components/PdfView", () => ({
|
||||
PdfView: ({ title }: { title: string }) => <div data-testid="pdf-view">{title}</div>,
|
||||
}));
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: { list: vi.fn() },
|
||||
@@ -35,6 +39,8 @@ vi.mock("../../lib/api", () => ({
|
||||
settings: vi.fn(),
|
||||
list: vi.fn(),
|
||||
update: vi.fn(),
|
||||
people: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
install: vi.fn(),
|
||||
updateInstalled: vi.fn(),
|
||||
@@ -386,6 +392,120 @@ describe("Artifacts", () => {
|
||||
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
||||
});
|
||||
|
||||
it("shares with the people picked, never with everyone unless asked", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({ items: [item({ id: 4, title: "deck" })] });
|
||||
vi.mocked(api.artifacts.people).mockResolvedValue({
|
||||
people: [
|
||||
{ id: 7, username: "ann", name: "Ann" },
|
||||
{ id: 8, username: "bob", name: "Bob" },
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.update).mockClear();
|
||||
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 4 }) } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-4");
|
||||
await menuAction(user, "Share", card);
|
||||
const dialog = await screen.findByTestId("share-dialog");
|
||||
const save = within(dialog).getByRole("button", { name: "Save" });
|
||||
expect(save).toBeDisabled(); // nobody picked yet
|
||||
await within(dialog).findByText("Ann");
|
||||
await user.type(within(dialog).getByLabelText("Find a person"), "an");
|
||||
expect(within(dialog).queryByText("Bob")).toBeNull();
|
||||
await user.click(within(dialog).getByRole("checkbox", { name: /Ann/ }));
|
||||
await user.click(save);
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.update).toHaveBeenCalledWith(4, { shared: false, sharedWith: [7] })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows who a row is shared with and lists Shared with you as its own tab", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 5, title: "mine", sharedWith: [{ id: 7, name: "Ann" }] }),
|
||||
item({ id: 6, title: "for-me", mine: false, canChange: false, sharedWithMe: true }),
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const badge = await screen.findByTestId("shared-people-5");
|
||||
expect(badge).toHaveTextContent("Shared with 1 person");
|
||||
expect(badge).toHaveAttribute("title", "Ann");
|
||||
// A row shared with me by name is "Shared by others", not private.
|
||||
expect(within(screen.getByTestId("artifact-6")).queryByText("Private")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Shared with you" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ scope: "withme" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("views a page's copy here: an app runs in the sandbox, a PDF is drawn, an older page is copied first", async () => {
|
||||
const page = (over: Partial<Artifact>) =>
|
||||
item({ kind: "page", url: "https://claude.ai/artifact/p", ...over });
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
page({ id: 11, title: "app", files: [{ path: "app.html", size: 9 }] }),
|
||||
page({ id: 12, title: "deck", files: [{ path: "deck-1.pdf", size: 9 }] }),
|
||||
page({ id: 13, title: "old", files: [], source: "/w/old.html" }),
|
||||
],
|
||||
});
|
||||
// The copy's own files come through the raw route.
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async () => new Response("<p>app</p>")) as typeof fetch;
|
||||
vi.mocked(api.artifacts.copy).mockResolvedValue({
|
||||
item: page({ id: 13, title: "old", files: [{ path: "old.html", size: 3 }] }),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await user.click(within(await screen.findByTestId("artifact-11")).getByTestId("artifact-view"));
|
||||
expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(within(screen.getByTestId("artifact-12")).getByTestId("artifact-view"));
|
||||
expect(await screen.findByTestId("pdf-view")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(within(screen.getByTestId("artifact-13")).getByTestId("artifact-view"));
|
||||
await waitFor(() => expect(api.artifacts.copy).toHaveBeenCalledWith(13));
|
||||
expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument();
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
it("keeps folders per account: make one, move an artifact in, filter by it", async () => {
|
||||
localStorage.removeItem("agenthub.artifacts.folders");
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("deploy");
|
||||
await user.click(screen.getByTestId("artifact-folder-new"));
|
||||
await user.type(screen.getByLabelText("Folder name"), "Slides{Enter}");
|
||||
// The new folder is picked at once, and empty.
|
||||
expect(await screen.findByTestId("artifacts-empty-filtered")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "All folders" }));
|
||||
await menuAction(user, "Move to folder…", screen.getByTestId("artifact-2"));
|
||||
const dialog = await screen.findByTestId("move-dialog");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Slides" }));
|
||||
const stored = JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!);
|
||||
expect(Object.values(stored.of)).toHaveLength(1);
|
||||
expect(stored.of["2"]).toBe(stored.folders[0].id);
|
||||
await user.click(screen.getByRole("button", { name: /^Slides/ }));
|
||||
expect(await screen.findByTestId("artifact-2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("artifact-1")).toBeNull();
|
||||
localStorage.removeItem("agenthub.artifacts.folders");
|
||||
});
|
||||
|
||||
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
@@ -404,7 +524,9 @@ describe("Artifacts", () => {
|
||||
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await menuAction(user, "Remove from Hub", card);
|
||||
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false, sharedWith: [] })
|
||||
);
|
||||
const calls = vi.mocked(confirmDialog).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||
});
|
||||
|
||||
@@ -91,6 +91,13 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
// AI tools → Company hub (local: not enrolled; hub: nothing yet).
|
||||
remoteClient: { status: vi.fn(async () => ({ enrolled: false })) },
|
||||
remoteHub: {
|
||||
audit: vi.fn(async () => ({ entries: [] })),
|
||||
devices: vi.fn(async () => ({ devices: [] })),
|
||||
sessions: vi.fn(async () => ({ sessions: [] })),
|
||||
},
|
||||
// AI tools → Profiles.
|
||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||
ccConfig: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { AuthState, ServerUserUsage } from "../../lib/api";
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
usePermissions: () => ({ isAdmin: true, local: false }),
|
||||
}));
|
||||
|
||||
const usage = { cost: 0, tokens: 0 };
|
||||
@@ -56,6 +57,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
users,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
remoteHub: {
|
||||
devices: vi.fn(async () => ({ devices: [] })),
|
||||
sessions: vi.fn(async () => ({ sessions: [] })),
|
||||
},
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.70 (rebuild client to match)
|
||||
UI build v2.8.71 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+13
-6
@@ -1366,8 +1366,10 @@ cannot collide on `index.lock`.
|
||||
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
|
||||
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
|
||||
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
|
||||
through the same `git status` check as `discard`. `unshelve` refuses a
|
||||
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
|
||||
through the same `git status` check as `discard`. `unshelve` refuses
|
||||
anything staged and any change in one of the shelf's own files
|
||||
(`400 EDIRTY`, those files in `paths`) so a restore never mixes with new
|
||||
work — changes in other files (the ones a partial `shelve` left) stay put; a clean
|
||||
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
|
||||
answers `conflicts: [...]`, `kept: true` — the files are resolved with
|
||||
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
|
||||
@@ -3514,8 +3516,8 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
||||
|
||||
```http
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, exportsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub (installs: server mode only)
|
||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
|
||||
POST /api/artifacts/page {url, title?, description?}
|
||||
@@ -3527,11 +3529,13 @@ POST /api/artifacts/skill/new {name, description, content, titl
|
||||
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
|
||||
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
|
||||
GET /api/artifacts/:id {item}
|
||||
PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?, skill?: {name, description, content}} (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
|
||||
GET /api/artifacts/people → {people: [{id, username, name}]} — server mode: every enabled account but the caller, names only (who an artifact can be shared with)
|
||||
PATCH /api/artifacts/:id {title?, description?, shared?, sharedWith?: [userId], source?, market?, skill?: {name, description, content}} (sharedWith: server mode, replaces who it is shared with by name — up to 100 enabled users, the owner dropped; a row out of its owner's library shared with nobody is deleted, `removed`) (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
|
||||
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
|
||||
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
POST /api/artifacts/:id/copy → {item} — a page captured before pages kept a copy: snapshot the HTML file it was published from, if still in its workspace (once per row per server run; any viewer, who gets only the copy)
|
||||
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
|
||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||
@@ -3554,7 +3558,10 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list.
|
||||
- **a skill follows its source** — a skill row taken from a folder (its owner's `~/.claude/skills/<name>`, or the workspace's `.claude/skills/<name>`; the host's in local mode) is brought up to that folder whenever the Hub is listed (`syncSkillSources`, at most every 15 s; only a folder whose file names, sizes or mtimes changed is read again, through the same checks as a pick — no symlink, no secret, ≤ 5 MB — a member's files as theirs). A change is a NEW snapshot directory, then the old one dropped, and an `artifact_changed` broadcast. `artifacts.version` is a snapshot's content hash and `source_hash` the folder's when last taken: an upload or a Hub edit records the folder as it is then, so only a LATER change to the folder replaces it — the newer change wins. A list row's `updateAvailable` is true when the skill is `installed` and the viewer's `~/.claude/skills/<name>` (any account they run as) differs from `version`; the page then shows **Update**. `install` with `update: true` (a skill, `ENOTINSTALLED` when it is not installed) replaces those copies like `replace: true` — the old ones moved aside — and is not counted as another install. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared, recipients}`) reaches everyone for a shared row, else its owner, the people it is shared with by name (`recipients`) and staff.
|
||||
- **Shared with chosen people** (`artifact_shares`, one row per person): `shared = 1` stays "everyone" (the Hub); a row may instead be shared with named users only. They see it (`canSee`), may install it, and find it under `?scope=withme` (**Shared with you**); `sharedWith` (the list) is shown only to the owner or an admin, `sharedWithMe` tells a recipient. Share again never widens a row shared by name to everyone. Changes are audited (`shared with ann, bob`).
|
||||
- **A page's own copy** (a claude.ai page is private to the account that published it): a publish from an HTML file inside the session's workspace snapshots that file plus the supporting files the tool named (`files`), under the pinned-file guards (read as the folder's account, no secret names, 25 MB in all), and a republish of the same link replaces it. A Claude Docs doc or a typed artifact (Slides, Design…) has no such file: `scripts/artifact-export-hint.js` (a synchronous PostToolUse hook, AgentHub panes only, off when the pane carries `AGENTHUB_ARTIFACT_EXPORT=0` — the admin's `exportsEnabled` switch) asks Claude to save `artifacts-export/<artifact id>.(md|pdf|html)` in the workspace root, and the Stop capture attaches that file to the page's row (no second card) and git-excludes the folder. The page then shows **View**: an HTML copy runs in `HtmlFrame`'s sandbox (scripts, never the dashboard's origin), Markdown in `MarkdownPreview`, a PDF drawn by pdf.js (`client/src/components/PdfView.tsx`, legacy build, loaded only when a PDF is opened). The claude.ai link stays under ⋯ → Open.
|
||||
- **Folders** are the viewer's own arrangement, not an artifact fact: the account preference `agenthub.artifacts.folders` (`/api/me/prefs`, 64 KB; `client/src/lib/artifactFolders.ts`), never seen by anyone else; deleting a folder only takes its artifacts out of it.
|
||||
|
||||
### Query Explorer
|
||||
|
||||
|
||||
+3
-1
@@ -552,7 +552,9 @@ you made in a terminal is never shown or touched. **Shelve (n)** beside
|
||||
ones included) with an optional one-line note; **Shelve and switch** does the
|
||||
same for every change before a switch, and puts the shelf straight back if the
|
||||
switch then fails. The **Shelves (n)** list above the files shows each shelf's
|
||||
branch, note, time and files, with **Restore** (on a clean tree only; a clean
|
||||
branch, note, time and files, with **Restore** (refused while anything is staged or one of the shelf's own files
|
||||
has changed since — the files a partial Shelve (n) left behind do not block
|
||||
it; a clean
|
||||
restore deletes the shelf, a conflicted one keeps it and lists the files under
|
||||
**Merge conflicts** — *Keep mine* is the branch, *Take theirs* the shelf — and
|
||||
you delete the shelf yourself once they are settled) and **Delete** (two
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ checkout unnecessary.
|
||||
|
||||
| Component | Where it comes from |
|
||||
|---|---|
|
||||
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` |
|
||||
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` — plus one synchronous `PostToolUse` entry (matcher `Artifact\|mcp__claude_ai_.*__batch`) running `scripts/artifact-export-hint.js`, which asks Claude for a viewable copy of a Doc / Slides / Design it just published (see docs/API.md § Artifacts hub) |
|
||||
| The dashboard server | started detached by the bootstrap, from the plugin cache |
|
||||
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
|
||||
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
|
||||
|
||||
+45
-1
@@ -534,6 +534,48 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
> launch file instead of tmux `-e`), but no other account's process or file.
|
||||
> The AI Proxy gateway does not cover pool tokens.
|
||||
|
||||
## Users' own machines (Company account)
|
||||
|
||||
A user with their own computer can run **AgentHub local** there and still use
|
||||
the Claude account pool. The pool's logins never leave this server: their
|
||||
sessions go through the server's gateway, which adds the account. Only those
|
||||
sessions are reported back here — their conversation and token usage, once an
|
||||
hour and when the session ends. A session on the user's own login sends
|
||||
nothing, and the server never reads or runs anything on their machine.
|
||||
Design and limits: `docs/plans/remote-clients/README.md`.
|
||||
|
||||
On the user's machine (company LAN only, plain HTTP):
|
||||
|
||||
```bash
|
||||
agenthub remote login http://10.1.13.7:4820 --user mia --name mia-laptop # hub password, asked hidden
|
||||
agenthub remote status # hub, device, what was sent and when
|
||||
agenthub remote upload # send now instead of waiting for the hour
|
||||
agenthub remote logout # the token stops working on the server at once
|
||||
```
|
||||
|
||||
or Settings → AI tools → **Company hub**. The machine then gets **Company
|
||||
account** in the AI Proxy menu of a new Claude session; that choice is kept on
|
||||
a resume. The device token lasts 90 days; after it expires or is revoked, log
|
||||
in again and resume any session that was open (a running pane keeps the token
|
||||
it started with).
|
||||
|
||||
On the server: Settings → AI tools → Company hub lists the user's own
|
||||
machines (Revoke, their password again) and who opened their sessions; an
|
||||
admin sees and revokes any user's machines on the **Users** page. Their
|
||||
sessions are listed device › folder, with what the gateway counted; one used
|
||||
with the device token outside AgentHub is flagged **unmonitored**, and an
|
||||
alert rule of type **Company account outside AgentHub** (Settings → Alerts)
|
||||
fires for it once it has stayed unregistered for a minute.
|
||||
|
||||
| Variable (server) | Default | Effect |
|
||||
|---|---|---|
|
||||
| `AGENTHUB_REMOTE_GATE` | `warn` | `block` refuses a session AgentHub did not register (403) |
|
||||
| `AGENTHUB_REMOTE_RETAIN_DAYS` | `30` | conversations older than this are deleted; token rows stay |
|
||||
| `AGENTHUB_DEVICE_TOKEN_DAYS` | `90` | device token lifetime |
|
||||
| `AGENTHUB_REMOTE_UPSTREAM` | `https://api.anthropic.com` | where the gateway relays |
|
||||
| `AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` | `60000` | how long a session may stay unregistered before the alert fires |
|
||||
| `AGENTHUB_REMOTE_FLUSH_MS` | `120000` | an upload round that stopped half-way is imported this long after its last chunk |
|
||||
|
||||
## Codex account pool
|
||||
|
||||
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
||||
@@ -1054,7 +1096,9 @@ a pane really is. Each case below is refused or bounded, with a test in
|
||||
running host server uses that server's own binary (`/proc/<pid>/exe`, found
|
||||
from its socket in `/proc`), so installing a newer tmux under a running
|
||||
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
|
||||
100% CPU.
|
||||
100% CPU. That binary is cached for 10 s; once its server has exited (the
|
||||
last pane stopped) the call falls back to PATH's `tmux` instead of failing
|
||||
with `ENOENT`.
|
||||
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
|
||||
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
|
||||
the call with the binary of the member's running server (`/proc/<pid>/exe`,
|
||||
|
||||
@@ -14,7 +14,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||
|
||||
- **Local mode:** one personal library, no sharing UI.
|
||||
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it; a shared artifact appears in the **Hub** for every signed-in user. A shared skill or plugin can be **installed** into the viewer's own account.
|
||||
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file, or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account.
|
||||
|
||||
## Rules (binding, same spirit as CLAUDE.md)
|
||||
|
||||
@@ -22,7 +22,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
|
||||
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
|
||||
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
|
||||
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
|
||||
- **Visibility follows ownership:** a developer sees their own artifacts plus those shared with everyone or with them by name; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
|
||||
|
||||
## Data
|
||||
|
||||
|
||||
+186
-140
@@ -1,175 +1,221 @@
|
||||
# Remote clients — work runs on each user's machine, the hub only manages
|
||||
# Remote clients — work runs on each user's machine, the hub only records company spend
|
||||
|
||||
Status: **PLAN, not built.** Decided 2026-10-09. Branch `server-s3` (one Linux
|
||||
user per account on the hub) stays as it is until this plan is proven or dropped.
|
||||
Status: **B1–B3 built 2026-11-04 for the Claude pool** (§ Built); B4 and
|
||||
Codex not started. First decided 2026-10-09, re-scoped 2026-11-04 (this
|
||||
version). Server mode with S3 stays and runs side by side: a user with a
|
||||
weak machine keeps working on the hub, a user with their own machine runs
|
||||
AgentHub local and enrolls it.
|
||||
|
||||
## Why
|
||||
|
||||
Everyone logging into one machine (server mode, S3) does not scale past ~10
|
||||
concurrent users: every pane, build and test runs on the hub. Here the compute
|
||||
moves to each user's machine; the hub keeps identity, the shared accounts, and
|
||||
every workspace/session/conversation/token record.
|
||||
the record of what the COMPANY's accounts were used for.
|
||||
|
||||
## Decisions (user, 2026-10-09)
|
||||
## Decisions (user, 2026-11-04 — replaces the 10-09 list)
|
||||
|
||||
1. Each user installs **AgentHub local** on their own machine.
|
||||
2. The **full conversation** is uploaded to the hub — only for sessions started
|
||||
through AgentHub.
|
||||
3. Shared accounts (the Claude pool, shared AI Proxies) are usable **only through
|
||||
AgentHub**. The company has at least as many seats as people; a seat may be
|
||||
used by more than one person.
|
||||
4. Use without AgentHub is **detected and warned about**, and **blocked when an
|
||||
admin turns blocking on**.
|
||||
5. The hub is reached over the company LAN at `http://10.1.13.7:4820` — **no
|
||||
HTTPS**.
|
||||
6. An admin sees, in one click from the Users page, which machines a user has,
|
||||
which workspaces they used, and what each session did.
|
||||
7. `server-s3` is kept until this architecture is settled.
|
||||
1. Each user may install **AgentHub local** on their own machine and enroll it
|
||||
with the hub once. **S3 stays** alongside it.
|
||||
2. **The account decides, not the workspace.** Only a session that runs on a
|
||||
company account — the hub's pool or a shared AI Proxy, i.e. one that goes
|
||||
through the hub's gateway — is reported. A session on the user's OWN login
|
||||
or own proxy sends **nothing**: no conversation, no tokens, no heartbeat,
|
||||
not even the workspace's name. Neither does anything run outside AgentHub.
|
||||
3. What a reported session sends: its **conversation and token usage**, never
|
||||
the repository. The hub **never reads the client's source tree, never runs
|
||||
anything on the client, never drives it** (no terminal, no start/stop, no
|
||||
file access). The client pushes; the hub never connects to a client.
|
||||
4. Cadence: **one registration** when a company-account session starts
|
||||
(session id + device, no content — what the gateway checks), then
|
||||
conversation and tokens **once an hour** and once more when the session
|
||||
ends. No per-minute heartbeat (user, 11-04). A queue on disk carries what
|
||||
an offline machine could not send; it is sent back in the office.
|
||||
The conversation is uploaded **whole** (tool results and code included —
|
||||
user, 11-04: no stripping); only strings that look like keys, tokens or
|
||||
passwords are redacted before sending.
|
||||
The hub keeps a conversation **30 days**; token and cost rows are kept.
|
||||
5. The company account's credential **never leaves the hub**. A client holds a
|
||||
device token only; its Claude calls go through the hub's gateway, which adds
|
||||
the real credential. Revoking the device token ends access at once.
|
||||
6. Use of a device token outside AgentHub is **detected**; `remote_gate`
|
||||
decides `warn` (default: forward, mark the session unmonitored, alert) or
|
||||
`block` (403).
|
||||
7. The hub is reached over the company LAN only (`10.1.13.7`); a device token
|
||||
**expires after 90 days** (`agenthub remote login` again). At home a
|
||||
company-account session simply cannot reach the hub — AgentHub local says
|
||||
so plainly — while the user's own login works as usual. Working from home
|
||||
later means a VPN, never exposing the hub. **No HTTPS** (user, 11-04): plain HTTP
|
||||
on the company LAN, accepting that someone already inside the network
|
||||
could sniff conversations, hub passwords and device tokens. Adding it
|
||||
later is a reverse proxy in front of the hub (docs/SERVER-MODE.md § Put
|
||||
HTTPS in front of it), no code change.
|
||||
8. Company vs own is decided by the **account picked at session start**: an
|
||||
enrolled machine's account menu gains "Company account", whose pane talks
|
||||
to the hub's gateway; the default login and the user's own AI Proxies are
|
||||
their own. It is recorded on the run (`dashboard_runs.proxy`), shown as a
|
||||
"Company" badge, and a resume keeps it. A company account is usable only
|
||||
through the gateway, so neither side can be mislabelled.
|
||||
9. Each user can see, on their local AgentHub, what was sent for each
|
||||
session and when, and on the hub who (admin/analyst) opened their
|
||||
sessions (`audit_log`).
|
||||
10. On the hub a client's sessions are shown grouped **device › folder** — no
|
||||
lane rows are created for them.
|
||||
|
||||
## Shape
|
||||
|
||||
```
|
||||
user machine — AgentHub LOCAL hub 10.1.13.7 — AgentHub SERVER mode
|
||||
┌───────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ pane: claude │─hooks +▶│ /api/remote/ingest (device token)│
|
||||
│ ANTHROPIC_BASE_URL = hub gateway│ transcr.│ → sessions/events/tokens, owned │
|
||||
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ by the device's user │
|
||||
│ │─/v1/… ─▶│ /api/proxy-gw → pool token added │
|
||||
└───────────────────────────────────┘ └──────────────────────────────────┘
|
||||
user machine — AgentHub LOCAL hub 10.1.13.7 — SERVER mode
|
||||
┌──────────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ pane on a COMPANY account: │─/v1/… ─▶│ gateway: device token + session │
|
||||
│ ANTHROPIC_BASE_URL = hub gateway │ │ check → pool credential added │
|
||||
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ → Anthropic; usage counted now │
|
||||
│ │─start ─▶│ session registered (id only) │
|
||||
│ │─hourly▶│ conversation + tokens │
|
||||
│ pane on the user's OWN login │ (none) │ │
|
||||
└──────────────────────────────────────┘ └──────────────────────────────────┘
|
||||
```
|
||||
|
||||
No pool token or proxy key ever reaches a user's machine; it holds one device
|
||||
token. Plain HTTP means anyone sniffing the LAN can read that token and the
|
||||
conversations — accepted for an internal network. Moving to HTTPS later is a
|
||||
reverse proxy in front of the hub (Caddy `tls internal`), no code change.
|
||||
## What "conversation, not source" can and cannot mean
|
||||
|
||||
## What the hub can see (per tool, checked 2026-10-09)
|
||||
The hub stores no file of the repository. But a conversation is made of what
|
||||
the agent read and wrote: a transcript's tool results hold the files Claude
|
||||
`Read`, its `Edit`/`Write` inputs hold code, and every request through the
|
||||
gateway carries the whole context (code included) on its way to Anthropic.
|
||||
So "conversation only" still contains source excerpts. Decided (user,
|
||||
11-04): they are NOT stripped — the transcript goes whole, only secret-shaped
|
||||
strings are redacted.
|
||||
|
||||
Uploads cover every session the machine's AgentHub started, in ANY of its
|
||||
workspaces — company repo or the user's own project alike. A session run
|
||||
outside AgentHub on the user's own login is invisible; only pool use is gated.
|
||||
## Gateway checks (verified 2026-10-09, Claude Code 2.1.295)
|
||||
|
||||
| Tool | Conversation | Tokens / cost | Pool / gate | Reader today |
|
||||
|---|---|---|---|---|
|
||||
| Claude Code | yes | yes | yes (R3) | hooks + transcript |
|
||||
| Codex | yes | yes | not yet (Codex pool = later) | `codex-usage.js` rollouts |
|
||||
| Antigravity | no — needs a renderer for `transcript_full.jsonl` | yes | no (own login) | `antigravity-usage.js` |
|
||||
| Oh My Pi, other catalog tools | no | no | no | none: only the pane run (workspace, time) |
|
||||
| DeepSeek CLI / not in catalog | no | no | no | not launchable as a tool; a shell pane records nothing |
|
||||
|
||||
R2 therefore uploads Claude transcripts, Codex rollouts and agy conversation
|
||||
files (`remote-sync.js` today mirrors only the first two). Any tool beyond that
|
||||
needs its own reader first — one per tool, on demand.
|
||||
|
||||
## Gating
|
||||
|
||||
Verified 2026-10-09 against Claude Code 2.1.295 with a capture server: every
|
||||
`/v1/messages` call carries `x-claude-code-session-id: <id>` (same id in
|
||||
`metadata.user_id`), and `--session-id <uuid>` sets it — `spawnRun` already
|
||||
starts Claude panes that way.
|
||||
|
||||
The gateway checks each request:
|
||||
Every `/v1/messages` call carries `x-claude-code-session-id: <id>`, and
|
||||
`--session-id <uuid>` sets it — `spawnRun` already starts Claude panes that
|
||||
way. The gateway checks each request:
|
||||
- bearer = a device token that is not revoked, of a user who is not disabled —
|
||||
else **always 401**;
|
||||
- `x-claude-code-session-id` = a session the hub received uploads for, from
|
||||
THAT device, within `REMOTE_LIVE_SEC` (300 s; 60 s grace for a new session).
|
||||
- `x-claude-code-session-id` = a session that SAME device registered at its
|
||||
start. The registration, not the hourly upload, is what this check reads.
|
||||
|
||||
A request failing the second check is a session AgentHub is not uploading
|
||||
(token copied out of AgentHub, or uploads switched off). Setting
|
||||
`remote_gate` decides: `warn` (default) forwards it and records the session
|
||||
as **unmonitored** with its token count from the response's `usage`, and
|
||||
fires an alert (existing `fireAlert`); `block` refuses it with 403. Ceiling:
|
||||
someone who also fakes the upload stream passes — detection, not proof.
|
||||
|
||||
Relay spike (2026-10-09, Claude Code 2.1.295, script kept in
|
||||
`~/.cache/agenthub-spike/relay.js`): a client with NO login and
|
||||
`ANTHROPIC_AUTH_TOKEN=<fake>` worked through a relay that swapped in a team
|
||||
subscription's OAuth token (+ `anthropic-beta: oauth-2025-04-20`). Checked:
|
||||
answers, `--resume`, a local Bash tool call, prompt cache reads on every turn
|
||||
(18–26k tokens), `anthropic-ratelimit-unified-*` headers passed back. Three
|
||||
findings for R3:
|
||||
- Through a base URL Claude Code writes **5-minute** cache entries; set
|
||||
`ENABLE_PROMPT_CACHING_1H=1` in the pane (verified: 1h entries) to keep the
|
||||
subscription's 1-hour TTL.
|
||||
- The cache is per ACCOUNT (a fresh client read 18k cached tokens on its first
|
||||
call), so the gateway must keep a session on ONE pool account — switching
|
||||
accounts mid-session re-writes the whole cache.
|
||||
- A repo's `.claude/settings*.json` `env` overrides the pane's
|
||||
`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (seen in this repo) — give the
|
||||
gateway through `--settings=<file>`, the layer that outranks project settings,
|
||||
as AI Proxy panes already do.
|
||||
Not checked yet (interactive features that call claude.ai with the user's own
|
||||
login, likely unavailable): `/usage` quota view, claude.ai MCP connectors,
|
||||
Remote Control / web sessions, org plugins from claude.ai.
|
||||
Relay spike (2026-10-09, script `~/.cache/agenthub-spike/relay.js`): a client
|
||||
with no login and a fake `ANTHROPIC_AUTH_TOKEN` worked through a relay that
|
||||
swapped in a team subscription's OAuth token (+ `anthropic-beta:
|
||||
oauth-2025-04-20`): answers, `--resume`, local tools, prompt-cache reads,
|
||||
rate-limit headers. Rules it gave:
|
||||
- set `ENABLE_PROMPT_CACHING_1H=1` in the pane (a base URL drops to 5-minute
|
||||
cache entries otherwise);
|
||||
- keep a session on ONE pool account (the cache is per account);
|
||||
- give the gateway through `--settings=<file>` (a repo's `.claude/settings*.json`
|
||||
`env` overrides the pane env), as AI Proxy panes already do.
|
||||
Not checked: `/usage`, claude.ai MCP connectors, Remote Control — likely lost
|
||||
through a relay.
|
||||
|
||||
What is and is not guaranteed:
|
||||
- **Guaranteed:** no hub account (or a disabled user / revoked device) → no
|
||||
issued credential, because the credential never leaves the hub. This holds
|
||||
only while the ADMIN alone holds each shared seat's login (setup-tokens
|
||||
made by the admin). A seat whose login (email/SSO) an employee knows can
|
||||
always be used directly at claude.ai or in a plain `claude` — AgentHub cannot
|
||||
stop that.
|
||||
- **Not guaranteed:** that the AgentHub APP was used. The device token sits on
|
||||
a machine the user controls; `block` stops a plain `claude` using it, unless
|
||||
the user also fakes the upload stream. Hard enforcement needs managed,
|
||||
locked-down machines.
|
||||
- **Closes the visibility gap (R3b):** the gateway sees every request body, so
|
||||
it can record the conversation of every pool request ITSELF — new messages
|
||||
per request by session id, plus the streamed answer and its `usage` — no
|
||||
matter which client sent it. Then nothing that uses an issued credential is
|
||||
invisible to the admin, AgentHub app or not.
|
||||
- **Plain HTTP:** a LAN sniffer can take a device token and act as that user.
|
||||
If that matters, HTTPS (reverse proxy + an internal CA every machine trusts,
|
||||
`NODE_EXTRA_CA_CERTS` for Claude Code).
|
||||
- **Guaranteed:** no hub account / disabled user / revoked device → no company
|
||||
credential, because it never leaves the hub — as long as the admin alone
|
||||
holds each shared seat's login.
|
||||
- **Not guaranteed:** that the AgentHub app was used. A device token copied out
|
||||
and used with a plain `claude` is caught by the registration check, unless
|
||||
the user also fakes a registration. Hard enforcement needs managed machines.
|
||||
- Quota and budget on company accounts stay **real-time** despite the hourly
|
||||
upload: the gateway counts `usage` from each response it relays.
|
||||
|
||||
## What already exists (2026-11-04)
|
||||
|
||||
| Need | In the code | Missing |
|
||||
|---|---|---|
|
||||
| Hooks from another machine | `POST /api/hooks/ingest-batch` + `push-token.js` (`push:<source>`) | one hub-wide token, no user/device; transcript dropped |
|
||||
| Credential swap | `proxy-gateway.js` (pane token → real key, SSE streamed) | loopback + pane token only |
|
||||
| Shared accounts | pools, pool groups, pins, own choice, `refreshLogins` | a path for a remote caller |
|
||||
| Spend per user | `pool-usage.js`, `user-quota.js`, `session-account.js`, Administration user detail | ownership of a session with no lane |
|
||||
| Transcript import | `importFromDirectory`, `codex-usage.js` | an upload path |
|
||||
|
||||
## Phases
|
||||
|
||||
Each one ships alone and changes nothing for a local-only install.
|
||||
Each ships alone and changes nothing for a local-only install.
|
||||
|
||||
**R0 — user activity view (hub, useful today).** Users page row → a user
|
||||
page: their devices (once R1 exists), workspaces, and sessions by day with
|
||||
tokens/cost; a session opens the existing session page (conversation, tool
|
||||
calls). Backend: an `owner=<user id>` filter for staff on `GET /api/sessions`
|
||||
and `GET /api/lanes`, reusing `sessionWhere`/`laneWhere` for that user — no
|
||||
new query logic. Admin reads are audited as they already are.
|
||||
Verify: server test for the filter (developer cannot use it); client snapshot.
|
||||
**B1 — devices and ownership.** Table `devices` (id, user_id, name,
|
||||
token_hash, created_at, last_seen_at, revoked_at); `agenthub remote login
|
||||
http://10.1.13.7:4820` (hub password, throttled like `/api/auth/login`) stores
|
||||
an `ahdev_` token 0600 in the runtime dir; an admin lists and revokes devices
|
||||
on the user's page (step-up + `audit_log`). Column `sessions.owner_id`,
|
||||
written from the device's user; `sessionWhere` honours it, so scoping needs no
|
||||
lane. Verify: mint/hash/revoke tests; revoked → 401; a developer sees only
|
||||
their own device sessions.
|
||||
|
||||
**R1 — devices (hub + CLI).** Table `devices` (id, user_id, name, token_hash,
|
||||
created_at, last_seen_at, revoked_at). `agenthub remote login
|
||||
http://10.1.13.7:4820` signs in with the user's hub password (throttled like
|
||||
`/api/auth/login`), receives an `ahdev_` token once, stores it 0600 in the
|
||||
runtime dir. The user page lists devices; an admin revokes one (re-auth +
|
||||
`audit_log`).
|
||||
Verify: mint/hash/revoke tests; a revoked token gets 401.
|
||||
**B2 — gateway for the Claude pool over LAN.** `proxy-gateway.js` gains the
|
||||
device-token path: heartbeat check, `pickAccount` + session-sticky account,
|
||||
`usage` counted per response. The local `spawnRun`, on an enrolled machine and
|
||||
only when the user picks a company account, points the pane at the hub through
|
||||
`--settings=` and sets `ENABLE_PROMPT_CACHING_1H=1`. Spike first on one real
|
||||
pool account. Verify: a pane answers "pong"; a curl with a device token and an
|
||||
unknown session id is logged (`warn`) or 403 (`block`).
|
||||
|
||||
**R2 — upload (both sides).** An enrolled local AgentHub uploads each of ITS
|
||||
sessions (`dashboard_runs`): hook events as they arrive, the session files of
|
||||
the table above (Claude transcript, Codex rollout, agy conversation)
|
||||
incrementally (byte offset, retry queue on disk, never blocking a hook). The
|
||||
hub stores transcripts under `<data dir>/remote/<user id>/<device id>/projects/`
|
||||
and imports them with the SAME `importFromDirectory` `remote-sync.js` uses, so
|
||||
tokens and cost match local sessions. Sessions are owned by the device's user
|
||||
(`source = device:<id>`); workspaces are mirrored as records (title, folder,
|
||||
tool) — shown, not driven. The existing `ingest-batch` stays as it is.
|
||||
Verify: two local instances → one hub; R0's page shows both users' work.
|
||||
**B3 — registration and hourly upload.** For company-account sessions only:
|
||||
one registration at start; conversation (Claude transcript, Codex rollout) and
|
||||
tokens hourly and at session end, incremental by byte offset, disk queue,
|
||||
never blocking a hook. The hub imports with `importFromDirectory` so cost
|
||||
matches local sessions; shown device › folder on the user's page.
|
||||
|
||||
**R3 — remote gateway.** The local `spawnRun` points an enrolled machine's
|
||||
Claude pane at the hub (`ANTHROPIC_BASE_URL`, device token as
|
||||
`ANTHROPIC_AUTH_TOKEN`, through tmux `-e`). `proxy-gateway.js` (from
|
||||
origin/main since 84e2a9f0) gains the remote path and checks above; for the pool it picks an
|
||||
account with `pickAccount` and sends its OAuth token. **Spike first** on ONE
|
||||
real pool account: which headers a setup-token needs through a relay.
|
||||
Verify: a pane gets "pong"; a curl with the device token and a session id
|
||||
nobody uploads is logged (`warn`) or 403 (`block`).
|
||||
**B4 — shared skills/plugins.** No AgentHub code: company marketplace (git) +
|
||||
a `managed-settings.json` template for user machines.
|
||||
|
||||
**R4 — shared skills/plugins.** No AgentHub code: the company marketplace (git)
|
||||
plus a `managed-settings.json` template (`extraKnownMarketplaces`,
|
||||
`enabledPlugins`) and an install script for user machines.
|
||||
Codex comes after B2 works for Claude (ChatGPT-login auth differs from an API
|
||||
key; its session-id header is unchecked).
|
||||
|
||||
Codex comes after R3 works for Claude: ChatGPT-account auth differs from an API
|
||||
key, and its session-id header still has to be checked.
|
||||
## Built (2026-11-04)
|
||||
|
||||
Hub side — `server/hub/`, loaded only in server mode (`server/index.js`
|
||||
requires it inside `isServerMode()`; `server/__tests__/hub-boundary.test.js`
|
||||
fails if anything outside the folder requires it):
|
||||
|
||||
| File | Does |
|
||||
|---|---|
|
||||
| `devices.js` | `ahdev_<32 bytes base64url>` tokens, only the SHA-256 kept; 90 days (`AGENTHUB_DEVICE_TOKEN_DAYS`); revoke |
|
||||
| `remote-sessions.js` | registration (UUID, absolute cwd, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
|
||||
| `remote-gateway.js` | `/api/remote/gw/v1/*`: device token → registered session (waits `AGENTHUB_REMOTE_REGISTER_GRACE_MS`, 5 s, for one in flight) → `claude-pool.forRemote` (sticky per session) → upstream `AGENTHUB_REMOTE_UPSTREAM`; usage read from a decompressed COPY of the answer (Anthropic sends it gzip/br) |
|
||||
| `remote-ingest.js` | upload chunks at the hub's own offset (`409 EOFFSET` + `expected`), copy under `<data>/remote/u<uid>/d<did>/<sid>/`, `importFromDirectory` once per round (chunks carry `more: true`, the round's closing request imports; a round cut short is imported after `AGENTHUB_REMOTE_FLUSH_MS`, 2 min), then `sessions.source = device:<id>`, `owner_id`; `purgeOld` after `AGENTHUB_REMOTE_RETAIN_DAYS` (30) keeps token rows |
|
||||
| `index.js` | `/api/remote/device/*` (device token: login, me, logout, sessions, end, files, upload), `/api/remote/*` (signed in: devices, revoke with password + audit, sessions, "who opened mine"), hourly purge |
|
||||
|
||||
Shared: `sessions.owner_id` (scope.js, session-account.js, users.js honour
|
||||
it); `claude-pool.forRemote` and live remote sessions in the pool ranking;
|
||||
`auth.checkLogin`.
|
||||
|
||||
Client side (local mode): `server/lib/remote-client.js` +
|
||||
`/api/remote-client` + `agenthub remote login|status|upload|logout`, Settings →
|
||||
AI tools → Company hub. The reserved AI Proxy name `agenthub.company` ("Company
|
||||
account", Claude only) writes `<data>/proxy-settings/agenthub.company.json` with the
|
||||
gateway URL, the device token and `ENABLE_PROMPT_CACHING_1H=1`. A company
|
||||
session is registered at spawn, again on a new session id (SessionStart /
|
||||
UserPromptSubmit hooks: `/clear`, a fork), uploaded hourly
|
||||
(`DASHBOARD_REMOTE_UPLOAD_MS`) and soon after SessionEnd; every JSON line goes
|
||||
through `privacy.redactValue` first.
|
||||
|
||||
Measured (2026-11-04): local mode loads none of `server/hub/` (216 server
|
||||
modules vs 214 before: `remote-client.js` + its route); startup and RSS
|
||||
unchanged within noise. E2E on one real pool account: answers, sticky account,
|
||||
`/clear`, resume, hub totals = transcript totals, no device token or key in the
|
||||
hub copy, revoke → 401, hub down → `503 EHUBDOWN` at start.
|
||||
|
||||
Known limits:
|
||||
- Claude Code reads `ANTHROPIC_AUTH_TOKEN` once: a pane open across a
|
||||
re-login (expired or revoked token) keeps failing with 401 until it is
|
||||
resumed.
|
||||
- An unmonitored session is flagged on the hub (`unmonitored`, a warning in
|
||||
the log) and, once it has stayed unregistered for
|
||||
`AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` (60 s), fires every enabled
|
||||
**Company account outside AgentHub** alert rule (`remote_unmonitored`). A
|
||||
registration that was only late clears the flag first.
|
||||
- `AGENTHUB_REMOTE_GATE=block` is covered by unit tests only.
|
||||
- A shared AI Proxy as a company account, and Codex, are not built.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should an admin be able to mark a workspace "company account required"?
|
||||
(Today's answer: no — a user's own login in any workspace is their business.)
|
||||
|
||||
## Open risks
|
||||
|
||||
- A hub outage stops every pool session; a machine can still use its own login.
|
||||
- The hub stores every conversation of an AgentHub session; tell users.
|
||||
- A hub outage stops every company-account session; the machine can still use
|
||||
its own login.
|
||||
- Every byte of a company-account session's context passes through the hub in
|
||||
transit; tell users.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.70",
|
||||
"version": "2.8.71",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.70",
|
||||
"version": "2.8.71",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.70",
|
||||
"version": "2.8.71",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @file artifact-export-hint.js — a synchronous PostToolUse hook (matcher
|
||||
* `Artifact` and the claude.ai connectors' `batch`) that asks Claude, right
|
||||
* after it publishes a Claude Docs doc or a typed artifact (Slides, Design,
|
||||
* Dashboard…), to also save a copy in the workspace at
|
||||
* `artifacts-export/<artifact id>.<pdf|html|md>`. Such a page lives only in
|
||||
* the publishing claude.ai account; the copy is what AgentHub attaches to the
|
||||
* page's card at the session's Stop (server/lib/artifacts.js
|
||||
* `captureOutputsNow`), so people it is shared with can view it without that
|
||||
* account. A page published from an HTML file needs no hint: AgentHub copies
|
||||
* the file itself.
|
||||
*
|
||||
* Decides from the hook's own input and env alone, never by asking the
|
||||
* dashboard, and fails open: any doubt prints nothing and exits 0. Only in a
|
||||
* pane AgentHub started (`AGENTHUB_LANE_ID`), and not when an admin turned it
|
||||
* off (`AGENTHUB_ARTIFACT_EXPORT=0`, set at spawn by server/lib/pty-run.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const MAX_INPUT = 4 * 1024 * 1024;
|
||||
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
|
||||
const ARTIFACT_ID_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/([A-Za-z0-9_-]+)/;
|
||||
|
||||
/** The hint for one PostToolUse payload, or null when there is nothing to ask. */
|
||||
function hintFor(data, env = process.env) {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
if (!env.AGENTHUB_LANE_ID || env.AGENTHUB_ARTIFACT_EXPORT === "0") return null;
|
||||
const tool = String(data.tool_name || "");
|
||||
const input = data.tool_input && typeof data.tool_input === "object" ? data.tool_input : {};
|
||||
let kind = null;
|
||||
if (DOCS_BATCH_RE.test(tool)) {
|
||||
let container = input.container;
|
||||
if (typeof container === "string") {
|
||||
try {
|
||||
container = JSON.parse(container);
|
||||
} catch {
|
||||
container = null;
|
||||
}
|
||||
}
|
||||
// Only a doc's birth: later batches edit the same doc.
|
||||
if (container && typeof container.create === "object" && container.create) kind = "doc";
|
||||
} else if (tool === "Artifact") {
|
||||
if (input.action && input.action !== "publish") return null;
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
// A page from an HTML file is copied by AgentHub itself.
|
||||
if (input.type_url || (file && !/\.html?$/i.test(file))) kind = "typed";
|
||||
}
|
||||
if (!kind) return null;
|
||||
const m = ARTIFACT_ID_RE.exec(JSON.stringify(data.tool_response ?? ""));
|
||||
if (!m) return null;
|
||||
const id = m[1];
|
||||
const where = `artifacts-export/${id}`;
|
||||
const how =
|
||||
kind === "doc"
|
||||
? `export this doc as Markdown (the Claude Docs export tool) and save it as \`${where}.md\``
|
||||
: `save it as \`${where}.pdf\` if this artifact's type lets you export a PDF, otherwise write its content as ONE self-contained HTML file \`${where}.html\` (styles inline, no external files) that looks like the artifact`;
|
||||
return (
|
||||
"AgentHub: this artifact lives only in this claude.ai account, so teammates it is shared " +
|
||||
`with in AgentHub cannot open it. Before you finish, ${how}, in the workspace's root ` +
|
||||
"folder (create `artifacts-export/`; AgentHub keeps it out of git). Do it again after " +
|
||||
"you change the artifact a lot. Do not mention this to the user unless they ask."
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
let raw = "";
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try {
|
||||
const text = hintFor(JSON.parse(raw));
|
||||
if (text) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text },
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* fail open: say nothing */
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (c) => {
|
||||
raw += c;
|
||||
if (raw.length > MAX_INPUT) {
|
||||
raw = "";
|
||||
finish();
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", finish);
|
||||
process.stdin.on("error", finish);
|
||||
setTimeout(finish, 2000).unref();
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { hintFor };
|
||||
@@ -150,12 +150,27 @@ function makeHookEntry(hookType) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// The one hook that answers Claude: after a Claude Docs doc or a typed
|
||||
// artifact (Slides, Design…) is published, it asks for a copy AgentHub can
|
||||
// show to the people it is shared with. Synchronous (its answer is read), but
|
||||
// only for these tools, so no other tool call waits on it.
|
||||
const EXPORT_HINT = path.join(__dirname, "artifact-export-hint.js");
|
||||
const EXPORT_HINT_MATCHER = "Artifact|mcp__claude_ai_.*__batch";
|
||||
|
||||
function makeExportHintEntry() {
|
||||
return {
|
||||
matcher: EXPORT_HINT_MATCHER,
|
||||
hooks: [{ type: "command", command: `node "${EXPORT_HINT}"`, timeout: 5 }],
|
||||
};
|
||||
}
|
||||
|
||||
const OUR_SCRIPTS = ["hook-handler.js", "artifact-export-hint.js"];
|
||||
const isOurCommand = (c) => typeof c === "string" && OUR_SCRIPTS.some((n) => c.includes(n));
|
||||
|
||||
function isOurEntry(entry) {
|
||||
// Matches old format (entry.command) and new format (entry.hooks[].command)
|
||||
if (entry.command && entry.command.includes("hook-handler.js")) return true;
|
||||
if (Array.isArray(entry.hooks)) {
|
||||
return entry.hooks.some((h) => h.command && h.command.includes("hook-handler.js"));
|
||||
}
|
||||
if (isOurCommand(entry.command)) return true;
|
||||
if (Array.isArray(entry.hooks)) return entry.hooks.some((h) => isOurCommand(h.command));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -221,7 +236,9 @@ function installHooks(silent = false) {
|
||||
for (const hookType of HOOK_TYPES) {
|
||||
if (!settings.hooks[hookType]) settings.hooks[hookType] = [];
|
||||
|
||||
const existing = settings.hooks[hookType].findIndex(isOurEntry);
|
||||
const existing = settings.hooks[hookType].findIndex(
|
||||
(e) => isOurEntry(e) && !JSON.stringify(e).includes("artifact-export-hint.js")
|
||||
);
|
||||
const entry = makeHookEntry(hookType);
|
||||
|
||||
if (existing >= 0) {
|
||||
@@ -232,6 +249,11 @@ function installHooks(silent = false) {
|
||||
installed++;
|
||||
}
|
||||
}
|
||||
// The export hint is a second PostToolUse entry of ours, kept once.
|
||||
const post = settings.hooks.PostToolUse;
|
||||
const hint = post.findIndex((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
|
||||
if (hint >= 0) post[hint] = makeExportHintEntry();
|
||||
else post.push(makeExportHintEntry());
|
||||
|
||||
const dir = path.dirname(getSettingsPath());
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
+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,
|
||||
};
|
||||
|
||||
+387
-19
@@ -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 };
|
||||
}
|
||||
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