feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.5.13 (2026-10-08) — roles admin/analyst/developer (`usePermissions`), Chat UI per account (Settings → Appearance, `/api/me/prefs`, `PaneChat.tsx`), one shared terminal size per pane (`pty-attach.js`). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.5.13 + S3 P4–P8 (2026-10-09) — S3 review round 5: host folders admin-only, pane cwd/token bound to the pane's account, bounded sudo, capped member reads (`readLines`). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -76,8 +76,9 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti
|
||||
- **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one.
|
||||
- **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id.
|
||||
- **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that).
|
||||
- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is still NOT OS isolation: every pane runs as the host account until S3, so do not call it multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode.
|
||||
- **A pool token reaches a pane only through tmux `-e`, and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add`). An empty pool must leave every spawn exactly as before.
|
||||
- **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode.
|
||||
- **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-<name>`, home `<USERS_ROOT>/<name>`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah-<name> <tmux|git|gh|write-as>` by the absolute paths in `server.conf` (`sudoArgv`); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable — it never deletes, never adopts a non-member account, and root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. Operator steps: `docs/SERVER-MODE.md` § Operator runbook.
|
||||
- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`). An empty pool must leave every spawn exactly as before. The ONE exception is a Codex ChatGPT login (`server/lib/codex-pool.js`), which Codex reads from no env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one; `syncBack` copies the newest back. A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it.
|
||||
- Preserve existing behavior unless explicitly asked to change it.
|
||||
- Prefer minimal, reversible diffs.
|
||||
- Never silently weaken safety controls around destructive actions.
|
||||
|
||||
+223
-8
@@ -2853,8 +2853,8 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name]",
|
||||
"Claude account pool: new sessions spread over these accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3952,6 +3952,23 @@ async function cmdServer(sub, flags) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "install-system") return installSystem(flags, settingsEnv);
|
||||
if (sub === "tools") {
|
||||
// S3/S4: copy this install's scripts and every installed AI tool under
|
||||
// /opt/agenthub, where every account's pane runs them (the server also
|
||||
// does this at start and after each install/update).
|
||||
const shared = require(path.join(REPO_ROOT, "server", "lib", "shared-tools.js"));
|
||||
await shared.publishApp(REPO_ROOT);
|
||||
const rows = await shared.publishAll();
|
||||
table(
|
||||
["tool", "shared copy"],
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
r.error ? c.red(r.error) : `${r.path}${r.copied ? "" : c.dim(" (unchanged)")}`,
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "off") {
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
|
||||
envNote("AGENTHUB_MODE");
|
||||
@@ -3969,11 +3986,120 @@ async function cmdServer(sub, flags) {
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
|
||||
c.red(
|
||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub server install-system` — one Linux user per account (S3,
|
||||
* docs/SERVER-MODE.md). Run as the HOST account, never as root: it resolves
|
||||
* this install's paths as the host, hands them to the root half
|
||||
* (`scripts/server/install-system.sh`) through ONE `sudo`, points the system
|
||||
* unit at this install, and moves the dashboard from the `systemd --user`
|
||||
* unit to the system one (panes survive: KillMode=process). `--uninstall`
|
||||
* goes back to the user unit.
|
||||
*/
|
||||
async function installSystem(flags, settingsEnv) {
|
||||
const os = require("node:os");
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
||||
}
|
||||
const which = (bin) =>
|
||||
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
||||
const host = os.userInfo().username;
|
||||
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||
const data = getDataDir();
|
||||
const port = String(process.env.DASHBOARD_PORT || "4820");
|
||||
const script = path.join(REPO_ROOT, "scripts", "server", "install-system.sh");
|
||||
const args = flags.uninstall
|
||||
? [script, "--uninstall", "--host", host]
|
||||
: [
|
||||
script,
|
||||
...["--host", host, "--node", process.execPath, "--root", REPO_ROOT, "--data", data],
|
||||
...["--port", port, "--tmux", which("tmux"), "--git", which("git")],
|
||||
...(which("gh") ? ["--gh", which("gh")] : []),
|
||||
];
|
||||
if (flags["dry-run"]) {
|
||||
console.log(["sudo", "bash", ...args].join(" "));
|
||||
return;
|
||||
}
|
||||
const userUnit = (...a) => spawnSync("systemctl", ["--user", ...a, "agenthub.service"]);
|
||||
// What either unit runs: this install's node and server, with its env.
|
||||
const runtime = path.join(data, "runtime");
|
||||
const clientDist = fs.existsSync(path.join(REPO_ROOT, "client", "dist", "index.html"))
|
||||
? path.join(REPO_ROOT, "client", "dist")
|
||||
: path.join(runtime, "client-dist");
|
||||
const env = { NODE_ENV: "production", PATH: process.env.PATH || "", DASHBOARD_PORT: port };
|
||||
env.DASHBOARD_CLIENT_DIST = clientDist;
|
||||
if (!fs.existsSync(path.join(REPO_ROOT, "node_modules"))) {
|
||||
env.NODE_PATH = path.join(runtime, "node_modules");
|
||||
}
|
||||
const serverArgv = [process.execPath, path.join(REPO_ROOT, "server", "index.js")];
|
||||
if (flags.uninstall) {
|
||||
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
|
||||
throw new Error("install-system --uninstall failed");
|
||||
}
|
||||
// Repoint the user unit first: the build it named may have been pruned
|
||||
// while the system unit was the one protected.
|
||||
require(path.join(REPO_ROOT, "scripts", "systemd-service.js")).writeUserService({
|
||||
argv: serverArgv,
|
||||
env,
|
||||
});
|
||||
userUnit("enable", "--now");
|
||||
console.log(`${c.green("✔")} back on the systemd --user unit`);
|
||||
return;
|
||||
}
|
||||
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
|
||||
throw new Error("install-system failed — nothing was started");
|
||||
}
|
||||
// The unit reads node, the plugin root and the env from a file this account
|
||||
// owns (the bootstrap repoints it after an update without root).
|
||||
const sys = require(path.join(REPO_ROOT, "scripts", "systemd-system-service.js"));
|
||||
const wrote = sys.writeUserService({ argv: serverArgv, env });
|
||||
if (wrote === "failed" || wrote === "unavailable") {
|
||||
throw new Error(`could not write ${sys.envPath()} (${wrote})`);
|
||||
}
|
||||
settingsEnv.writeSetting(
|
||||
"AGENTHUB_USERS_ROOT",
|
||||
require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js")).serverConf().usersRoot
|
||||
);
|
||||
// Hand over: the user unit stops (its panes stay), and so does a dashboard
|
||||
// started without one (detached) — else the system unit finds the port
|
||||
// taken and the old process, without the S3 groups, keeps serving.
|
||||
userUnit("disable", "--now");
|
||||
const boot = require(path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"));
|
||||
const sysUp = () =>
|
||||
spawnSync("systemctl", ["is-active", "agenthub.service"], {
|
||||
encoding: "utf8",
|
||||
}).stdout.trim() === "active";
|
||||
// A re-run while the system unit serves: that server is the one to keep.
|
||||
if (!sysUp()) boot.stopDashboard();
|
||||
for (let i = 0; i < 50 && !sysUp() && boot.livePids().length; i++) {
|
||||
spawnSync("sleep", ["0.2"]);
|
||||
}
|
||||
if (!sysUp() && boot.livePids().length) {
|
||||
throw new Error("a dashboard is still running — stop it first");
|
||||
}
|
||||
if (spawnSync("sudo", ["-n", "systemctl", "enable", "agenthub.service"]).status !== 0) {
|
||||
console.log(c.yellow("! could not enable agenthub.service for boot"));
|
||||
}
|
||||
if (spawnSync("sudo", ["-n", "systemctl", "start", "--no-block", "agenthub.service"]).status) {
|
||||
throw new Error("could not start agenthub.service — `sudo systemctl status agenthub`");
|
||||
}
|
||||
spawnSync("sleep", ["3"]);
|
||||
if (!sysUp()) {
|
||||
throw new Error("agenthub.service did not stay up — `sudo journalctl -u agenthub -n 50`");
|
||||
}
|
||||
console.log(`${c.green("✔")} the dashboard now runs as the system unit agenthub.service`);
|
||||
console.log(" Give the existing users their Linux accounts: agenthub users provision --all");
|
||||
console.log(
|
||||
c.dim(" Log out and back in once: your own session joins groups agenthub/agenthub-proc.")
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
|
||||
async function cmdUsers(sub, positional, flags) {
|
||||
const { auth } = serverAuth();
|
||||
@@ -4026,6 +4152,37 @@ async function cmdUsers(sub, positional, flags) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "provision": {
|
||||
// S3: the Linux account of a user made before install-system (or after a
|
||||
// failed create). Idempotent: an existing member account is kept.
|
||||
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
|
||||
if (!linux.s3Enabled()) {
|
||||
throw new Error(
|
||||
"one Linux user per account is not installed (agenthub server install-system)"
|
||||
);
|
||||
}
|
||||
const names = flags.all
|
||||
? auth
|
||||
.listUsers()
|
||||
.filter((u) => !u.disabled)
|
||||
.map((u) => u.username)
|
||||
: (need(), [name]);
|
||||
const memberClaude = require(path.join(REPO_ROOT, "server", "lib", "member-claude.js"));
|
||||
for (const n of names) {
|
||||
// One user that cannot be provisioned (a name Linux refuses, a folder
|
||||
// already there) is reported and skipped — never the end of the run.
|
||||
try {
|
||||
console.log(`${c.green("✔")} ${linux.provision("create", n)}`);
|
||||
} catch (e) {
|
||||
console.error(c.red(`✖ ${n}: ${e.message}`));
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
// Opt-in plugin baseline (/etc/agenthub/plugin-baseline.json); prints per entry.
|
||||
await memberClaude.applyPluginBaseline(n);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "disable":
|
||||
case "enable":
|
||||
need();
|
||||
@@ -4035,21 +4192,75 @@ async function cmdUsers(sub, positional, flags) {
|
||||
default:
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
|
||||
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | provision <name>|--all | assign <workspace id> <name> | audit [--limit N]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool codex` — the Codex account pool (server/lib/codex-pool.js). */
|
||||
async function cmdCodexAccounts(sub, name, flags) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (sub === "add") {
|
||||
let added;
|
||||
if (flags["api-key"]) {
|
||||
added = pool.addAccount(name, { apiKey: await askPassword(`OpenAI API key for ${name}: `) });
|
||||
} else if (typeof flags.from === "string") {
|
||||
// A `codex login` home (or its auth.json) — read here, on the host's shell.
|
||||
const from = path.resolve(flags.from);
|
||||
const file = fs.statSync(from).isDirectory() ? path.join(from, "auth.json") : from;
|
||||
added = pool.addAccount(name, { auth: JSON.parse(fs.readFileSync(file, "utf8")) });
|
||||
console.log(c.dim(` You can delete ${file} now; the pool keeps its own copy.`));
|
||||
} else {
|
||||
throw new Error(
|
||||
"usage: agenthub accounts add <name> --tool codex --api-key | --from <dir>\n" +
|
||||
" A ChatGPT account: `CODEX_HOME=$(mktemp -d) codex login` signed in as it, then --from that dir."
|
||||
);
|
||||
}
|
||||
console.log(`${c.green("✔")} ${added.name} (${added.kind}) added`);
|
||||
console.log(c.dim(" New Codex sessions now spread over the pool's accounts."));
|
||||
return;
|
||||
}
|
||||
if (sub === "remove") {
|
||||
pool.removeAccount(name);
|
||||
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||
return;
|
||||
}
|
||||
if (sub !== undefined && sub !== "list") {
|
||||
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool codex");
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
a.name,
|
||||
a.kind,
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
a.quota
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
a.full ? "full" : "ok",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||
async function cmdAccounts(sub, positional) {
|
||||
async function cmdAccounts(sub, positional, flags = {}) {
|
||||
serverAuth(); // the same settings + database the server reads
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||
const name = positional[0];
|
||||
if (sub === "add" || sub === "remove") {
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
}
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
@@ -4089,9 +4300,13 @@ async function cmdAccounts(sub, positional) {
|
||||
: "ok",
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
return;
|
||||
}
|
||||
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -4121,7 +4336,7 @@ async function runCommand(argv) {
|
||||
case "users":
|
||||
return cmdUsers(positional[0], positional.slice(1), flags);
|
||||
case "accounts":
|
||||
return cmdAccounts(positional[0], positional.slice(1));
|
||||
return cmdAccounts(positional[0], positional.slice(1), flags);
|
||||
case "stats":
|
||||
return cmdStats();
|
||||
case "kanban":
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||
|
||||
@@ -114,6 +117,18 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
|
||||
// per account, so a second login in this browser brings that account's.
|
||||
const account =
|
||||
state?.mode === "local"
|
||||
? "local"
|
||||
: state?.mode === "server" && state.user
|
||||
? `u${state.user.id}`
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
|
||||
@@ -83,6 +83,8 @@ import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
@@ -172,6 +174,8 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
<OnboardingTour />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -111,7 +112,14 @@ export function AddLaneModal({
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
// The account's own pick (first-run setup), while it is installed and on.
|
||||
const ownTool = readDefaultTool();
|
||||
const defaultTool =
|
||||
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
|
||||
? ownTool
|
||||
: null) ??
|
||||
aiTools.defaultId ??
|
||||
"claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
@@ -355,6 +363,7 @@ export function AddLaneModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-tour="add-lane"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
|
||||
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
@@ -449,7 +458,7 @@ export function AddLaneModal({
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
@@ -695,6 +704,7 @@ export function AddLaneModal({
|
||||
type="button"
|
||||
onClick={done ? handleCancel : submit}
|
||||
disabled={busy || disabled}
|
||||
data-tour="add-lane-create"
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
|
||||
@@ -173,6 +173,7 @@ export function SessionReplay({
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={session?.cwd || lane.cwd}
|
||||
lane={lane.id}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
|
||||
|
||||
@@ -85,6 +85,7 @@ export {
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
@@ -173,7 +174,8 @@ function HomeBody({
|
||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||||
// model only when its file names the flag, an effort never.
|
||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||
const [mode, setMode] = useState<PermissionMode>("auto");
|
||||
// The account's own default (first-run setup) — never bypass.
|
||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
@@ -529,6 +531,7 @@ function HomeBody({
|
||||
rows={3}
|
||||
slash={claude}
|
||||
fileCwd={lane.cwd}
|
||||
lane={lane.id}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @file OnboardingTour.tsx
|
||||
* @description The step-by-step spotlight tour after the first-run setup
|
||||
* (docs/plans/onboarding): it dims the page around ONE real control and says
|
||||
* what to do with it — Add workspace → the folder → Add → the prompt box →
|
||||
* the live session → done. The overlay never takes a click (the user works
|
||||
* the real UI); a step moves on when what it asked for has happened (the
|
||||
* dialog opened, the workspace exists, the session started), and steps back
|
||||
* when the user backed out (the dialog closed with nothing made). Targets are
|
||||
* existing `data-testid`s plus three `data-tour` marks in AddLaneModal. The
|
||||
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
|
||||
* account and survives a reload; `done` ends it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
const KEY = "agenthub.tour";
|
||||
const EVENT = "agenthub-tour";
|
||||
const POLL_MS = 250;
|
||||
|
||||
type StepId = "add" | "folder" | "create" | "prompt" | "session" | "finish";
|
||||
const ORDER: StepId[] = ["add", "folder", "create", "prompt", "session", "finish"];
|
||||
|
||||
const $ = (sel: string) => document.querySelector(sel);
|
||||
/** An element that is on screen (a console collapsed with CSS has no box). */
|
||||
function shown(sel: string): Element | null {
|
||||
const el = $(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 ? el : null;
|
||||
}
|
||||
|
||||
const modalOpen = () => !!$('[data-tour="add-lane"]');
|
||||
const composer = () => shown('[data-testid="workspace-home-composer"]');
|
||||
const sessionLive = () =>
|
||||
!!new URLSearchParams(window.location.search).get("run") && !!shown('[data-testid="pane-grid"]');
|
||||
|
||||
/** Where each step points, and what moves it on (or back). */
|
||||
const STEPS: Record<
|
||||
StepId,
|
||||
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
|
||||
> = {
|
||||
add: {
|
||||
target: () =>
|
||||
shown('[data-testid="lane-empty"]') || shown('[data-testid="workspace-tree-add"]'),
|
||||
next: () => (modalOpen() ? "folder" : null),
|
||||
},
|
||||
folder: {
|
||||
target: () => $('[data-tour="add-lane-folder"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
info: true,
|
||||
},
|
||||
create: {
|
||||
target: () => $('[data-tour="add-lane-create"]'),
|
||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||
},
|
||||
prompt: {
|
||||
target: composer,
|
||||
next: () => (sessionLive() ? "session" : null),
|
||||
},
|
||||
session: { target: () => shown('[data-testid="pane-grid"]'), info: true },
|
||||
finish: { target: () => null, info: true },
|
||||
};
|
||||
|
||||
function read(): StepId | null {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return (ORDER as string[]).includes(v ?? "") ? (v as StepId) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(v: StepId | "done") {
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage blocked: the tour just ends with the page */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
/** Begin the tour at its first step (the setup's last button). */
|
||||
export const startTour = () => write("add");
|
||||
|
||||
/** Mounted once in the layout; renders nothing unless a tour is running. */
|
||||
export function OnboardingTour() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [step, setStep] = useState<StepId | null>(read);
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
// The dialog the target sits in, if any: the card goes beside the dialog.
|
||||
const [box, setBox] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setStep(read());
|
||||
window.addEventListener(EVENT, sync);
|
||||
window.addEventListener("storage", sync);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, sync);
|
||||
window.removeEventListener("storage", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
const tick = () => {
|
||||
const def = STEPS[step];
|
||||
const moved = def.next?.();
|
||||
if (moved && moved !== step) return write(moved);
|
||||
const el = def.target();
|
||||
const r = el ? el.getBoundingClientRect() : null;
|
||||
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
|
||||
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
|
||||
// Only when it moved: a new DOMRect every tick would re-render forever.
|
||||
setRect((prev) =>
|
||||
prev &&
|
||||
r &&
|
||||
prev.x === r.x &&
|
||||
prev.y === r.y &&
|
||||
prev.width === r.width &&
|
||||
prev.height === r.height
|
||||
? prev
|
||||
: r
|
||||
);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, POLL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [step, location]);
|
||||
|
||||
if (!step) return null;
|
||||
const n = ORDER.indexOf(step) + 1;
|
||||
const end = () => write("done");
|
||||
const advance = () => {
|
||||
if (step === "folder") return write("create");
|
||||
if (step === "session") return write("finish");
|
||||
end();
|
||||
};
|
||||
const lost = !rect && step !== "finish";
|
||||
|
||||
// Beside a narrow target (a field in a dialog: below it, the card would
|
||||
// cover the rest of the dialog), else under it when there is room, else
|
||||
// above; centred when there is no target (the finish, or the page moved).
|
||||
const W = 320;
|
||||
let style: React.CSSProperties = { left: "50%", top: "50%", transform: "translate(-50%, -50%)" };
|
||||
if (rect) {
|
||||
const right = (box ?? rect).right;
|
||||
const top = Math.min(Math.max(12, rect.top), window.innerHeight - 220);
|
||||
const left = Math.min(Math.max(12, rect.left), window.innerWidth - W - 12);
|
||||
if (rect.width < window.innerWidth / 2 && right + W + 24 < window.innerWidth) {
|
||||
style = { left: right + 16, top };
|
||||
} else if (rect.bottom + 180 < window.innerHeight) {
|
||||
style = { left, top: rect.bottom + 12 };
|
||||
} else {
|
||||
style = { left, top: Math.max(12, rect.top - 12), transform: "translateY(-100%)" };
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-0 z-[70]" data-testid="onboarding-tour">
|
||||
{rect ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute rounded-lg ring-2 ring-accent transition-all duration-200"
|
||||
style={{
|
||||
left: rect.left - 6,
|
||||
top: rect.top - 6,
|
||||
width: rect.width + 12,
|
||||
height: rect.height + 12,
|
||||
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.55)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
step === "finish" && <div aria-hidden className="absolute inset-0 bg-black/55" />
|
||||
)}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label={t(`tour.${step}.title`)}
|
||||
className="pointer-events-auto absolute w-80 space-y-2 rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
style={style}
|
||||
>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
|
||||
{t("tour.progress", { n, total: ORDER.length })}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-fg-primary">{t(`tour.${step}.title`)}</p>
|
||||
<p className="text-xs leading-relaxed text-fg-secondary">{t(`tour.${step}.body`)}</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{step !== "finish" && (
|
||||
<button type="button" onClick={end} className="btn-ghost px-0 text-xs">
|
||||
{t("tour.skip")}
|
||||
</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{lost && !location.pathname.startsWith("/workspace") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/workspace")}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("tour.goWorkspaces")}
|
||||
</button>
|
||||
)}
|
||||
{STEPS[step].info && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={advance}
|
||||
data-testid="onboarding-tour-next"
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{step === "finish" ? t("tour.done") : t("tour.next")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* @file OnboardingWizard.tsx
|
||||
* @description The first-run setup, once per ACCOUNT (docs/plans/onboarding;
|
||||
* modelled on Orca's welcome): 1 the default agent — the AI tools detected on
|
||||
* this machine, the rest behind "show more" — and the permission mode a new
|
||||
* session starts in (never bypass: that stays per session); 2 the look —
|
||||
* System / Dark / Light, a theme for each mode, language and terminal font;
|
||||
* 3 the corner pet (`PetSettings`); 4 notifications — permission, sound,
|
||||
* a test. Every choice is the same localStorage write Settings makes, so it
|
||||
* applies at once and is synced to the account (`lib/userPrefs.ts`). Shown
|
||||
* when the account has not been through it and has no workspace yet (one that
|
||||
* has is marked done silently), or when Settings asks for it again. The last
|
||||
* step hands over to the tour (`OnboardingTour`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Check, ChevronLeft, Monitor, Moon, Play, Sun, Zap } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { Switch } from "../Switch";
|
||||
import { SettingsSegmented } from "../settings/SettingsRow";
|
||||
import { PetSettings } from "../settings/PetSettings";
|
||||
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
||||
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
||||
import {
|
||||
DEFAULT_MODES,
|
||||
readDefaultMode,
|
||||
readDefaultTool,
|
||||
writeDefaultMode,
|
||||
writeDefaultTool,
|
||||
type DefaultMode,
|
||||
} from "../../lib/sessionDefaults";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
readTerminalFont,
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import {
|
||||
NOTIF_SOUNDS,
|
||||
loadNotifPrefs,
|
||||
saveNotifPrefs,
|
||||
type NotifPrefs,
|
||||
type NotifSound,
|
||||
} from "../../lib/notifPrefs";
|
||||
import { notify, playSound } from "../../lib/notify";
|
||||
import { subscribeToPush } from "../../lib/push";
|
||||
import { startTour } from "./OnboardingTour";
|
||||
|
||||
const STEPS = ["agent", "look", "pet", "notify"] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
/** Mounted once in the layout: decides whether this account sees the setup. */
|
||||
export function OnboardingWizard() {
|
||||
const { loaded, onboardedAt, forced } = useOnboarding();
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!loaded || onboardedAt) return setShow(false);
|
||||
if (forced) return setShow(true);
|
||||
let alive = true;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
// An account already at work never needed a welcome.
|
||||
if (r.lanes.length) void markOnboarded();
|
||||
else setShow(true);
|
||||
})
|
||||
.catch(() => {
|
||||
/* cannot tell: ask again next load rather than interrupt */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [loaded, onboardedAt, forced]);
|
||||
return show ? <Wizard /> : null;
|
||||
}
|
||||
|
||||
function Wizard() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const navigate = useNavigate();
|
||||
const [index, setIndex] = useState(0);
|
||||
const step: Step = STEPS[index] ?? "agent";
|
||||
const last = index === STEPS.length - 1;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef);
|
||||
|
||||
const skip = () => void markOnboarded();
|
||||
const next = () => {
|
||||
if (!last) return setIndex(index + 1);
|
||||
void markOnboarded();
|
||||
startTour();
|
||||
navigate("/workspace");
|
||||
};
|
||||
|
||||
// Ctrl/⌘+Enter continues, like Orca.
|
||||
const nextRef = useRef(next);
|
||||
nextRef.current = next;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
nextRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
data-testid="onboarding-wizard"
|
||||
className="flex h-[min(820px,92vh)] w-full max-w-4xl flex-col rounded-2xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
||||
>
|
||||
<div className="space-y-5 px-8 pt-7">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/favicon.svg" alt="" className="h-7 w-7" />
|
||||
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3" aria-hidden>
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`h-1 w-9 rounded-full ${i <= index ? "bg-fg-primary" : "bg-surface-4"}`}
|
||||
/>
|
||||
))}
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t("progress", { n: index + 1, total: STEPS.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-6">
|
||||
{step === "agent" && <AgentStep />}
|
||||
{step === "look" && <LookStep />}
|
||||
{step === "pet" && (
|
||||
<StepHead title={t("pet.title")} body={t("pet.body")}>
|
||||
<PetSettings />
|
||||
</StepHead>
|
||||
)}
|
||||
{step === "notify" && <NotifyStep />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border px-8 py-4">
|
||||
<button type="button" onClick={skip} className="btn-ghost text-xs">
|
||||
{t("skip")}
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(index - 1)}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
{t("back")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
data-testid="onboarding-next"
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{last ? t("finish") : t("continue")}
|
||||
<kbd className="ml-1 rounded border border-white/30 px-1 text-[10px] font-normal opacity-80">
|
||||
Ctrl ↵
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHead({
|
||||
eyebrow,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 id="onboarding-title" className="text-3xl font-bold text-fg-primary">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-fg-secondary">{body}</p>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStep() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const aiTools = useAiTools();
|
||||
const installed = aiTools.tools.filter((x) => x.installed && x.enabled);
|
||||
const others = aiTools.tools.filter((x) => !(x.installed && x.enabled));
|
||||
const [more, setMore] = useState(false);
|
||||
const [picked, setPicked] = useState<string | null>(readDefaultTool);
|
||||
const current =
|
||||
(picked && installed.some((x) => x.id === picked) ? picked : null) ??
|
||||
aiTools.defaultId ??
|
||||
installed[0]?.id ??
|
||||
null;
|
||||
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||
|
||||
const pick = (id: string) => {
|
||||
writeDefaultTool(id);
|
||||
setPicked(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepHead eyebrow={t("agent.eyebrow")} title={t("agent.title")} body={t("agent.body")}>
|
||||
<div className="space-y-3">
|
||||
<p className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-fg-muted">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||
{t("agent.detected", { count: installed.length })}
|
||||
</p>
|
||||
{aiTools.loaded && !installed.length && (
|
||||
<p className="text-sm text-fg-muted">{t("agent.none")}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{installed.map((tool) => {
|
||||
const selected = tool.id === current;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => pick(tool.id)}
|
||||
aria-pressed={selected}
|
||||
data-testid={`onboarding-agent-${tool.id}`}
|
||||
className={`relative flex items-center gap-3 rounded-xl border px-4 py-3.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent bg-accent/10 ring-1 ring-accent/50"
|
||||
: "border-border bg-surface-2 hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{tool.label}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-fg-muted">
|
||||
{tool.command || tool.id}
|
||||
</span>
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="absolute right-2.5 top-2.5 flex h-5 w-5 items-center justify-center rounded-full bg-accent-solid text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{more &&
|
||||
others.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-3.5 opacity-60"
|
||||
>
|
||||
<AiToolIcon tool={tool} className="h-6 w-6 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-fg-primary">{tool.label}</span>
|
||||
<span className="block text-xs text-fg-muted">{t("agent.notInstalled")}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMore(!more)}
|
||||
className="text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{more ? t("agent.less") : `${t("agent.more", { count: others.length })} →`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl border border-border bg-surface-2 px-4 py-3.5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-fg-primary">{t("agent.permission")}</span>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("agent.permission")}
|
||||
value={mode}
|
||||
onChange={(m) => {
|
||||
writeDefaultMode(m);
|
||||
setMode(m);
|
||||
}}
|
||||
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||
</div>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_CARDS: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "system", Icon: Monitor },
|
||||
{ mode: "dark", Icon: Moon },
|
||||
{ mode: "light", Icon: Sun },
|
||||
];
|
||||
|
||||
function LookStep() {
|
||||
const { t, i18n } = useTranslation("onboarding");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
const { mode, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [font, setFont] = useState<TerminalFont>(readTerminalFont);
|
||||
|
||||
return (
|
||||
<StepHead title={t("look.title")} body={t("look.body")}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{MODE_CARDS.map(({ mode: m, Icon }) => {
|
||||
const selected = mode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
aria-pressed={selected}
|
||||
data-testid={`onboarding-mode-${m}`}
|
||||
className={`rounded-xl border p-2.5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
{m === "system" ? (
|
||||
<div className="grid grid-cols-2 overflow-hidden rounded-md">
|
||||
<Swatch mode="dark" name={themeFor.dark} />
|
||||
<Swatch mode="light" name={themeFor.light} />
|
||||
</div>
|
||||
) : (
|
||||
<Swatch mode={m} name={themeFor[m]} />
|
||||
)}
|
||||
<span className="flex items-center gap-1.5 px-1 pt-2 text-sm font-medium text-fg-primary">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{t(`look.modes.${m}`)}
|
||||
<span className="ml-auto text-[11px] font-normal text-fg-muted">
|
||||
{t(`look.modeHints.${m}`)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(["dark", "light"] as Theme[]).map((group) => (
|
||||
<div key={group} className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{THEMES[group].map((name) => {
|
||||
const selected = themeFor[group] === name;
|
||||
const label = THEME_LABELS[name] ?? ts("appearance.default");
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setThemeFor(group, name)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t(group === "dark" ? "look.darkThemes" : "look.lightThemes")}: ${label}`}
|
||||
className={`rounded-lg border p-1 text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent ring-2 ring-accent/40"
|
||||
: "border-border hover:border-border-light"
|
||||
}`}
|
||||
>
|
||||
<Swatch mode={group} name={name} />
|
||||
<span className="block px-1 pt-1 text-[11px] text-fg-primary">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">{t("look.language")}</p>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("look.language")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: ts("language.names.en") },
|
||||
{ value: "vi" as const, label: ts("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-fg-secondary">{t("look.font")}</p>
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("look.font")}
|
||||
value={font}
|
||||
onChange={(f) => {
|
||||
writeTerminalFont(f);
|
||||
setFont(f);
|
||||
}}
|
||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||
value: f,
|
||||
label: (
|
||||
<span style={{ fontFamily: TERMINAL_FONTS[f] }}>
|
||||
{ts(`terminalFont.names.${f}`)}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted">{t("look.more")}</p>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifyStep() {
|
||||
const { t } = useTranslation("onboarding");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
const [prefs, setPrefs] = useState<NotifPrefs>(loadNotifPrefs);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const supported = typeof window !== "undefined" && "Notification" in window;
|
||||
const [permission, setPermission] = useState(() =>
|
||||
supported ? Notification.permission : "default"
|
||||
);
|
||||
const update = (patch: Partial<NotifPrefs>) => {
|
||||
const next = { ...prefs, ...patch };
|
||||
saveNotifPrefs(next);
|
||||
setPrefs(next);
|
||||
};
|
||||
|
||||
const enable = async (on: boolean) => {
|
||||
if (!on) return update({ enabled: false });
|
||||
if (supported && Notification.permission !== "granted") {
|
||||
const perm = await Notification.requestPermission();
|
||||
setPermission(perm);
|
||||
if (perm !== "granted") return;
|
||||
}
|
||||
update({ enabled: true });
|
||||
await subscribeToPush().catch(() => {
|
||||
/* the local fallback still shows them */
|
||||
});
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
const r = await notify(ts("notifications.testTitle"), ts("notifications.testBody"));
|
||||
setResult(
|
||||
r.via === "push" || r.via === "native"
|
||||
? ts("notifications.testSent")
|
||||
: r.via === "local"
|
||||
? ts("notifications.testLocal")
|
||||
: r.via === "blocked"
|
||||
? ts("notifications.testBlocked")
|
||||
: ts("notifications.testFailed")
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StepHead title={t("notify.title")} body={t("notify.body")}>
|
||||
<div className="space-y-4 rounded-xl border border-border bg-surface-2 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-fg-primary">{t("notify.enable")}</p>
|
||||
<p className="text-xs text-fg-muted">{t("notify.enableHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={prefs.enabled}
|
||||
onChange={(v) => void enable(v)}
|
||||
label={t("notify.enable")}
|
||||
/>
|
||||
</div>
|
||||
{permission === "denied" && (
|
||||
<p className="text-xs text-status-danger">{t("notify.blocked")}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
<span className="mr-2 text-sm text-fg-primary">{t("notify.sound")}</span>
|
||||
<select
|
||||
aria-label={t("notify.sound")}
|
||||
value={prefs.sound}
|
||||
onChange={(e) => {
|
||||
const sound = e.target.value as NotifSound;
|
||||
update({ sound });
|
||||
playSound(sound);
|
||||
}}
|
||||
className="select py-1 text-xs"
|
||||
>
|
||||
{NOTIF_SOUNDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{ts(`notifications.sound.${k}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playSound(prefs.sound)}
|
||||
disabled={prefs.sound === "none"}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
{t("notify.preview")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void test()}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
{t("notify.test")}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<p role="status" className="text-xs text-fg-secondary">
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</StepHead>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @file Onboarding.test.tsx
|
||||
* @description The first-run setup (docs/plans/onboarding): the account's
|
||||
* preferences are loaded into localStorage on sign-in and every later change
|
||||
* is sent back; the starting choices never hold bypass; the wizard shows only
|
||||
* for an account that is new to it, writes each pick like Settings does and
|
||||
* ends in the tour; the tour follows the real UI step by step.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const prefsMock = vi.fn();
|
||||
const saveMock = vi.fn();
|
||||
const onboardedMock = vi.fn();
|
||||
const lanesMock = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
me: {
|
||||
prefs: () => prefsMock(),
|
||||
savePrefs: (p: unknown) => saveMock(p),
|
||||
onboarded: () => onboardedMock(),
|
||||
},
|
||||
lanes: { list: () => lanesMock() },
|
||||
aiTools: {
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
list: async () => ({
|
||||
default: "claude",
|
||||
catalog: [],
|
||||
tools: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
command: "claude",
|
||||
installed: true,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: "codex", label: "Codex", command: "codex", installed: true, enabled: true },
|
||||
{ id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../settings/PetSettings", () => ({
|
||||
PetSettings: () => <div data-testid="pet-settings" />,
|
||||
}));
|
||||
|
||||
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
||||
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||
import { OnboardingWizard } from "../OnboardingWizard";
|
||||
import { OnboardingTour, startTour } from "../OnboardingTour";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetUserPrefs();
|
||||
__resetAiTools();
|
||||
prefsMock.mockReset();
|
||||
saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" });
|
||||
lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} });
|
||||
});
|
||||
|
||||
describe("account preferences", () => {
|
||||
it("loads the account's values, sends up this browser's, then every change", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
localStorage.setItem("agenthub.terminal.font", "maple");
|
||||
localStorage.setItem("theme", "dark");
|
||||
prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null });
|
||||
await hydrate();
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(saveMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ "agenthub.terminal.font": "maple" })
|
||||
);
|
||||
saveMock.mockClear();
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
localStorage.setItem("unrelated.key", "x");
|
||||
localStorage.removeItem("agenthub.pet");
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(saveMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveMock).toHaveBeenCalledWith({
|
||||
"agenthub.theme.dark": "darcula",
|
||||
"agenthub.pet": null,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("never keeps bypass as the starting permission mode", () => {
|
||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||
expect(readDefaultMode()).toBe("auto");
|
||||
writeDefaultMode("bypassPermissions" as never);
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
||||
writeDefaultMode("plan");
|
||||
expect(readDefaultMode()).toBe("plan");
|
||||
});
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const s = useOnboarding();
|
||||
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
|
||||
}
|
||||
const renderWizard = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<OnboardingWizard />
|
||||
<Probe />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("the setup wizard", () => {
|
||||
it("does not show before the account's preferences load, nor once done", async () => {
|
||||
renderWizard();
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" });
|
||||
await act(() => hydrate());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips itself for an account that already has a workspace", async () => {
|
||||
lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} });
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
});
|
||||
|
||||
it("walks four steps, saving each pick, and ends in the tour", async () => {
|
||||
const user = userEvent.setup();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await screen.findByTestId("onboarding-wizard");
|
||||
expect(screen.getByText("Pick your default agent")).toBeInTheDocument();
|
||||
await user.click(await screen.findByTestId("onboarding-agent-codex"));
|
||||
expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex");
|
||||
// A tool that is not installed is not offered until asked for, and never pickable.
|
||||
expect(screen.queryByText("Kimi Code")).toBeNull();
|
||||
await user.click(screen.getByText(/Show 1 more agents/));
|
||||
expect(screen.getByText("Kimi Code").closest("button")).toBeNull();
|
||||
await user.click(screen.getByRole("radio", { name: "Plan" }));
|
||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan");
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByText("Make it feel like home")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-mode-light"));
|
||||
expect(localStorage.getItem("theme")).toBe("light");
|
||||
await user.click(screen.getByRole("button", { name: "Light theme: Canvas" }));
|
||||
expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas");
|
||||
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByTestId("pet-settings")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-next"));
|
||||
expect(screen.getByText("Set up notifications")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /Add your first workspace/ }));
|
||||
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("add");
|
||||
});
|
||||
|
||||
it("is skipped for good with Skip setup", async () => {
|
||||
const user = userEvent.setup();
|
||||
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
||||
renderWizard();
|
||||
await act(() => hydrate());
|
||||
await user.click(await screen.findByRole("button", { name: "Skip setup" }));
|
||||
await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no"));
|
||||
expect(onboardedMock).toHaveBeenCalled();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tour", () => {
|
||||
const mount = (html: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = html;
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
};
|
||||
// jsdom has no layout: give the targets a box.
|
||||
const boxed = () =>
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
|
||||
x: 10,
|
||||
y: 10,
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 30,
|
||||
right: 110,
|
||||
bottom: 40,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
it("follows the real UI: add → folder → create → prompt", async () => {
|
||||
const spy = boxed();
|
||||
const user = userEvent.setup();
|
||||
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
|
||||
try {
|
||||
startTour();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace"]}>
|
||||
<OnboardingTour />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
||||
// The dialog opened: the tour moves on by itself.
|
||||
const dialog = mount(
|
||||
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
|
||||
);
|
||||
expect(await screen.findByText("Choose the project folder")).toBeInTheDocument();
|
||||
await user.click(screen.getByTestId("onboarding-tour-next"));
|
||||
expect(screen.getByText("Create it")).toBeInTheDocument();
|
||||
// Created: the dialog is gone and the workspace's prompt box is there.
|
||||
dialog.remove();
|
||||
page.innerHTML = '<div data-testid="workspace-home-composer"></div>';
|
||||
expect(await screen.findByText("Start your first session")).toBeInTheDocument();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("prompt");
|
||||
await user.click(screen.getByRole("button", { name: "Skip tour" }));
|
||||
expect(screen.queryByTestId("onboarding-tour")).toBeNull();
|
||||
expect(localStorage.getItem("agenthub.tour")).toBe("done");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
|
||||
const spy = boxed();
|
||||
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
|
||||
try {
|
||||
localStorage.setItem("agenthub.tour", "create");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/workspace"]}>
|
||||
<OnboardingTour />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ export function PaneChat({
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={cwd}
|
||||
lane={runId}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||
|
||||
@@ -163,6 +163,8 @@ interface PromptEditorProps {
|
||||
/** An image upload started (true) or settled (false): the caller holds its
|
||||
* send button meanwhile, or the prompt would go out without the image. */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
||||
lane?: number | string;
|
||||
}
|
||||
|
||||
/** An attached file: its path on the server's machine, and a local preview
|
||||
@@ -187,6 +189,7 @@ export function PromptEditor({
|
||||
onImagesChange,
|
||||
bare,
|
||||
onBusyChange,
|
||||
lane,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -325,7 +328,7 @@ export function PromptEditor({
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
|
||||
@@ -192,7 +192,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
|
||||
@@ -186,6 +186,7 @@ describe("PromptEditor — newlines and images", () => {
|
||||
fileCwd="/Users/tester"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
lane={7}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("see", images)}</output>
|
||||
</>
|
||||
@@ -195,7 +196,8 @@ describe("PromptEditor — newlines and images", () => {
|
||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(img);
|
||||
// Saved where workspace 7's panes can read it.
|
||||
expect(api.run.dropFile).toHaveBeenCalledWith(img, 7);
|
||||
// The path is not typed into the prompt; it goes with it on send.
|
||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -481,7 +481,8 @@ describe("TerminalView", () => {
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||
// The pane's run id: saved where that pane can read it.
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||||
* **Run setup again** reopens the first-run wizard (`components/onboarding`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +22,7 @@ import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { resetOnboarding } from "../../lib/userPrefs";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
@@ -44,7 +46,7 @@ const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
{ mode: "system", Icon: Monitor },
|
||||
];
|
||||
|
||||
function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
export function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -221,6 +223,26 @@ export function AppearanceSettings() {
|
||||
</div>
|
||||
<PetSettings />
|
||||
</section>
|
||||
|
||||
<SettingsSection
|
||||
id="setup"
|
||||
title={t("onboarding:rerun.label")}
|
||||
description={t("onboarding:rerun.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("onboarding:rerun.label")}
|
||||
control={
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetOnboarding}
|
||||
data-testid="rerun-setup"
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("onboarding:rerun.button")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export const i18nReady = i18n
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
"onboarding",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"brand": "AgentHub",
|
||||
"progress": "{{n}} of {{total}}",
|
||||
"skip": "Skip setup",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"finish": "Add your first workspace",
|
||||
"agent": {
|
||||
"eyebrow": "Welcome to AgentHub",
|
||||
"title": "Pick your default agent",
|
||||
"body": "AgentHub works with every CLI agent. Choose the one you'll reach for most. Switch any time.",
|
||||
"detected": "Detected on this machine · {{count}}",
|
||||
"none": "No AI tool is installed yet. An admin installs them in Settings → AI Tools; you can finish setup and pick one later.",
|
||||
"more": "Show {{count}} more agents",
|
||||
"less": "Show fewer",
|
||||
"notInstalled": "not installed",
|
||||
"permission": "Start new sessions in",
|
||||
"permissionHint": "Bypass permissions is never a default: it stays a red switch you turn on for one session.",
|
||||
"modes": {
|
||||
"auto": "Auto",
|
||||
"acceptEdits": "Accept edits",
|
||||
"default": "Ask first",
|
||||
"plan": "Plan"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"title": "Make it feel like home",
|
||||
"body": "Pick the look you want to stare at for hours.",
|
||||
"modes": {
|
||||
"system": "System",
|
||||
"dark": "Dark",
|
||||
"light": "Light"
|
||||
},
|
||||
"modeHints": {
|
||||
"system": "Match OS",
|
||||
"dark": "Easy on the eyes",
|
||||
"light": "Bright & crisp"
|
||||
},
|
||||
"darkThemes": "Dark theme",
|
||||
"lightThemes": "Light theme",
|
||||
"language": "Language",
|
||||
"font": "Terminal font",
|
||||
"more": "More options — themes, fonts, the pet — in Settings → Appearance."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pick a companion",
|
||||
"body": "A small pet sits in the corner and reacts to what your agents do. Hide it any time."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Set up notifications",
|
||||
"body": "AgentHub tells you when agents are done or need you.",
|
||||
"enable": "Desktop notifications",
|
||||
"enableHint": "Your browser asks for permission once.",
|
||||
"blocked": "Notifications are blocked for this site — allow them in the browser's site settings.",
|
||||
"sound": "Notification sound",
|
||||
"preview": "Play",
|
||||
"test": "Send test notification"
|
||||
},
|
||||
"tour": {
|
||||
"progress": "Step {{n}} of {{total}}",
|
||||
"skip": "Skip tour",
|
||||
"next": "Next",
|
||||
"done": "Done",
|
||||
"goWorkspaces": "Go to Workspaces",
|
||||
"add": {
|
||||
"title": "Create your first workspace",
|
||||
"body": "A workspace is one project folder where your agents work. Click here to add one."
|
||||
},
|
||||
"folder": {
|
||||
"title": "Choose the project folder",
|
||||
"body": "Type a path or Browse to the folder of your project. A git repository gets a Changes panel too."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create it",
|
||||
"body": "Pick the AI it starts on, then click Add workspace."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Start your first session",
|
||||
"body": "Tell the agent what to do and press Enter. AI, model and permission mode sit right here."
|
||||
},
|
||||
"session": {
|
||||
"title": "Your session is live",
|
||||
"body": "Watch it work and type into it like a terminal. Changes and Files are in the panel on the right; every session of this workspace is listed in the sidebar."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set",
|
||||
"body": "Run setup again or change any of it in Settings."
|
||||
}
|
||||
},
|
||||
"rerun": {
|
||||
"label": "First-run setup",
|
||||
"description": "Go through the welcome steps and the tour again.",
|
||||
"button": "Run setup again"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
"bubbles": "Speech bubbles",
|
||||
"bubblesDesc": "Let the pet comment on your sessions in a bubble",
|
||||
"sizeDesc": "{{min}}–{{max}} px; the preview is capped at 120 px",
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved in this browser.",
|
||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
||||
"panel": {
|
||||
"label": "Sagi companion",
|
||||
@@ -189,7 +189,7 @@
|
||||
"alert": "Alert",
|
||||
"forType": "Sound for this type"
|
||||
},
|
||||
"scopeNote": "These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
|
||||
"scopeNote": "These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.",
|
||||
"insecureOrigin": "This page is served from {{origin}}, which is not a secure origin. Browsers only show notifications on HTTPS or localhost — open the dashboard through one of those to get them.",
|
||||
"unsupported": "This browser does not support notifications.",
|
||||
"deniedHelp": "Notifications are blocked for this site. To allow them again, open the site settings (the icon left of the address bar), set Notifications to Allow, then reload this page.",
|
||||
@@ -309,6 +309,7 @@
|
||||
"label": "Start AgentHub automatically",
|
||||
"desc_systemd": "Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_launchd": "Run the dashboard as a LaunchAgent, so it starts at login without Claude Code being opened. Turning it off keeps the running server up.",
|
||||
"desc_systemd-system": "Run the dashboard as the system service agenthub.service (server mode, one Linux user per account), so it starts with the machine.",
|
||||
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||
},
|
||||
@@ -662,7 +663,12 @@
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "no answer ({{status}}): {{message}}"
|
||||
"testFailed": "no answer ({{status}}): {{message}}",
|
||||
"personal": "Personal",
|
||||
"fieldScope": "Who can use it",
|
||||
"scopeShared": "Everyone (shared)",
|
||||
"scopePersonal": "Only me (personal)",
|
||||
"fieldScopeHint": "A shared profile's key never reaches anyone's browser. A personal one is listed, edited and used by you alone."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -697,7 +703,7 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only.",
|
||||
"description": "Colour mode and theme. Saved to your account, so every browser you sign in to gets them.",
|
||||
"mode": "Mode",
|
||||
"modes": {
|
||||
"dark": "Dark",
|
||||
@@ -882,7 +888,7 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "The language of the whole dashboard, remembered in this browser.",
|
||||
"description": "The language of the whole dashboard, saved to your account.",
|
||||
"label": "Interface language",
|
||||
"names": {
|
||||
"en": "English",
|
||||
@@ -891,7 +897,7 @@
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved in this browser only.",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1001,7 +1007,7 @@
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "This browser's preferences",
|
||||
"description": "Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||
"description": "Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.",
|
||||
"export": "Export preferences",
|
||||
"import": "Import preferences…",
|
||||
"importTitle": "Import preferences?",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"brand": "AgentHub",
|
||||
"progress": "{{n}} / {{total}}",
|
||||
"skip": "Bỏ qua thiết lập",
|
||||
"back": "Quay lại",
|
||||
"continue": "Tiếp tục",
|
||||
"finish": "Thêm workspace đầu tiên",
|
||||
"agent": {
|
||||
"eyebrow": "Chào mừng đến AgentHub",
|
||||
"title": "Chọn agent mặc định",
|
||||
"body": "AgentHub chạy được mọi CLI agent. Chọn cái bạn dùng nhiều nhất — đổi lúc nào cũng được.",
|
||||
"detected": "Đã có trên máy này · {{count}}",
|
||||
"none": "Chưa có AI tool nào được cài. Admin cài trong Cài đặt → AI Tools; bạn có thể hoàn tất thiết lập và chọn sau.",
|
||||
"more": "Xem thêm {{count}} agent",
|
||||
"less": "Thu gọn",
|
||||
"notInstalled": "chưa cài",
|
||||
"permission": "Session mới bắt đầu ở chế độ",
|
||||
"permissionHint": "Bypass permissions không bao giờ là mặc định: nó vẫn là công tắc đỏ bạn bật cho từng session.",
|
||||
"modes": {
|
||||
"auto": "Tự động",
|
||||
"acceptEdits": "Nhận sửa đổi",
|
||||
"default": "Hỏi trước",
|
||||
"plan": "Lập kế hoạch"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"title": "Làm cho nó thật quen thuộc",
|
||||
"body": "Chọn giao diện bạn sẽ nhìn hàng giờ liền.",
|
||||
"modes": {
|
||||
"system": "Hệ thống",
|
||||
"dark": "Tối",
|
||||
"light": "Sáng"
|
||||
},
|
||||
"modeHints": {
|
||||
"system": "Theo hệ điều hành",
|
||||
"dark": "Dịu mắt",
|
||||
"light": "Sáng rõ"
|
||||
},
|
||||
"darkThemes": "Theme tối",
|
||||
"lightThemes": "Theme sáng",
|
||||
"language": "Ngôn ngữ",
|
||||
"font": "Font terminal",
|
||||
"more": "Thêm tuỳ chọn — theme, font, thú cưng — trong Cài đặt → Giao diện."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Chọn một người bạn đồng hành",
|
||||
"body": "Một thú cưng nhỏ ngồi ở góc màn hình và phản ứng với việc agent đang làm. Ẩn lúc nào cũng được."
|
||||
},
|
||||
"notify": {
|
||||
"title": "Thiết lập thông báo",
|
||||
"body": "AgentHub báo cho bạn khi agent xong việc hoặc cần bạn.",
|
||||
"enable": "Thông báo trên máy",
|
||||
"enableHint": "Trình duyệt sẽ hỏi quyền một lần.",
|
||||
"blocked": "Trang này đang bị chặn thông báo — cho phép trong cài đặt trang của trình duyệt.",
|
||||
"sound": "Âm thanh thông báo",
|
||||
"preview": "Nghe thử",
|
||||
"test": "Gửi thông báo thử"
|
||||
},
|
||||
"tour": {
|
||||
"progress": "Bước {{n}} / {{total}}",
|
||||
"skip": "Bỏ qua hướng dẫn",
|
||||
"next": "Tiếp",
|
||||
"done": "Xong",
|
||||
"goWorkspaces": "Mở Workspaces",
|
||||
"add": {
|
||||
"title": "Tạo workspace đầu tiên",
|
||||
"body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm vào đây để thêm."
|
||||
},
|
||||
"folder": {
|
||||
"title": "Chọn thư mục dự án",
|
||||
"body": "Gõ đường dẫn hoặc bấm Browse tới thư mục dự án. Repo git sẽ có thêm bảng Changes."
|
||||
},
|
||||
"create": {
|
||||
"title": "Tạo workspace",
|
||||
"body": "Chọn AI sẽ dùng, rồi bấm Thêm workspace."
|
||||
},
|
||||
"prompt": {
|
||||
"title": "Bắt đầu session đầu tiên",
|
||||
"body": "Viết việc cần agent làm rồi nhấn Enter. Chọn AI, model và chế độ quyền ngay tại đây."
|
||||
},
|
||||
"session": {
|
||||
"title": "Session đang chạy",
|
||||
"body": "Xem agent làm việc và gõ vào như một terminal. Changes và Files nằm ở bảng bên phải; mọi session của workspace này có trong thanh bên."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Xong rồi!",
|
||||
"body": "Chạy lại thiết lập hoặc đổi bất cứ lựa chọn nào trong Cài đặt."
|
||||
}
|
||||
},
|
||||
"rerun": {
|
||||
"label": "Thiết lập ban đầu",
|
||||
"description": "Đi lại các bước chào mừng và phần hướng dẫn.",
|
||||
"button": "Chạy lại thiết lập"
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
"bubbles": "Bong bóng lời thoại",
|
||||
"bubblesDesc": "Cho pet bình luận về các session của bạn trong bong bóng",
|
||||
"sizeDesc": "{{min}}–{{max}} px; bản xem trước tối đa 120 px",
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu trong trình duyệt này.",
|
||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
|
||||
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
|
||||
"panel": {
|
||||
"label": "Trợ lý Sagi",
|
||||
@@ -189,7 +189,7 @@
|
||||
"alert": "Cảnh báo",
|
||||
"forType": "Âm thanh cho loại này"
|
||||
},
|
||||
"scopeNote": "Các cài đặt thông báo này chỉ lưu trong trình duyệt này. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
|
||||
"scopeNote": "Các cài đặt thông báo này được lưu vào tài khoản của bạn. Quy tắc cảnh báo và webhook bên dưới áp dụng cho toàn máy chủ: mọi người dùng bảng điều khiển này đều chịu ảnh hưởng.",
|
||||
"insecureOrigin": "Trang này được phục vụ từ {{origin}}, không phải nguồn bảo mật. Trình duyệt chỉ hiện thông báo trên HTTPS hoặc localhost — hãy mở bảng điều khiển qua một trong hai cách đó.",
|
||||
"unsupported": "Trình duyệt này không hỗ trợ thông báo.",
|
||||
"deniedHelp": "Thông báo đang bị chặn cho trang này. Để cho phép lại, mở cài đặt trang (biểu tượng bên trái thanh địa chỉ), đặt Thông báo thành Cho phép, rồi tải lại trang.",
|
||||
@@ -309,6 +309,7 @@
|
||||
"label": "Tự động khởi động AgentHub",
|
||||
"desc_systemd": "Chạy dashboard bằng systemd --user service để nó tự bật khi khởi động máy, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_launchd": "Chạy dashboard bằng LaunchAgent để nó tự bật khi đăng nhập, không cần mở Claude Code. Tắt đi thì server đang chạy vẫn giữ nguyên.",
|
||||
"desc_systemd-system": "Chạy dashboard bằng system service agenthub.service (chế độ server, mỗi tài khoản một Linux user) để nó tự bật cùng máy.",
|
||||
"unavailable": "Không khả dụng ở đây: cần systemd --user trên Linux, hoặc phiên đăng nhập GUI trên macOS.",
|
||||
"foreign": "Đã có một service tên agenthub không do AgentHub tạo — AgentHub không đụng vào."
|
||||
},
|
||||
@@ -662,7 +663,12 @@
|
||||
"test": "Kiểm tra",
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "không trả lời ({{status}}): {{message}}"
|
||||
"testFailed": "không trả lời ({{status}}): {{message}}",
|
||||
"personal": "Riêng",
|
||||
"fieldScope": "Ai được dùng",
|
||||
"scopeShared": "Mọi người (dùng chung)",
|
||||
"scopePersonal": "Chỉ tôi (riêng)",
|
||||
"fieldScopeHint": "Key của profile dùng chung không bao giờ tới trình duyệt của ai. Profile riêng chỉ mình bạn thấy, sửa và dùng."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -697,7 +703,7 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
|
||||
"description": "Chế độ màu và theme. Lưu vào tài khoản, nên mọi trình duyệt bạn đăng nhập đều dùng chúng.",
|
||||
"mode": "Chế độ",
|
||||
"modes": {
|
||||
"dark": "Tối",
|
||||
@@ -882,7 +888,7 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được ghi nhớ trong trình duyệt này.",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
|
||||
"label": "Ngôn ngữ giao diện",
|
||||
"names": {
|
||||
"en": "English",
|
||||
@@ -891,7 +897,7 @@
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Chỉ lưu trong trình duyệt này.",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1001,7 +1007,7 @@
|
||||
},
|
||||
"prefsBackup": {
|
||||
"title": "Tùy chọn của trình duyệt này",
|
||||
"description": "Giao diện, ngôn ngữ, cài đặt thông báo, thú cưng, thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
|
||||
"description": "Giao diện, ngôn ngữ, cài đặt thông báo và thú cưng theo tài khoản của bạn; thanh bên và bố cục không gian làm việc được lưu trong trình duyệt này. Lưu chúng ra tệp để chuyển sang trình duyệt khác, hoặc khôi phục từ tệp.",
|
||||
"export": "Xuất tùy chọn",
|
||||
"import": "Nhập tùy chọn…",
|
||||
"importTitle": "Nhập tùy chọn?",
|
||||
|
||||
+27
-16
@@ -555,6 +555,8 @@ export type AuditEntry = {
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
||||
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
* components mounting, a WS event fanning out to many listeners) share one
|
||||
@@ -675,13 +677,15 @@ export const api = {
|
||||
/** POST /api/auth/logout — ends this login. */
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
|
||||
// ───────────────────────── The account's own preferences (routes/me.js) ─────────────────────────
|
||||
me: {
|
||||
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
|
||||
prefs: () => request<MePrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
|
||||
/** GET /api/me/prefs — this account's preferences and first-run flag. */
|
||||
prefs: () => request<UserPrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -2448,13 +2452,19 @@ export const api = {
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
|
||||
* name so the agent sees what it is. */
|
||||
dropFile: (file: File) =>
|
||||
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
|
||||
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
|
||||
* run id) saves it where THAT workspace's panes can read it. */
|
||||
dropFile: (file: File, lane?: number | string | null) => {
|
||||
const laneId =
|
||||
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
|
||||
const qs = new URLSearchParams({ name: file.name });
|
||||
if (laneId != null) qs.set("laneId", String(laneId));
|
||||
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
});
|
||||
},
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -3600,6 +3610,10 @@ export interface AiProxy {
|
||||
label: string;
|
||||
/** 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;
|
||||
* "personal": the signed-in user's own, named `u<id>_<slug>` — server mode
|
||||
* only, and nobody else lists it. */
|
||||
scope?: "shared" | "personal";
|
||||
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
@@ -3651,6 +3665,9 @@ export interface AiProxyInput {
|
||||
models?: Partial<AiProxyModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
/** Server mode, on create: an admin's profile is shared unless "personal";
|
||||
* a member's is always personal. Ignored in local mode. */
|
||||
scope?: "shared" | "personal";
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
@@ -4312,12 +4329,6 @@ export interface RunStartArgs {
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
* name (`agenthub-lane-<laneId>`). */
|
||||
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
|
||||
export interface MePrefs {
|
||||
prefs: Record<string, string>;
|
||||
onboardedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
@@ -4528,7 +4539,7 @@ export interface RemoteSourceInput {
|
||||
* write, which it never touches. */
|
||||
export interface AutostartStatus {
|
||||
available: boolean;
|
||||
kind: "systemd" | "launchd";
|
||||
kind: "systemd" | "systemd-system" | "launchd";
|
||||
enabled: boolean;
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file sessionDefaults.ts
|
||||
* @description The account's own starting choices for new work, picked in the
|
||||
* first-run setup (docs/plans/onboarding) and synced per account like every
|
||||
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
|
||||
* the permission mode a new Claude session starts in. Bypass is NEVER one of
|
||||
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
|
||||
* is refused on read and on write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from "./api";
|
||||
|
||||
const TOOL_KEY = "agenthub.defaultTool";
|
||||
const MODE_KEY = "agenthub.permissionMode";
|
||||
|
||||
/** The permission modes a default may be. */
|
||||
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
|
||||
export type DefaultMode = (typeof DEFAULT_MODES)[number];
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}
|
||||
|
||||
/** The account's own default AI tool id, or null (the install's default applies). */
|
||||
export const readDefaultTool = (): string | null => read(TOOL_KEY);
|
||||
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
|
||||
|
||||
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
|
||||
export function readDefaultMode(): PermissionMode {
|
||||
const v = read(MODE_KEY);
|
||||
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
|
||||
}
|
||||
export function writeDefaultMode(mode: DefaultMode): void {
|
||||
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file userPrefs.ts
|
||||
* @description An account's UI preferences, kept per ACCOUNT on the server
|
||||
* (`/api/me/prefs`, docs/plans/onboarding) while every component goes on
|
||||
* reading and writing localStorage as it always did. On sign-in `hydrate()`
|
||||
* copies the account's stored values into localStorage (the account wins),
|
||||
* sends up the ones this browser has and the account does not yet, and makes
|
||||
* the theme, language and pet re-read them. From then on a write to one of
|
||||
* `PREF_KEYS` through `localStorage.setItem`/`removeItem` is sent to the
|
||||
* server, batched. `useOnboarding()` tells the setup wizard whether this
|
||||
* account has been through it. Never throws: a server that cannot answer
|
||||
* leaves the browser's own values in charge. The same row also holds
|
||||
* `agenthub.chatUi`, which `lib/chatUi.ts` reads and writes on the server
|
||||
* directly (no localStorage copy).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
"theme",
|
||||
"agenthub.theme.dark",
|
||||
"agenthub.theme.light",
|
||||
"i18nextLng",
|
||||
"agenthub.terminal.font",
|
||||
"agenthub.pet",
|
||||
"agenthub.pet.size",
|
||||
"agent-dashboard-sagi-enabled",
|
||||
"agent-dashboard-sagi-muted",
|
||||
"agent-monitor-notifications",
|
||||
"agenthub.defaultTool",
|
||||
"agenthub.permissionMode",
|
||||
"agenthub.tour",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
|
||||
// `forced`: Settings asked for the setup again, whatever the account has.
|
||||
type State = { loaded: boolean; onboardedAt: string | null; forced: boolean };
|
||||
let state: State = { loaded: false, onboardedAt: null, forced: false };
|
||||
const listeners = new Set<() => void>();
|
||||
const setState = (next: Partial<State>) => {
|
||||
state = { ...state, ...next };
|
||||
listeners.forEach((l) => l());
|
||||
};
|
||||
|
||||
let pending: Record<string, string | null> = {};
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let syncing = false;
|
||||
|
||||
function queue(key: string, value: string | null) {
|
||||
if (!syncing) return;
|
||||
pending[key] = value;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(flush, PUSH_DELAY_MS);
|
||||
}
|
||||
|
||||
/** Send what is queued now (also on page hide). */
|
||||
export function flush(): Promise<void> {
|
||||
clearTimeout(timer);
|
||||
const batch = pending;
|
||||
pending = {};
|
||||
if (!Object.keys(batch).length) return Promise.resolve();
|
||||
return api.me
|
||||
.savePrefs(batch)
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
/* offline or signed out: the browser keeps its own copy */
|
||||
});
|
||||
}
|
||||
|
||||
let wrapped = false;
|
||||
function wrapStorage() {
|
||||
if (wrapped || typeof Storage === "undefined") return;
|
||||
wrapped = true;
|
||||
const proto = Storage.prototype;
|
||||
const setItem = proto.setItem;
|
||||
const removeItem = proto.removeItem;
|
||||
proto.setItem = function (key: string, value: string) {
|
||||
setItem.call(this, key, value);
|
||||
if (this === window.localStorage && SYNCED.has(key)) queue(key, String(value));
|
||||
};
|
||||
proto.removeItem = function (key: string) {
|
||||
removeItem.call(this, key);
|
||||
if (this === window.localStorage && SYNCED.has(key)) queue(key, null);
|
||||
};
|
||||
window.addEventListener("pagehide", () => void flush());
|
||||
}
|
||||
|
||||
function readLocal(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Make what reads these keys read them again. */
|
||||
function reapply(prefs: Record<string, string>) {
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
const lng = prefs.i18nextLng;
|
||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the signed-in account's preferences into this browser and start
|
||||
* sending changes. Call once a login (or local mode) is known.
|
||||
*/
|
||||
export async function hydrate(): Promise<void> {
|
||||
syncing = false;
|
||||
pending = {};
|
||||
let answer;
|
||||
try {
|
||||
answer = await api.me.prefs();
|
||||
} catch {
|
||||
// No answer (an older server, a test with no API): no wizard, no sync —
|
||||
// the browser's own values stay in charge.
|
||||
return;
|
||||
}
|
||||
const up: Record<string, string> = {};
|
||||
for (const key of PREF_KEYS) {
|
||||
const theirs = answer.prefs[key];
|
||||
if (typeof theirs === "string") {
|
||||
try {
|
||||
window.localStorage.setItem(key, theirs);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
} else {
|
||||
const mine = readLocal(key);
|
||||
if (mine !== null) up[key] = mine;
|
||||
}
|
||||
}
|
||||
wrapStorage();
|
||||
syncing = true;
|
||||
reapply(answer.prefs);
|
||||
if (Object.keys(up).length) {
|
||||
pending = { ...up };
|
||||
void flush();
|
||||
}
|
||||
setState({ loaded: true, onboardedAt: answer.onboardedAt });
|
||||
}
|
||||
|
||||
/** The first-run setup is done or skipped, for this account. */
|
||||
export async function markOnboarded(): Promise<void> {
|
||||
await flush();
|
||||
const at = new Date().toISOString();
|
||||
setState({ onboardedAt: at, forced: false });
|
||||
try {
|
||||
const r = await api.me.onboarded();
|
||||
setState({ onboardedAt: r.onboardedAt || at });
|
||||
} catch {
|
||||
/* shown again next time — better than never saving the choices */
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the setup again (Settings → System). */
|
||||
export function resetOnboarding(): void {
|
||||
setState({ onboardedAt: null, forced: true });
|
||||
}
|
||||
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
};
|
||||
|
||||
/** `{loaded, onboardedAt, forced}` for the account signed in now. */
|
||||
export function useOnboarding(): State {
|
||||
return useSyncExternalStore(subscribe, () => state);
|
||||
}
|
||||
|
||||
/** Tests only. */
|
||||
export function __resetUserPrefs(): void {
|
||||
state = { loaded: false, onboardedAt: null, forced: false };
|
||||
pending = {};
|
||||
syncing = false;
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -21,6 +21,11 @@
|
||||
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
|
||||
* as a menu; the buttons stay.
|
||||
*
|
||||
* In server mode a profile is SHARED (an admin's, usable by everyone) or
|
||||
* PERSONAL (the signed-in user's own, badged "Personal"). A member edits only
|
||||
* their own and every profile they add is personal; an admin chooses when
|
||||
* adding; an analyst only reads.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
@@ -43,13 +48,15 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../compone
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
|
||||
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs"> & {
|
||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
|
||||
apiKey: string;
|
||||
behavesAs: string;
|
||||
models: Partial<AiProxyModels>;
|
||||
scope: "shared" | "personal";
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
@@ -59,10 +66,17 @@ const emptyForm = (): FormState => ({
|
||||
model: "",
|
||||
models: {},
|
||||
behavesAs: "",
|
||||
scope: "shared",
|
||||
});
|
||||
|
||||
export function AiProxyConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
// Server mode's signed-in user; null in local mode, where the host owns
|
||||
// every (shared) profile.
|
||||
const { state: auth } = useAuth();
|
||||
const user = auth.mode === "server" ? auth.user : null;
|
||||
const isAdmin = !user || user.role === "admin";
|
||||
const canWrite = (item: AiProxy) => !item.builtin && (item.scope === "personal" || isAdmin);
|
||||
const [items, setItems] = useState<AiProxy[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<AiProxy | null>(null);
|
||||
@@ -131,6 +145,7 @@ export function AiProxyConfig() {
|
||||
model: item.model,
|
||||
models: { ...item.models },
|
||||
behavesAs: item.behavesAs ?? "",
|
||||
scope: item.scope ?? "shared",
|
||||
});
|
||||
setFormError(null);
|
||||
setOpen(true);
|
||||
@@ -187,6 +202,7 @@ export function AiProxyConfig() {
|
||||
behavesAs: form.behavesAs.trim(),
|
||||
};
|
||||
if (form.apiKey.trim()) body.apiKey = form.apiKey.trim();
|
||||
if (user && isAdmin && !editing) body.scope = form.scope;
|
||||
|
||||
if (editing) await api.aiProxies.update(editing.name, body);
|
||||
else await api.aiProxies.create(body);
|
||||
@@ -253,13 +269,18 @@ export function AiProxyConfig() {
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.name}
|
||||
onContextMenu={item.builtin ? undefined : (e) => ctx.open(e, item)}
|
||||
onContextMenu={canWrite(item) ? (e) => ctx.open(e, item) : undefined}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
{item.label}
|
||||
{item.scope === "personal" && (
|
||||
<span className="rounded bg-accent/15 px-1.5 py-px text-[10px] font-normal text-accent">
|
||||
{t("aiProxy.personal")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.builtin && (
|
||||
<span className="mt-0.5 block text-[10px] text-fg-muted">
|
||||
@@ -277,7 +298,7 @@ export function AiProxyConfig() {
|
||||
{item.builtin ? "—" : item.hasKey ? t("aiProxy.keySet") : t("aiProxy.keyMissing")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!item.builtin && (
|
||||
{canWrite(item) && (
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -349,6 +370,31 @@ export function AiProxyConfig() {
|
||||
value={form.label}
|
||||
onChange={(v) => setForm((f) => ({ ...f, label: v }))}
|
||||
/>
|
||||
{user && !editing && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="proxy-scope">
|
||||
{t("aiProxy.fieldScope")}
|
||||
</label>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
id="proxy-scope"
|
||||
value={form.scope}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, scope: e.target.value as FormState["scope"] }))
|
||||
}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
>
|
||||
<option value="shared">{t("aiProxy.scopeShared")}</option>
|
||||
<option value="personal">{t("aiProxy.scopePersonal")}</option>
|
||||
</select>
|
||||
) : (
|
||||
<p id="proxy-scope" className="text-xs text-fg-primary">
|
||||
{t("aiProxy.scopePersonal")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("aiProxy.fieldScopeHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
<Field
|
||||
id="proxy-base-url"
|
||||
label={t("aiProxy.fieldBaseUrl")}
|
||||
|
||||
@@ -140,7 +140,8 @@ const MOVED: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Sections that only configure the host (ADMIN_WRITE_PREFIXES in server/lib/auth.js). */
|
||||
const ADMIN_SECTIONS = new Set(["data", "ai-proxy", "pricing"]);
|
||||
// AI Proxy is not here: every user keeps personal profiles (S3).
|
||||
const ADMIN_SECTIONS = new Set(["data", "pricing"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Tests for the AI Proxy settings page — listing profiles, creating one,
|
||||
* and the two rules that keep a key safe: the form never shows one, and an
|
||||
* edit that leaves the field blank must not send an empty key that would wipe
|
||||
* the stored one.
|
||||
* the stored one. In server mode: a developer or analyst edits only their
|
||||
* personal profiles and adds personal ones, an admin picks shared or personal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +13,12 @@ import userEvent from "@testing-library/user-event";
|
||||
import { AiProxyConfig } from "../AiProxyConfig";
|
||||
import { api } from "../../lib/api";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||
import type { AuthState } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
@@ -60,6 +67,7 @@ const nineRouter = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
authState = { mode: "local" };
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native] });
|
||||
@@ -286,3 +294,70 @@ describe("AiProxyConfig — testing an endpoint before saving it", () => {
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiProxyConfig in server mode", () => {
|
||||
const shared = { ...nineRouter, scope: "shared" as const };
|
||||
const mine = {
|
||||
...nineRouter,
|
||||
name: "u7_mine",
|
||||
label: "Mine",
|
||||
scope: "personal" as const,
|
||||
};
|
||||
const signIn = (role: "admin" | "developer" | "analyst") => {
|
||||
authState = { mode: "server", user: { id: 7, username: "mia", role } };
|
||||
};
|
||||
|
||||
it("lets a developer edit only their personal profile, badged Personal", async () => {
|
||||
signIn("developer");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Mine");
|
||||
expect(screen.getByText("aiProxy.personal")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a developer's profile as personal, with no scope choice", async () => {
|
||||
signIn("developer");
|
||||
const user = userEvent.setup();
|
||||
render(<AiProxyConfig />);
|
||||
await user.click(await screen.findByRole("button", { name: /aiProxy\.add/ }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).queryByRole("combobox")).toBeNull();
|
||||
expect(within(dialog).getByText("aiProxy.scopePersonal")).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Mine");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
|
||||
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
||||
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
|
||||
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets an admin choose a personal profile", async () => {
|
||||
signIn("admin");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared] });
|
||||
const user = userEvent.setup();
|
||||
render(<AiProxyConfig />);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /aiProxy\.edit 9Router/ })
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /aiProxy\.add/ }));
|
||||
await user.selectOptions(screen.getByLabelText("aiProxy.fieldScope"), "personal");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldLabel"), "Boss");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldBaseUrl"), "http://x:1");
|
||||
await user.type(screen.getByLabelText("aiProxy.fieldModel"), "m");
|
||||
await user.click(screen.getByRole("button", { name: "aiProxy.save" }));
|
||||
await waitFor(() => expect(api.aiProxies.create).toHaveBeenCalled());
|
||||
expect(vi.mocked(api.aiProxies.create).mock.calls[0]?.[0].scope).toBe("personal");
|
||||
});
|
||||
|
||||
it("gives an analyst Add and Edit on personal profiles only", async () => {
|
||||
signIn("analyst");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [native, shared, mine] });
|
||||
render(<AiProxyConfig />);
|
||||
await screen.findByText("Mine");
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.add/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /aiProxy\.edit 9Router/ })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /aiProxy\.edit Mine/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9444,7 +9444,7 @@ exit 0
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Theme, language, notification settings, pet, sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
|
||||
Theme, language, notification settings and pet follow your account; the sidebar and workspace layout are kept in this browser. Save them to a file to move them to another browser, or restore them from one.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
@@ -9609,7 +9609,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
<p
|
||||
class="text-xs text-fg-muted"
|
||||
>
|
||||
These notification settings are saved in this browser only. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
|
||||
These notification settings are saved to your account. Alert rules and webhooks below are server-wide: they apply to everyone using this dashboard.
|
||||
</p>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
|
||||
+58
-14
File diff suppressed because one or more lines are too long
+4
-2
@@ -105,9 +105,11 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
|
||||
| `agenthub repl` (aliases `shell`, `i`) | Open the **interactive shell** — see [Interactive REPL](#interactive-repl) |
|
||||
| `agenthub server setup [--hostname name] [--admin name]` | Turn **server mode** on (logins for a shared dashboard): writes `AGENTHUB_MODE=server` (and `DASHBOARD_ALLOWED_HOSTS`) to the settings file and creates the first admin, asking for its password. Works with the server down; restart it afterwards. See [SERVER-MODE.md](SERVER-MODE.md) |
|
||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database; `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
|
||||
+602
-29
@@ -15,12 +15,11 @@ Local mode does not change at all. The login code stays dormant until
|
||||
> **What server mode separates, and what it does not.** In the dashboard and
|
||||
> its API, each developer sees only their own workspaces and the sessions,
|
||||
> prompts, events, tokens and cost under them (see [What each user sees](#what-each-user-sees)).
|
||||
> Every terminal pane, though, still runs as the one Linux account that runs
|
||||
> the dashboard. An agent in a pane can therefore read every user's folders,
|
||||
> transcripts and the database straight from disk. Use server mode only with
|
||||
> people you trust until phase S3 (one Linux user per account) lands. S3 and S4
|
||||
> (shared tools) need `sudo` on the host and are planned in the AgentHub-Server
|
||||
> repo.
|
||||
> Until [one Linux user per account](#one-linux-user-per-account) is installed
|
||||
> (`agenthub server install-system`), every terminal pane runs as the one Linux
|
||||
> account that runs the dashboard. An agent in a pane can then read every
|
||||
> user's folders, transcripts and the database straight from disk. Without it,
|
||||
> use server mode only with people you trust.
|
||||
|
||||
## Set it up
|
||||
|
||||
@@ -93,22 +92,25 @@ The login cookie gets `Secure` when the request came over HTTPS
|
||||
start (`server/db.js`), and `member` is no longer accepted.
|
||||
|
||||
"The host's configuration" means a write (anything but GET/HEAD) under
|
||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`, `/ai-proxies`,
|
||||
`/ai-routers`, `/git-credentials`, `/ai-tools`, `/import`, `/pricing`,
|
||||
`/budgets`, `/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`). A developer
|
||||
or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only an admin
|
||||
can do this". An analyst's other writes are scoped like a developer's: one
|
||||
naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`,
|
||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). A
|
||||
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
|
||||
an admin can do this". An analyst's other writes are scoped like a
|
||||
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
||||
`server/lib/scope.js` limits every non-GET request of an analyst to their own
|
||||
rows).
|
||||
rows), and under S3 an analyst's workspace runs as their own Linux account
|
||||
exactly like a developer's.
|
||||
|
||||
The UI hides what a role cannot use (`usePermissions` in
|
||||
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
|
||||
no Data, Pricing, AI proxy or per-tool pages, no scheduled reports or git
|
||||
credentials, no start-at-login/icon/Quit/Uninstall on System, no alert
|
||||
rules or channels, no install/update/enable on AI tools, no pet upload or
|
||||
delete, and no update check. An analyst's Workspace page and sidebar list only
|
||||
no Data, Pricing or per-tool pages, no scheduled reports or git credentials,
|
||||
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
|
||||
no install/update/enable on AI tools, no pet upload or delete, and no update
|
||||
check; the AI proxy page shows them only their personal profiles' Edit. An
|
||||
analyst's Workspace page and sidebar list only
|
||||
their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
|
||||
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
|
||||
hidden for a session that is not theirs. An analyst's alert feed
|
||||
@@ -140,9 +142,38 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
|
||||
A developer also cannot open the host's own configuration, or any view across all
|
||||
users: Claude Code / Codex config (except the command and skill lists the
|
||||
prompt box uses), git credentials, remote sources, webhooks, reports, metrics,
|
||||
imports, data export, alert rules and `/api/users`. Those return
|
||||
`403 EADMIN`, and Settings hides them.
|
||||
prompt box uses), the AI tools' config files (`/api/ai-tools/<id>/config…`),
|
||||
git credentials, remote sources, webhooks, reports, metrics, imports, data
|
||||
export, alert rules and `/api/users`. Those return `403 EADMIN`, and Settings
|
||||
hides them for developers. The command and skill lists show a developer only their
|
||||
own workspaces' project items: `?cwd=` outside one of them answers
|
||||
`403 EFOLDER`, and with no `cwd` they list no project items at all.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
Each user may keep **personal** AI Proxy profiles beside the **shared** ones:
|
||||
|
||||
- A developer's or analyst's new profile is always personal. An admin's is shared unless
|
||||
they choose **Only me (personal)** in the form (`scope: "personal"`).
|
||||
- A personal profile is named `u<user id>_<slug>`. Only its owner lists,
|
||||
edits, deletes or tests it, and only the owner's workspaces can start a
|
||||
session on it. Anyone else, an admin included, gets `404`, the same answer
|
||||
as a profile that does not exist. A resume of a session that ran on someone
|
||||
else's personal profile goes back to native.
|
||||
- Only an admin writes a shared profile. Everyone can choose one.
|
||||
- **No pane is given a proxy's key, and no route returns one.**
|
||||
- A session's CLI talks to AgentHub's own loopback gateway,
|
||||
`http://127.0.0.1:<port>/api/proxy-gw/<profile>/v1/…`. Its key there is
|
||||
its pane's token (`ANTHROPIC_AUTH_TOKEN` for Claude, `AGENTHUB_PROXY_KEY`
|
||||
for Codex).
|
||||
- AgentHub swaps in the real key and streams the request to the profile's
|
||||
endpoint.
|
||||
- The gateway answers only on this machine, only while the pane lives, and
|
||||
only for the profile the pane was started on.
|
||||
- An agent can still spend through the profile while its pane runs, but it
|
||||
cannot carry the key away.
|
||||
- Claude account-pool tokens are not covered: they still reach the pane (see
|
||||
[Claude account pool](#claude-account-pool)).
|
||||
|
||||
### Keep developers' folders apart
|
||||
|
||||
@@ -152,10 +183,16 @@ AGENTHUB_USERS_ROOT=/srv/agenthub/users
|
||||
```
|
||||
|
||||
With this set, a developer or analyst can create a workspace only inside
|
||||
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. A folder
|
||||
`/srv/agenthub/users/<username>`; any other folder gets `403 EFOLDER`. The
|
||||
same root limits a developer's folder browser (`GET /api/lanes/browse`, which
|
||||
opens on their own folder) and `GET /api/lanes/branches`, both judged by real
|
||||
path, so a link cannot point them elsewhere. Under S3 the home itself is no
|
||||
workspace (its dot folders hold the member's logins); use a folder in it. A folder
|
||||
that already belongs to another user's workspace gets `409 EOWNED`. Admins are
|
||||
not limited. Create each user's folder yourself. Until S3 this keeps workspaces
|
||||
apart, not agents.
|
||||
not limited. Without [one Linux user per account](#one-linux-user-per-account)
|
||||
you create each user's folder yourself, and this keeps workspaces apart, not
|
||||
agents. `install-system` sets this value to `/srv/agenthub/users`, and adding a
|
||||
user makes the folder (it is the Linux account's home).
|
||||
|
||||
## What the gate does
|
||||
|
||||
@@ -187,9 +224,17 @@ apart, not agents.
|
||||
as the owner of the pane's workspace. Each pane the dashboard starts gets its
|
||||
own `AGENTHUB_PANE_TOKEN` through tmux `-e`, and the database stores only its
|
||||
hash (`dashboard_runs.pane_token_hash`). The token is accepted only from this
|
||||
machine and only while that pane is alive, as header `x-agenthub-pane-token`.
|
||||
machine and only while that pane is alive, as header `x-agenthub-pane-token`
|
||||
(with one Linux user per account on: only from the Linux account that pane
|
||||
runs as, read from the kernel like a hook's sender).
|
||||
A workspace with no owner has no such user. Give old workspaces an owner with
|
||||
`agenthub users assign <workspace id> <user>`.
|
||||
`agenthub users assign <workspace id> <user>`. With one Linux user per
|
||||
account on, a workspace inside a member's home can be owned by that member
|
||||
only, and one outside the users root (its panes run as the host account) by
|
||||
an admin only: creating, assigning or starting one against that answers
|
||||
`409 EOWNERFOLDER` — by the user's role at that moment, and demoting or
|
||||
disabling a user ends their panes that run as the host. A pane's folder must
|
||||
be its workspace's account's too (`409 ECWDACCOUNT`).
|
||||
- Named locks (`agenthub lock …`) are per user: a developer's lock names live in
|
||||
their own namespace, so one user's agents never see or take another's.
|
||||
- Browser push subscriptions belong to the user who made them. A
|
||||
@@ -262,9 +307,537 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
- `GET /api/accounts` returns the same table, without tokens.
|
||||
|
||||
> **Ceiling:** anyone with a terminal in AgentHub can read the token of the
|
||||
> account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`), and, as one
|
||||
> Linux user, every file of the pool. Hiding tokens from panes needs one Linux
|
||||
> user per account (phase S3), or AgentHub acting as the pane's API endpoint.
|
||||
> account their pane runs on (`echo $CLAUDE_CODE_OAUTH_TOKEN`). Without one
|
||||
> Linux user per account they can also read every file of the pool. With it,
|
||||
> they still see their own pane's token (a member's pane gets it through its
|
||||
> launch file instead of tmux `-e`), but no other account's process or file.
|
||||
> The AI Proxy gateway does not cover pool tokens.
|
||||
|
||||
## Codex account pool
|
||||
|
||||
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
|
||||
|
||||
```bash
|
||||
# An OpenAI API key (typed hidden, or piped on stdin)
|
||||
agenthub accounts add team-key --tool codex --api-key
|
||||
# A ChatGPT account: sign in in a scratch home, then hand that home over
|
||||
CODEX_HOME=$(mktemp -d) codex login
|
||||
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
|
||||
agenthub accounts list --tool codex
|
||||
agenthub accounts remove team-key --tool codex
|
||||
```
|
||||
|
||||
- **API key**: any number of panes at once. The pane gets the key through
|
||||
tmux `-e AGENTHUB_POOL_KEY=…`, and Codex is pointed at it with
|
||||
`-c model_providers.agenthub_pool={…env_key="AGENTHUB_POOL_KEY"…}` — the
|
||||
argv names the variable, never the key.
|
||||
- **ChatGPT**: Codex reads this login only from `$CODEX_HOME/auth.json`, so it
|
||||
is used only for a pane that runs as its developer (one Linux user per
|
||||
account): AgentHub writes the account's `auth.json` into that developer's
|
||||
`~/.codex` as them (0600) just before the pane starts.
|
||||
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
||||
- A developer who signed Codex in themselves keeps their own login.
|
||||
- While a pane of theirs runs on a pool account, their next pane uses the
|
||||
same account (one `auth.json` per home).
|
||||
- Codex rotates refresh tokens. Every 10 s AgentHub copies a developer's
|
||||
`auth.json` back into the pool when its `last_refresh` is newer, so a new
|
||||
pane starts from the newest login — only from a home AgentHub placed that
|
||||
account in, never with a `last_refresh` in the future, and only when its
|
||||
`id_token` names the same account and email. The `id_token` signature is
|
||||
not checked: a developer the account was placed with can still spoil it
|
||||
for others until a real refresh from another home wins it back. Several panes may share one account
|
||||
(checked on codex 0.161: an older refresh token still refreshes).
|
||||
- Ranking is the Claude pool's: quota ≥ 90% last, then fewest live Codex
|
||||
panes, then $ in 5 h / 7 d. The quota is the newest `rate_limits` in the
|
||||
rollouts of the sessions that account's runs started (no network), each
|
||||
read only from the `CODEX_HOME` of the account its workspace runs as, and
|
||||
never one dated in the future.
|
||||
- A session on an AI Proxy keeps the proxy and does not use the pool.
|
||||
|
||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||
> `auth.json`, like the Claude pool's token.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
account (`ah-<username>`). An agent in Alice's terminal then cannot read Bob's
|
||||
folders, transcripts or the dashboard's database: the OS stops it, not just the
|
||||
UI. This is phase S3 of `docs/plans/server-mode/S3-PLAN.md`. It is ON when
|
||||
server mode is on AND `/etc/agenthub/server.conf` exists
|
||||
(`server/lib/linux-accounts.js`, `s3Enabled`); without that file everything
|
||||
below is a no-op and server mode behaves as described above. To install it,
|
||||
follow the [operator runbook](#operator-runbook).
|
||||
|
||||
> **Status (2026-10-09):** built and checked on a real machine: the host
|
||||
> setup, the accounts, panes, git and file saves as the member, hooks checked
|
||||
> by account, reads across homes, shared tools in `/opt`, and the fixes from
|
||||
> the 2026-10-08 security review (P8 F1–F11). Built and tested with fake
|
||||
> accounts only: per-member quota (P4), the opt-in plugin baseline (P6) and
|
||||
> the Codex account pool (P7). The second review (2026-10-09, P2–P7) is fixed
|
||||
> (see [What the second review closed](#what-the-second-review-closed)).
|
||||
>
|
||||
> Not done yet (`docs/plans/server-mode/S3-PLAN.md`):
|
||||
> - the admin's plugin baseline through managed settings (P6 spike, not run):
|
||||
> the opt-in fallback ([Plugin baseline](#plugin-baseline-opt-in)) is
|
||||
> built; otherwise a member installs plugins in their own terminal
|
||||
> (`/plugin`);
|
||||
> - Codex and Antigravity hooks for members: provisioning installs none, so a
|
||||
> member's Codex and Antigravity sessions are tracked from their journals
|
||||
> (the pollers read every member's home), not from hooks;
|
||||
> - per-member quota, the plugin baseline and the Codex account pool (P4, P6,
|
||||
> P7) are not yet tried with real member accounts or a real Codex login.
|
||||
|
||||
### The model
|
||||
|
||||
- **Identity.** AgentHub user `alice` is Linux user `ah-alice`, home
|
||||
`/srv/agenthub/users/alice` (`0750`, owner `ah-alice`, group `agenthub`).
|
||||
The name must be lower case: a letter, then up to 28 letters, digits, `_` or
|
||||
`-` (`LINUX_NAME_RE`). While S3 is on, a user with any other name cannot be
|
||||
created (`400 EBADUSER`). Admins and analysts get a Linux account too. A
|
||||
workspace's folder decides who runs it (`server/lib/run-as.js`,
|
||||
`accountForPath`): a folder under `/srv/agenthub/users/<name>/` runs as
|
||||
`ah-<name>`; any other folder runs as the host account, as before (a
|
||||
workspace made before S3 outside the users root, for example). A folder under the users root whose
|
||||
user is unknown, disabled or not provisioned is an error (`409
|
||||
EMEMBERGONE`), never "run it as the host".
|
||||
- **The dashboard.** A SYSTEM unit, `agenthub.service`, runs it as the host
|
||||
account with `CAP_DAC_READ_SEARCH` (it reads every member's tool files —
|
||||
Claude writes transcripts `0600` — but writes none of them). Every program
|
||||
it starts goes through `setpriv --inh-caps=-all --ambient-caps=-all` first
|
||||
(`server/lib/drop-caps.js`), so host panes, git hooks and npm do not inherit
|
||||
the capability.
|
||||
- **Run-as through sudo.** Everything done AS a member is
|
||||
`sudo -n -u ah-<name> <command>`, and the sudoers file allows exactly
|
||||
`tmux`, `git`, `gh` (by the absolute paths in `server.conf`),
|
||||
`/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the
|
||||
plugin baseline) that way — never root. As root, the host may
|
||||
run only `/opt/agenthub/sbin/agenthub-provision` (create, disable or enable
|
||||
a member) and `systemctl start|stop|restart|enable|disable
|
||||
agenthub.service`. Members are never in sudoers.
|
||||
- **sudo logs every command line**, so nothing secret or private crosses it:
|
||||
a pane's environment (its pane token, a pool token) and its command (the
|
||||
prompt) go into a launch file that `write-as` writes as the member (`0600`,
|
||||
from stdin) and `/opt/agenthub/libexec/pane-launch` reads, deletes and
|
||||
execs. File contents go to `write-as` on stdin.
|
||||
- **hidepid is required.** `/proc` is mounted `hidepid=invisible`, with group
|
||||
`agenthub-proc` (the host account, `polkitd`) exempt. Without it any account
|
||||
could read another's command lines. `install-system` stops if the remount
|
||||
fails.
|
||||
- **Hooks are authenticated by the sender's uid**, read from the kernel's
|
||||
table of loopback connections (`/proc/net/tcp*`, `server/lib/hook-auth.js`),
|
||||
not from anything the hook says.
|
||||
- **The AI Proxy gateway.** A pane on an AI Proxy profile gets
|
||||
`http://127.0.0.1:<port>/api/proxy-gw/<profile>` and its own pane token as
|
||||
the key; AgentHub swaps in the real key upstream
|
||||
(`server/lib/proxy-gateway.js`, see [AI Proxy profiles](#ai-proxy-profiles)).
|
||||
A Claude pane gets the token in a per-run `--settings` file, so a user or
|
||||
project `env.ANTHROPIC_*` cannot override it.
|
||||
- **Shared tools.** Members run every AI tool, node and AgentHub's own scripts
|
||||
from `/opt/agenthub` (`server/lib/shared-tools.js`), first on their pane's
|
||||
`PATH`, with self-update off (`DISABLE_AUTOUPDATER=1`).
|
||||
|
||||
### What install-system sets up
|
||||
|
||||
The pieces, and what each is for (the install steps are in the
|
||||
[runbook](#install)):
|
||||
|
||||
| Piece | What it is |
|
||||
| --- | --- |
|
||||
| `ah-<name>` | Made by `agenthub-provision`, not by the installer. One Linux user per AgentHub user, home `/srv/agenthub/users/<name>` (`0750`, group `agenthub`): the member owns it, the dashboard reads it, nobody else gets in. `.agenthub/{tmux,handoff}` (0700) is made by the member: their tmux socket, and the files the dashboard writes for their panes (as them) |
|
||||
| groups | `agenthub` (reads member homes: the host account), `agenthub-members` (every `ah-*` account), `agenthub-proc` (sees every process: the host account, `polkitd`) |
|
||||
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
|
||||
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
|
||||
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
|
||||
| `write-as`, `pane-launch` | `/opt/agenthub/libexec/`, root's. `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
|
||||
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
|
||||
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
|
||||
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
|
||||
| `/opt/agenthub/{tools,app}` | Owned by the host account: the dashboard updates them, every account runs them. `tools/bin` holds node and every installed AI tool; this install's hook handler, CLI, MCP server and statusline go in `app`, with the `agenthub` launcher in `tools/bin`. The AI tools are published there after each install or update from the AI Tools page, at server start, and by `agenthub server tools`. A publish copies a native binary, an npm package's folder, or a script's own version folder. A tool that is not published yet answers a member's start with `409 ENOTSHARED` |
|
||||
|
||||
What runs as the member, and how:
|
||||
|
||||
- **Panes.**
|
||||
- Each member has their own tmux server (`~/.agenthub/tmux/sock`). Every tmux command on it, attaching a browser terminal included, runs as `sudo -u ah-<name> tmux …`. The dashboard never connects to it as itself: the member controls that socket.
|
||||
- sudo logs every command line it runs, so a pane's environment (its tokens) and its command (the prompt) are not on it. `write-as` writes them to a launch file (`0600`, from stdin), and `/opt/agenthub/libexec/pane-launch` reads it, deletes it and starts the command. A prompt pasted later goes in on stdin.
|
||||
- A pane gets the member's `HOME`, `CODEX_HOME` and `PATH` (`/opt/agenthub/tools/bin` first), with `DISABLE_AUTOUPDATER=1` and none of the host's desktop variables.
|
||||
- **Git, `gh` and file saves.**
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the dashboard is refused (`400 EMEMBERCRED`): the member signs in to their git host from their own terminal.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
- files dropped into the prompt box;
|
||||
- a pane's launch file.
|
||||
|
||||
Codex gets its proxy as `-c` overrides, never a file in the member's `CODEX_HOME`.
|
||||
- **Reading a member's files.** A transcript, a Codex, Antigravity or Cursor journal, or a file in the workspace is read only when the opened file is a regular file the member owns. A link to another account's file is refused, and so is a FIFO. A journal speaks only for its own account: its session's folder must be inside that home. A member's Antigravity conversation (SQLite, which opens its `-journal`/`-wal` by name) is read from a private copy; the walk that turns a Cursor project slug back into a folder follows no link and gives up after 500 folders.
|
||||
- **Hooks.** A hook is matched to its sender's Linux account, read from the kernel's table of loopback connections.
|
||||
- A member's Claude Code reports through `/etc/claude-code/managed-settings.json` → `/opt/agenthub/libexec/member-hook` → `/opt/agenthub/app/scripts/hook-handler.js`.
|
||||
- The host's hooks are accepted as before.
|
||||
- A member's hook may report only work inside their own home: another account's folder, session or pane is `403 EFOREIGN`, and a transcript path outside the home is dropped.
|
||||
- Any other account is `403 EPEER`.
|
||||
- **Liveness.** A member's session counts as alive while the pane holding it lives, because the dashboard cannot read another account's process directory. A session the member started in their own shell ends once it has been idle for the idle timeout.
|
||||
|
||||
Not offered for a workspace in a member's folder (`403 EMEMBERLANE`): the
|
||||
Add-workspace **Worktree** mode and the runtime layer (`lanes up/down/hook`,
|
||||
profiles, proof, lane MCP). Both run as the dashboard's own account. Use
|
||||
**Repo** mode and the prompt box's **New worktree** instead.
|
||||
|
||||
**First sign-in.** Every account gets AgentHub's own setup once, in the
|
||||
browser: the default agent and permission mode, the look (System, Dark or
|
||||
Light, a theme for each, language, terminal font), the pet and notifications,
|
||||
then a guided tour to the first workspace and session. The choices are stored
|
||||
per account (`/api/me/prefs`), so they follow the user to any browser; an
|
||||
account that already has a workspace skips it, and Settings → Appearance →
|
||||
**Run setup again** brings it back.
|
||||
|
||||
A member's own Claude Code skips its first-run questions: `agenthub-provision`
|
||||
marks its onboarding done and trusts the member's home (which covers every
|
||||
folder under it), and the dark/light mode picked in AgentHub becomes Claude's
|
||||
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
||||
in their terminal.
|
||||
|
||||
### Plugin baseline (opt-in)
|
||||
|
||||
Write `/etc/agenthub/plugin-baseline.json` (as
|
||||
root) to give every NEW member your plugins:
|
||||
|
||||
```json
|
||||
{ "marketplaces": ["owner/repo"], "plugins": ["name@marketplace"] }
|
||||
```
|
||||
|
||||
After a user is created, AgentHub runs, as that member and in the
|
||||
background, `claude plugin marketplace add <source>` for each marketplace and
|
||||
then `claude plugin install --scope user <plugin>` for each plugin; each
|
||||
result is logged as `[plugin-baseline]`. `agenthub users provision <name>`
|
||||
(or `--all`) runs it again in the foreground. No file, nothing runs. `-y` is
|
||||
never passed, so a plugin that installs by running a marketplace-declared
|
||||
command fails here; the member installs it from their own terminal. Members
|
||||
add their own plugins with `/plugin` as usual.
|
||||
|
||||
### What the second review closed
|
||||
|
||||
The 2026-10-09 review looked for ways a member can point the dashboard's own
|
||||
rights (`CAP_DAC_READ_SEARCH`, the host account, sudo as any member) at files
|
||||
that are not theirs. Each case below is refused, with a test in
|
||||
`server/__tests__/server-s3-review2.test.js`:
|
||||
|
||||
- **A workspace folder swapped for a link.** The file view, image view, blame
|
||||
and the file editor resolve the workspace's real path and refuse it
|
||||
(`403 EFOREIGN`) when it lands in another account's files — another
|
||||
member's folder, the host's home, `/etc`. The account a read or save runs as
|
||||
is never taken from where a link points.
|
||||
- **A member's git dir naming a path outside their home.** The `info/exclude`
|
||||
line AgentHub adds for a new worktree or `.mcp.json` is written only when the
|
||||
repo's real git dir is the same account's; a FIFO there cannot hang the
|
||||
dashboard.
|
||||
- **Browsing and branch lists.** A developer's `GET /api/lanes/browse` and
|
||||
`GET /api/lanes/branches` stay inside their own folder (`403 EFOLDER`), and
|
||||
so does a workspace's extra folder (`POST /api/lanes/:id/sources/folder`,
|
||||
refused before the path is looked at, so the answer says nothing about
|
||||
another member's files).
|
||||
- **The top of a member's home.** Its dot folders (`.claude`, `.codex`,
|
||||
`.config`, `.ssh`, `.agenthub`…) hold logins and pane tokens: no workspace
|
||||
file view serves them to anyone (`403 ESECRET`), and the home itself is no
|
||||
workspace.
|
||||
- **Transcripts by session id.** A scan by id looks only in the session
|
||||
owner's home, so a file another member plants under that id is never shown.
|
||||
- **Codex journals.** A rollout journal sets the status only of a lane in its
|
||||
own account's home, and the newest-40 window is per home.
|
||||
- **A disabled member's sessions.** Opening one reads from their home instead
|
||||
of crashing the server, and the liveness reaper skips them instead of
|
||||
stopping.
|
||||
- **Host terminals.** A browser terminal's `tmux attach` (node-pty, which
|
||||
forks outside `child_process`) is started through `setpriv` too, so it does
|
||||
not keep `CAP_DAC_READ_SEARCH`.
|
||||
- **AI Proxy edits.** Editing a profile rewrites a running gateway pane's own
|
||||
settings file (with its own token), and deletes the file of a pane that has
|
||||
ended.
|
||||
|
||||
### What review round 5 closed
|
||||
|
||||
The fifth review (2026-10-09) looked at the dashboard's ONE thread and at who
|
||||
a pane really is. Each case below is refused or bounded, with a test in
|
||||
`server/__tests__/server-s3-review5.test.js`:
|
||||
|
||||
- **AgentHub's data dir is never a config file.** `GET /api/cc-config/file`
|
||||
refuses anything in it — the database, the push keys, and each gateway
|
||||
pane's settings file, which holds that pane's live token.
|
||||
- **A member's stopped or flooding tmux server.** Every call on it has a 3 s
|
||||
limit and a 1 MiB answer cap; one that hit either is not asked again for a
|
||||
minute. Meanwhile only that member's panes count as unknown (kept, not
|
||||
ended); everyone else's are still checked. Every other synchronous call
|
||||
that runs as a member (git in their folder, `write-as`) stops after 5 s.
|
||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||
by line with a 64 MiB cap per line (a 512 MB line used to crash the server);
|
||||
a whole member file is read only up to a cap (1 MiB for a login or a
|
||||
settings file, 16 MiB for an index or a workflow journal, 256 MiB for a
|
||||
SQLite copy, 64 MiB otherwise); a member transcript over 256 MiB is neither
|
||||
parsed nor snapshotted, and the snapshot keeps a sparse file's holes.
|
||||
- **A request that makes a route throw** answers 500 instead of stopping the
|
||||
server (a member folder of a gone user crashed `POST /api/lanes/worktree`).
|
||||
- **Who a pane runs as.** A non-admin owns no workspace outside their home
|
||||
(above), a pane's folder is its workspace's account's, and a pane token
|
||||
works only from that account's uid.
|
||||
- **A path an analyst names.** Browsing, branch lists and the `@` file search
|
||||
stay in their own folder or workspaces, like a developer's.
|
||||
- **Drops** are saved where the pane that reads them can read them: as the
|
||||
workspace's account (the client names the workspace), not the dropper's.
|
||||
- **The Codex pool and ChatGPT Teams.** A login is the pool's only when
|
||||
AgentHub placed it in that home AND it is the same person (email): every
|
||||
seat of a Team shares one `account_id`, so a member's own seat is theirs.
|
||||
|
||||
### Accepted ceilings
|
||||
|
||||
- The dashboard process can read any file, `/etc/shadow` included. `sudo`
|
||||
needs that file, so the unit cannot hide it (`InaccessiblePaths` hides only
|
||||
`/root`). Treat the host account as privileged, and keep it out of the
|
||||
`sudo` group on a real server: there the sudoers file above is all it may
|
||||
do.
|
||||
- Anything that runs as the host account can run anything as any member: the
|
||||
sudoers file lets it start `tmux` as them. A host compromise is every
|
||||
member's compromise.
|
||||
- `install-system` installs and runs code from the host account's install, as
|
||||
root. Re-run it only from a tree you trust.
|
||||
- sudo's log (readable by root and `adm`) shows members' commit messages,
|
||||
branch names, pull-request titles and file paths, never a token or a prompt.
|
||||
- A member may point a personal AI Proxy at any URL, so the dashboard will
|
||||
POST to it. They have a shell on the machine anyway.
|
||||
- The gateway forwards any path under `/v1/` to the profile's upstream, and an
|
||||
agent can spend through a profile while its pane lives.
|
||||
- A Claude pool token is in its pane's environment: the member of that pane
|
||||
can read it (see [Claude account pool](#claude-account-pool)). The same goes
|
||||
for a Codex pool API key, and for a Codex ChatGPT login written into the
|
||||
member's `~/.codex/auth.json` ([Codex account pool](#codex-account-pool)).
|
||||
- The Codex pool's quota reading comes from rollout journals, which their
|
||||
member writes: a member can forge the reading of their OWN session (say a
|
||||
pool account is full, or empty) and so steer the balancer for everyone
|
||||
until a newer reading wins. They cannot speak for another account's run:
|
||||
a run's rollout is read only from its own account's `CODEX_HOME`, and never
|
||||
one dated in the future.
|
||||
- A member session started in the member's own shell, outside a dashboard
|
||||
pane, counts as alive only until its idle timeout: the dashboard cannot read
|
||||
another account's `/proc/<pid>`.
|
||||
|
||||
## Operator runbook
|
||||
|
||||
Every command below runs as the **host account** (the one that runs the
|
||||
dashboard) unless it says `sudo`. `agenthub server install-system` calls sudo
|
||||
itself, once.
|
||||
|
||||
### What a new machine needs before `install-system`
|
||||
|
||||
`install-system` does not do any of this:
|
||||
|
||||
1. A Linux machine with systemd, kernel ≥ 5.8 (for `hidepid=invisible`),
|
||||
`sudo` with `visudo`, the shadow tools (`useradd`, `usermod`, `groupadd`),
|
||||
util-linux (`runuser`, `setpriv` at `/usr/bin/setpriv`, `mount`), `pkill`,
|
||||
`sha256sum`.
|
||||
2. A host account that is not root, with sudo rights for this one run (take
|
||||
them away again afterwards, see step 9 below).
|
||||
3. Node (the version AgentHub needs), `tmux`, `git` and, if you want it, `gh`
|
||||
on that account's `PATH`. Their absolute paths are recorded once, in
|
||||
`/etc/agenthub/server.conf` and the sudoers file.
|
||||
4. AgentHub installed for the host account, as the plugin or a checkout
|
||||
(`npm run setup && npm run build`), and running once
|
||||
(`agenthub start`).
|
||||
5. Server mode on, with the first admin:
|
||||
`agenthub server setup --hostname agenthub.example.com --admin boss`
|
||||
(see [Set it up](#set-it-up)).
|
||||
6. The AI tools members will use, installed for the host account (the AI
|
||||
Tools page or by hand).
|
||||
7. The HTTPS reverse proxy (see [Put HTTPS in front of it](#put-https-in-front-of-it)).
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
agenthub server install-system --dry-run # print the one sudo command it will run
|
||||
agenthub server install-system # install, then switch to the system unit
|
||||
agenthub users provision --all # Linux accounts for users made earlier (the admin from setup)
|
||||
agenthub server tools # publish the AI tools to /opt now (the server also does it at start)
|
||||
```
|
||||
|
||||
What `agenthub server install-system` does, in order (`installSystem` in
|
||||
`bin/agenthub.js`, root half `scripts/server/install-system.sh`; running it
|
||||
again changes nothing that is already in place):
|
||||
|
||||
1. Refuses to run as root. Resolves, as the host account: its name, node, this
|
||||
install's root, the data dir, the port (`DASHBOARD_PORT`, else 4820) and
|
||||
the paths of `tmux`, `git` and `gh`.
|
||||
2. As root (`sudo bash install-system.sh …`), after checking each value:
|
||||
1. creates groups `agenthub`, `agenthub-members`, `agenthub-proc`; adds the
|
||||
host account to `agenthub` and `agenthub-proc`, and `polkitd` (if it
|
||||
exists) to `agenthub-proc`;
|
||||
2. creates `/srv/agenthub/users` (`0751` root), `/opt/agenthub/{sbin,libexec}`
|
||||
and `/etc/agenthub` (root), `/opt/agenthub/{tools,tools/bin,app}` (the
|
||||
host account's);
|
||||
3. writes `/etc/agenthub/server.conf` (`HOST_USER`, `HOST_UID`,
|
||||
`USERS_ROOT`, `PORT`, `TMUX_BIN`, `GIT_BIN`, `GH_BIN`);
|
||||
4. installs `agenthub-provision`, `write-as` and `pane-launch` from
|
||||
`scripts/server/`;
|
||||
5. copies node to `/opt/agenthub/tools/node/<version>` (linked from
|
||||
`tools/bin/node`) and this install's `scripts/` and `server/lib/` to
|
||||
`/opt/agenthub/app`;
|
||||
6. writes `/opt/agenthub/libexec/member-hook` (the port is baked in);
|
||||
7. writes `/etc/sudoers.d/agenthub`, only after `visudo -c` accepts it;
|
||||
8. writes `/etc/claude-code/managed-settings.json` (the member hooks) unless
|
||||
a file that is not AgentHub's is already there;
|
||||
9. `chmod o-rwx` the host's `~/.claude`, and warns when the host's home is
|
||||
open to other accounts;
|
||||
10. adds the `hidepid=invisible,gid=agenthub-proc` line to `/etc/fstab`
|
||||
(backup: `/etc/fstab.agenthub-bak`) and remounts `/proc` with it;
|
||||
11. writes `/etc/systemd/system/agenthub.service` and runs
|
||||
`systemctl daemon-reload`. It does not start it.
|
||||
3. As the host account again: writes `<data dir>/runtime/agenthub.system.env`
|
||||
(node, plugin root, port, client dist), sets `AGENTHUB_USERS_ROOT` in
|
||||
AgentHub's settings file, and disables the `systemd --user` unit (its tmux
|
||||
panes stay).
|
||||
4. Stops a dashboard still running without the system unit, then
|
||||
`sudo -n systemctl enable` and `start --no-block agenthub.service`, and
|
||||
fails unless the unit is active 3 seconds later.
|
||||
|
||||
What a new machine still needs by hand after it:
|
||||
|
||||
1. `agenthub users provision --all`: users created before S3 was on (the
|
||||
admin from `server setup` is one) have no Linux account yet.
|
||||
2. Log out and back in once, so your own shell joins `agenthub` and
|
||||
`agenthub-proc` (the unit gets them on its own).
|
||||
3. `chmod o-rwx ~` for the host account if the installer warned: the database
|
||||
lives under it.
|
||||
4. If the installer left a foreign `/etc/claude-code/managed-settings.json`
|
||||
alone, add the member hooks to it yourself: for each of `PreToolUse`,
|
||||
`PostToolUse` (both with `"matcher": "*"`), `Stop`, `SubagentStop`,
|
||||
`Notification`, `SessionStart`, `SessionEnd` and `UserPromptSubmit`, a
|
||||
`command` hook `/opt/agenthub/libexec/member-hook <Event>` with
|
||||
`"timeout": 5`.
|
||||
5. Any other daemon that must see every process (monitoring agents, for
|
||||
example) needs group `agenthub-proc`: `sudo usermod -aG agenthub-proc <it>`.
|
||||
6. A tool `agenthub server tools` reports as `ESHAREDLAYOUT` (a script in a
|
||||
shared folder) must be installed under `/opt/agenthub/tools` by hand.
|
||||
7. Remove the host account from the `sudo` group (`sudo deluser <host> sudo`
|
||||
on Debian/Ubuntu). Re-running `install-system` later needs it back for
|
||||
that run.
|
||||
8. Pool accounts, if you use a pool: `agenthub accounts add <name>` (Claude)
|
||||
or `agenthub accounts add <name> --tool codex --api-key|--from <dir>`
|
||||
([Codex account pool](#codex-account-pool)).
|
||||
9. Optional: `/etc/agenthub/plugin-baseline.json` for new members' plugins
|
||||
([Plugin baseline](#plugin-baseline-opt-in)).
|
||||
|
||||
### Add, disable and enable a user
|
||||
|
||||
```bash
|
||||
agenthub users add alice [--role admin|analyst|developer] # or the Users page
|
||||
agenthub users disable alice
|
||||
agenthub users enable alice
|
||||
agenthub users provision alice # the Linux account only (a user made before S3, or after a failed create)
|
||||
```
|
||||
|
||||
- **Add** creates the Linux account FIRST (`agenthub-provision create`):
|
||||
`useradd ah-alice` with home `/srv/agenthub/users/alice`, group
|
||||
`agenthub-members`; the home is set to `ah-alice:agenthub 0750`; as the
|
||||
member it makes `~/.agenthub/{tmux,handoff}` (`0700`) and marks Claude
|
||||
Code's onboarding done and the home trusted in `~/.claude.json`. If any of
|
||||
that fails, no AgentHub user is made (`502 EPROVISION`, with the helper's own
|
||||
words). An account `ah-alice` that already exists outside
|
||||
`agenthub-members`, or an existing folder `/srv/agenthub/users/alice` with no
|
||||
account, is refused. With a [plugin baseline](#plugin-baseline-opt-in)
|
||||
file in place, its plugins are then installed as the member in the
|
||||
background.
|
||||
- **Disable** disables the AgentHub user and ends their logins FIRST, then
|
||||
locks the Linux account (`usermod --lock --expiredate 1`) and kills every
|
||||
process of `ah-alice` (`pkill -KILL -u`: their tmux server and every pane).
|
||||
A failed lock still answers `502 EPROVISION`, but the logins are already
|
||||
gone. A user with no Linux account (made before S3, or a name that does not
|
||||
fit) is disabled without any sudo. Their files stay.
|
||||
- **Enable** clears the expiry date (when there is a Linux account) and
|
||||
re-enables the AgentHub user.
|
||||
- **Delete**: AgentHub never deletes a user, an account or a home. To remove
|
||||
one for good, disable it, then by hand: `sudo userdel ah-alice`, and move or
|
||||
delete `/srv/agenthub/users/alice` yourself. The AgentHub user row stays.
|
||||
- **A name that does not fit** (upper case, a dot): `provision` refuses it
|
||||
(`provision --all` reports it and goes on with the next user, exit code 1).
|
||||
Create a new user with a fitting name, move the old workspaces' folders into
|
||||
their home (`/srv/agenthub/users/<name>/…`, owned by `ah-<name>`) and add
|
||||
them there again. `agenthub users assign <workspace id> <name>` gives a
|
||||
workspace only to the member whose home holds it, or — for a folder outside
|
||||
the users root, whose panes run as the host — to an admin; anything else
|
||||
answers `EOWNERFOLDER`.
|
||||
|
||||
### Upgrade
|
||||
|
||||
- Update AgentHub as usual (Settings → Updates, `/agenthub-update`). The
|
||||
bootstrap repoints the unit by rewriting
|
||||
`<data dir>/runtime/agenthub.system.env` (a host-owned file, no root) and
|
||||
restarts it with `sudo -n systemctl restart --no-block agenthub.service`.
|
||||
Panes survive (`KillMode=process`).
|
||||
- At start, the server republishes its own scripts to `/opt/agenthub/app`
|
||||
and every installed AI tool to `/opt/agenthub/tools`, in the background. An
|
||||
install or update from the AI Tools page publishes that tool right after.
|
||||
- An update never touches a root-owned file. **Re-run `agenthub server
|
||||
install-system`** when a release changes anything under `scripts/server/`
|
||||
(provision helper, `write-as`, `pane-launch`, the member hook, sudoers, the
|
||||
unit; an install from before the plugin baseline needs it once for the
|
||||
`claude` sudoers entry), when the port changes (the member hook has it baked in), and when
|
||||
`tmux`, `git` or `gh` moves to another path (sudoers allows only the
|
||||
recorded ones).
|
||||
- Keep every other AgentHub server off the same database (a dev server on
|
||||
another port): an older build cannot see members' tmux servers and marks
|
||||
their runs abandoned.
|
||||
|
||||
### Undo and uninstall
|
||||
|
||||
```bash
|
||||
agenthub server install-system --uninstall # back to the systemd --user unit
|
||||
```
|
||||
|
||||
As root it stops, disables and deletes the system unit (only if it is
|
||||
AgentHub's), deletes the sudoers file, the managed settings (only if
|
||||
unchanged since it wrote them), the `/etc/fstab` line (backup:
|
||||
`/etc/fstab.agenthub-bak`; `/proc` is remounted `hidepid=off`),
|
||||
`/opt/agenthub/{sbin,libexec}` and `server.conf`. Then, as the host account,
|
||||
it repoints the `systemd --user` unit at this install and runs `systemctl
|
||||
--user enable --now agenthub`.
|
||||
|
||||
It keeps `/srv/agenthub/users` and every home, the `ah-*` accounts, the groups
|
||||
and `/opt/agenthub/{tools,app}`. It does not stop members' running tmux
|
||||
servers: disable those users first if they should end. With `server.conf`
|
||||
gone, S3 is off and server mode works as without it.
|
||||
|
||||
To remove everything:
|
||||
|
||||
1. `agenthub server install-system --uninstall`
|
||||
2. `agenthub server off`, then `agenthub uninstall --yes` (the Uninstall
|
||||
button refuses in server mode, `ESERVERMODE`).
|
||||
3. By hand, as root, only if you want them gone: `userdel` each `ah-*`
|
||||
account, `groupdel agenthub agenthub-members agenthub-proc`, and delete
|
||||
`/srv/agenthub`, `/opt/agenthub` and `/etc/agenthub`. These hold members'
|
||||
work: AgentHub never deletes them.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Cause, and what to do |
|
||||
| --- | --- |
|
||||
| `install-system: generated sudoers did not validate` | Nothing was installed past that step. Check the tool paths it printed (`--dry-run`). |
|
||||
| `install-system: …/managed-settings.json exists and is not ours` | Members' Claude hooks are not installed; add them by hand (above). |
|
||||
| `install-system` stops at the `/proc` remount | The kernel does not support `hidepid=invisible` (< 5.8). The `/etc/fstab` line is already added: remove it or restore `/etc/fstab.agenthub-bak` before rebooting. |
|
||||
| `a dashboard is still running — stop it first` | A dashboard outside both units holds the port: `agenthub stop`, then run it again. |
|
||||
| `agenthub.service did not stay up` | `sudo journalctl -u agenthub -n 50`. The unit reads `<data dir>/runtime/agenthub.system.env`. |
|
||||
| `502 EPROVISION` when adding a user | The helper's own message says why (name, existing account or folder). No AgentHub user was made. |
|
||||
| `400 EBADUSER` | The username does not fit the Linux rule (above). |
|
||||
| `409 EMEMBERGONE` on a start or a git action | The workspace's folder is under the users root but its user is disabled, unknown or not provisioned: `agenthub users enable <name>` or `agenthub users provision <name>`. |
|
||||
| `409 ENOTSHARED` on a member's start | That tool is not published to `/opt/agenthub/tools` yet: `agenthub server tools`. |
|
||||
| `403 EMEMBERLANE` | Worktree mode and the runtime layer are not offered in a member's folder; use Repo mode and the prompt box's New worktree. |
|
||||
| `400 EMEMBERCRED` | A member signs in to their git host from their own terminal; AgentHub's stored git logins are not used for them. |
|
||||
| `403 EPEER` on a hook | The hook came from an account that is neither the host nor an enabled member, or the uid could not be read. |
|
||||
| `403 EFOREIGN` on a hook | A member's hook named a folder, session or pane outside their own home. |
|
||||
| `403 EFOREIGN` on a file view or save | The workspace's folder is now a link into another account's files. Put the real folder back, or make a workspace on the real one. |
|
||||
| A member's runs show as abandoned | Another, older AgentHub server on the same database (see Upgrade). |
|
||||
| `sudo: a password is required` in the server log | The sudoers file is missing, or a tool moved off its recorded path: re-run `install-system`. |
|
||||
| `[drop-caps] setpriv is missing` | Install util-linux's `setpriv` at `/usr/bin/setpriv`; until then the dashboard's children inherit `CAP_DAC_READ_SEARCH`. |
|
||||
| `[plugin-baseline] <name>: claude is not in the shared tools yet` | `claude` is not published to `/opt/agenthub/tools` yet: `agenthub server tools`, then `agenthub users provision <name>`. |
|
||||
| `[plugin-baseline] <name>: … failed` with `sudo: a password is required` | The sudoers file predates the baseline: re-run `install-system`. |
|
||||
| A member's first Claude run asks about Bypass Permissions | Expected: that consent stays the member's own. |
|
||||
|
||||
## Turn it off
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# First-run setup and tour (per account)
|
||||
|
||||
Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
|
||||
|
||||
- **Bypass is never remembered** (CLAUDE.md rule). The wizard remembers a default *permission mode* (Auto / Accept edits / Ask / Plan); bypass stays the red per-session switch.
|
||||
- **Per account, on the server.** The choices follow the account to any browser. Local mode is one account, `local`.
|
||||
- **The tutorial is a step-by-step spotlight tour.**
|
||||
|
||||
## Server
|
||||
|
||||
- [x] The `user_prefs` table: `user_key` (`u<id>`, or `local`), `prefs` (JSON), `onboarded_at`, `updated_at`. It is migration-safe (`CREATE IF NOT EXISTS`).
|
||||
- [x] `server/routes/me.js`, mounted at `/api/me`:
|
||||
- `GET /prefs` returns `{prefs, onboardedAt}`;
|
||||
- `PUT /prefs` merges `{prefs}`, takes only keys in `PREF_KEYS`, string values of at most 8 KB each, and `null` deletes a key;
|
||||
- `POST /onboarded` marks the account as onboarded.
|
||||
- Writes are same-origin. A member may write their own prefs, and nobody else's.
|
||||
- [x] S3: when a member saves a mode, the theme in their Claude Code `~/.claude/settings.json` follows it (dark or light), written through `write-as`.
|
||||
- [x] P6: `agenthub-provision create` seeds the member's `~/.claude.json` with `hasCompletedOnboarding` and trust for the home folder. It runs as the member (`runuser` + the shared node). Verified: trusting the home also covers folders under it.
|
||||
|
||||
## Client
|
||||
|
||||
- [x] `lib/userPrefs.ts`:
|
||||
- **On login:** hydrate localStorage from the server, then re-apply the theme, language and pet.
|
||||
- **On save:** wrapping `localStorage.setItem` for `PREF_KEYS` pushes each change (debounced).
|
||||
- [x] New preference keys:
|
||||
- `agenthub.defaultTool`, read by AddLaneModal before the install default;
|
||||
- `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is never accepted here.
|
||||
- [x] `components/onboarding/OnboardingWizard.tsx`, mounted in Layout. It shows when the account is not onboarded AND has no workspace (an account that already has one is marked onboarded silently). Steps:
|
||||
1. Default agent (detected tools as a grid, plus "show more") and the default permission mode.
|
||||
2. Appearance: System, Dark and Light as preview cards, a theme grid for dark and for light below them, then language and terminal font.
|
||||
3. Pet: none or an installed pet, and its size.
|
||||
4. Notifications: enable, sound and preview, send a test.
|
||||
|
||||
The footer has Skip, Back and Continue (Ctrl+Enter). The last step's button is "Add your first workspace", which starts the tour.
|
||||
- [x] `components/onboarding/Tour.tsx`: a spotlight on `data-tour` targets. Each step moves on when its condition holds, and the step reached is saved in prefs (`agenthub.tour`). The steps:
|
||||
- Add workspace
|
||||
- the folder field
|
||||
- Create
|
||||
- the prompt box
|
||||
- sending the prompt
|
||||
- the session pane
|
||||
- done
|
||||
- [x] "Run setup again" lives in Settings → Appearance (every account can open it; System is not a member's page).
|
||||
- [x] i18n in English and Vietnamese. Tests: the server route; the client wizard, tour and prefs; snapshots.
|
||||
- [x] Docs: `docs/API.md`, `docs/SERVER-MODE.md` and the CLAUDE.md marker.
|
||||
@@ -18,12 +18,12 @@ Goal (2026-10-07): finish the server part — S2 ownership as proposed, review i
|
||||
Decided: own Linux user per account; skills/plugins = admin baseline + user's own; AI Proxy = user's own + admin's shared (no key shown); tool binaries shared under `/opt/agenthub/tools`; Claude pool shared (exists); NEW Codex multi-account pool. Spike allowed on this machine with sudo (undo log: `/tmp/agh-spike/UNDO.sh`).
|
||||
|
||||
- [x] S0 spike (2026-10-08, this machine; test users agh-test1/2 kept for S3 dev, undo `/tmp/agh-spike/UNDO.sh`) → results below
|
||||
- [ ] Per-user AI Proxy store (+ admin shared)
|
||||
- [ ] S3 provisioning + spawn/attach as the account
|
||||
- [ ] S4 shared tools in /opt
|
||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline)
|
||||
- [ ] Codex account pool
|
||||
- [ ] Security review, docs, suites
|
||||
- [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b
|
||||
- [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3
|
||||
- [x] S4 shared tools in /opt — S3-PLAN P5
|
||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike not run, member Settings not built
|
||||
- [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried)
|
||||
- [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook
|
||||
|
||||
### S0 results (claude 2.1.293, codex 0.155.1, tmux 3.7c, node 26)
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# S3 plan: one Linux user per account (2026-10-08, after S0 and a 4-way code review)
|
||||
|
||||
Scope (user, 2026-10-08):
|
||||
- own workspaces, sessions and stats;
|
||||
- one Linux user per account;
|
||||
- own skills and plugins: an admin baseline, plus the member's own;
|
||||
- own AI Proxy, with the admin's shared ones usable by everyone;
|
||||
- one shared set of tool binaries;
|
||||
- the shared Claude pool;
|
||||
- a new Codex pool.
|
||||
|
||||
The S0 results and test users are recorded in `CHECKLIST.md`.
|
||||
|
||||
## Decisions 2026-10-08 (after the review)
|
||||
|
||||
- `hidepid=invisible` is required: `install-system` refuses without it.
|
||||
- **Hide shared proxy keys** (new phase P1b): AgentHub becomes a loopback endpoint for AI Proxy profiles.
|
||||
- The pane gets `ANTHROPIC_BASE_URL`/`base_url` = `http://127.0.0.1:<port>/api/proxy-gw/<profile>` and a per-pane token as its key.
|
||||
- AgentHub checks the token (pane alive, the lane owner may use the profile), swaps in the real key, and streams the request to the profile's `baseUrl`.
|
||||
- Pool OAuth tokens are NOT covered: that ceiling stays documented.
|
||||
- **Codex ChatGPT accounts may be used concurrently** (user's choice, against the recommendation). Refresh tokens rotate, so P7 starts with a spike on a real spare account. The design to verify:
|
||||
- AgentHub owns the refresh: one canonical `auth.json` per account in the pool;
|
||||
- it refreshes before expiry under a lock and rewrites every live copy;
|
||||
- it checks whether codex re-reads `auth.json` and what happens when two copies refresh.
|
||||
- If codex refreshes on its own and logs the others out, report back to the user before building.
|
||||
- Members do NOT get (a) the Add-workspace **Worktree** mode (managed worktrees) or (b) the runtime layer. They use **Repo** mode plus the prompt box's **New worktree** (session worktrees). Both refusals are `EMEMBERLANE`, and admins keep both on ownerless workspaces.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Identity**
|
||||
- AgentHub user `alice` maps to Linux user `ah-alice`. The prefix cannot clash with a system account or the host account.
|
||||
- Her home is `$AGENTHUB_USERS_ROOT/alice`, which is today's `folderAllowed` rule; in server mode it becomes mandatory.
|
||||
- Admins get a Linux user too.
|
||||
- Ownerless (pre-S3) workspaces stay host-run and staff-only.
|
||||
|
||||
**Dashboard process.** In server mode only, it runs as a system unit:
|
||||
- `User=<host>`
|
||||
- `SupplementaryGroups=agenthub`
|
||||
- `AmbientCapabilities=CAP_DAC_READ_SEARCH`
|
||||
- `InaccessiblePaths=-/etc/shadow -/etc/gshadow -/etc/sudoers -/etc/sudoers.d -/root`
|
||||
- `Restart=always`
|
||||
- `KillMode=process`
|
||||
|
||||
It must **not** set `NoNewPrivileges` or `CapabilityBoundingSet`, because both block `sudo`. `/home` stays visible, because the host's DB lives there.
|
||||
|
||||
**Three privileges, each narrow:**
|
||||
1. **Read anything.** `CAP_DAC_READ_SEARCH` covers every tool and every file mode, with no per-read cost and no per-tool code. It does NOT cover `/proc/<pid>/cwd` of another uid; that needs CAP_SYS_PTRACE, which is root-equivalent, so liveness changes instead (see P3).
|
||||
2. **Act as a member.** sudoers: `<host> ALL=(%agenthub-members) NOPASSWD: <tmux>, <git>, <gh>, /opt/agenthub/libexec/write-as`. It never allows root.
|
||||
3. **Provision.** sudoers: `<host> ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision`, a fixed script with a strict name check.
|
||||
|
||||
**Panes**
|
||||
- Each account has its own tmux server, with its socket at `<home>/.agenthub/tmux/sock`.
|
||||
- The socket's dir is setgid `agenthub`, and the socket is 0770 with `server-access -a <host>`.
|
||||
- S0 showed that tmux keeps group rw across attach, and that another member is refused.
|
||||
- The server is started once via `sudo -u ah-x tmux -S … new-session`. After that every command is a plain host-side `tmux -S` client (no sudo): send-keys, respawn, paste, kill, attach.
|
||||
- Pane env:
|
||||
- `HOME`
|
||||
- `PATH=/opt/agenthub/tools/bin:…`
|
||||
- `CODEX_HOME=<home>/.codex`
|
||||
- `CLAUDE_DASHBOARD_PORT`
|
||||
- `DISABLE_AUTOUPDATER=1`
|
||||
- none of the host's `XDG_RUNTIME_DIR`/`DISPLAY`.
|
||||
|
||||
**Hooks**
|
||||
- A hook is authenticated by the peer uid, read from `/proc/net/tcp`. S0 confirmed this: a connection from `agh-test1` mapped to uid 1002.
|
||||
- That uid's account may only touch its own sessions, runs and lanes, and `transcript_path` must lie under that account's home. Today any local process can forge any hook.
|
||||
- Claude hooks are installed via `/etc/claude-code/managed-settings.json` and call `node /opt/agenthub/app/scripts/hook-handler.js`. The Codex and agy forwarders are written into each member's home at provisioning.
|
||||
|
||||
**Secrets in argv**
|
||||
- `/proc` on this host is not `hidepid`. Every member's `ps` therefore sees what the dashboard passes as `tmux -e TOKEN=…` and `sudo … env K=V`.
|
||||
- Mount `/proc` with `hidepid=invisible`, plus a `gid=` exemption for the host. `install-system` refuses to continue without it.
|
||||
|
||||
## Not offered for member-owned workspaces in S3 v1
|
||||
|
||||
These answer 403 `EMEMBERLANE` in server mode:
|
||||
- managed worktrees (`POST /api/lanes/worktree`). Members use session worktrees instead (`claude --worktree`, made by Claude as the member).
|
||||
- the runtime layer: lanes up, down, hook, profile, slots, proof, lane-mcp, env seed, merge driver and gc. These spawn as the host, write into `LANES_ROOT` under the host's home, and kill and probe pids.
|
||||
|
||||
Boot-time cleanups (`clearLaneProxies`, `removeStageDoc`) skip member lanes.
|
||||
|
||||
## Phases (order: P0 → P1 → P1b → P2 → P3 → P4 → P5 → P8 review → P6 → P7)
|
||||
|
||||
Branch: `server-s3`. Commits happen only when the user asks.
|
||||
|
||||
### P0. Leaks that exist today (no sudo; can ship at any time)
|
||||
- [x] Make `GET /api/ai-tools/:id/config/:index` staff-only. Today a member can read the host's catalog config files.
|
||||
- [x] Members' `/cc-config/commands|skills`: `?cwd=` must lie inside one of their own workspaces. Today they can list another user's skills.
|
||||
- [x] (moved to P3) `cc-reload`: before S3 every pane shares the host's `~/.claude`, so reloading member panes after a host plugin change is CORRECT today. Restrict it to panes on the changed home only once panes have their own (P3).
|
||||
- Verify: one server-mode test per leak.
|
||||
|
||||
### P1. Per-user AI Proxy (no sudo; also useful without S3)
|
||||
- [x] Store layout:
|
||||
- shared = today's `agenthub-proxies/`, writable by admins only;
|
||||
- personal = `agenthub-proxies/users/<userId>/`, writable by its owner;
|
||||
- a user's names are unique across both (`freeName` checks the shared ones).
|
||||
- [x] API: the list stays an array and each item gets `scope: "shared"|"personal"`. `/ai-proxies` leaves `ADMIN_WRITE_PREFIXES`, and route checks take its place. A member may test only their own profiles; the stored-key fallback still requires the saved `baseUrl`.
|
||||
- [x] `resolveProxy(tool, name, ownerId)` looks in personal, then shared. `dashboard_runs.proxy` keeps the name, and a resume resolves with the lane owner's id.
|
||||
- [x] Client: Settings shows two groups, and the picker lists both.
|
||||
- [x] Docs: a shared key is hidden from the browser only; a pane can read it (the same ceiling as the pool).
|
||||
- Done 2026-10-08: server-mode-proxies.test.js (11), AiProxyConfig tests (+4); full suites server 2657/2660 (3 = lane-detect, port 3000 held by a foreign next-server), client 1135/1135.
|
||||
- Verify: server tests (A cannot see, use or test B's; a member cannot write shared; an admin can), client tests, a snapshot diff review.
|
||||
|
||||
### P1b. Hide shared proxy keys (no sudo)
|
||||
- [x] `/api/proxy-gw/:profile/*`:
|
||||
- loopback only;
|
||||
- a per-pane token (hash in `dashboard_runs`, alive pane only), and the run's lane owner may use the profile;
|
||||
- streams body and SSE both ways and swaps `x-api-key`/`Authorization`;
|
||||
- never logs a key;
|
||||
- has a timeout.
|
||||
- [x] `proxyForSpawn` points Claude/Codex at the gateway, never at the real key, for shared profiles (and for personal ones too: one code path).
|
||||
- Done 2026-10-08: server-mode-proxy-gateway.test.js (11); verified the real CLIs' requests: claude 2.1.293 → `<base>/v1/messages?beta=true` with `Authorization: Bearer <ANTHROPIC_AUTH_TOKEN>`, codex 0.155.1 → `<base_url>/responses` with `Bearer <env_key>`. Server mode only; local mode unchanged.
|
||||
- Verify: an e2e against a fake upstream (header swapped, streaming kept, a dead pane's token refused, another member's profile refused).
|
||||
|
||||
### P2. Host setup (sudo; on this machine with the agh-test users)
|
||||
- [x] `sudo agenthub server install-system` is idempotent, has `--uninstall`, and does the following:
|
||||
- creates groups `agenthub` and `agenthub-members`;
|
||||
- creates `/srv/agenthub/users` (0751) and `/opt/agenthub/{tools,app,libexec,sbin}`;
|
||||
- writes the sudoers file and checks it with `visudo -c`;
|
||||
- writes the system unit and the managed-settings hooks;
|
||||
- checks `hidepid`.
|
||||
- [x] `service-manager` gets a system variant used when server mode is on and the system unit exists; stop and restart go through sudoers `systemctl … agenthub`.
|
||||
- [x] `plugin-bootstrap`: in server mode it never starts, stops or repoints the server (deps only). Under any uid other than the host it does nothing (otherwise a member's SessionStart would spawn a second dashboard).
|
||||
- [x] Provision helper (root, bash):
|
||||
- checks the name against `^[a-z][a-z0-9_-]{0,28}$`, makes the Linux name `ah-<name>`, and refuses an existing account;
|
||||
- create: `useradd`, the home, `.agenthub/{tmux,handoff}`, the Codex/agy hook forwarders, the baseline settings;
|
||||
- disable: `usermod -L`, then end that account's tmux server;
|
||||
- never deletes.
|
||||
- [x] `agenthub users add` and the Users page call it; `users.linux_user` is set.
|
||||
- Done 2026-10-08 and INSTALLED on this machine (system unit runs the server-s3 worktree on :4820; ah-admin, ah-vinnt provisioned; undo: /tmp/agh-spike/UNDO.sh step 0). Findings: `InaccessiblePaths=/etc/shadow` breaks sudo (PAM) — only /root is hidden; a host with its own `(ALL) NOPASSWD: ALL` makes the sudoers allowlist moot (keep the host out of `sudo` on a real server); `usermod --unlock` refuses a passwordless account (enable = clear the expiry); the test runner must not see the host's install (`AGENTHUB_SYSTEM_UNIT_DIR`, `AGENTHUB_SERVER_CONF` in scripts/test-server.js). Tests: server-s3-system.test.js (14); full suite 2679/2682 (3 = port 3000).
|
||||
- Verify: on this machine, `agenthub users add alice` creates `ah-alice`; running it again changes nothing.
|
||||
|
||||
### P3. Spawning and acting as the member
|
||||
- [x] `server/lib/run-as.js`:
|
||||
- `accountForLane` / `accountForCwd` return `{linuxUser, home, uid}`, or null (null = the host, which is always the case in local mode);
|
||||
- `sudoArgv(acct, cmd, args, env)`.
|
||||
- [x] `tmux.js`:
|
||||
- one socket per account, chosen from session name → lane → owner;
|
||||
- start via sudo and add `server-access`;
|
||||
- `listSessions`, `liveSessions` and `serverPid` loop over every socket, with per-socket caches.
|
||||
- Also: `pty-attach` gets `-S`; same change in `cc-update-all-helper` and `lanes shell`.
|
||||
- [x] `worktree.js` `git()`: member lanes go through `sudo -u`. The same for `git-exclude` and `gh`. In `git-ops`, the patch goes via stdin and the temp index under the member's tmp.
|
||||
- [x] fs writes in member lanes go through `/opt/agenthub/libexec/write-as`: `lane-file-write`, the `.gitignore` append. Callers keep their existing guards.
|
||||
- [x] Pane handoff files go to `<home>/.agenthub/handoff/` (member-owned, setgid `agenthub` 2770; the host writes 0644). These are:
|
||||
- proxy settings;
|
||||
- the Codex proxy (`-c` flags, never a file in the member's `CODEX_HOME`);
|
||||
- workspace context and prompt files;
|
||||
- drop uploads.
|
||||
- [x] `cc-reload`: only panes whose Claude home is the one that changed (host-run panes for a host plugin update).
|
||||
- [x] Liveness: a member session is alive iff its pane's tmux session is live. No `/proc/<pid>/cwd` read; without this, live member sessions get reaped.
|
||||
- [x] Hooks: peer-uid auth, `transcript_path` under the account's home, and `threadOf` and the agy check accept member homes.
|
||||
- [x] Member-lane refusals (`EMEMBERLANE`).
|
||||
- Done 2026-10-08, verified LIVE on this machine as member `s3probe` (ah-s3probe):
|
||||
- a shell pane and a Claude pane run as the member; attach over /ws-pty works;
|
||||
- stage, commit and file save through the API run as the member, and the file stays the member's;
|
||||
- Claude → `/api/proxy-gw` → a fake upstream received the REAL key, and the pane showed "pong";
|
||||
- member-hook hooks were accepted by peer uid and bound to the workspace;
|
||||
- the 0600 transcript was served.
|
||||
Found on the way:
|
||||
- sudo LOGS its argv to the journal, so no secret may cross sudo: the member's tmux server starts with a secret-free `agenthub-boot` session, and the real one is created as a client;
|
||||
- tmux gives a pane created by an unattached client THAT CLIENT's PATH, so member-socket calls carry MEMBER_PATH;
|
||||
- a member's first Claude run asks for theme, trust and bypass consent → P6 seeds onboarding + trust of the home (bypass consent stays the member's);
|
||||
- git commit messages appear in the sudo journal (not secret; privacy note for P8);
|
||||
- an OLD dashboard on the same DB (the 4830 dev server) abandons member runs, because it cannot see their tmux.
|
||||
- Verify:
|
||||
- unit tests with fakes: argv builders, socket choice, the peer-uid parser on fixture text;
|
||||
- an opt-in integration script (`AGENTHUB_S3_IT=1`, real test users): start a pane, commit, save a file, ingest a hook;
|
||||
- a browser e2e as two members.
|
||||
|
||||
### P4. Reads across homes
|
||||
- [x] `homes()` = the host plus each enabled user's home (`run-as.memberHomes`). Done per home: Claude transcript resolvers (`claude-home.projectsDirFor`/`allProjectsDirs`), session sync, codex-usage, codex-ingest, codex threadOf, agy hook path. Moved to P5 (no member can run agy/Cursor before /opt): antigravity and cursor pollers. Per-member quota readers: P5. Legacy-import marker: not needed (the 30 s sync sweeps member dirs). Originally per home:
|
||||
- Claude session sync (watchers plus the 30 s poll);
|
||||
- the codex-usage, codex-ingest, antigravity and cursor pollers (`running` keyed by home);
|
||||
- the legacy-import marker.
|
||||
- [x] Transcript routes: fixed at the resolvers instead (cwd → the member's projects dir), which covers every caller. Verified live: the member's 0600 transcript was served.
|
||||
- [x] Quota readers per member home: Claude, Codex, Cursor, Grok, Kimi, OpenCode. agy's live `/usage` stays host-only, because it needs the agy binary, which updates itself.
|
||||
Done 2026-10-09: `quotas({home})` (`server/lib/quota.js`) reads each tool at its default path under the member's home through `run-as`'s owner-checked reads (`opencode.db` + WAL from a private copy); `GET /api/pricing/quota` gives a developer/analyst their own (`accountForUser`), nothing when unprovisioned, the host's to an admin. No member Claude profiles (P6). Test: `server-s3-quota.test.js`. Not yet checked live on the test users.
|
||||
- Verify: tests with two homes in a tmp dir, then a real check on the test users.
|
||||
|
||||
### P5. Shared tools in /opt (S4)
|
||||
- [x] `/opt/agenthub/tools` is owned by the host: the dashboard updates it, members get r-x.
|
||||
- [x] Publish step after an install or update (and once at `install-system`):
|
||||
- copy the versioned binary to `tools/<id>/<version>` and flip `bin/<id>`;
|
||||
- npm tools use `--prefix /opt/agenthub/tools/npm`.
|
||||
- [x] For members, publish the running build (scripts, server, bin, mcp, statusline, production deps; no client) to `/opt/agenthub/app/<version>` at server start in server mode. Members need it for hooks, the CLI, MCP and the statusline.
|
||||
- [x] The pane `PATH` puts `/opt/agenthub/tools/bin` first, and the catalog detects tools there.
|
||||
- Done 2026-10-08 (`server/lib/shared-tools.js`, `agenthub server tools`):
|
||||
- a publish copies an ELF file, an npm package folder, or a script's own version folder; a shared `bin` folder is refused;
|
||||
- publishing runs after each install/update job, in the background at server start, and on the CLI command;
|
||||
- a member's catalog-tool pane runs `<root>/bin/<command>`, or gets 409 ENOTSHARED;
|
||||
- the app scripts are published with the `agenthub` launcher;
|
||||
- the agy and cursor pollers also read member homes.
|
||||
Live: claude, codex, agy and cursor-agent published (1.6 GB); claude, codex, cursor-agent and agenthub run as `ah-s3probe` (agy was not run: it self-updates).
|
||||
Follow-up: per-member quota readers. A member's Claude runs on the shared pool, and the quota route already masks accounts for members.
|
||||
- Verify: after an update from the UI, a member pane sees the new version.
|
||||
|
||||
### P6. Skills and plugins
|
||||
- [x] Seed a new member's `~/.claude.json`: `hasCompletedOnboarding`, theme, and trust for their home (live finding 2026-10-08). The Bypass Permissions consent stays the member's own.
|
||||
- Done: `agenthub-provision create` seeds onboarding + trust of the home (merged; a non-JSON file is left alone); the theme follows AgentHub's dark/light mode (`member-claude.setTheme`). Test: `server-s3-provision.test.js` runs the real script with stub `id`/`useradd`/`chown`/`runuser` and its fixed paths pointed at a temp dir.
|
||||
- [ ] Spike: does `/etc/claude-code/managed-settings.json` (`enabledPlugins` plus marketplaces) give a new Linux user the admin's baseline plugins? (Script ready, NOT run — see "P6 spike: how to run it" below.)
|
||||
- [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live.
|
||||
- [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code.
|
||||
- [ ] Later, optional: Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`.
|
||||
|
||||
#### P6 spike: how to run it (a human, on the S3 box)
|
||||
`scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after.
|
||||
1. Write the fragment to test, e.g. `/tmp/spike-fragment.json`:
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"agenthub": { "source": { "source": "github", "repo": "<owner>/agenthub" } }
|
||||
},
|
||||
"enabledPlugins": { "agenthub@agenthub": true }
|
||||
}
|
||||
```
|
||||
2. Note the current file: `sudo sha256sum /etc/claude-code/managed-settings.json`.
|
||||
3. Run: `sudo bash scripts/server/spike-managed-plugins.sh run /tmp/spike-fragment.json 60`. It backs the file up once (`managed-settings.json.agenthub-spike-bak`, or a `.agenthub-spike-none` marker when there was none), merges only those two keys, creates the throwaway user `agh-spike` (not an AgentHub member), lists its marketplaces/plugins, opens one interactive `claude` for 60 s in a private tmux socket, lists again, and prints a verdict per plugin. Report: `/var/tmp/agenthub-spike-managed/` (`verdict.txt`, `session-screen.txt`, the before/after JSON).
|
||||
- If the screen shows a login prompt, the result before login may not be final: attach with `sudo runuser -u agh-spike -- tmux -L agh-spike attach` during the wait, or rerun after `undo` with a longer wait.
|
||||
4. Undo (always): `sudo bash scripts/server/spike-managed-plugins.sh undo` — restores the file byte for byte (or removes it), ends `agh-spike`'s processes and deletes the user and its home, then checks install-system's mark (`/etc/agenthub/managed-settings.sha256`) matches again. Check step 2's hash matches.
|
||||
5. Record here: Claude Code version, `verdict.txt`, and whether plugins appeared before a session, only after one, or never. "Listed after" ⇒ managed settings are enough, and install-system can write the baseline there (the fallback stays for plugins managed settings cannot express). "Absent" ⇒ keep the fallback.
|
||||
|
||||
### P7. Codex pool
|
||||
- [x] `codex-pool.js`, modelled on `claude-pool.js`, with two kinds of account:
|
||||
- `apikey`: any concurrency, the key in env;
|
||||
- `chatgpt`: `auth.json`, and ONE live pane per account, because refresh tokens rotate. It is copied into the member's `CODEX_HOME` at spawn and copied back under a lock when the pane ends; the newest `last_refresh` wins.
|
||||
- [x] `pickAccount` for Codex, `dashboard_runs.account`, `agenthub accounts add --tool codex`, and quota from that account's rollouts. Built 2026-10-09: an API key goes `-e AGENTHUB_POOL_KEY` + a `-c` provider (Codex's TUI ignores `CODEX_API_KEY`/`OPENAI_API_KEY`); a ChatGPT `auth.json` is written only into a member's CODEX_HOME (never the host's login, never over the member's own, sticky while a pane of theirs runs on one); copy-back runs on the run-heal beat (newest `last_refresh`). Not yet run against a real account.
|
||||
- **Spike done 2026-10-08** (codex 0.161.0, a free ChatGPT account): two `CODEX_HOME` copies of one `auth.json` ran at the same time; a refresh in one copy (forced by an expired `access_token` — 0.161 ignores `last_refresh`) rotated its refresh token, and the OLD refresh token still refreshed in a second and a third copy minutes later; every copy kept working and nothing was logged out. So a ChatGPT account may run in several panes at once: copy `auth.json` per pane, copy the newest back when a pane ends. Unverified: whether reuse of an old refresh token is still accepted days later (keep the copy-back so a new pane starts from the newest).
|
||||
|
||||
### P8. Security review and docs
|
||||
Review ran 2026-10-08 (four lenses: root/sudo, cross-member, secrets, local-mode). Root cause of every serious finding: the dashboard (host uid, `CAP_DAC_READ_SEARCH`) connects to, writes into and reads from paths inside a member's home, which the member can swap. Fixes:
|
||||
- [x] F1 tmux: every call on a member's socket runs AS the member (`sudo -u`), attach included; a session's env + argv go in a launch file written by `write-as` (stdin) and run by `/opt/agenthub/libexec/pane-launch` — nothing secret or private crosses sudo; no boot session, no `server-access`, no socket chmod (drop `chmod` from sudoers).
|
||||
- [x] F2 the host never writes, chmods or deletes inside a member's home: handoff files, drops, context copies and git scratch all go through `write-as` (new modes: `unlink`, `mkdir`).
|
||||
- [x] F3 a member's file is read only after `open` + `fstat` says it is a regular file owned by that member (O_NONBLOCK: a FIFO cannot hang the event loop) — transcripts, subagents, Codex/agy/Cursor journals, the lane file reads.
|
||||
- [x] F4 provision: root never touches a path inside an existing home; `.agenthub` is made by the member (`runuser`), 0700.
|
||||
- [x] F5 the ambient capability is dropped for every child the dashboard spawns (`setpriv`), so host panes, git hooks and npm do not inherit read-everything.
|
||||
- [x] F6 `accountForPath` fails closed for a path under the users root with no usable account (`EMEMBERGONE`), never "run it as the host".
|
||||
- [x] F7 small: `buildArgv` gets `laneId` (ENOTSHARED never fired); peerUid compares loopback addresses; `testProxy` `redirect: "manual"`; `sudoArgv` key check; `startSessionSync` catches `allProjectsDirs`; `tmux.__reset` resets `memberImpl`; `accountForLane` checks `s3Enabled` first; run.js maps ENOTSHARED/EMEMBERGONE 409; absolute `git`/`tmux`/`gh` paths from server.conf in sudo argv. NOT done: commit message via `-F -` — the sudo log is root/adm-readable and root reads the repo anyway (documented ceiling).
|
||||
- [x] F8 member hooks: the transcript path is checked before the Codex mapping reads it; a member hook touches only a lane inside its own home (status + detection); Claude/Codex/agy/Cursor journals speak only for their own account (`sameOwner`: cwd + existing row; a Codex thread id never takes over another account's row). `livenessReap`: kept — a member session no pane holds already falls through to the idle gate (documented).
|
||||
- [x] F9 install-system: stops a detached dashboard before the unit starts; keeps env-file keys the bootstrap did not pass (`DASHBOARD_PORT`); `--uninstall` rewrites the user unit before enabling it; `chown -R -h` / `cp --no-dereference`; `chmod o-rwx` the host's `~/.claude`; UI text for `systemd-system`.
|
||||
- [x] F10 shared tools: never copy a tool folder that is a direct child of `$HOME`, and skip files matching `CONFIG_SECRET_RE`.
|
||||
- [x] F11 Claude via the gateway: the pane token goes in a per-run `--settings` file, so a user/project `env.ANTHROPIC_*` cannot override it.
|
||||
- Accepted ceilings (document): a host-account compromise is every member's (it may sudo as any member); install-system trusts the host's tree; members may make the host POST to any URL through a personal proxy (they have a shell on the box anyway); the gateway forwards any `/v1/` path; sudo logs branch names, PR titles and paths.
|
||||
- [x] A multi-lens adversarial review of P2–P7 before merging (2026-10-09, lenses root/sudo, cross-member, secrets, local-mode; 12 confirmed findings, each fixed with a test in `server/__tests__/server-s3-review2.test.js`, `drop-caps.test.js`, `server-mode-proxies.test.js`):
|
||||
- F12 lane file read/save: the workspace's real path must stay the same account's (`git-diff.js` `laneRoot`, `EFOREIGN`), so a folder swapped for a link never picks the account a read or `write-as` runs as;
|
||||
- F13 `excludeFromGit`/`excludeFromRepoRoot`: the git dir the member's git prints must realpath into the same account's files; read with `readFileSafe` (no FIFO hang), never a host write for a member;
|
||||
- F14 node-pty `tmux attach` goes through `setpriv` (`drop-caps.ptyArgv`);
|
||||
- F15 `GET /lanes/browse` and `GET /lanes/branches`: a developer stays in their own folder (real path), `EFOLDER`;
|
||||
- F16 a by-id transcript/subagent scan looks only in the session owner's projects dir (`claude-home.sessionProjectsDirs`);
|
||||
- F17 Codex status mirror: a journal flips only its own account's lanes (`sameOwner`), `MAX_FILES` per home;
|
||||
- F18 a member's home is no workspace under S3 (`folderAllowed`), and the dot folders at the top of a member's home are `ESECRET` for every workspace file view (`inMemberDotDir`);
|
||||
- F19 reads use `ownerForRead`, never the throwing `accountForPath`: `projectsDirFor`, the liveness reaper, `cc-reload`, the Codex pool's live check;
|
||||
- F20 an AI Proxy edit rewrites a live gateway pane's own `--settings` file (its own token) and deletes a dead pane's.
|
||||
- Not done (optional in the finding): deleting a run's handoff `proxy-*.json` when the run ends — its token dies with the run.
|
||||
- [x] A third review (2026-10-09; 10 confirmed findings, each fixed with a test in `server/__tests__/server-s3-review3.test.js`, `codex-pool.test.js`, `server-s3-system.test.js`):
|
||||
- F21 a workspace inside a member's home is owned by that member only (`run-as.assertOwnerFits`: `createLane`, `assignWorkspace`, `spawnRun`, `spawnShell` — `EOWNERFOLDER`), so no pane carries another owner's token;
|
||||
- F22 the transcript snapshot copies through `openSafe` (a member's planted link is never copied into the host's data dir);
|
||||
- F23 a member's long first prompt stays in its launch file (no `run-prompts` file the member cannot read);
|
||||
- F24 disable ends the logins first and locks only a Linux account that exists; F25 `provision --all` goes on past a failing user;
|
||||
- F26 the Codex settle pass names a session from its own home's index;
|
||||
- F27 the workspace memory view reads through `openSafe`;
|
||||
- F28 a non-admin's `?cwd=` on `/cc-config` and `/codex-config` names only their own workspaces (`scope.ownCwdQuery`), and the config `file` reads never serve a credential file (`isSecretConfigPath`);
|
||||
- F29 the Codex pool copies a refresh back only from a home it placed the account in, never dated in the future, with the same `id_token` identity (signature unchecked: documented ceiling);
|
||||
- F30 the startup AGENTS.md cleanup skips members' folders.
|
||||
- [x] Review round 5 (2026-10-09; 5 confirmed findings, each fixed with a test in `server/__tests__/server-s3-review5.test.js`, `cursor-usage.test.js`, `run.test.js`, `codex-pool.test.js`):
|
||||
- F31 the Cursor slug walk (`cwdFromSlug`) follows no symlink and gives up after 500 directories, once per transcript — a member's crafted tree or link loop no longer freezes the dashboard;
|
||||
- F32 a member's Antigravity conversation is read from a private copy (`readFileSafe` of the `.db` and its `-wal`), so a FIFO `-journal`/`-wal` beside it, which SQLite opens by name, blocks nothing;
|
||||
- F33 a non-member's drops go to `<data dir>/drops/` (0700), never a predictable `/tmp/agenthub-drops-<uid>` another account could create first;
|
||||
- F34 a developer's extra workspace folder is checked against their own folder (`scope.folderAllowed`, real path) before anything probes it, and again on the path it records (`403 EFOLDER`);
|
||||
- F35 the Codex pool's quota reads a run's rollout only from the `CODEX_HOME` of the account its workspace runs as, never one dated in the future. Accepted ceiling (documented): a member can still forge their own session's reading.
|
||||
- F36 (critical) `GET /api/cc-config/file` never serves the data dir (`isSecretConfigPath`): an analyst read an admin gateway pane's live pane token from `proxy-settings/`, plus `vapid-keys.json` and the database;
|
||||
- F37 every tmux call on a member's socket is bounded (`memberCall`: 3 s, SIGKILL, 1 MiB) with a 60 s back-off — a SIGSTOPped member server froze the event loop for good;
|
||||
- F38 a failing member server makes only that member's panes unknown (`liveSessions().has` true for them), never null for everyone; member list errors are cached;
|
||||
- F39 `execIn` and `write-as` (every sync sudo into a member) stop after 5 s (`MEMBER_EXEC`) — a FIFO `include.path` or a FIFO write target hung git and write-as;
|
||||
- F40 transcripts and journals are read with `readLines` (chunked, a line over 64 MiB is skipped, never built) — readline crashed the server on a one-line 512 MB sparse file (import-history, sessions, codex-usage);
|
||||
- F41 `readFileSafe*` read a member's file only up to a cap (`max`; 1 MiB logins/settings, 16 MiB indexes/journals, 256 MiB SQLite copies, 64 MiB default), and transcript-cache skips a member transcript over 256 MiB;
|
||||
- F42 the transcript snapshot skips a member file over 256 MiB and writes zero runs as holes — a sparse file was copied out as real blocks into the host's disk;
|
||||
- F43 an async route's rejection goes to `next(err)` (Express `Layer.handle_request`), and `refuseS3Folder` uses `ownerForRead` — `POST /api/lanes/worktree` with a gone member's folder crashed the server;
|
||||
- F44 a workspace outside the users root may be owned only by an admin (live role: `assertOwnerFits` at create, assign and every spawn), and demoting/disabling a user ends their host panes — a developer-owned host folder started host-account panes;
|
||||
- F45 a pane's cwd must be its workspace's account's (`ECWDACCOUNT`, checked before anything is written for the pane);
|
||||
- F46 a pane token (dashboard and AI Proxy gateway) is accepted only from the uid its pane runs as (`peerIsPaneAccount`);
|
||||
- F47 the Codex pool counts a home's login as placed only when AgentHub placed it there AND the id_token's email/account match — a ChatGPT Team's seats share `account_id`; the live-pane stickiness follows the live run;
|
||||
- F48 a path an analyst names in a GET (`browse`, `branches`, `/run/files`) is scoped like a developer's (`ownFolder`, `insideOwnWorkspace`);
|
||||
- F49 a drop is saved as the account of the workspace the client names (`?laneId=`, or the pane's run id), not the requester's.
|
||||
- [x] Update (2026-10-09, branch `s3-docs`):
|
||||
- `docs/SERVER-MODE.md`: an S3 section and a runbook;
|
||||
- CLAUDE.md rules: a pane runs as its owner; the dashboard never uses sudo to root except the provision helper; hooks are authenticated by peer uid; `hidepid` is required;
|
||||
- the AgentHub-Server operator docs.
|
||||
|
||||
## Known risks
|
||||
- `sudo` costs about 10 ms per git call. That is fine: an open Changes panel makes 5 calls every 30 s.
|
||||
- The host's tmux client and the member's server must be the same binary, so the path is pinned.
|
||||
- A member can revoke `server-access` or chmod their socket. Only their own panes go dark, and the UI says so.
|
||||
- Per-member tmux servers live in the system unit's cgroup. `KillMode=process` keeps them across restarts (the rule stays), and Quit kills them explicitly.
|
||||
- Existing usernames that do not fit the Linux rule (uppercase, dots) are refused with a clear message, and the admin renames them.
|
||||
+128
-9
@@ -13,7 +13,7 @@ externalDocs:
|
||||
description: Project documentation
|
||||
url: https://git.smartgift.io.vn/Smartgift-AI/AgentHub#readme
|
||||
servers:
|
||||
- url: http://localhost:4830
|
||||
- url: http://localhost:4820
|
||||
description: Local dashboard server (default)
|
||||
- url: http://127.0.0.1:4820
|
||||
description: Local loopback endpoint used by hook-handler
|
||||
@@ -4760,6 +4760,19 @@ components:
|
||||
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardEvent'
|
||||
UserPrefs:
|
||||
type: object
|
||||
required:
|
||||
- prefs
|
||||
- onboardedAt
|
||||
properties:
|
||||
prefs:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
onboardedAt:
|
||||
type: string
|
||||
nullable: true
|
||||
AuthUser:
|
||||
type: object
|
||||
required:
|
||||
@@ -11453,6 +11466,8 @@ paths:
|
||||
description: '{branches: string[], current: string | null, worktrees: {path, branch?, locked?}[]}. The worktrees ride along so the picker can grey out branches git would refuse to check out twice.'
|
||||
'400':
|
||||
description: repo is missing, relative, does not exist, or is not a git repo.
|
||||
'403':
|
||||
description: 'EFOLDER — server mode with AGENTHUB_USERS_ROOT: a developer named a repo outside their own folder.'
|
||||
/api/lanes/{id}/git/changes:
|
||||
get:
|
||||
tags:
|
||||
@@ -11570,7 +11585,7 @@ paths:
|
||||
'400':
|
||||
description: EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.
|
||||
'403':
|
||||
description: ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
|
||||
description: ESECRET — `.claude/settings.local.json` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`); under S3 also a dot folder at the top of a member's home. EFOREIGN — under S3 the workspace folder now resolves into another account's files.
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/blame:
|
||||
@@ -11597,7 +11612,7 @@ paths:
|
||||
'400':
|
||||
description: EBADPATH, ENOFILE or ETOOBIG.
|
||||
'403':
|
||||
description: ESECRET.
|
||||
description: 'ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account''s files).'
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/raw:
|
||||
@@ -11629,7 +11644,7 @@ paths:
|
||||
'400':
|
||||
description: EBADPATH, EBADREV, ENOFILE, ENOTIMAGE or ETOOBIG.
|
||||
'403':
|
||||
description: ESECRET.
|
||||
description: 'ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account''s files).'
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/ls:
|
||||
@@ -13676,6 +13691,81 @@ paths:
|
||||
error:
|
||||
code: WORKFLOW_DETAIL_FAILED
|
||||
message: database is locked
|
||||
/api/me/prefs:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: The signed-in account's own UI preferences
|
||||
description: Local mode is the one account `local`; server mode one row per account. Keys are the browser's localStorage names (docs/API.md).
|
||||
responses:
|
||||
'200':
|
||||
description: Preferences
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserPrefs'
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Merge the account's preferences (null deletes a key)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- prefs
|
||||
properties:
|
||||
prefs:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
maxLength: 8192
|
||||
responses:
|
||||
'200':
|
||||
description: Saved
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserPrefs'
|
||||
'400':
|
||||
description: EBADPREFS — an unknown key or a value that is not a short string
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/onboarded:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: The account went through (or skipped) the first-run setup
|
||||
responses:
|
||||
'200':
|
||||
description: Marked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserPrefs'
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users:
|
||||
get:
|
||||
tags:
|
||||
@@ -13768,7 +13858,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13833,7 +13923,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13900,7 +13990,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -13966,7 +14056,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EREADONLY / EADMIN / ELOCALMODE
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -14042,7 +14132,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: listPoolAccounts
|
||||
summary: The Claude account pool as the balancer sees it (never a token)
|
||||
summary: The Claude and Codex account pools as the balancer sees them (never a key or token)
|
||||
parameters:
|
||||
- name: force
|
||||
in: query
|
||||
@@ -14088,6 +14178,35 @@ paths:
|
||||
nullable: true
|
||||
full:
|
||||
type: boolean
|
||||
codex:
|
||||
type: array
|
||||
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- apikey
|
||||
- chatgpt
|
||||
addedAt:
|
||||
type: string
|
||||
livePanes:
|
||||
type: integer
|
||||
spend5h:
|
||||
type: number
|
||||
spend7d:
|
||||
type: number
|
||||
quota:
|
||||
type: object
|
||||
nullable: true
|
||||
quotaPercent:
|
||||
type: number
|
||||
nullable: true
|
||||
full:
|
||||
type: boolean
|
||||
/api/auth/me:
|
||||
get:
|
||||
tags:
|
||||
|
||||
+54
-29
@@ -13,7 +13,6 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
const {
|
||||
bucketKey,
|
||||
emptyBucket,
|
||||
@@ -93,24 +92,51 @@ function snapshotTranscript(sourceJsonlPath, sessionId) {
|
||||
|
||||
/**
|
||||
* Copy `src` to `dest` only when `dest` is missing or smaller than `src`
|
||||
* (i.e. the source grew). Creates parent dirs as needed.
|
||||
* (i.e. the source grew). Creates parent dirs as needed. The source is OPENED
|
||||
* through run-as (S3): a file in a member's home is copied only when the open
|
||||
* file is a regular file that member owns — `statSync`/`copyFileSync` would
|
||||
* follow a link the member planted to another account's transcript, with the
|
||||
* dashboard's CAP_DAC_READ_SEARCH, into a snapshot the member may then read.
|
||||
*/
|
||||
const SNAPSHOT_MEMBER_MAX = 256 * 1024 * 1024;
|
||||
const ZERO_MB = Buffer.alloc(1 << 20);
|
||||
function copyIfNewer(src, dest) {
|
||||
let srcSize;
|
||||
let fd;
|
||||
try {
|
||||
srcSize = fs.statSync(src).size;
|
||||
fd = require("../server/lib/run-as").openSafe(src);
|
||||
} catch {
|
||||
return; // source vanished mid-import — nothing to copy
|
||||
return; // source vanished mid-import, or is not its member's own file
|
||||
}
|
||||
let destSize = -1;
|
||||
try {
|
||||
destSize = fs.statSync(dest).size;
|
||||
} catch {
|
||||
/* dest missing */
|
||||
const srcSize = fs.fstatSync(fd).size;
|
||||
let destSize = -1;
|
||||
try {
|
||||
destSize = fs.statSync(dest).size;
|
||||
} catch {
|
||||
/* dest missing */
|
||||
}
|
||||
if (destSize >= srcSize) return; // snapshot already at least as complete
|
||||
// S3: a member's file is copied into the HOST's disk, so only up to a cap,
|
||||
// and a run of zeros stays a hole — a sparse file costs its member nothing.
|
||||
// ponytail: sync copy of ≤ 256 MiB; go async if member transcripts get that big.
|
||||
if (srcSize > SNAPSHOT_MEMBER_MAX && require("../server/lib/run-as").isMemberFile(src)) return;
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
const out = fs.openSync(dest, "w", 0o600);
|
||||
try {
|
||||
const buf = Buffer.allocUnsafe(ZERO_MB.length);
|
||||
let pos = 0;
|
||||
for (let n; pos < srcSize; pos += n) {
|
||||
n = fs.readSync(fd, buf, 0, Math.min(buf.length, srcSize - pos), pos);
|
||||
if (n <= 0) break;
|
||||
if (!buf.subarray(0, n).equals(ZERO_MB.subarray(0, n))) fs.writeSync(out, buf, 0, n, pos);
|
||||
}
|
||||
fs.ftruncateSync(out, pos);
|
||||
} finally {
|
||||
fs.closeSync(out);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
if (destSize >= srcSize) return; // snapshot already at least as complete
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,10 +156,8 @@ function firstUserLabel(text) {
|
||||
async function parseSessionFile(filePath) {
|
||||
const sessionId = path.basename(filePath, ".jsonl");
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath, { encoding: "utf8" }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
// readLines, never readline: a member's one-line 512 MB file crashes readline.
|
||||
const rl = require("../server/lib/run-as").readLines(filePath);
|
||||
|
||||
let cwd = null;
|
||||
let model = null;
|
||||
@@ -386,10 +410,8 @@ async function parseSessionFile(filePath) {
|
||||
async function parseSubagentFile(filePath) {
|
||||
const agentId = path.basename(filePath, ".jsonl").replace(/^agent-/, "");
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath, { encoding: "utf8" }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
// readLines, never readline: a member's one-line 512 MB file crashes readline.
|
||||
const rl = require("../server/lib/run-as").readLines(filePath);
|
||||
|
||||
let task = null;
|
||||
let model = null;
|
||||
@@ -531,7 +553,7 @@ async function parseSubagentFile(filePath) {
|
||||
const metaPath = filePath.replace(/\.jsonl$/, ".meta.json");
|
||||
try {
|
||||
if (fs.existsSync(metaPath)) {
|
||||
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
||||
const meta = JSON.parse(require("../server/lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 }));
|
||||
if (meta.agentType) agentType = meta.agentType;
|
||||
}
|
||||
} catch {
|
||||
@@ -1746,10 +1768,7 @@ async function backfillCompactions(dbModule) {
|
||||
if (!session) continue;
|
||||
|
||||
const filePath = path.join(projPath, file);
|
||||
const rl = readline.createInterface({
|
||||
input: fs.createReadStream(filePath, { encoding: "utf8" }),
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
const rl = require("../server/lib/run-as").readLines(filePath);
|
||||
|
||||
const compactions = [];
|
||||
for await (const line of rl) {
|
||||
@@ -1910,13 +1929,15 @@ async function ingestWorkflowsForBatch(dbModule, sessions) {
|
||||
*/
|
||||
async function syncDefaultProjects(dbModule, options = {}) {
|
||||
const mtimeCache = options.mtimeCache instanceof Map ? options.mtimeCache : new Map();
|
||||
// One projects dir per call: this account's, or (S3) a member's own.
|
||||
const root = options.projectsDir || getProjectsDir();
|
||||
const changed = [];
|
||||
if (!fs.existsSync(getProjectsDir())) return { changed };
|
||||
if (!fs.existsSync(root)) return { changed };
|
||||
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = fs
|
||||
.readdirSync(getProjectsDir(), { withFileTypes: true })
|
||||
.readdirSync(root, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
} catch {
|
||||
@@ -1928,7 +1949,7 @@ async function syncDefaultProjects(dbModule, options = {}) {
|
||||
let scanned = 0;
|
||||
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = path.join(getProjectsDir(), projDir);
|
||||
const projPath = path.join(root, projDir);
|
||||
let files;
|
||||
try {
|
||||
files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
|
||||
@@ -1983,6 +2004,10 @@ async function syncDefaultProjects(dbModule, options = {}) {
|
||||
// re-parse an unusable file every tick.
|
||||
mtimeCache.set(sourcePath, mtime);
|
||||
if (!session) continue;
|
||||
// S3: a transcript in a member's home names only their own folder and
|
||||
// session — never another account's (a crafted file's sessionId/cwd).
|
||||
if (!require("../server/lib/run-as").sameOwner(sourcePath, session.cwd, existingRow?.cwd))
|
||||
continue;
|
||||
|
||||
const result = importSession(dbModule, session);
|
||||
snapshotTranscript(session._sourceJsonlPath, session.sessionId);
|
||||
@@ -2249,7 +2274,7 @@ if (require.main === module) {
|
||||
function findCompactionsInFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const compactions = [];
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const content = require("../server/lib/run-as").readFileSafe(filePath, "utf8");
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
|
||||
@@ -628,14 +628,31 @@ function pruneRetiredPluginRoots(rt, root = PLUGIN_ROOT) {
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server mode with one Linux user per account (S3): this machine's dashboard
|
||||
* belongs to the host account named in `/etc/agenthub/server.conf`. A member
|
||||
* who installs the plugin in their own `~/.claude` must never bootstrap — it
|
||||
* would start a second dashboard, as them, onto the host's port. Pure fs.
|
||||
*/
|
||||
function notTheHost() {
|
||||
try {
|
||||
const conf = process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
|
||||
const m = /^HOST_UID=(\d+)$/m.exec(fs.readFileSync(conf, "utf8"));
|
||||
return Boolean(m) && typeof process.getuid === "function" && process.getuid() !== Number(m[1]);
|
||||
} catch {
|
||||
return false; // no S3 install: every account bootstraps as before
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real work. Runs detached from the SessionStart hook, so its only channel
|
||||
* is the log file; `/agenthub-update` runs it in the foreground with `force`, which
|
||||
* reinstalls dependencies and restarts the server unconditionally.
|
||||
*
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"}
|
||||
* @returns {"ok"|"locked"|"node-too-old"|"install-failed"|"superseded"|"not-host"}
|
||||
*/
|
||||
function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
if (notTheHost()) return "not-host";
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
|
||||
// First, and ahead of the lock: a retired build has nothing to do and must
|
||||
@@ -852,7 +869,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
|
||||
* worker so the session never waits on an install.
|
||||
*/
|
||||
function main() {
|
||||
if (fastPathOk()) return;
|
||||
if (notTheHost() || fastPathOk()) return;
|
||||
const child = spawn(process.execPath, [__filename, "--worker"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
@@ -869,6 +886,7 @@ module.exports = {
|
||||
LOCK_STALE_MS,
|
||||
KEEP_RETIRED_ROOTS,
|
||||
supersededBy,
|
||||
notTheHost,
|
||||
pruneRetiredPluginRoots,
|
||||
installedPluginsPath,
|
||||
pluginVersion,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# @file agenthub-provision.sh
|
||||
# @description Server mode, one Linux user per account (S3): the ONE root
|
||||
# command the dashboard's host account may run (sudoers, written by
|
||||
# install-system.sh; installed as /opt/agenthub/sbin/agenthub-provision).
|
||||
#
|
||||
# agenthub-provision create <name> Linux user ah-<name>, home <USERS_ROOT>/<name>
|
||||
# agenthub-provision disable <name> lock the account and end its processes
|
||||
# agenthub-provision enable <name> unlock it
|
||||
#
|
||||
# Nothing but an action and a name comes from the caller; every path comes
|
||||
# from /etc/agenthub/server.conf (root's). It never deletes an account or a
|
||||
# home, never touches an account that is not an AgentHub member (one that
|
||||
# already exists outside group agenthub-members is refused), and never
|
||||
# grants a member sudo.
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
umask 022
|
||||
cd / # runuser keeps the cwd: one every account may enter
|
||||
|
||||
die() { echo "agenthub-provision: $*" >&2; exit "${2:-1}"; }
|
||||
[ "$(id -u)" = 0 ] || die "must run as root (through sudo)"
|
||||
[ $# -eq 2 ] || die "usage: agenthub-provision create|disable|enable <name>" 2
|
||||
|
||||
CONF=/etc/agenthub/server.conf
|
||||
[ -f "$CONF" ] || die "$CONF is missing — run agenthub server install-system first"
|
||||
USERS_ROOT=$(sed -n 's/^USERS_ROOT=//p' "$CONF")
|
||||
[[ "$USERS_ROOT" == /* ]] || die "USERS_ROOT in $CONF is not absolute"
|
||||
|
||||
ACTION=$1 NAME=$2
|
||||
# Lower case, starts with a letter, ≤ 29 so "ah-<name>" fits Linux's 32.
|
||||
[[ "$NAME" =~ ^[a-z][a-z0-9_-]{0,28}$ ]] || die "invalid name: $NAME" 3
|
||||
LOGIN="ah-$NAME"
|
||||
HOME_DIR="$USERS_ROOT/$NAME"
|
||||
|
||||
is_member() { id -nG "$LOGIN" 2>/dev/null | tr ' ' '\n' | grep -qx agenthub-members; }
|
||||
|
||||
case "$ACTION" in
|
||||
create)
|
||||
if id "$LOGIN" >/dev/null 2>&1; then
|
||||
is_member || die "$LOGIN exists and is not an AgentHub member — refusing to adopt it" 4
|
||||
else
|
||||
[ ! -e "$HOME_DIR" ] || die "$HOME_DIR already exists" 5
|
||||
useradd --create-home --home-dir "$HOME_DIR" --shell /bin/bash --user-group \
|
||||
--groups agenthub-members --comment "AgentHub member $NAME" "$LOGIN"
|
||||
fi
|
||||
# Home: the member's, readable by the dashboard (group agenthub), nobody else.
|
||||
# The home itself sits in root's USERS_ROOT, so the member cannot swap it.
|
||||
chown "$LOGIN:agenthub" "$HOME_DIR"
|
||||
chmod 0750 "$HOME_DIR"
|
||||
# Nothing INSIDE the home is touched as root: the member owns every name
|
||||
# there and could have made one a link to a system file. Their tmux socket
|
||||
# folder and the folder the dashboard writes a pane's files to (through
|
||||
# write-as, as them) are made by the member.
|
||||
runuser -u "$LOGIN" -- mkdir -p -m 0700 "$HOME_DIR/.agenthub/tmux" "$HOME_DIR/.agenthub/handoff"
|
||||
# Claude Code's first run, as the member: onboarding done (the dashboard's
|
||||
# own setup chose theme and the rest) and the home trusted, which covers
|
||||
# every folder under it. Merged into what is there; a file that is not
|
||||
# JSON is the member's and is left alone.
|
||||
if [ -x /opt/agenthub/tools/bin/node ]; then
|
||||
runuser -u "$LOGIN" -- /opt/agenthub/tools/bin/node -e '
|
||||
const fs = require("fs"), home = process.argv[1], p = home + "/.claude.json";
|
||||
let d = {};
|
||||
try { d = JSON.parse(fs.readFileSync(p, "utf8")); } catch (e) { if (e.code !== "ENOENT") process.exit(0); }
|
||||
d.hasCompletedOnboarding = true;
|
||||
d.projects = d.projects || {};
|
||||
(d.projects[home] = d.projects[home] || {}).hasTrustDialogAccepted = true;
|
||||
fs.writeFileSync(p, JSON.stringify(d, null, 2) + "\n", { mode: 0o600 });
|
||||
' "$HOME_DIR" || echo "agenthub-provision: Claude Code first run not seeded" >&2
|
||||
fi
|
||||
echo "created $LOGIN $HOME_DIR"
|
||||
;;
|
||||
disable)
|
||||
is_member || die "$LOGIN is not an AgentHub member" 4
|
||||
usermod --lock --expiredate 1 "$LOGIN"
|
||||
pkill -KILL -u "$LOGIN" 2>/dev/null || true
|
||||
echo "disabled $LOGIN"
|
||||
;;
|
||||
enable)
|
||||
is_member || die "$LOGIN is not an AgentHub member" 4
|
||||
usermod --expiredate "" "$LOGIN"
|
||||
# A member has no password (they never log in to the OS); unlocking one
|
||||
# that never had any is refused by usermod and changes nothing.
|
||||
usermod --unlock "$LOGIN" >/dev/null 2>&1 || true
|
||||
echo "enabled $LOGIN"
|
||||
;;
|
||||
*) die "unknown action: $ACTION" 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/bin/bash
|
||||
# @file install-system.sh
|
||||
# @description Server mode, one Linux user per account (S3) — the ROOT half of
|
||||
# `agenthub server install-system` (bin/agenthub.js runs it through sudo with
|
||||
# values it resolved as the host account). Idempotent; `--uninstall` reverses
|
||||
# everything but the members' homes, their Linux accounts and the groups.
|
||||
#
|
||||
# Installs:
|
||||
# groups agenthub (reads member homes: the host), agenthub-members (every
|
||||
# member account; sudoers runs-as target), agenthub-proc (sees all
|
||||
# processes under hidepid: the host, polkitd)
|
||||
# dirs /srv/agenthub/users 0751 root; /opt/agenthub/{sbin,libexec} root;
|
||||
# /opt/agenthub/{tools,app} owned by the host (it updates them)
|
||||
# /etc/agenthub/server.conf HOST_USER, HOST_UID, USERS_ROOT, PORT (root, 0644)
|
||||
# /opt/agenthub/sbin/agenthub-provision the only root command the host may run
|
||||
# /opt/agenthub/libexec/member-hook Claude hook forwarder for members
|
||||
# /opt/agenthub/libexec/write-as writes in a member's home AS the member (sudo -u)
|
||||
# /opt/agenthub/libexec/pane-launch starts a member's pane from its launch file
|
||||
# /etc/sudoers.d/agenthub checked with visudo before it is installed
|
||||
# /etc/claude-code/managed-settings.json the member hooks (never over a foreign file)
|
||||
# /proc hidepid=invisible,gid=agenthub-proc (now and in /etc/fstab)
|
||||
# /etc/systemd/system/agenthub.service reads the host-owned env file
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "install-system: $*" >&2; exit 1; }
|
||||
[ "$(id -u)" = 0 ] || die "run through sudo (agenthub server install-system)"
|
||||
|
||||
HOST="" NODE="" ROOT="" DATA="" PORT="" TMUX_BIN="" GIT_BIN="" GH_BIN="" UNINSTALL=0
|
||||
USERS_ROOT=/srv/agenthub/users
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) HOST="$2"; shift 2 ;;
|
||||
--node) NODE="$2"; shift 2 ;;
|
||||
--root) ROOT="$2"; shift 2 ;;
|
||||
--data) DATA="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--tmux) TMUX_BIN="$2"; shift 2 ;;
|
||||
--git) GIT_BIN="$2"; shift 2 ;;
|
||||
--gh) GH_BIN="$2"; shift 2 ;;
|
||||
--uninstall) UNINSTALL=1; shift ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
UNIT=/etc/systemd/system/agenthub.service
|
||||
SUDOERS=/etc/sudoers.d/agenthub
|
||||
MANAGED=/etc/claude-code/managed-settings.json
|
||||
MANAGED_MARK=/etc/agenthub/managed-settings.sha256
|
||||
FSTAB_TAG="# agenthub hidepid"
|
||||
|
||||
[[ "$HOST" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--host: not a Linux user name"
|
||||
HOST_UID=$(id -u "$HOST") || die "no such user: $HOST"
|
||||
[ "$HOST_UID" -ne 0 ] || die "--host may not be root"
|
||||
|
||||
if [ "$UNINSTALL" = 1 ]; then
|
||||
systemctl disable --now agenthub.service 2>/dev/null || true
|
||||
if grep -q '^# agenthub-managed-system-unit' "$UNIT" 2>/dev/null; then rm -f "$UNIT"; fi
|
||||
systemctl daemon-reload
|
||||
rm -f "$SUDOERS"
|
||||
if [ -f "$MANAGED_MARK" ] && sha256sum -c --status "$MANAGED_MARK" 2>/dev/null; then
|
||||
rm -f "$MANAGED" "$MANAGED_MARK"
|
||||
fi
|
||||
if grep -q "$FSTAB_TAG" /etc/fstab; then
|
||||
cp /etc/fstab /etc/fstab.agenthub-bak
|
||||
sed -i "/$FSTAB_TAG/,+1d" /etc/fstab
|
||||
mount -o remount,hidepid=off /proc 2>/dev/null || mount -o remount,hidepid=0 /proc || true
|
||||
fi
|
||||
rm -rf /opt/agenthub/sbin /opt/agenthub/libexec /etc/agenthub/server.conf
|
||||
echo "install-system: removed. Kept: $USERS_ROOT, the ah-* accounts, groups agenthub*, /opt/agenthub/{tools,app}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for v in NODE ROOT DATA TMUX_BIN GIT_BIN; do
|
||||
val="${!v}"
|
||||
[[ "$val" == /* && "$val" != *..* && "$val" != *[[:space:]]* ]] || die "--${v,,}: absolute path without spaces"
|
||||
done
|
||||
[[ "$PORT" =~ ^[0-9]{2,5}$ ]] || die "--port: a number"
|
||||
[ -x "$NODE" ] && [ -f "$ROOT/server/index.js" ] && [ -x "$TMUX_BIN" ] && [ -x "$GIT_BIN" ] ||
|
||||
die "node, the plugin root, tmux or git not found"
|
||||
[ -z "$GH_BIN" ] || [ -x "$GH_BIN" ] || die "--gh: not executable"
|
||||
command -v visudo >/dev/null || die "visudo is missing"
|
||||
|
||||
# ── groups ───────────────────────────────────────────────────────────────────
|
||||
for g in agenthub agenthub-members agenthub-proc; do getent group "$g" >/dev/null || groupadd "$g"; done
|
||||
usermod -aG agenthub,agenthub-proc "$HOST"
|
||||
if getent passwd polkitd >/dev/null; then usermod -aG agenthub-proc polkitd; fi
|
||||
|
||||
# ── dirs ─────────────────────────────────────────────────────────────────────
|
||||
install -d -m 0751 -o root -g root /srv/agenthub "$USERS_ROOT"
|
||||
install -d -m 0755 -o root -g root /opt/agenthub /opt/agenthub/sbin /opt/agenthub/libexec /etc/agenthub
|
||||
install -d -m 0755 -o "$HOST" -g "$HOST" /opt/agenthub/tools /opt/agenthub/tools/bin /opt/agenthub/app
|
||||
|
||||
cat >/etc/agenthub/server.conf <<EOF
|
||||
HOST_USER=$HOST
|
||||
HOST_UID=$HOST_UID
|
||||
USERS_ROOT=$USERS_ROOT
|
||||
PORT=$PORT
|
||||
TMUX_BIN=$TMUX_BIN
|
||||
GIT_BIN=$GIT_BIN
|
||||
GH_BIN=$GH_BIN
|
||||
EOF
|
||||
chmod 0644 /etc/agenthub/server.conf
|
||||
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/agenthub-provision.sh" /opt/agenthub/sbin/agenthub-provision
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/write-as.sh" /opt/agenthub/libexec/write-as
|
||||
install -m 0755 -o root -g root "$ROOT/scripts/server/pane-launch.sh" /opt/agenthub/libexec/pane-launch
|
||||
|
||||
# ── node + the hook handler, for members (host-owned: the dashboard refreshes them) ──
|
||||
NODE_VER=$("$NODE" -p process.version)
|
||||
install -d -m 0755 -o "$HOST" -g "$HOST" "/opt/agenthub/tools/node/$NODE_VER/bin"
|
||||
install -m 0755 -o "$HOST" -g "$HOST" "$NODE" "/opt/agenthub/tools/node/$NODE_VER/bin/node"
|
||||
ln -sfn "../node/$NODE_VER/bin/node" /opt/agenthub/tools/bin/node
|
||||
for d in scripts server/lib; do
|
||||
install -d -m 0755 -o "$HOST" -g "$HOST" "/opt/agenthub/app/$d"
|
||||
cp -rP "$ROOT/$d/." "/opt/agenthub/app/$d/"
|
||||
done
|
||||
chown -R -h "$HOST:$HOST" /opt/agenthub/app /opt/agenthub/tools
|
||||
chmod -R a+rX /opt/agenthub/app /opt/agenthub/tools
|
||||
|
||||
cat >/opt/agenthub/libexec/member-hook <<EOF
|
||||
#!/bin/sh
|
||||
# AgentHub: forwards a member's Claude Code hook to the dashboard (written by
|
||||
# install-system). The host's own sessions report through the agenthub plugin.
|
||||
[ "\$(id -u)" = "$HOST_UID" ] && exit 0
|
||||
CLAUDE_DASHBOARD_PORT=$PORT exec /opt/agenthub/tools/bin/node /opt/agenthub/app/scripts/hook-handler.js "\$@"
|
||||
EOF
|
||||
chmod 0755 /opt/agenthub/libexec/member-hook
|
||||
|
||||
# ── sudoers: run-as members only (a fixed command list), and three root commands ──
|
||||
TMP_SUDOERS=$(mktemp)
|
||||
{
|
||||
echo "# AgentHub server mode (install-system). Members are never in sudoers."
|
||||
echo "Defaults:$HOST !requiretty"
|
||||
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
|
||||
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
|
||||
for a in start stop restart enable disable; do
|
||||
[ "$a" = start ] || [ "$a" = stop ] || [ "$a" = restart ] && extra=" --no-block" || extra=""
|
||||
echo "$HOST ALL=(root) NOPASSWD: /usr/bin/systemctl $a$extra agenthub.service, /bin/systemctl $a$extra agenthub.service"
|
||||
done
|
||||
} >"$TMP_SUDOERS"
|
||||
visudo -cf "$TMP_SUDOERS" >/dev/null || { rm -f "$TMP_SUDOERS"; die "generated sudoers did not validate"; }
|
||||
install -m 0440 -o root -g root "$TMP_SUDOERS" "$SUDOERS"
|
||||
rm -f "$TMP_SUDOERS"
|
||||
|
||||
# ── managed settings: the members' Claude Code hooks ────────────────────────
|
||||
install -d -m 0755 /etc/claude-code
|
||||
if [ -f "$MANAGED" ] && ! { [ -f "$MANAGED_MARK" ] && sha256sum -c --status "$MANAGED_MARK"; }; then
|
||||
echo "install-system: $MANAGED exists and is not ours — left alone; add the member hooks by hand (docs/SERVER-MODE.md)" >&2
|
||||
else
|
||||
"$NODE" -e '
|
||||
const ev = ["PreToolUse","PostToolUse","Stop","SubagentStop","Notification","SessionStart","SessionEnd","UserPromptSubmit"];
|
||||
const hook = (e) => [{ ...(["PreToolUse","PostToolUse"].includes(e) ? { matcher: "*" } : {}),
|
||||
hooks: [{ type: "command", command: `/opt/agenthub/libexec/member-hook ${e}`, timeout: 5 }] }];
|
||||
process.stdout.write(JSON.stringify({ hooks: Object.fromEntries(ev.map((e) => [e, hook(e)])) }, null, 2) + "\n");
|
||||
' >"$MANAGED"
|
||||
chmod 0644 "$MANAGED"
|
||||
sha256sum "$MANAGED" >"$MANAGED_MARK"
|
||||
fi
|
||||
|
||||
# ── the host's own Claude data: no other account reads it ───────────────────
|
||||
HOST_HOME=$(getent passwd "$HOST" | cut -d: -f6)
|
||||
[ ! -d "$HOST_HOME/.claude" ] || chmod o-rwx "$HOST_HOME/.claude"
|
||||
[ "$(( $(stat -c %a "$HOST_HOME") % 10 ))" = 0 ] ||
|
||||
echo "install-system: $HOST_HOME is open to other accounts — chmod o-rwx it (the dashboard's database lives under it)" >&2
|
||||
|
||||
# ── hidepid: nobody sees another account's processes (argv carries tokens) ──
|
||||
if ! grep -q "$FSTAB_TAG" /etc/fstab; then
|
||||
cp /etc/fstab /etc/fstab.agenthub-bak
|
||||
printf '%s\nproc /proc proc defaults,hidepid=invisible,gid=agenthub-proc 0 0\n' "$FSTAB_TAG" >>/etc/fstab
|
||||
fi
|
||||
mount -o remount,hidepid=invisible,gid="$(getent group agenthub-proc | cut -d: -f3)" /proc
|
||||
|
||||
# ── the system unit ──────────────────────────────────────────────────────────
|
||||
cat >"$UNIT" <<EOF
|
||||
[Unit]
|
||||
# agenthub-managed-system-unit
|
||||
Description=AgentHub dashboard server (server mode, one Linux user per account)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$HOST
|
||||
Group=$HOST
|
||||
SupplementaryGroups=agenthub agenthub-proc
|
||||
AmbientCapabilities=CAP_DAC_READ_SEARCH
|
||||
# Not /etc/shadow: sudo (PAM) reads it, and every member action goes through sudo.
|
||||
InaccessiblePaths=-/root
|
||||
WorkingDirectory=~
|
||||
EnvironmentFile=$DATA/runtime/agenthub.system.env
|
||||
ExecStart=/usr/bin/env \${AGENTHUB_NODE} \${AGENTHUB_ROOT}/server/index.js
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
KillMode=process
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
chmod 0644 "$UNIT"
|
||||
systemctl daemon-reload
|
||||
echo "install-system: done (unit installed, not started)"
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# @file pane-launch.sh
|
||||
# @description Server mode, one Linux user per account (S3): installed as
|
||||
# /opt/agenthub/libexec/pane-launch. A member's pane starts as
|
||||
# `pane-launch <launch file>`: the dashboard creates the session AS the member
|
||||
# through sudo, and sudo logs its whole command line — so the pane's
|
||||
# environment (a pane token, a pool account's token) and its argv (the prompt)
|
||||
# are not in that command line but in the launch file, which `write-as`
|
||||
# wrote as the member (0600) from stdin. This reads it, deletes it, exports
|
||||
# the environment and execs the command; no shell ever parses a value.
|
||||
#
|
||||
# The file is NUL-separated: the number of environment entries, the
|
||||
# NAME=value entries, then the argv (none = a login shell).
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -eu
|
||||
f=${1:?usage: pane-launch <launch file>}
|
||||
mapfile -d '' -t parts <"$f"
|
||||
rm -f -- "$f"
|
||||
n=${parts[0]}
|
||||
[[ "$n" =~ ^[0-9]+$ ]] || { echo "pane-launch: malformed launch file" >&2; exit 2; }
|
||||
for kv in "${parts[@]:1:n}"; do
|
||||
[[ "$kv" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || { echo "pane-launch: bad variable" >&2; exit 2; }
|
||||
export "$kv"
|
||||
done
|
||||
argv=("${parts[@]:n+1}")
|
||||
if [ ${#argv[@]} -eq 0 ]; then exec -l "${SHELL:-/bin/bash}"; fi
|
||||
exec "${argv[@]}"
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
# @file spike-managed-plugins.sh
|
||||
# @description S3 plan, P6 spike — run BY HAND, as root, never by AgentHub:
|
||||
# does /etc/claude-code/managed-settings.json alone (`extraKnownMarketplaces`
|
||||
# + `enabledPlugins`) give a brand-new Linux user the admin's plugins, with no
|
||||
# `claude plugin install` run as that user?
|
||||
#
|
||||
# sudo bash spike-managed-plugins.sh run <fragment.json> [seconds]
|
||||
# sudo bash spike-managed-plugins.sh undo
|
||||
#
|
||||
# run: backs up managed-settings.json (once), merges the fragment's two keys
|
||||
# into it, makes the throwaway user agh-spike (NOT an AgentHub member),
|
||||
# seeds its onboarding like agenthub-provision, lists its marketplaces
|
||||
# and plugins, starts one interactive `claude` in a private tmux socket
|
||||
# for [seconds] (default 45), lists again, and prints a verdict per
|
||||
# enabled plugin. Everything lands in /var/tmp/agenthub-spike-managed/.
|
||||
# undo: puts managed-settings.json back exactly (or removes it when there was
|
||||
# none), ends agh-spike's processes and deletes that user and its home.
|
||||
#
|
||||
# While it is applied, EVERY Claude Code on this machine (the host's own
|
||||
# sessions too) reads the merged file. Run it when that is acceptable and
|
||||
# undo right after. It never touches /etc/agenthub or an ah-* account.
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
die() { echo "spike: $*" >&2; exit 1; }
|
||||
[ "$(id -u)" = 0 ] || die "run as root (sudo bash $0 …)"
|
||||
|
||||
MANAGED=/etc/claude-code/managed-settings.json
|
||||
BACKUP=$MANAGED.agenthub-spike-bak
|
||||
NONE=$MANAGED.agenthub-spike-none # marker: there was no file before
|
||||
OUT=/var/tmp/agenthub-spike-managed
|
||||
U=agh-spike
|
||||
CLAUDE=/opt/agenthub/tools/bin/claude
|
||||
NODE=/opt/agenthub/tools/bin/node
|
||||
|
||||
as_user() {
|
||||
local h
|
||||
h=$(getent passwd "$U" | cut -d: -f6)
|
||||
(cd / && runuser -u "$U" -- env -i HOME="$h" USER="$U" LOGNAME="$U" TERM=xterm-256color \
|
||||
PATH=/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin DISABLE_AUTOUPDATER=1 "$@")
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
run)
|
||||
FRAG=${2:-}
|
||||
SECS=${3:-45}
|
||||
[ -f "$FRAG" ] || die "usage: $0 run <fragment.json> [seconds]"
|
||||
[[ "$SECS" =~ ^[0-9]+$ ]] || die "seconds: a number"
|
||||
[ -x "$CLAUDE" ] && [ -x "$NODE" ] || die "$CLAUDE or $NODE missing (agenthub server install-system)"
|
||||
! id "$U" >/dev/null 2>&1 || die "$U already exists — run '$0 undo' first"
|
||||
install -d -m 0700 "$OUT"
|
||||
|
||||
# 1. Back up once; a second run never overwrites the first backup.
|
||||
install -d -m 0755 /etc/claude-code
|
||||
if [ ! -e "$BACKUP" ] && [ ! -e "$NONE" ]; then
|
||||
if [ -f "$MANAGED" ]; then cp -p "$MANAGED" "$BACKUP"; else touch "$NONE"; fi
|
||||
fi
|
||||
|
||||
# 2. Merge only the two keys under test; everything else (the member hooks) stays.
|
||||
"$NODE" -e '
|
||||
const fs = require("fs"), [file, frag] = process.argv.slice(1);
|
||||
let d = {};
|
||||
try { d = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { if (e.code !== "ENOENT") throw e; }
|
||||
const f = JSON.parse(fs.readFileSync(frag, "utf8"));
|
||||
for (const k of ["extraKnownMarketplaces", "enabledPlugins"]) if (f[k]) d[k] = { ...(d[k] || {}), ...f[k] };
|
||||
fs.writeFileSync(file, JSON.stringify(d, null, 2) + "\n", { mode: 0o644 });
|
||||
' "$MANAGED" "$FRAG"
|
||||
cp "$MANAGED" "$OUT/managed-settings.applied.json"
|
||||
|
||||
# 3. A fresh account, first run seeded like agenthub-provision does.
|
||||
useradd --create-home --shell /bin/bash --comment "AgentHub P6 spike (delete me)" "$U"
|
||||
as_user "$NODE" -e '
|
||||
const fs = require("fs"), home = process.env.HOME, p = home + "/.claude.json";
|
||||
fs.writeFileSync(p, JSON.stringify({ hasCompletedOnboarding: true,
|
||||
projects: { [home]: { hasTrustDialogAccepted: true } } }, null, 2) + "\n", { mode: 0o600 });'
|
||||
as_user "$CLAUDE" --version >"$OUT/claude-version.txt" 2>&1 || true
|
||||
|
||||
# 4. Before any session: does the CLI alone see them?
|
||||
as_user "$CLAUDE" plugin marketplace list --json >"$OUT/marketplaces.before.json" 2>&1 || true
|
||||
as_user "$CLAUDE" plugin list --json >"$OUT/plugins.before.json" 2>&1 || true
|
||||
|
||||
# 5. One interactive start (managed marketplaces may sync only there). A
|
||||
# private socket; kill-session only — never kill-server.
|
||||
as_user tmux -L agh-spike new-session -d -s spike -x 200 -y 50 "$CLAUDE"
|
||||
sleep "$SECS"
|
||||
as_user tmux -L agh-spike capture-pane -p -t spike >"$OUT/session-screen.txt" 2>&1 || true
|
||||
as_user tmux -L agh-spike kill-session -t spike 2>/dev/null || true
|
||||
|
||||
# 6. After.
|
||||
as_user "$CLAUDE" plugin marketplace list --json >"$OUT/marketplaces.after.json" 2>&1 || true
|
||||
as_user "$CLAUDE" plugin list --json >"$OUT/plugins.after.json" 2>&1 || true
|
||||
find "$(getent passwd "$U" | cut -d: -f6)/.claude" -maxdepth 3 >"$OUT/claude-dir.txt" 2>&1 || true
|
||||
|
||||
# 7. Verdict per plugin the fragment enables.
|
||||
"$NODE" -e '
|
||||
const fs = require("fs"), [frag, out] = process.argv.slice(1);
|
||||
const want = Object.keys(JSON.parse(fs.readFileSync(frag, "utf8")).enabledPlugins || {});
|
||||
const read = (f) => fs.readFileSync(`${out}/${f}`, "utf8");
|
||||
for (const p of want) {
|
||||
const at = (f) => (read(f).includes(p) ? "listed" : "absent");
|
||||
console.log(`${p}: before session ${at("plugins.before.json")}, after ${at("plugins.after.json")}`);
|
||||
}
|
||||
' "$FRAG" "$OUT" | tee "$OUT/verdict.txt"
|
||||
echo "spike: details in $OUT; the screen it showed: $OUT/session-screen.txt"
|
||||
echo "spike: now run '$0 undo'"
|
||||
;;
|
||||
undo)
|
||||
if [ -e "$BACKUP" ]; then
|
||||
mv -f "$BACKUP" "$MANAGED"
|
||||
elif [ -e "$NONE" ]; then
|
||||
rm -f "$MANAGED" "$NONE"
|
||||
else
|
||||
echo "spike: no backup — managed-settings.json left as it is"
|
||||
fi
|
||||
if id "$U" >/dev/null 2>&1; then
|
||||
pkill -KILL -u "$U" 2>/dev/null || true
|
||||
userdel --remove "$U" 2>/dev/null || userdel "$U"
|
||||
fi
|
||||
# The install-system mark matches again only if the file is back byte for byte.
|
||||
if [ -f /etc/agenthub/managed-settings.sha256 ]; then
|
||||
sha256sum -c --status /etc/agenthub/managed-settings.sha256 &&
|
||||
echo "spike: managed-settings.json matches install-system's mark again" ||
|
||||
echo "spike: WARNING managed-settings.json differs from install-system's mark" >&2
|
||||
fi
|
||||
echo "spike: undone (the report in $OUT is kept)"
|
||||
;;
|
||||
*) die "usage: $0 run <fragment.json> [seconds] | undo" ;;
|
||||
esac
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/sh
|
||||
# @file write-as.sh
|
||||
# @description Server mode, one Linux user per account (S3): installed as
|
||||
# /opt/agenthub/libexec/write-as. The dashboard runs it AS a member through
|
||||
# sudo (`sudo -n -u ah-<name> /opt/agenthub/libexec/write-as <path> [mode]`)
|
||||
# for every change it makes inside that member's home — the file editor, a
|
||||
# `.gitignore` line, the files a pane reads (prompt, proxy settings, context
|
||||
# copies, drops, a session's launch file) — because the dashboard itself may
|
||||
# never write, chmod or delete there: the member can swap any name in their
|
||||
# own home, and a write by another account would follow the swap. As the
|
||||
# member this can only do what the member could. Content comes on stdin,
|
||||
# never in argv (sudo logs argv). Modes:
|
||||
# (none) stdin into the file in place (keeps its mode and owner)
|
||||
# append stdin appended
|
||||
# replace stdin into a new 0644 file renamed over the path (atomic)
|
||||
# private same, 0600
|
||||
# mkdir the directory, 0755, parents included
|
||||
# remove the path, recursively (rm never follows a symlink it removes)
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -eu
|
||||
if [ $# -lt 1 ] || [ $# -gt 2 ]; then
|
||||
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove]" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$1" in
|
||||
/*) ;;
|
||||
*) echo "write-as: an absolute path is required" >&2; exit 2 ;;
|
||||
esac
|
||||
swap() {
|
||||
t=$(mktemp "$1.XXXXXX")
|
||||
cat >"$t" && chmod "$2" "$t" && mv -f "$t" "$1" || { rm -f "$t"; exit 1; }
|
||||
}
|
||||
case "${2:-}" in
|
||||
"") exec cat >"$1" ;;
|
||||
append) exec cat >>"$1" ;;
|
||||
replace) swap "$1" 0644 ;;
|
||||
private) swap "$1" 0600 ;;
|
||||
mkdir) exec mkdir -p -m 0755 -- "$1" ;;
|
||||
remove) exec rm -rf -- "$1" ;;
|
||||
*) echo "write-as: unknown mode: $2" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -4,9 +4,17 @@
|
||||
* @description Picks the per-user service manager that keeps the dashboard
|
||||
* running without Claude Code: a LaunchAgent on macOS (`launchd-service.js`),
|
||||
* a systemd `--user` unit everywhere else (`systemd-service.js`, which reports
|
||||
* itself unavailable off Linux). Both export the same API.
|
||||
* itself unavailable off Linux). Both export the same API — and so does the
|
||||
* third, `systemd-system-service.js`, chosen while the server-mode SYSTEM unit
|
||||
* `sudo agenthub server install-system` wrote exists (pure fs check).
|
||||
* Require THIS module, never one of the three directly.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
module.exports =
|
||||
process.platform === "darwin" ? require("./launchd-service") : require("./systemd-service");
|
||||
function pick() {
|
||||
if (process.platform === "darwin") return require("./launchd-service");
|
||||
const system = require("./systemd-system-service");
|
||||
return system.unitIsOurs("agenthub") ? system : require("./systemd-service");
|
||||
}
|
||||
|
||||
module.exports = pick();
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file systemd-system-service.js
|
||||
* @description Server mode with one Linux user per account (S3): the dashboard
|
||||
* runs as a SYSTEM unit, `/etc/systemd/system/agenthub.service`, written once
|
||||
* by `sudo agenthub server install-system` (scripts/server/install-system.sh).
|
||||
* The unit runs as the host account with group `agenthub` and
|
||||
* CAP_DAC_READ_SEARCH — the two things a `systemd --user` unit cannot give.
|
||||
*
|
||||
* Same API as `systemd-service.js`, so the bootstrap, Quit, the autostart
|
||||
* switch and the doctor keep their rules unchanged (`service-manager.js`
|
||||
* picks this module while that unit exists and is ours). The difference is who
|
||||
* may write what:
|
||||
* - the unit file is root's and never rewritten here. It reads the plugin
|
||||
* root, node and env from `<runtime dir>/agenthub.system.env`, a file the
|
||||
* HOST owns — so `writeUserService` (the bootstrap's repoint after a plugin
|
||||
* update) is a plain file write, no root;
|
||||
* - start, stop, restart, enable and disable go through
|
||||
* `sudo -n systemctl …`, allowed for exactly those commands by the
|
||||
* sudoers file the installer wrote. `--no-block` everywhere it matters,
|
||||
* for the same cgroup reason as the user unit (CLAUDE.md).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const MARKER = "# agenthub-managed-system-unit";
|
||||
const UNIT_DIR = "/etc/systemd/system";
|
||||
|
||||
function unitPath(name) {
|
||||
return path.join(process.env.AGENTHUB_SYSTEM_UNIT_DIR || UNIT_DIR, `${name}.service`);
|
||||
}
|
||||
|
||||
/** The host-owned env file the unit's `EnvironmentFile=` names. */
|
||||
function envPath() {
|
||||
const { getDataDir } = require("../server/lib/claude-home");
|
||||
return path.join(getDataDir(), "runtime", "agenthub.system.env");
|
||||
}
|
||||
|
||||
// Test seams: `systemctl` itself (reads) and `sudo -n systemctl` (writes).
|
||||
const defaultExec = (args, opts) => spawnSync("systemctl", args, opts);
|
||||
const defaultSudo = (args, opts) => spawnSync("sudo", ["-n", "systemctl", ...args], opts);
|
||||
let execImpl = defaultExec;
|
||||
let sudoImpl = defaultSudo;
|
||||
function __setExecImpl(fn, sudoFn) {
|
||||
execImpl = fn;
|
||||
sudoImpl = sudoFn || fn;
|
||||
}
|
||||
function __reset() {
|
||||
execImpl = defaultExec;
|
||||
sudoImpl = defaultSudo;
|
||||
}
|
||||
|
||||
/** Is there a system unit by this name, written by the installer? Pure fs. */
|
||||
function unitIsOurs(name) {
|
||||
try {
|
||||
return fs.readFileSync(unitPath(name), "utf8").includes(MARKER);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function systemctlAvailable() {
|
||||
if (process.platform !== "linux" || !unitIsOurs("agenthub")) return false;
|
||||
return execImpl(["--version"], { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
function readEnv() {
|
||||
try {
|
||||
return Object.fromEntries(
|
||||
fs
|
||||
.readFileSync(envPath(), "utf8")
|
||||
.split("\n")
|
||||
.map((l) => /^([A-Z_][A-Z0-9_]*)=(.*)$/.exec(l))
|
||||
.filter(Boolean)
|
||||
.map((m) => [m[1], m[2]])
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin root the unit runs from (env file), or null. Pure fs. */
|
||||
function unitRoot(name) {
|
||||
if (!fs.existsSync(unitPath(name))) return null;
|
||||
return readEnv()?.AGENTHUB_ROOT || null;
|
||||
}
|
||||
|
||||
/** Does the env file still name `root`? Pure fs, like the user unit's. */
|
||||
function unitIsStale(name, root) {
|
||||
if (!unitIsOurs(name)) return false;
|
||||
return unitRoot(name) !== root;
|
||||
}
|
||||
|
||||
function unitIsEnabled(name) {
|
||||
if (!systemctlAvailable()) return false;
|
||||
const res = execImpl(["is-enabled", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
|
||||
return String(res.stdout || "").trim() === "enabled";
|
||||
}
|
||||
|
||||
function unitIsActive(name) {
|
||||
if (!systemctlAvailable()) return false;
|
||||
const res = execImpl(["is-active", `${name}.service`], { encoding: "utf8", stdio: "pipe" });
|
||||
return String(res.stdout || "").trim() === "active";
|
||||
}
|
||||
|
||||
function setUnitEnabled(name, on) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const res = sudoImpl([on ? "enable" : "disable", `${name}.service`], { stdio: "ignore" });
|
||||
return res.status === 0 ? "ok" : "failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the unit at this plugin version: rewrite the host-owned env file the
|
||||
* unit reads. Starts nothing, enables nothing (the unit file is root's).
|
||||
* @returns {"written"|"unchanged"|"unavailable"|"failed"}
|
||||
*/
|
||||
function writeUserService({ argv, env = {} }) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const root = path.dirname(path.dirname(argv[1])); // <root>/server/index.js
|
||||
// The port install-system chose outlives a bootstrap that does not pass one
|
||||
// (server.conf and the member hook still name it).
|
||||
// ponytail: only DASHBOARD_PORT is carried over; others if they ever matter.
|
||||
let kept = {};
|
||||
try {
|
||||
const m = /^DASHBOARD_PORT=(\d+)$/m.exec(fs.readFileSync(envPath(), "utf8"));
|
||||
if (m) kept = { DASHBOARD_PORT: m[1] };
|
||||
} catch {
|
||||
/* no file yet */
|
||||
}
|
||||
const body =
|
||||
Object.entries({ AGENTHUB_NODE: argv[0], AGENTHUB_ROOT: root, ...kept, ...env })
|
||||
.map(([k, v]) => `${k}=${String(v).replace(/\n/g, "")}`)
|
||||
.join("\n") + "\n";
|
||||
try {
|
||||
const file = envPath();
|
||||
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === body) return "unchanged";
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(`${file}.tmp`, body, { mode: 0o600 });
|
||||
fs.renameSync(`${file}.tmp`, file);
|
||||
return "written";
|
||||
} catch {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
/** `--no-block`: the caller may sit in this unit's cgroup (CLAUDE.md). */
|
||||
function restartUserService(name) {
|
||||
if (!systemctlAvailable()) return "unavailable";
|
||||
const res = sudoImpl(["restart", "--no-block", `${name}.service`], { stdio: "ignore" });
|
||||
return res.status === 0 ? "restarted" : "failed";
|
||||
}
|
||||
|
||||
/** Point the unit here, then start it (without blocking). */
|
||||
function installUserService(opts) {
|
||||
const result = writeUserService(opts);
|
||||
if (result === "written" || result === "unchanged") {
|
||||
sudoImpl(["start", "--no-block", `${opts.name}.service`], { stdio: "ignore" });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Removing the system unit is root's: `sudo agenthub server install-system --uninstall`. */
|
||||
function uninstallUserService() {
|
||||
return "foreign";
|
||||
}
|
||||
|
||||
/** A system unit starts at boot; there is no linger to enable. */
|
||||
function enableLinger() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The argv that stops the unit without waiting (Quit, server/lib/quit.js). */
|
||||
function stopArgv(name) {
|
||||
return ["sudo", ["-n", "systemctl", "stop", "--no-block", `${name}.service`]];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
kind: "systemd-system",
|
||||
label: "systemd system service",
|
||||
MARKER,
|
||||
systemctlAvailable,
|
||||
unitIsOurs,
|
||||
unitIsStale,
|
||||
unitRoot,
|
||||
unitIsActive,
|
||||
unitIsEnabled,
|
||||
setUnitEnabled,
|
||||
writeUserService,
|
||||
restartUserService,
|
||||
installUserService,
|
||||
uninstallUserService,
|
||||
enableLinger,
|
||||
stopArgv,
|
||||
unitPath,
|
||||
envPath,
|
||||
__setExecImpl,
|
||||
__reset,
|
||||
};
|
||||
@@ -50,6 +50,11 @@ const env = {
|
||||
GROK_HOME: tmuxDir,
|
||||
KIMI_CODE_HOME: tmuxDir,
|
||||
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
|
||||
// Never this machine's server-mode S3 install: its system unit would become
|
||||
// "the" service manager and its server.conf would send user changes to sudo.
|
||||
AGENTHUB_SYSTEM_UNIT_DIR: path.join(tmuxDir, "system-units"),
|
||||
AGENTHUB_SERVER_CONF: path.join(tmuxDir, "server.conf"),
|
||||
AGENTHUB_PASSWD: path.join(tmuxDir, "passwd"),
|
||||
};
|
||||
delete env.TMUX;
|
||||
delete env.TMUX_PANE;
|
||||
|
||||
+2
-2
@@ -762,8 +762,8 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
|
||||
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
|
||||
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory, `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @file codex-pool.test.js
|
||||
* @description The Codex account pool (server/lib/codex-pool.js), fake
|
||||
* accounts only: key/auth.json validation and file modes, the balancer
|
||||
* (full quota last — read from the account's own rollouts —, then fewest live
|
||||
* Codex panes, then spend), an API-key account reaching a pane only through
|
||||
* tmux `-e` with a `-c` provider naming the variable, a ChatGPT account's
|
||||
* `auth.json` written AS the member into their CODEX_HOME (never a host pane's,
|
||||
* never over the member's own login, sticky while a pane of theirs runs on
|
||||
* one), the newest `auth.json` copied back into the pool, and an empty pool
|
||||
* leaving every spawn as it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = 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 TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "codex-pool-test-")));
|
||||
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");
|
||||
|
||||
const { db } = require("../db");
|
||||
const pool = require("../lib/codex-pool");
|
||||
const tmux = require("../lib/tmux");
|
||||
const runAs = require("../lib/run-as");
|
||||
const pty = require("../lib/pty-run");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
|
||||
const KEY = (c) => `sk-proj-${c.repeat(40)}`;
|
||||
const jwt = (claims) =>
|
||||
`e30.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig-${Math.random()}`;
|
||||
const AUTH = (accountId, lastRefresh = "2026-10-01T00:00:00Z", rt = `rt-${accountId}`) => ({
|
||||
auth_mode: "chatgpt",
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: jwt({ email: `${accountId}@example.com` }),
|
||||
access_token: jwt({ exp: 2e9 }),
|
||||
refresh_token: rt,
|
||||
account_id: accountId,
|
||||
},
|
||||
last_refresh: lastRefresh,
|
||||
});
|
||||
|
||||
const MIA = { name: "mia", login: "ah-mia", home: path.join(TMP, "users", "mia"), uid: 5001 };
|
||||
const MIA_AUTH = path.join(MIA.home, ".codex", "auth.json");
|
||||
const realAccountForLane = runAs.accountForLane;
|
||||
const realAccountForPath = runAs.accountForPath;
|
||||
const realMemberHomes = runAs.memberHomes;
|
||||
let sudo;
|
||||
let tmuxArgv; // the last new-session argv
|
||||
|
||||
function run(
|
||||
id,
|
||||
account,
|
||||
{ status = "completed", session = null, tool = "codex", lane = null } = {}
|
||||
) {
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account, tool, lane_id)
|
||||
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?, ?, ?)`
|
||||
).run(id, session, status, new Date().toISOString(), account, tool, lane);
|
||||
}
|
||||
|
||||
function rollout(sessionId, usedPercent, home = process.env.CODEX_HOME) {
|
||||
const dir = path.join(home, "sessions", "2026", "10", "09");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const line = {
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "token_count",
|
||||
rate_limits: {
|
||||
primary: { used_percent: usedPercent, window_minutes: 300, resets_at: 4e9 },
|
||||
secondary: { used_percent: 5, window_minutes: 10080, resets_at: 4e9 },
|
||||
},
|
||||
},
|
||||
};
|
||||
const file = path.join(dir, `rollout-2026-10-09T00-00-00-${sessionId}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify({ type: "session_meta", payload: { id: sessionId } })}\n${JSON.stringify(line)}\n`
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Record that AgentHub placed pool account `name` in mia's home (what place() does). */
|
||||
function placedWithMia(name) {
|
||||
const file = path.join(pool.poolDir(), `${name}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify({ ...pool.__read(name), placedIn: ["ah-mia"] }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
const envOf = (argv, name) =>
|
||||
argv
|
||||
.slice(0, argv.indexOf("--"))
|
||||
.find((x) => String(x).startsWith(`${name}=`))
|
||||
?.slice(name.length + 1);
|
||||
|
||||
before(() => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") tmuxArgv = args;
|
||||
return "";
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__setExecImpl(null);
|
||||
runAs.__setExecImpl(null);
|
||||
runAs.accountForLane = realAccountForLane;
|
||||
runAs.accountForPath = realAccountForPath;
|
||||
runAs.memberHomes = realMemberHomes;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(TMP, "users"), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(process.env.CODEX_HOME, "sessions"), { recursive: true, force: true });
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
pool.resetQuota();
|
||||
tmuxArgv = null;
|
||||
sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
runAs.accountForLane = (laneId) => (laneId >= 50 ? MIA : null);
|
||||
// ...and her folder is hers (a pane's cwd must be its workspace's account's).
|
||||
runAs.accountForPath = (p) => (String(p).startsWith(MIA.home) ? MIA : realAccountForPath(p));
|
||||
runAs.memberHomes = () => [MIA.home];
|
||||
fs.mkdirSync(path.join(MIA.home, ".agenthub", "handoff"), { recursive: true });
|
||||
});
|
||||
|
||||
describe("codex pool: accounts", () => {
|
||||
it("is empty until an admin adds one, and stores it 0600 in a 0700 dir", () => {
|
||||
assert.equal(pool.pickAccount(null), null);
|
||||
assert.throws(() => pool.addAccount("a", { apiKey: "nope" }), { code: "EBADTOKEN" });
|
||||
assert.throws(() => pool.addAccount("../x", { apiKey: KEY("a") }), { code: "EBADNAME" });
|
||||
assert.throws(() => pool.addAccount("c", { auth: { tokens: {} } }), { code: "EBADTOKEN" });
|
||||
assert.equal(pool.addAccount("a", { apiKey: KEY("a") }).kind, "apikey");
|
||||
assert.equal(pool.addAccount("c", { auth: AUTH("acct-c") }).kind, "chatgpt");
|
||||
// An auth.json holding only an API key is an API-key account.
|
||||
assert.equal(pool.addAccount("k", { auth: { OPENAI_API_KEY: KEY("k") } }).kind, "apikey");
|
||||
assert.throws(() => pool.addAccount("a", { apiKey: KEY("b") }), { code: "EEXISTS" });
|
||||
assert.equal(fs.statSync(path.join(pool.poolDir(), "a.json")).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
|
||||
const shown = JSON.stringify(pool.poolStatus());
|
||||
assert.ok(
|
||||
!shown.includes(KEY("a")) && !shown.includes("rt-acct-c"),
|
||||
"status never shows a secret"
|
||||
);
|
||||
pool.removeAccount("a");
|
||||
assert.throws(() => pool.removeAccount("a"), { code: "ENOACCOUNT" });
|
||||
});
|
||||
|
||||
it("ranks by Codex live panes (a Claude run of the same name does not count), then quota", () => {
|
||||
for (const n of ["a", "b"]) pool.addAccount(n, { apiKey: KEY(n) });
|
||||
run("c1", "a", { status: "running", tool: "claude" });
|
||||
assert.equal(pool.pickAccount(null).name, "a");
|
||||
run("x1", "a", { status: "running" });
|
||||
assert.equal(pool.pickAccount(null).name, "b");
|
||||
|
||||
// b's own rollout says its 5-hour window is 95% used: b goes last.
|
||||
run("x2", "b", { session: "11111111-2222-3333-4444-555555555555" });
|
||||
rollout("11111111-2222-3333-4444-555555555555", 95);
|
||||
pool.resetQuota();
|
||||
assert.equal(pool.pickAccount(null).name, "a");
|
||||
const b = pool.poolStatus().find((x) => x.name === "b");
|
||||
assert.equal(b.full, true);
|
||||
assert.equal(b.quota.windows[0].usedPercent, 95);
|
||||
});
|
||||
|
||||
it("reads a run's quota only from its own account's CODEX_HOME, never dated in the future", () => {
|
||||
pool.addAccount("b", { apiKey: KEY("b") });
|
||||
const MIA_CODEX = path.join(MIA.home, ".codex");
|
||||
// A host run's session id, forged in mia's home: not the host's reading.
|
||||
run("h1", "b", { session: "11111111-2222-3333-4444-000000000001" });
|
||||
rollout("11111111-2222-3333-4444-000000000001", 95, MIA_CODEX);
|
||||
pool.resetQuota();
|
||||
assert.equal(pool.poolStatus().find((x) => x.name === "b").quota, null);
|
||||
// Mia's own run, but her rollout is dated 2030 to stay the newest forever.
|
||||
run("m1", "b", { session: "11111111-2222-3333-4444-000000000002", lane: 50 });
|
||||
const future = rollout("11111111-2222-3333-4444-000000000002", 0, MIA_CODEX);
|
||||
fs.utimesSync(future, new Date("2030-01-01"), new Date("2030-01-01"));
|
||||
pool.resetQuota();
|
||||
assert.equal(pool.poolStatus().find((x) => x.name === "b").quota, null);
|
||||
// Her own current reading of her own run still counts.
|
||||
fs.utimesSync(future, new Date(), new Date());
|
||||
pool.resetQuota();
|
||||
assert.equal(pool.poolStatus().find((x) => x.name === "b").quotaPercent, 5);
|
||||
});
|
||||
|
||||
it("never gives a host pane a ChatGPT account (it would replace the host's login)", () => {
|
||||
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||
assert.equal(pool.pickAccount(null), null);
|
||||
assert.equal(pool.pickAccount(MIA).name, "c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex pool: spawn", () => {
|
||||
it("an empty pool leaves a Codex spawn exactly as before", () => {
|
||||
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "codex" });
|
||||
const plain = tmuxArgv;
|
||||
assert.ok(!plain.some((x) => /AGENTHUB_POOL_KEY|agenthub_pool/.test(String(x))));
|
||||
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||
assert.equal(row.account, null);
|
||||
});
|
||||
|
||||
it("an API-key account reaches a host pane through tmux -e only", () => {
|
||||
pool.addAccount("a", { apiKey: KEY("a") });
|
||||
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "codex", initialPrompt: "hi" });
|
||||
assert.equal(envOf(tmuxArgv, "AGENTHUB_POOL_KEY"), KEY("a"));
|
||||
const after = tmuxArgv.slice(tmuxArgv.indexOf("--"));
|
||||
assert.ok(!after.some((x) => String(x).includes(KEY("a"))), "never in the pane's argv");
|
||||
assert.ok(after.includes('model_provider="agenthub_pool"'));
|
||||
assert.ok(after.some((x) => String(x).includes('env_key="AGENTHUB_POOL_KEY"')));
|
||||
assert.ok(after.indexOf('model_provider="agenthub_pool"') < after.indexOf("hi"));
|
||||
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||
assert.equal(row.account, "a");
|
||||
|
||||
// Claude panes and Codex panes on an AI Proxy keep their own way in.
|
||||
tmuxArgv = null;
|
||||
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "claude" });
|
||||
assert.ok(!tmuxArgv.some((x) => String(x).includes("AGENTHUB_POOL_KEY")));
|
||||
});
|
||||
|
||||
it("writes a ChatGPT account's auth.json AS the member, 0600, nothing secret over sudo", () => {
|
||||
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||
const written = JSON.parse(fs.readFileSync(MIA_AUTH, "utf8"));
|
||||
assert.equal(written.tokens.refresh_token, "rt-acct-c");
|
||||
assert.equal(fs.statSync(MIA_AUTH).mode & 0o777, 0o600);
|
||||
assert.ok(
|
||||
sudo.calls.some((a) => a.includes(MIA_AUTH)),
|
||||
"written through write-as"
|
||||
);
|
||||
assert.ok(!JSON.stringify(sudo.calls).includes("rt-acct-c"));
|
||||
assert.ok(!tmuxArgv.some((x) => String(x).includes("rt-acct-c")));
|
||||
const row = db
|
||||
.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-50'")
|
||||
.get();
|
||||
assert.equal(row.account, "c");
|
||||
});
|
||||
|
||||
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
|
||||
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||
pool.addAccount("d", { auth: AUTH("acct-d") });
|
||||
fs.mkdirSync(path.dirname(MIA_AUTH), { recursive: true });
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("mias-own")));
|
||||
assert.equal(pool.pickAccount(MIA), null);
|
||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.account_id, "mias-own");
|
||||
|
||||
// A pool login in place, its pane live on d: the next pane is d too, though
|
||||
// d already has more live panes than c.
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-d")));
|
||||
placedWithMia("d");
|
||||
run("agenthub-lane-51", "d", { status: "running", lane: 51 });
|
||||
assert.equal(pool.pickAccount(MIA).name, "d");
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
assert.equal(pool.pickAccount(MIA).name, "c");
|
||||
});
|
||||
|
||||
it("a member's own seat in a pool account's ChatGPT Team stays theirs", () => {
|
||||
// Codex's account_id is the WORKSPACE: every seat of a Team shares it.
|
||||
pool.addAccount("c", { auth: AUTH("team-x") });
|
||||
placedWithMia("c"); // even in a home AgentHub once placed c in
|
||||
const own = AUTH("team-x", "2026-10-02T00:00:00Z", "rt-mias-seat");
|
||||
own.tokens.id_token = jwt({ email: "mia@example.com" });
|
||||
fs.mkdirSync(path.dirname(MIA_AUTH), { recursive: true });
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
||||
assert.equal(pool.pickAccount(MIA), null);
|
||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat");
|
||||
});
|
||||
|
||||
it("copies the newest auth.json back into the pool", () => {
|
||||
pool.addAccount("c", { auth: AUTH("acct-c", "2026-10-01T00:00:00Z") });
|
||||
// A refresh comes back only from a home AgentHub placed the account in.
|
||||
fs.mkdirSync(path.dirname(MIA_AUTH), { recursive: true });
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-c", "2026-10-05T00:00:00Z", "rt-new")));
|
||||
pool.syncBack();
|
||||
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "never placed there");
|
||||
fs.rmSync(MIA_AUTH);
|
||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||
assert.deepEqual(pool.__read("c").placedIn, ["ah-mia"]);
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-c", "2026-09-01T00:00:00Z", "rt-old")));
|
||||
pool.syncBack();
|
||||
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "older: kept ours");
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(AUTH("acct-c", "2026-10-05T00:00:00Z", "rt-new")));
|
||||
pool.syncBack();
|
||||
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-new");
|
||||
assert.equal(fs.statSync(path.join(pool.poolDir(), "c.json")).mode & 0o777, 0o600);
|
||||
// A new pane starts from the newest copy.
|
||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-new");
|
||||
});
|
||||
|
||||
it("never takes back a forged auth.json: a future date, or another account's id_token", () => {
|
||||
pool.addAccount("c", { auth: AUTH("acct-c", "2026-10-01T00:00:00Z") });
|
||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||
const forged = AUTH("acct-c", "9999-12-31T00:00:00Z", "rt-forged");
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(forged));
|
||||
pool.syncBack();
|
||||
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "future last_refresh");
|
||||
// mia's own ChatGPT tokens relabelled with the pool account's id.
|
||||
const own = AUTH("mias-own", "2026-10-05T00:00:00Z", "rt-mia");
|
||||
own.tokens.account_id = "acct-c";
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
||||
pool.syncBack();
|
||||
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "another identity");
|
||||
// A garbage id_token proves nothing either.
|
||||
own.tokens.id_token = "x";
|
||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
||||
pool.syncBack();
|
||||
assert.equal(pool.__read("c").auth.tokens.refresh_token, "rt-acct-c", "no identity");
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,43 @@ describe("cursor usage", () => {
|
||||
assert.equal(cursor.cwdFromSlug(""), null);
|
||||
});
|
||||
|
||||
it("bounds the walk: no symlink is followed, and it gives up after a budget", () => {
|
||||
const base = path.join(fs.realpathSync(ROOT), "maze");
|
||||
// A symlink loop: every `a` and `a-a` is the same directory again.
|
||||
const loop = path.join(base, "loop");
|
||||
fs.mkdirSync(loop, { recursive: true });
|
||||
fs.symlinkSync(".", path.join(loop, "a"));
|
||||
fs.symlinkSync(".", path.join(loop, "a-a"));
|
||||
// A real tree with ~1000 directories that all prefix the slug.
|
||||
const tree = path.join(base, "tree");
|
||||
const grow = (dir, t) => {
|
||||
if (t <= 0) return;
|
||||
fs.mkdirSync(path.join(dir, "a"), { recursive: true });
|
||||
grow(path.join(dir, "a"), t - 1);
|
||||
if (t < 2) return;
|
||||
fs.mkdirSync(path.join(dir, "a-a"), { recursive: true });
|
||||
grow(path.join(dir, "a-a"), t - 2);
|
||||
};
|
||||
grow(tree, 13);
|
||||
const real = fs.readdirSync;
|
||||
let calls = 0;
|
||||
fs.readdirSync = (...a) => {
|
||||
if (++calls > 5000) throw new Error("unbounded walk");
|
||||
return real(...a);
|
||||
};
|
||||
try {
|
||||
assert.equal(cursor.cwdFromSlug(`${slugOf(loop)}${"-a".repeat(30)}-z`), null);
|
||||
assert.ok(calls < 20, `followed a symlink loop: ${calls} reads`);
|
||||
calls = 0;
|
||||
assert.equal(cursor.cwdFromSlug(`${slugOf(tree)}${"-a".repeat(13)}-z`), null);
|
||||
assert.ok(calls <= 501, `walked ${calls} directories`);
|
||||
assert.equal(cursor.cwdFromSlug(slugOf(PROJECT)), PROJECT);
|
||||
} finally {
|
||||
fs.readdirSync = real;
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates the session with its name and cwd, and no tokens", async () => {
|
||||
transcript(ID);
|
||||
assert.deepEqual(await cursor.ingestCursorUsage(), { files: 1, sessions: 1 });
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @file drop-caps.test.js
|
||||
* @description S3 review fix F5: with ambient capabilities (faked here), every
|
||||
* child goes through setpriv and still behaves the same — argv, shell
|
||||
* commands, sync and async forms — and a browser terminal's `tmux attach`,
|
||||
* which node-pty forks natively (outside child_process), goes through
|
||||
* setpriv too. Runs in a child node process so the patch never touches this
|
||||
* test runner's child_process.
|
||||
* @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 { execFileSync } = require("node:child_process");
|
||||
|
||||
const MOD = path.join(__dirname, "..", "lib", "drop-caps.js");
|
||||
|
||||
describe("drop-caps", () => {
|
||||
it("reads CapAmb", () => {
|
||||
const { hasAmbientCaps } = require(MOD);
|
||||
assert.equal(hasAmbientCaps("CapAmb:\t0000000000000004\n"), true);
|
||||
assert.equal(hasAmbientCaps("CapAmb:\t0000000000000000\n"), false);
|
||||
});
|
||||
|
||||
it(
|
||||
"wraps every form and keeps what each one does",
|
||||
{ skip: process.platform !== "linux" },
|
||||
() => {
|
||||
const script = `
|
||||
const cp = require("node:child_process");
|
||||
const ok = require(${JSON.stringify(MOD)}).install(cp, "CapAmb:\\t0000000000000004\\n");
|
||||
const out = [ok];
|
||||
out.push(cp.execFileSync("printf", ["%s|", "a b", "$HOME"]).toString());
|
||||
out.push(cp.execSync("echo $((1+2)) | tr 3 x").toString().trim());
|
||||
out.push(cp.spawnSync("printf", ["%s", "z"]).stdout.toString());
|
||||
out.push(cp.execFileSync("printf", ["%s", "s"], { shell: true }).toString());
|
||||
cp.execFile("printf", ["%s", "async"], (e, so) => {
|
||||
out.push(so);
|
||||
cp.exec("echo shell | tr a-z A-Z", (e2, so2) => {
|
||||
out.push(so2.trim());
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
});
|
||||
});`;
|
||||
const got = JSON.parse(execFileSync(process.execPath, ["-e", script]).toString());
|
||||
assert.deepEqual(got, [true, "a b|$HOME|", "x", "z", "s", "async", "SHELL"]);
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"starts a browser terminal's tmux attach through setpriv (node-pty bypasses child_process)",
|
||||
{ skip: process.platform !== "linux" },
|
||||
() => {
|
||||
const ATTACH = path.join(__dirname, "..", "lib", "pty-attach.js");
|
||||
const TMUX = path.join(__dirname, "..", "lib", "tmux.js");
|
||||
const script = `
|
||||
const dc = require(${JSON.stringify(MOD)});
|
||||
const before = dc.ptyArgv("tmux", ["a"]);
|
||||
dc.install(require("node:child_process"), "CapAmb:\\t0000000000000004\\n");
|
||||
const tmux = require(${JSON.stringify(TMUX)});
|
||||
tmux.__setExecImpl(() => "");
|
||||
tmux.__setAccountImpl(() => null);
|
||||
const pa = require(${JSON.stringify(ATTACH)});
|
||||
let got;
|
||||
pa.__setSpawnImpl((file, args) => {
|
||||
got = [file, args];
|
||||
return { onData() {}, onExit() {}, write() {}, resize() {}, kill() {} };
|
||||
});
|
||||
pa.attach({ on() {}, send() {} }, "agenthub-lane-7", { cols: 80, rows: 24 });
|
||||
process.stdout.write(JSON.stringify({ before, got }));`;
|
||||
const { before, got } = JSON.parse(execFileSync(process.execPath, ["-e", script]).toString());
|
||||
assert.deepEqual(before, ["tmux", ["a"]]);
|
||||
assert.equal(got[0], "/usr/bin/setpriv");
|
||||
assert.deepEqual(got[1].slice(0, 3), ["--inh-caps=-all", "--ambient-caps=-all", "--"]);
|
||||
assert.deepEqual(got[1].slice(-3), ["attach-session", "-t", "=agenthub-lane-7"]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file fake-sudo.js
|
||||
* @description Test helper for the S3 suites: an `execImpl` for
|
||||
* `run-as.__setExecImpl` that plays `sudo -n -u <login> [VAR=v…] <bin> …`
|
||||
* without root — it records the argv (so a test can assert nothing secret
|
||||
* crossed sudo) and runs the real `scripts/server/write-as.sh` for
|
||||
* `/opt/agenthub/libexec/write-as`, as this account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const WRITE_AS = path.join(__dirname, "..", "..", "..", "scripts", "server", "write-as.sh");
|
||||
|
||||
/** `{ exec, calls }`: pass `exec` to `runAs.__setExecImpl`; `calls` = every sudo argv. */
|
||||
function fakeSudo() {
|
||||
const calls = [];
|
||||
const exec = (cmd, argv, opts = {}) => {
|
||||
calls.push([cmd, ...argv]);
|
||||
if (cmd !== "sudo") return execFileSync(cmd, argv, opts);
|
||||
let i = argv.indexOf("-u") + 2; // past the login
|
||||
while (/^[A-Z_][A-Z0-9_]*=/.test(argv[i])) i++;
|
||||
const [bin, ...rest] = argv.slice(i);
|
||||
if (bin === "/opt/agenthub/libexec/write-as")
|
||||
return execFileSync("sh", [WRITE_AS, ...rest], opts);
|
||||
return execFileSync(bin, rest, opts);
|
||||
};
|
||||
return { exec, calls };
|
||||
}
|
||||
|
||||
module.exports = { fakeSudo };
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file me-prefs.test.js
|
||||
* @description `/api/me` (docs/plans/onboarding): an account's own UI
|
||||
* preferences and first-run flag — local mode is the one account `local`;
|
||||
* in server mode each account reads and writes only its own row, only known
|
||||
* keys and short strings are kept, `null` deletes one; an S3 member's
|
||||
* dark/light mode becomes their Claude Code theme, written as them.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "me-prefs-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
const call = (method, p, { body, cookie } = {}) =>
|
||||
fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
async function login(username, password) {
|
||||
const r = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return r.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("local mode: one account", () => {
|
||||
it("starts empty, keeps known keys, deletes on null, refuses the rest", async () => {
|
||||
assert.deepEqual((await call("GET", "/api/me/prefs")).body, { prefs: {}, onboardedAt: null });
|
||||
let r = await call("PUT", "/api/me/prefs", {
|
||||
body: { prefs: { theme: "light", "agenthub.theme.light": "canvas" } },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
r = await call("PUT", "/api/me/prefs", { body: { prefs: { theme: null } } });
|
||||
assert.deepEqual(r.body.prefs, { "agenthub.theme.light": "canvas" });
|
||||
for (const prefs of [{ evil: "x" }, { theme: 1 }, { theme: "x".repeat(9000) }, []]) {
|
||||
r = await call("PUT", "/api/me/prefs", { body: { prefs } });
|
||||
assert.equal(r.status, 400, JSON.stringify(prefs));
|
||||
assert.equal(r.body.error.code, "EBADPREFS");
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the first-run setup done, once", async () => {
|
||||
const r = await call("POST", "/api/me/onboarded");
|
||||
assert.match(r.body.onboardedAt, /^\d{4}-/);
|
||||
const again = await call("POST", "/api/me/onboarded");
|
||||
assert.equal(again.body.onboardedAt, r.body.onboardedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("server mode: per account", () => {
|
||||
let mia;
|
||||
let max;
|
||||
before(async () => {
|
||||
const uid = process.getuid();
|
||||
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
runAs.__resetCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await auth.createUser("mia", "member-pass-1", "developer");
|
||||
await auth.createUser("max", "member-pass-2", "developer");
|
||||
fs.mkdirSync(path.join(USERS, "mia"), { recursive: true });
|
||||
mia = await login("mia", "member-pass-1");
|
||||
max = await login("max", "member-pass-2");
|
||||
});
|
||||
|
||||
it("needs a login", async () => {
|
||||
assert.equal((await call("GET", "/api/me/prefs")).status, 401);
|
||||
});
|
||||
|
||||
it("keeps each account's own, apart from local mode's and each other's", async () => {
|
||||
await call("PUT", "/api/me/prefs", { cookie: mia, body: { prefs: { i18nextLng: "vi" } } });
|
||||
await call("PUT", "/api/me/prefs", { cookie: max, body: { prefs: { i18nextLng: "en" } } });
|
||||
assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: mia })).body.prefs, {
|
||||
i18nextLng: "vi",
|
||||
});
|
||||
const m = (await call("GET", "/api/me/prefs", { cookie: max })).body;
|
||||
assert.deepEqual(m, { prefs: { i18nextLng: "en" }, onboardedAt: null });
|
||||
});
|
||||
|
||||
it("puts an S3 member's mode into their Claude Code theme, as them", async () => {
|
||||
const sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
const settings = path.join(USERS, "mia", ".claude", "settings.json");
|
||||
fs.mkdirSync(path.dirname(settings), { recursive: true });
|
||||
fs.writeFileSync(settings, JSON.stringify({ model: "opus" }));
|
||||
await call("PUT", "/api/me/prefs", { cookie: mia, body: { prefs: { theme: "light" } } });
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(settings, "utf8")), {
|
||||
model: "opus",
|
||||
theme: "light",
|
||||
});
|
||||
assert.ok(sudo.calls.some((c) => c.includes("ah-mia") && c.includes(settings)));
|
||||
// "system" is the browser's to resolve: Claude's file is left as it is.
|
||||
await call("PUT", "/api/me/prefs", { cookie: mia, body: { prefs: { theme: "system" } } });
|
||||
assert.equal(JSON.parse(fs.readFileSync(settings, "utf8")).theme, "light");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const http = require("node:http");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "run-route-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const tmux = require("../lib/tmux");
|
||||
@@ -226,7 +227,9 @@ describe("POST /api/run/drop-image", () => {
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
|
||||
assert.ok(r.body.path.startsWith(os.tmpdir()));
|
||||
// The host's own data dir, never a predictable name in the shared tmp.
|
||||
assert.equal(path.dirname(r.body.path), path.join(TMP, "data", "drops"));
|
||||
assert.equal(fs.statSync(path.dirname(r.body.path)).mode & 0o777, 0o700);
|
||||
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
|
||||
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
|
||||
fs.unlinkSync(r.body.path);
|
||||
@@ -256,10 +259,7 @@ describe("POST /api/run/drop-image", () => {
|
||||
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(
|
||||
path.dirname(r.body.path),
|
||||
path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`)
|
||||
);
|
||||
assert.equal(path.dirname(r.body.path), path.join(TMP, "data", "drops"));
|
||||
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}-b_o_c_o_Q3\.pdf$/);
|
||||
assert.equal(fs.readFileSync(r.body.path, "utf8"), "%PDF");
|
||||
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @file server-mode-leaks.test.js
|
||||
* @description Server mode (S3 plan, P0): a member cannot read the host's AI
|
||||
* tool config files through `/api/ai-tools/:id/config[/:index]`, and the
|
||||
* member-readable `/api/cc-config/{skills,commands}` list project items only
|
||||
* from the member's own workspaces — never another user's folder, nor the
|
||||
* dashboard's own working directory when no `cwd` is given.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-leaks-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
// The user-scope skills come from CLAUDE_HOME — keep it out of the real one.
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "claude-home");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const MIA_DIR = path.join(TMP, "w", "mia");
|
||||
const MAX_DIR = path.join(TMP, "w", "max");
|
||||
|
||||
function writeSkill(dir, name) {
|
||||
const d = path.join(dir, ".claude", "skills", name);
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
fs.writeFileSync(path.join(d, "SKILL.md"), `---\nname: ${name}\n---\nbody\n`);
|
||||
}
|
||||
|
||||
function writeCommand(dir, name) {
|
||||
const d = path.join(dir, ".claude", "commands");
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
fs.writeFileSync(path.join(d, `${name}.md`), "do it\n");
|
||||
}
|
||||
|
||||
async function call(p, cookie) {
|
||||
const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} });
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
const names = (r) => (r.body?.items || []).map((i) => i.name);
|
||||
|
||||
before(async () => {
|
||||
writeSkill(MIA_DIR, "mia-skill");
|
||||
writeSkill(MAX_DIR, "max-skill");
|
||||
writeCommand(MAX_DIR, "max-cmd");
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const mia = await auth.createUser("mia", "member-pass-1", "developer");
|
||||
const max = await auth.createUser("max", "member-pass-2", "developer");
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
|
||||
lane.run(MIA_DIR, mia.id);
|
||||
lane.run(MAX_DIR, max.id);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("server mode: AI tool config files are staff-only", () => {
|
||||
for (const p of [
|
||||
"/api/ai-tools/claude/config",
|
||||
"/api/ai-tools/claude/config/0",
|
||||
"/api/AI-Tools/claude/Config/0",
|
||||
"/api/ai-tools/claude/%63onfig/0",
|
||||
"/api/ai-tools//claude/config/0",
|
||||
]) {
|
||||
it(`refuses a member ${p}`, async () => {
|
||||
const r = await call(p, await login("mia", "member-pass-1"));
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EADMIN");
|
||||
});
|
||||
}
|
||||
|
||||
it("still lets an admin reach it", async () => {
|
||||
const r = await call("/api/ai-tools/claude/config", await login("boss", "admin-pass-1"));
|
||||
assert.notEqual(r.status, 403);
|
||||
});
|
||||
|
||||
it("keeps the rest of /api/ai-tools open to a member", async () => {
|
||||
const r = await call("/api/ai-tools", await login("mia", "member-pass-1"));
|
||||
assert.equal(r.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("server mode: a member's skills/commands come from their own workspaces", () => {
|
||||
it("lists the project skills of the member's own workspace", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(MIA_DIR)}`, mia);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(names(r).includes("mia-skill"));
|
||||
});
|
||||
|
||||
it("refuses another user's folder (skills and commands)", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
for (const kind of ["skills", "commands"]) {
|
||||
const r = await call(`/api/cc-config/${kind}?cwd=${encodeURIComponent(MAX_DIR)}`, mia);
|
||||
assert.equal(r.status, 403, kind);
|
||||
assert.equal(r.body.error.code, "EFOLDER");
|
||||
assert.ok(!JSON.stringify(r.body).includes("max-"));
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a path that walks out of the member's folder", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const sneaky = path.join(MIA_DIR, "..", "max");
|
||||
const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(sneaky)}`, mia);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
|
||||
it("lists no project items without a cwd (never the dashboard's own folder)", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await call("/api/cc-config/skills?scope=project", mia);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.items, []);
|
||||
const all = await call("/api/cc-config/commands", mia);
|
||||
assert.equal(all.status, 200);
|
||||
assert.ok(all.body.items.every((i) => i.scope === "user"));
|
||||
});
|
||||
|
||||
it("leaves an admin free to name any folder", async () => {
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(MAX_DIR)}`, boss);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(names(r).includes("max-skill"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file server-mode-proxies.test.js
|
||||
* @description Personal AI Proxy profiles (S3 plan, P1): each user keeps
|
||||
* their own beside the admin's shared ones. A member lists, edits, deletes and
|
||||
* tests only their own; nobody else sees, edits or starts a session on them;
|
||||
* only an admin writes a shared one; local mode is unchanged (shared only).
|
||||
* Also `resolveProxy`/`resumeProxy`: a personal profile resolves only for a
|
||||
* workspace its owner owns.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-proxies-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
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");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const proxies = require("../lib/ai-proxies");
|
||||
const { resolveProxy, resumeProxy } = require("../lib/proxy-apply");
|
||||
const { db } = require("../db");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
let mia;
|
||||
let max;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
const profile = (label, extra = {}) => ({
|
||||
label,
|
||||
baseUrl: "http://127.0.0.1:9/v1",
|
||||
apiKey: "sk-secret-" + label.replace(/\W/g, ""),
|
||||
model: "m1",
|
||||
...extra,
|
||||
});
|
||||
|
||||
const namesOf = (r) => r.body.items.map((i) => `${i.scope}:${i.name}`);
|
||||
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
mia = await auth.createUser("mia", "member-pass-1", "developer");
|
||||
max = await auth.createUser("max", "member-pass-2", "developer");
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("local mode keeps one shared store", () => {
|
||||
it("creates a shared profile with the plain slug name", async () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
const r = await call("POST", "/api/ai-proxies", { body: profile("Team Router") });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.name, "team-router");
|
||||
assert.equal(r.body.scope, "shared");
|
||||
assert.equal(r.body.hasKey, true);
|
||||
assert.equal(r.body.apiKey, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("server mode: personal profiles", () => {
|
||||
before(() => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
});
|
||||
|
||||
it("gives a member's new profile a personal name and keeps it out of others' lists", async () => {
|
||||
const cm = await login("mia", "member-pass-1");
|
||||
const r = await call("POST", "/api/ai-proxies", { cookie: cm, body: profile("Mia Proxy") });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.name, `u${mia.id}_mia-proxy`);
|
||||
assert.equal(r.body.scope, "personal");
|
||||
assert.equal(r.body.apiKey, undefined);
|
||||
|
||||
const mine = await call("GET", "/api/ai-proxies", { cookie: cm });
|
||||
assert.deepEqual(namesOf(mine), [
|
||||
"shared:native",
|
||||
"shared:team-router",
|
||||
`personal:u${mia.id}_mia-proxy`,
|
||||
]);
|
||||
assert.ok(!JSON.stringify(mine.body).includes("sk-secret"));
|
||||
|
||||
const cx = await login("max", "member-pass-2");
|
||||
const theirs = await call("GET", "/api/ai-proxies", { cookie: cx });
|
||||
assert.ok(!namesOf(theirs).some((n) => n.includes("mia")));
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const admins = await call("GET", "/api/ai-proxies", { cookie: boss });
|
||||
assert.ok(!namesOf(admins).some((n) => n.includes("mia")), "not even an admin lists it");
|
||||
});
|
||||
|
||||
it("refuses a member a shared profile, and anyone else's personal one", async () => {
|
||||
const cm = await login("mia", "member-pass-1");
|
||||
const shared = await call("POST", "/api/ai-proxies", {
|
||||
cookie: cm,
|
||||
body: profile("Sneaky", { scope: "shared" }),
|
||||
});
|
||||
assert.equal(shared.status, 403);
|
||||
assert.equal(shared.body.error.code, "EADMIN");
|
||||
const edit = await call("PATCH", "/api/ai-proxies/team-router", {
|
||||
cookie: cm,
|
||||
body: { model: "m2" },
|
||||
});
|
||||
assert.equal(edit.status, 403);
|
||||
const del = await call("DELETE", "/api/ai-proxies/team-router", { cookie: cm });
|
||||
assert.equal(del.status, 403);
|
||||
|
||||
const cx = await login("max", "member-pass-2");
|
||||
const name = `u${mia.id}_mia-proxy`;
|
||||
const e2 = await call("PATCH", `/api/ai-proxies/${name}`, { cookie: cx, body: { model: "x" } });
|
||||
assert.equal(e2.status, 404);
|
||||
const d2 = await call("DELETE", `/api/ai-proxies/${name}`, { cookie: cx });
|
||||
assert.equal(d2.status, 404);
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const d3 = await call("DELETE", `/api/ai-proxies/${name}`, { cookie: boss });
|
||||
assert.equal(d3.status, 404, "an admin does not manage someone's personal profile");
|
||||
assert.ok(proxies.getProxy(name), "still there");
|
||||
});
|
||||
|
||||
it("lets the owner edit and delete their own", async () => {
|
||||
const cm = await login("mia", "member-pass-1");
|
||||
const made = await call("POST", "/api/ai-proxies", { cookie: cm, body: profile("Temp One") });
|
||||
const name = made.body.name;
|
||||
const e = await call("PATCH", `/api/ai-proxies/${name}`, { cookie: cm, body: { model: "m9" } });
|
||||
assert.equal(e.status, 200);
|
||||
assert.equal(e.body.model, "m9");
|
||||
const d = await call("DELETE", `/api/ai-proxies/${name}`, { cookie: cm });
|
||||
assert.equal(d.status, 200);
|
||||
});
|
||||
|
||||
it("never uses another user's stored key in a test", async () => {
|
||||
const cx = await login("max", "member-pass-2");
|
||||
const r = await call("POST", "/api/ai-proxies/test", {
|
||||
cookie: cx,
|
||||
body: { name: `u${mia.id}_mia-proxy`, baseUrl: "http://127.0.0.1:9", model: "m1" },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EBADKEY");
|
||||
});
|
||||
|
||||
it("lets an admin write shared profiles, and keep a personal one of their own", async () => {
|
||||
const boss = await login("boss", "admin-pass-1");
|
||||
const s = await call("POST", "/api/ai-proxies", { cookie: boss, body: profile("Ops Gateway") });
|
||||
assert.equal(s.status, 201);
|
||||
assert.equal(s.body.scope, "shared");
|
||||
const p = await call("POST", "/api/ai-proxies", {
|
||||
cookie: boss,
|
||||
body: profile("Boss Own", { scope: "personal" }),
|
||||
});
|
||||
assert.equal(p.status, 201);
|
||||
assert.equal(p.body.scope, "personal");
|
||||
assert.match(p.body.name, /^u\d+_boss-own$/);
|
||||
const cm = await login("mia", "member-pass-1");
|
||||
const list = await call("GET", "/api/ai-proxies", { cookie: cm });
|
||||
assert.ok(namesOf(list).includes("shared:ops-gateway"));
|
||||
assert.ok(!namesOf(list).some((n) => n.includes("boss-own")));
|
||||
});
|
||||
|
||||
it("lets an analyst keep a personal profile, never a shared one", async () => {
|
||||
const ca = await login("ana", "analyst-pass-1");
|
||||
const r = await call("POST", "/api/ai-proxies", { cookie: ca, body: profile("Ana Proxy") });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.scope, "personal");
|
||||
const s = await call("POST", "/api/ai-proxies", {
|
||||
cookie: ca,
|
||||
body: { ...profile("Ana Shared"), scope: "shared" },
|
||||
});
|
||||
assert.equal(s.status, 403);
|
||||
assert.equal(s.body.error.code, "EADMIN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProxy / resumeProxy: a personal profile only for its owner's workspace", () => {
|
||||
it("resolves for the owner, refuses anyone else and an ownerless workspace", () => {
|
||||
const name = `u${mia.id}_mia-proxy`;
|
||||
assert.equal(resolveProxy("claude", name, mia.id).name, name);
|
||||
assert.throws(() => resolveProxy("claude", name, max.id), { code: "EBADPROXY" });
|
||||
assert.throws(() => resolveProxy("claude", name, null), { code: "EBADPROXY" });
|
||||
assert.equal(resolveProxy("claude", "team-router", max.id).name, "team-router");
|
||||
assert.equal(resolveProxy("claude", "team-router", null).name, "team-router");
|
||||
});
|
||||
|
||||
it("resumes on a personal profile only for its owner, else on native", () => {
|
||||
const name = `u${mia.id}_mia-proxy`;
|
||||
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/p', 'claude', ?)").run(mia.id);
|
||||
const laneId = db.prepare("SELECT id FROM lanes WHERE cwd = '/w/p'").get().id;
|
||||
db.prepare(
|
||||
"INSERT INTO dashboard_runs (id, lane_id, cwd, status, session_id, proxy) VALUES ('agenthub-lane-p', ?, '/w/p', 'exited', 'sess-proxy-1', ?)"
|
||||
).run(laneId, name);
|
||||
assert.equal(resumeProxy("sess-proxy-1", mia.id), name);
|
||||
assert.equal(resumeProxy("sess-proxy-1", max.id), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an edited profile reaches a live gateway pane (review fix)", () => {
|
||||
it("rewrites a running pane's own settings file and deletes a dead one", () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
const crypto = require("node:crypto");
|
||||
const { proxyForSpawn, refreshProfileFiles } = require("../lib/proxy-apply");
|
||||
const { name } = proxies.createProxy(profile("Refresh me"));
|
||||
const live = "ahpane_live_token";
|
||||
const fileLive = proxyForSpawn(
|
||||
"claude",
|
||||
proxies.getProxy(name),
|
||||
live,
|
||||
null,
|
||||
"agenthub-lane-91"
|
||||
).argv[0].slice("--settings=".length);
|
||||
const fileDead = proxyForSpawn(
|
||||
"claude",
|
||||
proxies.getProxy(name),
|
||||
"ahpane_dead",
|
||||
null,
|
||||
"agenthub-lane-92"
|
||||
).argv[0].slice("--settings=".length);
|
||||
const hash = crypto.createHash("sha256").update(live).digest("hex");
|
||||
db.prepare(
|
||||
"INSERT INTO dashboard_runs (id, cwd, mode, status, pane_token_hash) VALUES ('agenthub-lane-91', ?, 'interactive', 'running', ?)"
|
||||
).run(TMP, hash);
|
||||
proxies.updateProxy(name, { model: "m2" });
|
||||
refreshProfileFiles(name);
|
||||
const env = JSON.parse(fs.readFileSync(fileLive, "utf8")).env;
|
||||
assert.equal(env.ANTHROPIC_MODEL, "m2");
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, live, "the pane keeps its own token");
|
||||
assert.equal(fs.existsSync(fileDead), false, "a file whose pane is gone holds a dead token");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @file server-mode-proxy-gateway.test.js
|
||||
* @description The AI Proxy key gateway (S3 plan, P1b). In server mode a pane
|
||||
* never gets a proxy's key: its CLI talks to `/api/proxy-gw/<name>/v1/…` on
|
||||
* loopback with its own pane token, and the gateway forwards to the profile's
|
||||
* endpoint with the real key, streaming both ways. Refused: local mode, a
|
||||
* non-loopback request, an unknown/dead pane token, a profile the pane was not
|
||||
* started on, a path outside `/v1/`. Also what a spawn writes in server mode:
|
||||
* no key in any file, the gateway as the endpoint, the pane token as the key.
|
||||
* @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 { execFileSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-proxy-gw-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
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");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const proxies = require("../lib/ai-proxies");
|
||||
const { proxyForSpawn, resolveProxy } = require("../lib/proxy-apply");
|
||||
const { db } = require("../db");
|
||||
|
||||
const KEY = "sk-upstream-real-key";
|
||||
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
let upstream;
|
||||
let seen = []; // what the fake upstream received
|
||||
const LIVE_TOKEN = "ahpane_live-token-for-tests-0001";
|
||||
const DEAD_TOKEN = "ahpane_dead-token-for-tests-0002";
|
||||
const LIVE_RUN = "agenthub-lane-900";
|
||||
const DEAD_RUN = "agenthub-lane-901";
|
||||
let profileName;
|
||||
let otherName;
|
||||
|
||||
function gw(p, { token = LIVE_TOKEN, headers = {}, body, method = "POST" } = {}) {
|
||||
return fetch(`${BASE}/api/proxy-gw/${p}`, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
if (req.url.endsWith("/stream")) {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.write("event: a\ndata: 1\n\n");
|
||||
setTimeout(() => res.end("event: b\ndata: 2\n\n"), 30);
|
||||
return;
|
||||
}
|
||||
res.writeHead(201, { "content-type": "application/json", "x-upstream": "yes" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
|
||||
const upBase = `http://127.0.0.1:${upstream.address().port}/root`;
|
||||
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const mia = await auth.createUser("mia", "member-pass-1", "developer");
|
||||
profileName = proxies.createProxy({
|
||||
label: "Team GW",
|
||||
baseUrl: upBase,
|
||||
apiKey: KEY,
|
||||
model: "m",
|
||||
}).name;
|
||||
otherName = proxies.createProxy({
|
||||
label: "Other GW",
|
||||
baseUrl: upBase,
|
||||
apiKey: KEY,
|
||||
model: "m",
|
||||
}).name;
|
||||
const laneId = db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/gw', 'claude', ?)")
|
||||
.run(mia.id).lastInsertRowid;
|
||||
const run = db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status, proxy, pane_token_hash)
|
||||
VALUES (?, ?, '/w/gw', 'interactive', 'running', ?, ?)`
|
||||
);
|
||||
run.run(LIVE_RUN, laneId, profileName, sha256(LIVE_TOKEN));
|
||||
run.run(DEAD_RUN, laneId, profileName, sha256(DEAD_TOKEN));
|
||||
// A live pane for LIVE_RUN only, on the suite's private tmux socket.
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", LIVE_RUN, "sleep 300"]);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
execFileSync("tmux", ["kill-session", "-t", LIVE_RUN]);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
server?.close();
|
||||
upstream?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("the proxy gateway", () => {
|
||||
it("forwards with the real key, never the pane token, body and status intact", async () => {
|
||||
seen = [];
|
||||
const res = await gw(`${profileName}/v1/messages?beta=true`, {
|
||||
headers: { "anthropic-version": "2023-06-01", "x-api-key": LIVE_TOKEN },
|
||||
body: JSON.stringify({ hello: "world" }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.headers.get("x-upstream"), "yes");
|
||||
assert.deepEqual(await res.json(), { ok: true });
|
||||
assert.equal(seen.length, 1);
|
||||
const got = seen[0];
|
||||
assert.equal(got.url, "/root/v1/messages?beta=true");
|
||||
assert.equal(got.headers.authorization, `Bearer ${KEY}`);
|
||||
assert.equal(got.headers["x-api-key"], KEY);
|
||||
assert.equal(got.headers["anthropic-version"], "2023-06-01");
|
||||
assert.ok(!JSON.stringify(got.headers).includes("ahpane_"));
|
||||
assert.equal(got.body, JSON.stringify({ hello: "world" }));
|
||||
});
|
||||
|
||||
it("streams an SSE answer through", async () => {
|
||||
const res = await gw(`${profileName}/v1/stream`, { body: "{}" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(await res.text(), "event: a\ndata: 1\n\nevent: b\ndata: 2\n\n");
|
||||
});
|
||||
|
||||
it("refuses a missing, unknown or dead pane token", async () => {
|
||||
for (const token of [null, "ahpane_nope", DEAD_TOKEN]) {
|
||||
seen = [];
|
||||
const res = await gw(`${profileName}/v1/messages`, { token, body: "{}" });
|
||||
assert.equal(res.status, 401, String(token));
|
||||
assert.equal(seen.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a profile the pane was not started on", async () => {
|
||||
seen = [];
|
||||
const res = await gw(`${otherName}/v1/messages`, { body: "{}" });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
|
||||
it("refuses a path that leaves /v1/", async () => {
|
||||
seen = [];
|
||||
for (const p of [`${profileName}/v1/../../admin`, `${profileName}/v2/x`, `${profileName}`]) {
|
||||
const res = await gw(p, { body: "{}" });
|
||||
assert.equal(res.status, 400, p);
|
||||
}
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
|
||||
it("refuses a raw `..` path the client did not normalise", async () => {
|
||||
seen = [];
|
||||
for (const raw of [`${profileName}/v1/../../admin`, `${profileName}/v1/%2e%2e/%2e%2e/admin`]) {
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
`${BASE}/api/proxy-gw/x`,
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/proxy-gw/${raw}`,
|
||||
headers: { authorization: `Bearer ${LIVE_TOKEN}`, "content-type": "application/json" },
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end("{}");
|
||||
});
|
||||
assert.equal(status, 400, raw);
|
||||
}
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
|
||||
it("refuses a request that came through a reverse proxy", async () => {
|
||||
const res = await gw(`${profileName}/v1/messages`, {
|
||||
headers: { "x-forwarded-for": "10.0.0.9" },
|
||||
body: "{}",
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it("is not there in local mode", async () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
const res = await gw(`${profileName}/v1/messages`, { body: "{}" });
|
||||
assert.equal(res.status, 404);
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a spawn on a proxy in server mode", () => {
|
||||
it("puts no key in Claude's settings file — the pane's own, with its pane token", () => {
|
||||
const spawn = proxyForSpawn(
|
||||
"claude",
|
||||
resolveProxy("claude", profileName),
|
||||
LIVE_TOKEN,
|
||||
null,
|
||||
"agenthub-lane-3"
|
||||
);
|
||||
const file = spawn.argv[0].replace(/^--settings=/, "");
|
||||
assert.equal(path.basename(file), `${profileName}--agenthub-lane-3.json`);
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
assert.ok(!text.includes(KEY));
|
||||
const env = JSON.parse(text).env;
|
||||
assert.match(env.ANTHROPIC_BASE_URL, new RegExp(`/api/proxy-gw/${profileName}$`));
|
||||
// The command-line layer: a user's or project's env.ANTHROPIC_* cannot override it.
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, LIVE_TOKEN);
|
||||
assert.deepEqual(spawn.env, { ANTHROPIC_AUTH_TOKEN: LIVE_TOKEN });
|
||||
});
|
||||
|
||||
it("points Codex's layer at the gateway, keyed by the pane token", () => {
|
||||
const spawn = proxyForSpawn("codex", resolveProxy("codex", profileName), LIVE_TOKEN);
|
||||
const layer = fs.readFileSync(
|
||||
path.join(process.env.CODEX_HOME, `${profileName}.config.toml`),
|
||||
"utf8"
|
||||
);
|
||||
assert.ok(!layer.includes(KEY));
|
||||
assert.match(layer, new RegExp(`base_url = ".*/api/proxy-gw/${profileName}/v1"`));
|
||||
assert.match(layer, /env_key = "AGENTHUB_PROXY_KEY"/);
|
||||
assert.deepEqual(spawn.env, { AGENTHUB_PROXY_KEY: LIVE_TOKEN });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @file server-s3-git.test.js
|
||||
* @description S3 plan, P3b: in a member's folder the dashboard runs git, gh
|
||||
* and file writes AS the member (sudo), and only non-secret variables cross
|
||||
* sudo (it logs its command line). A fake `sudo` on PATH records the argv; a
|
||||
* fake passwd and server.conf make `mia` a provisioned member. Outside the
|
||||
* users root nothing changes. The credential store is never handed to a
|
||||
* member's git, and a typed password is refused.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-git-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const HOME = path.join(USERS, "mia");
|
||||
const HANDOFF = path.join(HOME, ".agenthub", "handoff");
|
||||
const BIN = path.join(TMP, "bin");
|
||||
const LOG = path.join(TMP, "sudo.log");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const { git } = require("../lib/worktree");
|
||||
const { gitConfigFor } = require("../lib/git-credentials");
|
||||
|
||||
const sudoCalls = () =>
|
||||
fs.existsSync(LOG)
|
||||
? fs
|
||||
.readFileSync(LOG, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => JSON.parse(l))
|
||||
: [];
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(HANDOFF, { recursive: true });
|
||||
fs.mkdirSync(BIN, { recursive: true });
|
||||
// A fake sudo: records its argv, then answers like the command would.
|
||||
fs.writeFileSync(
|
||||
path.join(BIN, "sudo"),
|
||||
`#!/usr/bin/env node\nrequire("fs").appendFileSync(${JSON.stringify(LOG)}, JSON.stringify(process.argv.slice(2)) + "\\n");\nprocess.stdout.write("fake-git-out\\n");\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
process.env.PATH = `${BIN}:${process.env.PATH}`;
|
||||
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-mia:x:5001:5001::/:/bin/sh\n");
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
runAs.__resetCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await auth.createUser("mia", "member-pass-1", "developer");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("git and files in a member's folder run as the member", () => {
|
||||
it("maps a path under the users root to its member", () => {
|
||||
assert.deepEqual(runAs.accountForPath(path.join(HOME, "proj")), {
|
||||
name: "mia",
|
||||
login: "ah-mia",
|
||||
home: HOME,
|
||||
uid: 5001,
|
||||
});
|
||||
assert.equal(runAs.accountForPath(TMP), null);
|
||||
});
|
||||
|
||||
it("runs git through sudo -u with only plain variables", async () => {
|
||||
fs.rmSync(LOG, { force: true });
|
||||
const out = await git(HOME, ["status", "--porcelain"], {
|
||||
literal: true,
|
||||
env: { GIT_INDEX_FILE: "/x/index" },
|
||||
});
|
||||
assert.equal(out.stdout, "fake-git-out\n");
|
||||
assert.deepEqual(sudoCalls(), [
|
||||
[
|
||||
"-n",
|
||||
"-u",
|
||||
"ah-mia",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
`HOME=${HOME}`,
|
||||
"GIT_INDEX_FILE=/x/index",
|
||||
"GIT_LITERAL_PATHSPECS=1",
|
||||
"git",
|
||||
"status",
|
||||
"--porcelain",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves git outside the users root alone", async () => {
|
||||
fs.rmSync(LOG, { force: true });
|
||||
await git(TMP, ["--version"]);
|
||||
assert.deepEqual(sudoCalls(), []);
|
||||
});
|
||||
|
||||
it("writes a member's file through write-as, with the content on stdin", () => {
|
||||
const seen = [];
|
||||
runAs.__setExecImpl((cmd, argv, opts) => seen.push({ cmd, argv, input: opts.input }));
|
||||
runAs.writeFileAs(path.join(HOME, "a.txt"), "hello");
|
||||
runAs.writeFileAs(path.join(HOME, ".gitignore"), "/x\n", { append: true });
|
||||
assert.deepEqual(seen, [
|
||||
{
|
||||
cmd: "sudo",
|
||||
argv: ["-n", "-u", "ah-mia", "/opt/agenthub/libexec/write-as", path.join(HOME, "a.txt")],
|
||||
input: "hello",
|
||||
},
|
||||
{
|
||||
cmd: "sudo",
|
||||
argv: [
|
||||
"-n",
|
||||
"-u",
|
||||
"ah-mia",
|
||||
"/opt/agenthub/libexec/write-as",
|
||||
path.join(HOME, ".gitignore"),
|
||||
"append",
|
||||
],
|
||||
input: "/x\n",
|
||||
},
|
||||
]);
|
||||
const mine = path.join(TMP, "host.txt");
|
||||
runAs.writeFileAs(mine, "direct");
|
||||
assert.equal(fs.readFileSync(mine, "utf8"), "direct");
|
||||
assert.equal(seen.length, 2);
|
||||
});
|
||||
|
||||
it("names a scratch index the member's git creates, and removes it as the member", () => {
|
||||
const seen = [];
|
||||
runAs.__setExecImpl((cmd, argv) => seen.push([cmd, ...argv]));
|
||||
const s = runAs.scratchFile(path.join(HOME, "proj"), "agenthub-index-");
|
||||
assert.equal(path.dirname(s.file), HANDOFF);
|
||||
assert.ok(!fs.existsSync(s.file), "the dashboard writes nothing in a member's home");
|
||||
s.remove();
|
||||
assert.deepEqual(
|
||||
seen.map((c) => c.slice(-2)),
|
||||
[
|
||||
[s.file, "remove"],
|
||||
[`${s.file}.lock`, "remove"],
|
||||
]
|
||||
);
|
||||
assert.ok(seen.every((c) => c[0] === "sudo" && c.includes("ah-mia")));
|
||||
const host = runAs.scratchFile(TMP, "agenthub-index-");
|
||||
assert.equal(path.dirname(path.dirname(host.file)), os.tmpdir());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("never hands a member's git the credential store, nor a typed password", async () => {
|
||||
assert.deepEqual(await gitConfigFor(HOME), { args: [], env: {} });
|
||||
await assert.rejects(
|
||||
gitConfigFor(HOME, { protocol: "https", host: "github.com", username: "u", password: "p" }),
|
||||
{ code: "EMEMBERCRED" }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @file server-s3-handoff.test.js
|
||||
* @description S3 plan, P3c: what a member's pane must read lives in the
|
||||
* member's handoff dir, never the dashboard's data dir (which the member's
|
||||
* account cannot reach): the workspace prompt and context files, the AI Proxy
|
||||
* settings for Claude (no key — the gateway), Codex's provider as `-c`
|
||||
* overrides (no file in the member's CODEX_HOME), and files dropped into the
|
||||
* prompt box. Every one is written AS the member (`write-as`, played by
|
||||
* fixtures/fake-sudo.js), never by the dashboard itself. A fake passwd and
|
||||
* server.conf make `mia` a provisioned member.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-handoff-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const HOME = path.join(USERS, "mia");
|
||||
const HANDOFF = path.join(HOME, ".agenthub", "handoff");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const proxies = require("../lib/ai-proxies");
|
||||
const { proxyForSpawn, resolveProxy } = require("../lib/proxy-apply");
|
||||
const { launchExtras } = require("../lib/lane-sources");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
const sudo = fakeSudo();
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
let laneId;
|
||||
let acct;
|
||||
let profile;
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
|
||||
fs.mkdirSync(HANDOFF, { recursive: true });
|
||||
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-mia:x:5001:5001::/:/bin/sh\n");
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
runAs.__resetCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const mia = await auth.createUser("mia", "member-pass-1", "developer");
|
||||
laneId = Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (cwd, tool, owner_id, instructions) VALUES (?, 'claude', ?, 'Be brief.')"
|
||||
)
|
||||
.run(path.join(HOME, "proj"), mia.id).lastInsertRowid
|
||||
);
|
||||
const ctxFile = path.join(TMP, "data", "workspace-context", String(laneId), "spec.md");
|
||||
fs.mkdirSync(path.dirname(ctxFile), { recursive: true });
|
||||
fs.writeFileSync(ctxFile, "# spec\n");
|
||||
db.prepare(
|
||||
"INSERT INTO lane_sources (lane_id, kind, path, name, added_at) VALUES (?, 'file', ?, 'spec.md', 'now')"
|
||||
).run(laneId, ctxFile);
|
||||
acct = runAs.accountForLane(laneId);
|
||||
profile = proxies.createProxy({
|
||||
label: "Team GW",
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "sk-real-key",
|
||||
model: "m1",
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const mode = (f) => fs.statSync(f).mode & 0o777;
|
||||
|
||||
describe("a member pane's files live in the member's handoff dir", () => {
|
||||
it("resolves the workspace to its member", () => {
|
||||
assert.equal(acct?.login, "ah-mia");
|
||||
});
|
||||
|
||||
it("copies the context files and writes the prompt there, readable", () => {
|
||||
const x = launchExtras(laneId, "claude");
|
||||
const ctx = path.join(HANDOFF, `context-${laneId}`);
|
||||
assert.deepEqual(x.addDirs, [ctx]);
|
||||
assert.equal(fs.readFileSync(path.join(ctx, "spec.md"), "utf8"), "# spec\n");
|
||||
assert.equal(mode(path.join(ctx, "spec.md")), 0o644);
|
||||
assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}.md`));
|
||||
assert.equal(mode(x.systemPromptFile), 0o644);
|
||||
const text = fs.readFileSync(x.systemPromptFile, "utf8");
|
||||
assert.match(text, /Be brief\./);
|
||||
assert.ok(text.includes(path.join(ctx, "spec.md")), "the prompt names the copy");
|
||||
assert.ok(!text.includes(path.join(TMP, "data")), "never the data dir");
|
||||
const writes = sudo.calls.filter((c) => c.includes("/opt/agenthub/libexec/write-as"));
|
||||
assert.ok(writes.length >= 3, "copies and prompt written as the member");
|
||||
assert.ok(writes.every((c) => c.includes("ah-mia")));
|
||||
assert.ok(!writes.flat().some((a) => a.includes("Be brief")), "content never in argv");
|
||||
});
|
||||
|
||||
it("gives Claude a keyless settings file of the pane's own in the handoff dir", () => {
|
||||
const spawn = proxyForSpawn(
|
||||
"claude",
|
||||
resolveProxy("claude", profile.name),
|
||||
"ahpane_t",
|
||||
acct,
|
||||
"agenthub-lane-1"
|
||||
);
|
||||
const file = spawn.argv[0].replace(/^--settings=/, "");
|
||||
assert.equal(file, path.join(HANDOFF, `proxy-${profile.name}--agenthub-lane-1.json`));
|
||||
assert.equal(mode(file), 0o600);
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
assert.ok(!text.includes("sk-real-key"));
|
||||
assert.match(JSON.parse(text).env.ANTHROPIC_BASE_URL, /\/api\/proxy-gw\//);
|
||||
assert.equal(JSON.parse(text).env.ANTHROPIC_AUTH_TOKEN, "ahpane_t");
|
||||
assert.deepEqual(spawn.env, { ANTHROPIC_AUTH_TOKEN: "ahpane_t" });
|
||||
});
|
||||
|
||||
it("gives Codex the provider as -c overrides, never a file in CODEX_HOME", () => {
|
||||
const spawn = proxyForSpawn("codex", resolveProxy("codex", profile.name), "ahpane_t", acct);
|
||||
assert.ok(!spawn.argv.includes("-p"));
|
||||
const joined = spawn.argv.join(" ");
|
||||
assert.match(joined, new RegExp(`model_providers\\.${profile.name}=\\{`));
|
||||
assert.match(joined, /base_url="http:\/\/127\.0\.0\.1:\d+\/api\/proxy-gw\/[^"]+\/v1"/);
|
||||
assert.match(joined, /env_key="AGENTHUB_PROXY_KEY"/);
|
||||
assert.ok(!joined.includes("sk-real-key"));
|
||||
assert.ok(!fs.existsSync(path.join(process.env.CODEX_HOME, `${profile.name}.config.toml`)));
|
||||
assert.deepEqual(spawn.env, { AGENTHUB_PROXY_KEY: "ahpane_t" });
|
||||
});
|
||||
|
||||
it("saves a member's dropped file in their handoff dir", async () => {
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "mia", password: "member-pass-1" }),
|
||||
});
|
||||
const cookie = login.headers.get("set-cookie").split(";")[0];
|
||||
const res = await fetch(`${BASE}/api/run/drop-image`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "image/png", cookie },
|
||||
body: Buffer.from("89504e47", "hex"),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const { path: saved } = await res.json();
|
||||
assert.equal(path.dirname(saved), path.join(HANDOFF, "drops"));
|
||||
assert.equal(mode(saved), 0o644);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @file server-s3-hooks.test.js
|
||||
* @description S3 plan, P3d: a hook is authenticated by the uid behind its
|
||||
* loopback connection (`/proc/net/tcp*`, faked here). A member may report only
|
||||
* work in their own home — not another member's folder, session or pane, and
|
||||
* no transcript outside it; an account that is neither the host nor a member
|
||||
* is refused; the host is unchanged. A member's session stays alive while its
|
||||
* pane lives (the dashboard cannot read another uid's /proc cwd).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = 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 TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-hooks-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const MIA = path.join(USERS, "mia");
|
||||
const MAX = path.join(USERS, "max");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const hookAuth = require("../lib/hook-auth");
|
||||
const tmux = require("../lib/tmux");
|
||||
const hooksRouter = require("../routes/hooks");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
let peer = 5001; // mia
|
||||
|
||||
const post = (data, extra = {}) =>
|
||||
fetch(`${BASE}/api/hooks/event`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hook_type: "UserPromptSubmit", data, ...extra }),
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
for (const d of [MIA, MAX]) fs.mkdirSync(path.join(d, "proj"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_PASSWD,
|
||||
"ah-mia:x:5001:5001::/:/bin/sh\nah-max:x:5002:5002::/:/bin/sh\nother:x:5003:5003::/:/bin/sh\n"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=${process.getuid()}\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
runAs.__resetCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
hookAuth.__setPeerUid(() => peer);
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await auth.createUser("mia", "member-pass-1", "developer");
|
||||
const max = await auth.createUser("max", "member-pass-2", "developer");
|
||||
const lane = db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)")
|
||||
.run(path.join(MAX, "proj"), max.id).lastInsertRowid;
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd) VALUES ('s-max', 'max', 'active', ?)"
|
||||
).run(path.join(MAX, "proj"));
|
||||
db.prepare(
|
||||
"INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status) VALUES ('agenthub-lane-777', ?, ?, 'interactive', 'running')"
|
||||
).run(lane, path.join(MAX, "proj"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
peer = 5001;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
hookAuth.__setPeerUid(null);
|
||||
tmux.__reset();
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("peerUid reads the client end of the connection from /proc/net/tcp", () => {
|
||||
it("matches the client's port, our port and ESTABLISHED", () => {
|
||||
hookAuth.__setTables(() => [
|
||||
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid\n" +
|
||||
" 0: 0100007F:12D4 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1001\n" +
|
||||
" 1: 0100007F:C7F2 0100007F:12D4 01 00000000:00000000 00:00000000 00000000 5001\n" +
|
||||
" 2: 0100007F:12D4 0100007F:C7F2 01 00000000:00000000 00:00000000 00000000 1001\n",
|
||||
"",
|
||||
]);
|
||||
assert.equal(hookAuth.peerUid({ remotePort: 0xc7f2, localPort: 0x12d4 }), 5001);
|
||||
assert.equal(hookAuth.peerUid({ remotePort: 0xc7f3, localPort: 0x12d4 }), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's hooks report only their own work", () => {
|
||||
it("ingests a hook from a member's own folder", async () => {
|
||||
const res = await post({ session_id: "s-mia-1", cwd: path.join(MIA, "proj"), prompt: "hi" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id = 's-mia-1'").get());
|
||||
});
|
||||
|
||||
it("refuses a hook claiming another member's folder", async () => {
|
||||
const res = await post({ session_id: "s-mia-2", cwd: path.join(MAX, "proj"), prompt: "x" });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal((await res.json()).error.code, "EFOREIGN");
|
||||
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = 's-mia-2'").get(), undefined);
|
||||
});
|
||||
|
||||
it("refuses a hook naming another member's session", async () => {
|
||||
const res = await post({ session_id: "s-max", cwd: path.join(MIA, "proj"), prompt: "x" });
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it("drops a transcript path outside the member's home", async () => {
|
||||
const res = await post({
|
||||
session_id: "s-mia-3",
|
||||
cwd: path.join(MIA, "proj"),
|
||||
transcript_path: path.join(MAX, ".claude", "projects", "x", "s.jsonl"),
|
||||
prompt: "x",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const row = db.prepare("SELECT transcript_path FROM sessions WHERE id = 's-mia-3'").get();
|
||||
assert.ok(!row?.transcript_path, "never another account's file");
|
||||
});
|
||||
|
||||
it("never binds a member's session to another member's pane", async () => {
|
||||
const res = await post(
|
||||
{ session_id: "s-mia-4", cwd: path.join(MIA, "proj"), prompt: "x" },
|
||||
{ run_id: "agenthub-lane-777" }
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-777'");
|
||||
assert.equal(run.get().session_id, null);
|
||||
});
|
||||
|
||||
it("refuses an account that is neither the host nor a member", async () => {
|
||||
peer = 5003;
|
||||
const res = await post({ session_id: "s-x", cwd: path.join(MIA, "proj"), prompt: "x" });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal((await res.json()).error.code, "EPEER");
|
||||
});
|
||||
|
||||
it("leaves the host's own hooks as they were", async () => {
|
||||
peer = process.getuid();
|
||||
const res = await post({ session_id: "s-host", cwd: TMP, prompt: "x" });
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's session is alive while its pane lives", () => {
|
||||
it("keeps it with a live pane, settles it once the pane is gone", () => {
|
||||
const cwd = path.join(MIA, "proj");
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, tool) VALUES ('s-live', 'l', 'active', ?, 'claude')"
|
||||
).run(cwd);
|
||||
db.prepare(
|
||||
"INSERT INTO dashboard_runs (id, cwd, mode, status, session_id) VALUES ('agenthub-lane-778', ?, 'interactive', 'running', 's-live')"
|
||||
).run(cwd);
|
||||
let panes = "agenthub-lane-778\n";
|
||||
tmux.__setExecImpl((args) => (args.includes("list-sessions") ? panes : ""));
|
||||
const status = () => db.prepare("SELECT status FROM sessions WHERE id = 's-live'").get().status;
|
||||
const probe = { available: true, cwds: new Set() };
|
||||
hooksRouter.livenessReap({ ignoreIdleGate: true, probe });
|
||||
assert.equal(status(), "active");
|
||||
panes = "";
|
||||
tmux.__setExecImpl((args) => (args.includes("list-sessions") ? panes : ""));
|
||||
hooksRouter.livenessReap({ ignoreIdleGate: true, probe });
|
||||
assert.equal(status(), "completed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @file server-s3-lanes.test.js
|
||||
* @description S3 plan, P3e: a workspace in a member's folder gets neither the
|
||||
* managed-worktree mode nor the runtime layer (they run as the dashboard's own
|
||||
* account and write into LANES_ROOT) — 403 EMEMBERLANE; a host workspace is
|
||||
* untouched. A host plugin reload never types into a member's pane. An analyst
|
||||
* WRITES like a developer: their workspace only in their own folder, run as
|
||||
* their own Linux account.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-lanes-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const PROJ = path.join(USERS, "mia", "proj");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
let memberLane;
|
||||
let hostLane;
|
||||
let cookie;
|
||||
|
||||
const call = (method, p, body) =>
|
||||
fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", cookie },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(PROJ, { recursive: true });
|
||||
execFileSync("git", ["init", "-q", PROJ]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
PROJ,
|
||||
"-c",
|
||||
"user.email=a@b",
|
||||
"-c",
|
||||
"user.name=a",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"x",
|
||||
]);
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_PASSWD,
|
||||
"ah-mia:x:5001:5001::/:/bin/sh\nah-ana:x:5002:5002::/:/bin/sh\n"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
runAs.__resetCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
await auth.createUser("mia", "member-pass-1", "developer");
|
||||
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
||||
const boss = db.prepare("SELECT id FROM users WHERE username = 'boss'").get().id;
|
||||
const insert = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
|
||||
memberLane = Number(insert.run(PROJ, boss).lastInsertRowid);
|
||||
hostLane = Number(insert.run(path.join(TMP, "hostproj"), boss).lastInsertRowid);
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "boss", password: "admin-pass-1" }),
|
||||
});
|
||||
cookie = login.headers.get("set-cookie").split(";")[0];
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__reset();
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("no runtime layer and no managed worktree in a member's folder", () => {
|
||||
for (const [method, p] of [
|
||||
["POST", "up"],
|
||||
["POST", "down"],
|
||||
["POST", "hook/bootstrap"],
|
||||
["POST", "sync-base"],
|
||||
["POST", "profile/init"],
|
||||
["POST", "mcp/sync"],
|
||||
]) {
|
||||
it(`refuses ${method} /:id/${p}`, async () => {
|
||||
const r = await call(method, `/api/lanes/${memberLane}/${p}`, {});
|
||||
assert.equal(r.status, 403, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "EMEMBERLANE");
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses a managed worktree of a member's repository", async () => {
|
||||
const r = await call("POST", "/api/lanes/worktree", { sourceRepo: PROJ, slug: "feat" });
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EMEMBERLANE");
|
||||
});
|
||||
|
||||
it("leaves a host workspace's runtime routes as they were", async () => {
|
||||
const r = await call("POST", `/api/lanes/${hostLane}/up`, {});
|
||||
assert.notEqual(r.body?.error?.code, "EMEMBERLANE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a host plugin reload", () => {
|
||||
it("never types into a member's pane", () => {
|
||||
const { reloadableLanes } = require("../lib/cc-reload");
|
||||
const runs = require("../lib/pty-run");
|
||||
const orig = runs.listRuns;
|
||||
db.prepare(
|
||||
"INSERT INTO dashboard_runs (id, lane_id, cwd, mode, status, tool) VALUES (?, ?, ?, 'interactive', 'running', 'claude')"
|
||||
).run(`agenthub-lane-${memberLane}`, memberLane, PROJ);
|
||||
tmux.__setExecImpl((args) =>
|
||||
args.includes("list-sessions") ? `agenthub-lane-${memberLane}\n` : "claude\n"
|
||||
);
|
||||
try {
|
||||
assert.ok(!reloadableLanes().some((l) => l.id === `agenthub-lane-${memberLane}`));
|
||||
} finally {
|
||||
runs.listRuns = orig;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("an analyst's workspace is theirs, like a developer's", () => {
|
||||
it("is created only in their own folder and runs as their Linux account", async () => {
|
||||
const own = path.join(USERS, "ana", "proj");
|
||||
fs.mkdirSync(own, { recursive: true });
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
try {
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "ana", password: "analyst-pass-1" }),
|
||||
});
|
||||
const ca = login.headers.get("set-cookie").split(";")[0];
|
||||
const post = (cwd) =>
|
||||
fetch(`${BASE}/api/lanes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", cookie: ca },
|
||||
body: JSON.stringify({ cwd }),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||
const theirs = await post(path.join(USERS, "mia"));
|
||||
assert.equal(theirs.status, 403);
|
||||
assert.equal(theirs.body.error.code, "EFOLDER");
|
||||
const mine = await post(own);
|
||||
assert.equal(mine.status, 201, JSON.stringify(mine.body));
|
||||
assert.equal(runAs.accountForLane(mine.body.lane.id).login, "ah-ana");
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @file server-s3-panes.test.js
|
||||
* @description S3 plan, P3a + review fix F1, with tmux and sudo faked: a
|
||||
* member's session lives on the member's own tmux server, and EVERY call on
|
||||
* it runs AS the member (sudo) — never as a host client on a socket the
|
||||
* member controls. A session's env and argv travel in a launch file written
|
||||
* as the member, a paste on stdin: nothing secret or private in sudo's argv.
|
||||
* A member's server is believed only about the member's own sessions. The
|
||||
* host's own sessions are untouched. A member pane's environment is the
|
||||
* member's (home, shared tools on PATH, no host desktop). The run-as path rule.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-panes-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
|
||||
const tmux = require("../lib/tmux");
|
||||
const runAs = require("../lib/run-as");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const auth = require("../lib/auth");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
|
||||
const HOME = path.join(TMP, "users", "mia");
|
||||
const ACCT = { name: "mia", login: "ah-mia", home: HOME, uid: 5001 };
|
||||
const SOCK = path.join(HOME, ".agenthub", "tmux", "sock");
|
||||
const HANDOFF = path.join(HOME, ".agenthub", "handoff");
|
||||
const MEMBER = "agenthub-lane-7";
|
||||
const HOST = "agenthub-lane-8";
|
||||
fs.mkdirSync(HANDOFF, { recursive: true });
|
||||
|
||||
let calls; // what the host's own tmux client ran
|
||||
let asMember; // [login, ...tmux args, stdin] run as the member (sudo)
|
||||
let sudo;
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
asMember = [];
|
||||
sudo = fakeSudo();
|
||||
runAs.__setExecImpl(sudo.exec);
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args.includes("list-sessions")) return `${HOST}\nother\n`;
|
||||
return "";
|
||||
});
|
||||
tmux.__setAccountImpl(
|
||||
(name) => (name === MEMBER ? ACCT : null),
|
||||
(acct, args, input) => {
|
||||
asMember.push([acct.login, ...args, input]);
|
||||
// A server the member runs could claim another workspace's session.
|
||||
if (args.includes("list-sessions")) return `${MEMBER}\nagenthub-lane-9\n`;
|
||||
return "";
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__reset();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a member's panes on the member's own tmux server", () => {
|
||||
it("creates the session AS the member, env and argv only in the launch file", () => {
|
||||
tmux.newSession({
|
||||
name: MEMBER,
|
||||
cwd: path.join(HOME, "p"),
|
||||
argv: ["claude", "--", "a private prompt"],
|
||||
env: { AGENTHUB_PANE_TOKEN: "ahpane_secret", CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-secret" },
|
||||
});
|
||||
assert.deepEqual(calls, [], "no host client ever touches a member's server");
|
||||
const create = asMember.find((a) => a.includes("new-session"));
|
||||
const launch = create.slice(-3, -1);
|
||||
assert.deepEqual(create.slice(0, 9), [
|
||||
"ah-mia",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
MEMBER,
|
||||
"-c",
|
||||
path.join(HOME, "p"),
|
||||
"--",
|
||||
"/opt/agenthub/libexec/pane-launch",
|
||||
]);
|
||||
assert.equal(path.dirname(launch[1]), HANDOFF);
|
||||
const body = fs.readFileSync(launch[1], "utf8").split("\0");
|
||||
assert.deepEqual(body.slice(0, 6), [
|
||||
"2",
|
||||
"AGENTHUB_PANE_TOKEN=ahpane_secret",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat-secret",
|
||||
"claude",
|
||||
"--",
|
||||
"a private prompt",
|
||||
]);
|
||||
assert.equal(fs.statSync(launch[1]).mode & 0o777, 0o600);
|
||||
const logged = JSON.stringify([asMember, sudo.calls]);
|
||||
assert.ok(!/secret|private prompt/.test(logged), "sudo logs its argv");
|
||||
assert.ok(asMember.some((a) => a.includes("mouse")));
|
||||
});
|
||||
|
||||
it("runs every per-session call as the member; a paste goes in on stdin", () => {
|
||||
tmux.hasSession(MEMBER);
|
||||
tmux.sendText(MEMBER, "/reload-plugins");
|
||||
tmux.killSession(MEMBER);
|
||||
tmux.paneCommand(MEMBER);
|
||||
tmux.sendCommand(MEMBER, ["codex"], { K: "v-secret" }, path.join(HOME, "p"));
|
||||
tmux.pasteText(MEMBER, "a private\nprompt");
|
||||
assert.deepEqual(calls, []);
|
||||
const load = asMember.find((a) => a.includes("load-buffer"));
|
||||
assert.equal(load.at(-2), "-");
|
||||
assert.equal(load.at(-1), "a private\nprompt");
|
||||
const respawn = asMember.find((a) => a.includes("respawn-pane"));
|
||||
assert.ok(respawn.includes("/opt/agenthub/libexec/pane-launch"));
|
||||
assert.ok(!JSON.stringify(asMember.map((a) => a.slice(0, -1))).includes("secret"));
|
||||
});
|
||||
|
||||
it("attaches a terminal as the member, a host session with our own binary", () => {
|
||||
assert.deepEqual(tmux.memberAttach(MEMBER), [
|
||||
"sudo",
|
||||
["-n", "-u", "ah-mia", "tmux", "-S", SOCK, "attach-session", "-t", `=${MEMBER}`],
|
||||
]);
|
||||
assert.equal(tmux.memberAttach(HOST), null);
|
||||
});
|
||||
|
||||
it("leaves the host's own sessions on the default server", () => {
|
||||
tmux.newSession({ name: HOST, cwd: "/tmp", argv: ["bash"], env: {} });
|
||||
tmux.killSession(HOST);
|
||||
assert.deepEqual(asMember, []);
|
||||
assert.ok(calls.every((a) => a[0] !== "-S"));
|
||||
});
|
||||
|
||||
it("lists sessions across every server, believing a member's only about their own", () => {
|
||||
const orig = runAs.membersWithTmux;
|
||||
runAs.membersWithTmux = () => [ACCT];
|
||||
try {
|
||||
assert.deepEqual([...tmux.liveSessions()].sort(), [MEMBER, HOST, "other"].sort());
|
||||
assert.deepEqual(tmux.listSessions("agenthub-lane-").sort(), [HOST, MEMBER].sort());
|
||||
} finally {
|
||||
runAs.membersWithTmux = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it("never falls back to the host's server for a member with no account", () => {
|
||||
tmux.__setAccountImpl(() => {
|
||||
throw Object.assign(new Error("gone"), { code: "EMEMBERGONE" });
|
||||
});
|
||||
assert.throws(() => tmux.newSession({ name: MEMBER, cwd: HOME, argv: ["bash"], env: {} }), {
|
||||
code: "EMEMBERGONE",
|
||||
});
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member pane's environment", () => {
|
||||
it("is the member's home and identity, the shared tools, no host desktop", () => {
|
||||
const env = runAs.memberPaneEnv(
|
||||
ACCT,
|
||||
{
|
||||
AGENTHUB_RUN_ID: MEMBER,
|
||||
PATH: "/home/host/.local/bin:/usr/bin",
|
||||
DISPLAY: ":0",
|
||||
XDG_RUNTIME_DIR: "/run/user/1001",
|
||||
CODEX_HOME: "/home/host/.codex",
|
||||
ANTHROPIC_AUTH_TOKEN: "ahpane_x",
|
||||
},
|
||||
{ codex: true }
|
||||
);
|
||||
assert.equal(env.HOME, HOME);
|
||||
assert.equal(env.USER, "ah-mia");
|
||||
assert.equal(env.PATH, "/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin");
|
||||
assert.equal(env.CODEX_HOME, path.join(HOME, ".codex"));
|
||||
assert.equal(env.DISABLE_AUTOUPDATER, "1");
|
||||
assert.match(env.CLAUDE_DASHBOARD_PORT, /^\d+$/);
|
||||
assert.equal(env.DISPLAY, undefined);
|
||||
assert.equal(env.XDG_RUNTIME_DIR, undefined);
|
||||
assert.equal(env.AGENTHUB_RUN_ID, MEMBER);
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "ahpane_x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("run-as: a path under the users root belongs to that user", () => {
|
||||
it("maps only enabled, provisioned users; null outside S3 or the root; refuses a gone one", async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
try {
|
||||
await auth.createUser("root", "member-pass-1", "developer"); // a name, not uid 0
|
||||
// `ah-root` is not in /etc/passwd on a test box: not provisioned — a
|
||||
// folder of theirs is an error, never "the host runs it".
|
||||
const gone = { code: "EMEMBERGONE" };
|
||||
assert.throws(() => runAs.accountForPath(`${TMP}/users/root/p`), gone);
|
||||
assert.throws(() => runAs.accountForPath(`${TMP}/users/../users/x`), gone);
|
||||
assert.equal(runAs.accountForPath("/etc"), null);
|
||||
assert.equal(runAs.accountForPath(`${TMP}/users`), null);
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
assert.equal(runAs.accountForPath(`${TMP}/users/root/p`), null);
|
||||
assert.equal(runAs.accountForSession(MEMBER), null);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("names a member by uid only for ah-<name> accounts", () => {
|
||||
assert.equal(runAs.memberNameForUid(0), null); // root
|
||||
assert.equal(runAs.memberNameForUid(process.getuid()), null); // the host
|
||||
});
|
||||
|
||||
it("builds sudo argv with env assignments before the command", () => {
|
||||
assert.deepEqual(runAs.sudoArgv(ACCT, "git", ["status"], { GIT_TERMINAL_PROMPT: "0" }), [
|
||||
"sudo",
|
||||
["-n", "-u", "ah-mia", "GIT_TERMINAL_PROMPT=0", "git", "status"],
|
||||
]);
|
||||
assert.throws(() => runAs.sudoArgv(ACCT, "git", [], { "-E": "x" }));
|
||||
assert.throws(() => runAs.sudoArgv(ACCT, "git", [], { "A=B": "x" }));
|
||||
});
|
||||
|
||||
it("names the binary by the absolute path server.conf records (what sudoers lists)", () => {
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${TMP}/users\nGIT_BIN=/usr/bin/git\nTMUX_BIN=/opt/x/tmux\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
assert.equal(runAs.sudoArgv(ACCT, "git", ["status"])[1][3], "/usr/bin/git");
|
||||
assert.equal(runAs.sudoArgv(ACCT, "tmux", [])[1][3], "/opt/x/tmux");
|
||||
assert.equal(
|
||||
runAs.sudoArgv(ACCT, "/opt/agenthub/libexec/write-as", [])[1][3],
|
||||
"/opt/agenthub/libexec/write-as"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @file server-s3-provision.test.js
|
||||
* @description S3 plan, P6, without root: (1) `agenthub-provision create`
|
||||
* seeds the member's `~/.claude.json` (onboarding done, home trusted, merged,
|
||||
* a non-JSON file left alone) — the real script, run with stub `id`,
|
||||
* `useradd`, `chown` and `runuser` first on PATH and its two fixed system
|
||||
* paths pointed at a temp dir; (2) the opt-in plugin baseline runs
|
||||
* `claude plugin marketplace add|install` AS the member through sudo (played
|
||||
* by fixtures/fake-sudo.js), skips entries that could be read as options, and
|
||||
* starts by itself after a user is created.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = 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 { spawnSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-provision-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "etc", "server.conf");
|
||||
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "tools");
|
||||
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const memberClaude = require("../lib/member-claude");
|
||||
const auth = require("../lib/auth");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
|
||||
const SCRIPT = path.join(__dirname, "..", "..", "scripts", "server", "agenthub-provision.sh");
|
||||
const BASELINE = path.join(TMP, "etc", "plugin-baseline.json");
|
||||
const CLAUDE_LOG = path.join(TMP, "claude.log");
|
||||
|
||||
before(() => {
|
||||
fs.mkdirSync(path.join(TMP, "etc"), { recursive: true });
|
||||
fs.mkdirSync(USERS, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("agenthub-provision create seeds ~/.claude.json", () => {
|
||||
const BIN = path.join(TMP, "bin");
|
||||
const STATE = path.join(TMP, "accounts");
|
||||
const script = path.join(TMP, "agenthub-provision");
|
||||
const home = path.join(USERS, "mia");
|
||||
const file = path.join(home, ".claude.json");
|
||||
const stub = (name, body) =>
|
||||
fs.writeFileSync(path.join(BIN, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
|
||||
const create = () =>
|
||||
spawnSync("bash", [script, "create", "mia"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${BIN}:${process.env.PATH}`, STATE },
|
||||
});
|
||||
|
||||
before(() => {
|
||||
fs.mkdirSync(BIN, { recursive: true });
|
||||
fs.mkdirSync(STATE, { recursive: true });
|
||||
// Root, the account database and the switch of user are stubs; mkdir,
|
||||
// chmod and the seed itself are real.
|
||||
stub(
|
||||
"id",
|
||||
`case "$1" in
|
||||
-u) echo 0 ;;
|
||||
-nG) [ -f "$STATE/$2" ] && echo "$2 agenthub-members" || exit 1 ;;
|
||||
*) [ -f "$STATE/$1" ] || exit 1 ;;
|
||||
esac`
|
||||
);
|
||||
stub(
|
||||
"useradd",
|
||||
`while [ $# -gt 1 ]; do [ "$1" = --home-dir ] && h=$2; shift; done
|
||||
mkdir -p "$h" && touch "$STATE/$1"`
|
||||
);
|
||||
stub("chown", "exit 0");
|
||||
stub("runuser", `shift 2; [ "$1" = -- ] && shift; exec "$@"`);
|
||||
fs.symlinkSync(process.execPath, path.join(TMP, "node"));
|
||||
const text = fs
|
||||
.readFileSync(SCRIPT, "utf8")
|
||||
.replace("CONF=/etc/agenthub/server.conf", `CONF=${process.env.AGENTHUB_SERVER_CONF}`)
|
||||
.replaceAll("/opt/agenthub/tools/bin/node", path.join(TMP, "node"));
|
||||
fs.writeFileSync(script, text);
|
||||
});
|
||||
|
||||
it("marks onboarding done and trusts the home, 0600", () => {
|
||||
const r = create();
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout.trim(), `created ah-mia ${home}`);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), {
|
||||
hasCompletedOnboarding: true,
|
||||
projects: { [home]: { hasTrustDialogAccepted: true } },
|
||||
});
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
assert.equal(fs.statSync(path.join(home, ".agenthub", "handoff")).mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
it("merges into what the member already has", () => {
|
||||
const mine = {
|
||||
theme: "dark",
|
||||
projects: { "/x": { a: 1 }, [home]: { allowedTools: ["Bash"] } },
|
||||
};
|
||||
fs.writeFileSync(file, JSON.stringify(mine));
|
||||
assert.equal(create().status, 0);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), {
|
||||
theme: "dark",
|
||||
hasCompletedOnboarding: true,
|
||||
projects: {
|
||||
"/x": { a: 1 },
|
||||
[home]: { allowedTools: ["Bash"], hasTrustDialogAccepted: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a file that is not JSON alone", () => {
|
||||
fs.writeFileSync(file, "{ not json");
|
||||
const r = create();
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the plugin baseline (opt-in)", () => {
|
||||
const sudo = fakeSudo();
|
||||
const home = path.join(USERS, "rex");
|
||||
const claude = path.join(process.env.AGENTHUB_TOOLS_ROOT, "bin", "claude");
|
||||
|
||||
before(() => {
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(claude), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
claude,
|
||||
`#!/bin/sh
|
||||
echo "$PWD|$*" >> '${CLAUDE_LOG}'
|
||||
case "$*" in *broken*) echo "boom: $*" >&2; exit 1 ;; esac
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
memberClaude.__setExecImpl(async (cmd, argv, opts) => sudo.exec(cmd, argv, opts));
|
||||
linux.__setRunImpl(() => "ok");
|
||||
});
|
||||
beforeEach(() => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
sudo.calls.length = 0;
|
||||
fs.rmSync(CLAUDE_LOG, { force: true });
|
||||
fs.rmSync(BASELINE, { force: true });
|
||||
});
|
||||
|
||||
it("does nothing without the baseline file, or outside S3", async () => {
|
||||
assert.equal(await memberClaude.applyPluginBaseline("rex"), null);
|
||||
fs.writeFileSync(BASELINE, JSON.stringify({ plugins: ["a@b"] }));
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
assert.equal(await memberClaude.applyPluginBaseline("rex"), null);
|
||||
assert.deepEqual(sudo.calls, []);
|
||||
});
|
||||
|
||||
it("adds each marketplace, then installs each plugin, AS the member", async () => {
|
||||
fs.writeFileSync(
|
||||
BASELINE,
|
||||
JSON.stringify({
|
||||
marketplaces: ["acme/market", "--claudeai x"],
|
||||
plugins: ["good@market", "broken@market", "-y@market"],
|
||||
})
|
||||
);
|
||||
const results = await memberClaude.applyPluginBaseline("rex");
|
||||
assert.deepEqual(
|
||||
results.map((r) => [r.item, r.ok]),
|
||||
[
|
||||
["acme/market", true],
|
||||
["--claudeai x", false],
|
||||
["good@market", true],
|
||||
["broken@market", false],
|
||||
["-y@market", false],
|
||||
]
|
||||
);
|
||||
assert.match(results[3].error, /boom/);
|
||||
assert.equal(results[1].error, "not a valid entry");
|
||||
const env = ["HOME=" + home, "DISABLE_AUTOUPDATER=1"];
|
||||
assert.deepEqual(sudo.calls, [
|
||||
["sudo", "-n", "-u", "ah-rex", ...env, claude, "plugin", "marketplace", "add", "acme/market"],
|
||||
[
|
||||
"sudo",
|
||||
"-n",
|
||||
"-u",
|
||||
"ah-rex",
|
||||
...env,
|
||||
claude,
|
||||
"plugin",
|
||||
"install",
|
||||
"--scope",
|
||||
"user",
|
||||
"good@market",
|
||||
],
|
||||
[
|
||||
"sudo",
|
||||
"-n",
|
||||
"-u",
|
||||
"ah-rex",
|
||||
...env,
|
||||
claude,
|
||||
"plugin",
|
||||
"install",
|
||||
"--scope",
|
||||
"user",
|
||||
"broken@market",
|
||||
],
|
||||
]);
|
||||
// Run from the member's home, never the dashboard's cwd.
|
||||
assert.ok(
|
||||
fs
|
||||
.readFileSync(CLAUDE_LOG, "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.every((l) => l.startsWith(`${home}|`))
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses when claude is not in the shared tools", async () => {
|
||||
fs.writeFileSync(BASELINE, JSON.stringify({ marketplaces: ["acme/market"] }));
|
||||
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "no-tools");
|
||||
try {
|
||||
await assert.rejects(memberClaude.applyPluginBaseline("rex"), { code: "ENOTSHARED" });
|
||||
} finally {
|
||||
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "tools");
|
||||
}
|
||||
assert.deepEqual(sudo.calls, []);
|
||||
});
|
||||
|
||||
it("starts by itself once a user is created", async () => {
|
||||
fs.writeFileSync(BASELINE, JSON.stringify({ plugins: ["good@market"] }));
|
||||
fs.mkdirSync(path.join(USERS, "zed"), { recursive: true });
|
||||
await auth.createUser("zed", "member-pass-1", "developer");
|
||||
for (let i = 0; i < 100 && !sudo.calls.length; i++) await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(sudo.calls.length, 1);
|
||||
assert.equal(sudo.calls[0][3], "ah-zed");
|
||||
assert.equal(sudo.calls[0].at(-1), "good@market");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* @file server-s3-quota.test.js
|
||||
* @description S3 plan, P4: plan quota per member home. `quotas({home})`
|
||||
* reads Claude, Codex, Cursor, Grok, Kimi Code and OpenCode Go from that
|
||||
* Linux account's home only — never the host's logins, never agy — and only
|
||||
* files the member owns (review fix F3: a FIFO or another account's file is
|
||||
* not read). `GET /api/pricing/quota` gives a developer or an analyst their
|
||||
* OWN account's quota, an admin the host's. A fake passwd (mia, bob = this
|
||||
* test's uid) and server.conf make them provisioned members.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-quota-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const MIA = path.join(USERS, "mia");
|
||||
const BOB = path.join(USERS, "bob");
|
||||
const HOST = path.join(TMP, "host");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
// The host's own logins, every one a different token than a member's.
|
||||
process.env.CLAUDE_HOME = path.join(HOST, ".claude");
|
||||
process.env.CODEX_HOME = path.join(HOST, ".codex");
|
||||
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor");
|
||||
process.env.GROK_HOME = path.join(HOST, ".grok");
|
||||
process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code");
|
||||
process.env.XDG_DATA_HOME = path.join(HOST, ".local", "share");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const { Database } = require("../db");
|
||||
|
||||
const LATER = Date.now() + 3600_000;
|
||||
const write = (file, body) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, typeof body === "string" ? body : JSON.stringify(body));
|
||||
};
|
||||
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
|
||||
/** Every login a tool keeps under `home`, each token named `<who>-<tool>`. */
|
||||
function seedHome(home, who, { codexPercent }) {
|
||||
write(path.join(home, ".claude", ".credentials.json"), {
|
||||
claudeAiOauth: { accessToken: `${who}-claude`, expiresAt: LATER, subscriptionType: "pro" },
|
||||
});
|
||||
write(path.join(home, ".claude.json"), { oauthAccount: { emailAddress: `${who}@x.y` } });
|
||||
write(path.join(home, ".codex", "auth.json"), {
|
||||
tokens: {
|
||||
id_token: jwt({ email: `${who}@codex` }),
|
||||
access_token: jwt({ exp: Math.floor(LATER / 1000) }),
|
||||
},
|
||||
});
|
||||
write(
|
||||
path.join(home, ".codex", "sessions", "2026", "10", "09", `rollout-${who}.jsonl`),
|
||||
`${JSON.stringify({
|
||||
payload: {
|
||||
rate_limits: {
|
||||
primary: { used_percent: codexPercent, window_minutes: 300, resets_at: 4102444800 },
|
||||
},
|
||||
},
|
||||
})}\n`
|
||||
);
|
||||
write(path.join(home, ".config", "cursor", "auth.json"), { accessToken: `${who}-cursor` });
|
||||
write(path.join(home, ".cursor", "cli-config.json"), {
|
||||
authInfo: { email: `${who}@cursor`, userId: "u1" },
|
||||
});
|
||||
write(path.join(home, ".grok", "auth.json"), {
|
||||
"https://auth.x.ai": { key: `${who}-grok`, expires_at: new Date(LATER).toISOString() },
|
||||
});
|
||||
write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), {
|
||||
access_token: `${who}-kimi`,
|
||||
expires_at: Math.floor(LATER / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
/** OpenCode 2 keeps its key in `opencode.db` (WAL), not `auth.json`. */
|
||||
function seedOpenCodeDb(home, key) {
|
||||
const dir = path.join(home, ".local", "share", "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(path.join(dir, "opencode.db"));
|
||||
db.pragma?.("journal_mode = WAL");
|
||||
db.exec(
|
||||
"CREATE TABLE credential (integration_id TEXT, value TEXT, active INT, time_created INT)"
|
||||
);
|
||||
db.prepare("INSERT INTO credential VALUES ('opencode-go', ?, 1, 1)").run(JSON.stringify({ key }));
|
||||
db.close();
|
||||
}
|
||||
|
||||
let asked = [];
|
||||
const realFetch = global.fetch;
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
if (String(url).startsWith("http://127.0.0.1")) return realFetch(url, opts);
|
||||
const h = opts.headers || {};
|
||||
asked.push(h.Authorization || h.Cookie || "");
|
||||
const u = String(url);
|
||||
const body = u.includes("anthropic.com")
|
||||
? u.endsWith("/profile")
|
||||
? { account: { email: "owner@x.y" } }
|
||||
: { five_hour: { utilization: 10, resets_at: null } }
|
||||
: {};
|
||||
return { ok: true, status: 200, json: async () => body };
|
||||
};
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const setUid = (uid) => {
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_PASSWD,
|
||||
`ah-mia:x:${uid}:${uid}::/:/bin/sh\nah-bob:x:${uid}:${uid}::/:/bin/sh\n`
|
||||
);
|
||||
runAs.__resetCache();
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
seedHome(HOST, "host", { codexPercent: 77 });
|
||||
write(path.join(HOST, ".local", "share", "opencode", "auth.json"), {
|
||||
"opencode-go": { type: "api", key: "host-opencode" },
|
||||
});
|
||||
seedHome(MIA, "mia", { codexPercent: 21 });
|
||||
seedOpenCodeDb(MIA, "mia-opencode");
|
||||
setUid(process.getuid());
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
fs.mkdirSync(BOB, { recursive: true });
|
||||
linux.__resetConfCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
await auth.createUser("mia", "member-pass-1", "developer");
|
||||
await auth.createUser("bob", "analyst-pass-1", "analyst");
|
||||
await auth.createUser("carl", "member-pass-2", "developer"); // never provisioned
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
global.fetch = realFetch;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("quotas({home}) reads a member's own logins", () => {
|
||||
it("reads every tool from that home, never the host's, never agy", async () => {
|
||||
__resetCache();
|
||||
asked = [];
|
||||
const { tools } = await quotas({ home: MIA });
|
||||
assert.deepEqual(tools.map((t) => t.id).sort(), [
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"grok",
|
||||
"kimi",
|
||||
"opencode",
|
||||
]);
|
||||
const codex = tools.find((t) => t.id === "codex");
|
||||
assert.equal(codex.account, "mia@codex");
|
||||
assert.equal(codex.windows[0].usedPercent, 21);
|
||||
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor");
|
||||
assert.ok(asked.length >= 6, asked.join(","));
|
||||
assert.ok(
|
||||
asked.every((a) => a.includes("mia-")),
|
||||
`only mia's tokens leave: ${asked.join(",")}`
|
||||
);
|
||||
assert.ok(
|
||||
asked.some((a) => a.includes("mia-opencode")),
|
||||
"OpenCode 2's db is read"
|
||||
);
|
||||
assert.ok(tools.every((t) => t.label === undefined || t.label === null));
|
||||
});
|
||||
|
||||
it("leaves the host's own reading unchanged", async () => {
|
||||
__resetCache();
|
||||
asked = [];
|
||||
const { tools } = await quotas();
|
||||
assert.equal(tools.find((t) => t.id === "codex").windows[0].usedPercent, 77);
|
||||
assert.ok(
|
||||
asked.every((a) => a.includes("host-")),
|
||||
asked.join(",")
|
||||
);
|
||||
});
|
||||
|
||||
it("reads no file the member does not own, and never blocks on a FIFO", async () => {
|
||||
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true });
|
||||
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]);
|
||||
__resetCache();
|
||||
assert.deepEqual((await quotas({ home: BOB })).tools, []);
|
||||
// mia's files now belong to another uid: none is read.
|
||||
setUid(5001);
|
||||
try {
|
||||
__resetCache();
|
||||
asked = [];
|
||||
assert.deepEqual((await quotas({ home: MIA })).tools, []);
|
||||
assert.deepEqual(asked, []);
|
||||
} finally {
|
||||
setUid(process.getuid());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/pricing/quota is the caller's own", () => {
|
||||
const as = async (username, password) => {
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const cookie = login.headers.get("set-cookie").split(";")[0];
|
||||
__resetCache();
|
||||
const res = await fetch(`${BASE}/api/pricing/quota`, { headers: { cookie } });
|
||||
assert.equal(res.status, 200);
|
||||
return (await res.json()).tools;
|
||||
};
|
||||
const codexPct = (tools) => tools.find((t) => t.id === "codex")?.windows[0].usedPercent;
|
||||
|
||||
it("a developer gets their own home's, with their own account shown", async () => {
|
||||
const tools = await as("mia", "member-pass-1");
|
||||
assert.equal(codexPct(tools), 21);
|
||||
assert.equal(tools.find((t) => t.id === "codex").account, "mia@codex");
|
||||
});
|
||||
|
||||
it("an analyst gets their own, never the host's", async () => {
|
||||
assert.deepEqual(await as("bob", "analyst-pass-1"), []);
|
||||
});
|
||||
|
||||
it("a member with no provisioned account gets nothing", async () => {
|
||||
assert.deepEqual(await as("carl", "member-pass-2"), []);
|
||||
});
|
||||
|
||||
it("an admin gets the host's", async () => {
|
||||
assert.equal(codexPct(await as("boss", "admin-pass-1")), 77);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @file server-s3-reads.test.js
|
||||
* @description S3 plan, P4: a member's tool data lives under their own home,
|
||||
* and the dashboard reads it there — a session in a member's folder finds its
|
||||
* transcript in `<home>/.claude/projects` (by cwd, and by id within that home
|
||||
* when its name is unknown), the transcript route serves it, the session sync sweeps every
|
||||
* member's projects dir, and Codex's rollouts are read from every member's
|
||||
* CODEX_HOME. A member's file is read only when the OPEN file is a regular
|
||||
* file that member owns (review fix F3): never a FIFO, never a link to
|
||||
* another account's file. A fake passwd (mia = this test's uid) and
|
||||
* server.conf make `mia` a provisioned member.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-reads-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const HOME = path.join(USERS, "mia");
|
||||
const PROJ = path.join(HOME, "proj");
|
||||
const SID = "11111111-2222-4333-8444-555555555555";
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "host-claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const dbModule = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const claudeHome = require("../lib/claude-home");
|
||||
|
||||
const TRANSCRIPT = path.join(
|
||||
HOME,
|
||||
".claude",
|
||||
"projects",
|
||||
claudeHome.encodeCwd(PROJ),
|
||||
`${SID}.jsonl`
|
||||
);
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.dirname(TRANSCRIPT), { recursive: true });
|
||||
const ts = new Date().toISOString();
|
||||
fs.writeFileSync(
|
||||
TRANSCRIPT,
|
||||
[
|
||||
{
|
||||
type: "user",
|
||||
sessionId: SID,
|
||||
cwd: PROJ,
|
||||
timestamp: ts,
|
||||
uuid: "u1",
|
||||
message: { role: "user", content: "say hi" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId: SID,
|
||||
cwd: PROJ,
|
||||
timestamp: ts,
|
||||
uuid: "a1",
|
||||
message: {
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-5",
|
||||
content: [{ type: "text", text: "pong" }],
|
||||
usage: { input_tokens: 5, output_tokens: 1 },
|
||||
},
|
||||
},
|
||||
]
|
||||
.map((l) => JSON.stringify(l))
|
||||
.join("\n") + "\n",
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
const uid = process.getuid();
|
||||
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
runAs.__resetCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
await auth.createUser("mia", "member-pass-1", "developer");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a member's Claude transcripts are found under their own home", () => {
|
||||
it("resolves by cwd and by id", () => {
|
||||
assert.equal(claudeHome.projectsDirFor(PROJ), path.join(HOME, ".claude", "projects"));
|
||||
assert.equal(claudeHome.projectsDirFor(TMP), path.join(TMP, "host-claude", "projects"));
|
||||
assert.equal(claudeHome.getTranscriptPath(SID, PROJ), TRANSCRIPT);
|
||||
assert.equal(claudeHome.findTranscriptPath(SID, PROJ), TRANSCRIPT);
|
||||
assert.ok(claudeHome.allProjectsDirs().includes(path.join(HOME, ".claude", "projects")));
|
||||
});
|
||||
|
||||
it("is imported by the session sync's sweep of every projects dir", async () => {
|
||||
const { syncDefaultProjects } = require("../../scripts/import-history");
|
||||
const { changed } = await syncDefaultProjects(dbModule, {
|
||||
projectsDir: path.join(HOME, ".claude", "projects"),
|
||||
});
|
||||
assert.ok(changed.some((c) => c.sessionId === SID));
|
||||
const row = dbModule.db.prepare("SELECT cwd FROM sessions WHERE id = ?").get(SID);
|
||||
assert.equal(row.cwd, PROJ);
|
||||
});
|
||||
|
||||
it("is served by the transcript route", async () => {
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "boss", password: "admin-pass-1" }),
|
||||
});
|
||||
const cookie = login.headers.get("set-cookie").split(";")[0];
|
||||
const res = await fetch(`${BASE}/api/sessions/${SID}/transcript`, { headers: { cookie } });
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(body.total >= 2, JSON.stringify(body).slice(0, 200));
|
||||
assert.ok(JSON.stringify(body.messages).includes("pong"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's Codex rollouts are read from their own CODEX_HOME", () => {
|
||||
it("lists them beside this account's", () => {
|
||||
const day = path.join(HOME, ".codex", "sessions", "2026", "10", "08");
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const f = path.join(
|
||||
day,
|
||||
"rollout-2026-10-08T00-00-00-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl"
|
||||
);
|
||||
fs.writeFileSync(f, "{}\n");
|
||||
const { allRolloutFiles } = require("../lib/codex-ingest");
|
||||
const members = runAs.memberHomes().map((h) => path.join(h, ".codex"));
|
||||
assert.deepEqual(members, [path.join(HOME, ".codex")]);
|
||||
assert.ok(allRolloutFiles(members[0]).some((r) => r.path === f));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's file is read only if it is theirs", () => {
|
||||
it("refuses a FIFO without blocking, and a file another account owns", () => {
|
||||
const fifo = path.join(HOME, "pipe.jsonl");
|
||||
require("node:child_process").execFileSync("mkfifo", [fifo]);
|
||||
assert.throws(() => runAs.readFileSafe(fifo, "utf8"), { code: "EFOREIGNFILE" });
|
||||
assert.equal(runAs.readFileSafe(TRANSCRIPT, "utf8").includes("pong"), true);
|
||||
// A link out of the home is read through, and must still be mia's file.
|
||||
const outside = path.join(TMP, "host-secret.txt");
|
||||
fs.writeFileSync(outside, "host only");
|
||||
const link = path.join(HOME, "link.jsonl");
|
||||
fs.symlinkSync(outside, link);
|
||||
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-mia:x:5001:5001::/:/bin/sh\n");
|
||||
runAs.__resetCache();
|
||||
try {
|
||||
assert.throws(() => runAs.readFileSafe(link, "utf8"), { code: "EFOREIGNFILE" });
|
||||
assert.throws(() => runAs.readFileSafe(TRANSCRIPT, "utf8"), { code: "EFOREIGNFILE" });
|
||||
assert.equal(runAs.readFileSafe(outside, "utf8"), "host only", "a host path reads as before");
|
||||
} finally {
|
||||
const uid = process.getuid();
|
||||
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
||||
runAs.__resetCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* @file server-s3-review2.test.js
|
||||
* @description S3 plan, second review: a member cannot point the dashboard's
|
||||
* own rights at another account's files. A workspace folder swapped for a
|
||||
* link is refused for file reads and saves (EFOREIGN); a member's git dir
|
||||
* that names a path outside their home is never read or written by the host,
|
||||
* and a FIFO there cannot hang it; a developer browses and lists branches
|
||||
* only in their own folder; a transcript scan by id looks only in the
|
||||
* session owner's home; a Codex journal flips only its own account's lanes,
|
||||
* capped per home; nobody reads the dot folders at the top of a member's home
|
||||
* (logins, pane tokens), and the home itself is no workspace; a disabled
|
||||
* member's session neither throws in a read nor stops the liveness reaper.
|
||||
* A fake passwd (every member = this test's uid) and server.conf make the
|
||||
* members provisioned; fake sudo runs their commands as this account.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review2-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const MIA = path.join(USERS, "mia");
|
||||
const BOB = path.join(USERS, "bob");
|
||||
const DAN = path.join(USERS, "dan");
|
||||
const HOSTDIR = path.join(TMP, "hostdir");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "host-claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
const claudeHome = require("../lib/claude-home");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const cookies = {};
|
||||
const lanes = {};
|
||||
|
||||
const call = (who, method, p, body) =>
|
||||
fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", cookie: cookies[who] },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
const gitInit = (dir) => {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
execFileSync("git", ["init", "-q", dir]);
|
||||
execFileSync("git", [
|
||||
"-C",
|
||||
dir,
|
||||
"-c",
|
||||
"user.email=a@b",
|
||||
"-c",
|
||||
"user.name=a",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"x",
|
||||
]);
|
||||
};
|
||||
|
||||
const addLane = (cwd, owner, tool = "claude") =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, ?, ?, 'idle')")
|
||||
.run(cwd, tool, db.prepare("SELECT id FROM users WHERE username = ?").get(owner).id)
|
||||
.lastInsertRowid
|
||||
);
|
||||
|
||||
before(async () => {
|
||||
gitInit(path.join(MIA, "proj"));
|
||||
gitInit(path.join(BOB, "proj"));
|
||||
fs.writeFileSync(path.join(BOB, "proj", "bob.txt"), "bob's file\n");
|
||||
fs.mkdirSync(HOSTDIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(HOSTDIR, "host.txt"), "host's file\n");
|
||||
// mia swaps folders in her own home for links: to bob's, and to the host's.
|
||||
fs.symlinkSync(path.join(BOB, "proj"), path.join(MIA, "tobob"));
|
||||
fs.symlinkSync(HOSTDIR, path.join(MIA, "tohost"));
|
||||
// bob's logins and a pane's handoff file, at the top of his home.
|
||||
fs.mkdirSync(path.join(BOB, ".claude"), { recursive: true });
|
||||
fs.writeFileSync(path.join(BOB, ".claude", ".credentials.json"), '{"token":"secret"}\n');
|
||||
fs.mkdirSync(path.join(BOB, ".agenthub", "handoff"), { recursive: true });
|
||||
fs.writeFileSync(path.join(BOB, ".agenthub", "handoff", "proxy-x--agenthub-lane-1.json"), "{}");
|
||||
fs.writeFileSync(path.join(BOB, "notes.txt"), "plain\n");
|
||||
fs.mkdirSync(path.join(DAN, "proj"), { recursive: true });
|
||||
|
||||
const uid = process.getuid();
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_PASSWD,
|
||||
["mia", "bob", "ana", "dan"].map((n) => `ah-${n}:x:${uid}:${uid}::/:/bin/sh`).join("\n") + "\n"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
runAs.__resetCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
runAs.__setExecImpl(fakeSudo().exec);
|
||||
tmux.__setExecImpl(() => "");
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
for (const [n, role] of [
|
||||
["boss", "admin"],
|
||||
["mia", "developer"],
|
||||
["bob", "developer"],
|
||||
["ana", "analyst"],
|
||||
["dan", "developer"],
|
||||
]) {
|
||||
await auth.createUser(n, `${n}-pass-123`, role);
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
|
||||
});
|
||||
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
lanes.tobob = addLane(path.join(MIA, "tobob"), "mia");
|
||||
lanes.tohost = addLane(path.join(MIA, "tohost"), "mia");
|
||||
lanes.bobHome = addLane(BOB, "bob");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__reset();
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a workspace folder swapped for a link", () => {
|
||||
it("is refused for a file read, into another member's folder or the host's", async () => {
|
||||
const a = await call("mia", "GET", `/api/lanes/${lanes.tobob}/git/file?path=bob.txt`);
|
||||
assert.equal(a.status, 403, JSON.stringify(a.body));
|
||||
assert.equal(a.body.error.code, "EFOREIGN");
|
||||
const b = await call("mia", "GET", `/api/lanes/${lanes.tohost}/git/file?path=host.txt`);
|
||||
assert.equal(b.status, 403, JSON.stringify(b.body));
|
||||
});
|
||||
|
||||
it("is refused for a save", async () => {
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const sha = require("../lib/git-diff").contentSha(
|
||||
fs.readFileSync(path.join(HOSTDIR, "host.txt"))
|
||||
);
|
||||
await assert.rejects(writeLaneFile(path.join(MIA, "tohost"), "host.txt", "pwned\n", sha), {
|
||||
code: "EFOREIGN",
|
||||
});
|
||||
assert.equal(fs.readFileSync(path.join(HOSTDIR, "host.txt"), "utf8"), "host's file\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's git dir that names a path outside their home", () => {
|
||||
it("is neither read nor written by the host", () => {
|
||||
const { excludeFromGit } = require("../lib/git-exclude");
|
||||
// mia's repo: `.git` is a file pointing at a git dir she made in /tmp,
|
||||
// whose info/exclude is a link to a host-owned file.
|
||||
const evil = path.join(TMP, "evil");
|
||||
gitInit(evil);
|
||||
const target = path.join(HOSTDIR, "settings.json");
|
||||
fs.writeFileSync(target, '{"a":1}\n');
|
||||
fs.rmSync(path.join(evil, ".git", "info", "exclude"), { force: true });
|
||||
fs.symlinkSync(target, path.join(evil, ".git", "info", "exclude"));
|
||||
const repo = path.join(MIA, "redirect");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, ".git"), `gitdir: ${path.join(evil, ".git")}\n`);
|
||||
excludeFromGit(repo, [".claude/worktrees/"]);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), '{"a":1}\n');
|
||||
});
|
||||
|
||||
it("cannot hang it on a FIFO", () => {
|
||||
const { excludeFromGit } = require("../lib/git-exclude");
|
||||
const repo = path.join(MIA, "fifo");
|
||||
gitInit(repo);
|
||||
const ex = path.join(repo, ".git", "info", "exclude");
|
||||
fs.rmSync(ex, { force: true });
|
||||
execFileSync("mkfifo", [ex]);
|
||||
const t0 = Date.now();
|
||||
excludeFromGit(repo, [".mcp.json"]);
|
||||
assert.ok(Date.now() - t0 < 5000);
|
||||
});
|
||||
|
||||
it("still gets its exclude line in its own repo", () => {
|
||||
const { excludeFromGit } = require("../lib/git-exclude");
|
||||
const repo = path.join(MIA, "proj");
|
||||
excludeFromGit(repo, [".mcp.json"]);
|
||||
const text = fs.readFileSync(path.join(repo, ".git", "info", "exclude"), "utf8");
|
||||
assert.match(text, /^\/\.mcp\.json$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a developer browses and lists branches only in their own folder", () => {
|
||||
it("browse", async () => {
|
||||
const other = await call("mia", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`);
|
||||
assert.equal(other.status, 403);
|
||||
assert.equal(other.body.error.code, "EFOLDER");
|
||||
const viaLink = await call(
|
||||
"mia",
|
||||
"GET",
|
||||
`/api/lanes/browse?path=${encodeURIComponent(path.join(MIA, "tohost"))}`
|
||||
);
|
||||
assert.equal(viaLink.status, 403);
|
||||
const own = await call("mia", "GET", "/api/lanes/browse");
|
||||
assert.equal(own.status, 200, JSON.stringify(own.body));
|
||||
assert.equal(own.body.path, MIA);
|
||||
assert.equal(own.body.parent, null);
|
||||
assert.ok(own.body.entries.some((e) => e.name === "proj"));
|
||||
const admin = await call("boss", "GET", `/api/lanes/browse?path=${encodeURIComponent(BOB)}`);
|
||||
assert.equal(admin.status, 200);
|
||||
});
|
||||
|
||||
it("branches", async () => {
|
||||
const other = await call(
|
||||
"mia",
|
||||
"GET",
|
||||
`/api/lanes/branches?repo=${encodeURIComponent(path.join(BOB, "proj"))}`
|
||||
);
|
||||
assert.equal(other.status, 403);
|
||||
const own = await call(
|
||||
"mia",
|
||||
"GET",
|
||||
`/api/lanes/branches?repo=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
// Not refused (the real sudo it would run is not this test's).
|
||||
assert.notEqual(own.status, 403, JSON.stringify(own.body));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a transcript scan by id looks only in the session owner's home", () => {
|
||||
it("never serves a file another member planted under that session id", () => {
|
||||
const sid = "aaaaaaaa-1111-4222-8333-444444444444";
|
||||
const cwd = path.join(MIA, "proj");
|
||||
const planted = path.join(BOB, ".claude", "projects", "x");
|
||||
fs.mkdirSync(path.join(planted, sid, "subagents"), { recursive: true });
|
||||
fs.writeFileSync(path.join(planted, `${sid}.jsonl`), "{}\n");
|
||||
fs.writeFileSync(path.join(planted, sid, "subagents", "agent-1.jsonl"), "{}\n");
|
||||
assert.equal(claudeHome.getTranscriptPath(sid, cwd), null);
|
||||
assert.equal(claudeHome.findTranscriptPath(sid, cwd), null);
|
||||
assert.equal(claudeHome.findTranscriptPath(sid, null), null, "no folder: the host's dir only");
|
||||
assert.equal(claudeHome.getSubagentTranscriptPath(sid, cwd, "1"), null);
|
||||
assert.deepEqual(claudeHome.sessionProjectsDirs(cwd), [path.join(MIA, ".claude", "projects")]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a Codex journal speaks only for its own account", () => {
|
||||
const journal = (home, name, cwd, mtime) => {
|
||||
const day = path.join(home, ".codex", "sessions", "2026", "10", "09");
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const f = path.join(day, `rollout-2026-10-09T00-00-00-${name}.jsonl`);
|
||||
fs.writeFileSync(f, JSON.stringify({ type: "session_meta", payload: { cwd } }) + "\n");
|
||||
fs.utimesSync(f, mtime / 1000, mtime / 1000);
|
||||
return f;
|
||||
};
|
||||
|
||||
it("never flips another member's lane, and one home cannot crowd the others out", () => {
|
||||
const ingest = require("../lib/codex-ingest");
|
||||
const bobLane = addLane(path.join(BOB, "proj"), "bob", "codex");
|
||||
const now = Date.now();
|
||||
// mia writes bob's folder into her own fresh journal…
|
||||
journal(MIA, "forged", path.join(BOB, "proj"), now);
|
||||
ingest.__resetCache();
|
||||
ingest.ingestCodexOnce();
|
||||
assert.equal(db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status, "idle");
|
||||
// …and keeps 40 fresher journals of her own; bob's real one must still count.
|
||||
for (let i = 0; i < 41; i++) journal(MIA, `own${i}`, path.join(MIA, "proj"), now);
|
||||
journal(BOB, "real", path.join(BOB, "proj"), now - 5000);
|
||||
ingest.__resetCache();
|
||||
ingest.ingestCodexOnce();
|
||||
assert.equal(
|
||||
db.prepare("SELECT status FROM lanes WHERE id = ?").get(bobLane).status,
|
||||
"running"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the top of a member's home", () => {
|
||||
it("is not readable through a workspace rooted at it, by anyone", async () => {
|
||||
for (const p of [
|
||||
".claude/.credentials.json",
|
||||
".agenthub/handoff/proxy-x--agenthub-lane-1.json",
|
||||
]) {
|
||||
const r = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=${p}`);
|
||||
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
|
||||
assert.equal(r.body.error.code, "ESECRET");
|
||||
}
|
||||
const ok = await call("ana", "GET", `/api/lanes/${lanes.bobHome}/git/file?path=notes.txt`);
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
});
|
||||
|
||||
it("is no workspace (S3)", async () => {
|
||||
const r = await call("mia", "POST", "/api/lanes", { cwd: MIA });
|
||||
assert.equal(r.status, 403, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "EFOLDER");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled member's session", () => {
|
||||
it("is still read from their home, and does not stop the liveness reaper", () => {
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
|
||||
const danCwd = path.join(DAN, "proj");
|
||||
assert.equal(claudeHome.projectsDirFor(danCwd), path.join(DAN, ".claude", "projects"));
|
||||
assert.doesNotThrow(() => claudeHome.getTranscriptPath("dan-sess", danCwd));
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, tool, updated_at) VALUES (?, ?, 'active', ?, 'claude', '2020-01-01T00:00:00.000Z')"
|
||||
);
|
||||
ins.run("dan-sess", "dan", danCwd);
|
||||
ins.run("host-sess", "host", HOSTDIR);
|
||||
const { livenessReap } = require("../routes/hooks");
|
||||
assert.doesNotThrow(() =>
|
||||
livenessReap({ ignoreIdleGate: true, probe: { available: true, cwds: new Set() } })
|
||||
);
|
||||
const st = db.prepare("SELECT status FROM sessions WHERE id = 'host-sess'").get().status;
|
||||
assert.notEqual(st, "active");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* @file server-s3-review3.test.js
|
||||
* @description S3 plan, third review: a workspace in a member's home is owned
|
||||
* by that member only (create, assign and every pane start refuse another
|
||||
* owner, whose pane token the folder's member could read); a long first
|
||||
* prompt in a member's pane travels in its launch file, never through the
|
||||
* host's private run-prompts dir; the workspace memory view, the transcript
|
||||
* snapshot and the startup AGENTS.md cleanup never follow a member's planted
|
||||
* link with the dashboard's rights; a non-admin's `?cwd=` on the config
|
||||
* readers names only their own workspaces, and no credential file is served
|
||||
* by name; a member's quiet Codex session keeps the name from their own
|
||||
* index; `agenthub users provision --all` goes on past a user it cannot
|
||||
* provision. A fake passwd (every member = this test's uid, or another uid
|
||||
* when a test needs the file to be someone else's) and server.conf make the
|
||||
* members provisioned; fake sudo runs their commands as this account.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review3-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const MIA = path.join(USERS, "mia");
|
||||
const BOB = path.join(USERS, "bob");
|
||||
const ANA = path.join(USERS, "ana");
|
||||
const HOSTDIR = path.join(TMP, "hostdir");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "host-claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
const pty = require("../lib/pty-run");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const cookies = {};
|
||||
let memberCalls = []; // tmux calls run as a member: [login, ...args, stdin]
|
||||
|
||||
const call = (who, method, p, body) =>
|
||||
fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", cookie: cookies[who] },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
|
||||
const addLane = (cwd, owner, tool = "claude") =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, ?, ?, 'idle')")
|
||||
.run(cwd, tool, owner ? idOf(owner) : null).lastInsertRowid
|
||||
);
|
||||
|
||||
const UID = process.getuid();
|
||||
const passwd = (miaUid = UID) => {
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_PASSWD,
|
||||
[
|
||||
`ah-mia:x:${miaUid}:${miaUid}::/:/bin/sh`,
|
||||
...["bob", "ana"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
runAs.__resetCache();
|
||||
};
|
||||
/** Run `fn` while mia is another uid: every file this test made is not hers. */
|
||||
const asForeign = async (fn) => {
|
||||
passwd(UID + 1);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
passwd();
|
||||
}
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(path.join(MIA, ".agenthub", "handoff"), { recursive: true });
|
||||
for (const d of [path.join(MIA, "proj"), path.join(BOB, "proj"), path.join(ANA, "proj")])
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
fs.mkdirSync(HOSTDIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(HOSTDIR, "secret.txt"), "HOST-SECRET\n");
|
||||
fs.mkdirSync(path.join(BOB, ".claude"), { recursive: true });
|
||||
fs.writeFileSync(path.join(BOB, ".claude", ".credentials.json"), '{"token":"BOB-SECRET"}\n');
|
||||
passwd();
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
runAs.__setExecImpl(fakeSudo().exec);
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "-V") return "tmux 3.4";
|
||||
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
|
||||
return "";
|
||||
});
|
||||
tmux.__setAccountImpl(
|
||||
(name) => runAs.accountForSession(name),
|
||||
(acct, args, input) => {
|
||||
memberCalls.push([acct.login, ...args, input]);
|
||||
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
|
||||
return "";
|
||||
}
|
||||
);
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
// A user made before S3 whose name fits no Linux login — first in id order.
|
||||
db.prepare("INSERT INTO users (username, password_hash) VALUES ('Old.Name', 'x')").run();
|
||||
for (const [n, role] of [
|
||||
["boss", "admin"],
|
||||
["mia", "developer"],
|
||||
["bob", "developer"],
|
||||
["ana", "analyst"],
|
||||
]) {
|
||||
await auth.createUser(n, `${n}-pass-123`, role);
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
|
||||
});
|
||||
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__reset();
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a workspace in a member's home is that member's only", () => {
|
||||
it("refuses an admin creating one there, and lets the member", async () => {
|
||||
const byBoss = await call("boss", "POST", "/api/lanes", { cwd: path.join(MIA, "proj") });
|
||||
assert.equal(byBoss.status, 409, JSON.stringify(byBoss.body));
|
||||
assert.equal(byBoss.body.error.code, "EOWNERFOLDER");
|
||||
const ensured = await call("boss", "POST", "/api/lanes/ensure", {
|
||||
cwd: path.join(MIA, "proj"),
|
||||
});
|
||||
assert.equal(ensured.status, 409, JSON.stringify(ensured.body));
|
||||
const byMia = await call("mia", "POST", "/api/lanes", { cwd: path.join(MIA, "proj") });
|
||||
assert.equal(byMia.status, 201, JSON.stringify(byMia.body));
|
||||
// Nor can it be given to someone else afterwards.
|
||||
assert.throws(() => auth.assignWorkspace(byMia.body.lane.id, "bob"), {
|
||||
code: "EOWNERFOLDER",
|
||||
});
|
||||
auth.assignWorkspace(byMia.body.lane.id, "mia");
|
||||
});
|
||||
|
||||
it("starts no pane carrying another owner's token there", () => {
|
||||
const lane = addLane(path.join(MIA, "proj", "sub"), "boss");
|
||||
memberCalls = [];
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: lane, cwd: path.join(MIA, "proj"), tool: "claude" }),
|
||||
{ code: "EOWNERFOLDER" }
|
||||
);
|
||||
assert.throws(() => pty.spawnShell({ laneId: lane, cwd: path.join(MIA, "proj") }), {
|
||||
code: "EOWNERFOLDER",
|
||||
});
|
||||
assert.ok(!memberCalls.some((a) => a.includes("new-session")), "no pane started");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a long first prompt in a member's pane", () => {
|
||||
it("travels in the launch file, never the host's run-prompts dir", () => {
|
||||
const lane = addLane(path.join(MIA, "long"), "mia");
|
||||
fs.mkdirSync(path.join(MIA, "long"), { recursive: true });
|
||||
memberCalls = [];
|
||||
const prompt = `spec: ${"x".repeat(6000)}`;
|
||||
pty.spawnRun({
|
||||
laneId: lane,
|
||||
cwd: path.join(MIA, "long"),
|
||||
tool: "claude",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
const create = memberCalls.find((a) => a.includes("new-session"));
|
||||
assert.ok(create, "started on mia's server");
|
||||
const launch = create[create.indexOf("/opt/agenthub/libexec/pane-launch") + 1];
|
||||
const body = fs.readFileSync(launch, "utf8").split("\0");
|
||||
assert.equal(body.at(-2), prompt, "the prompt is the CLI's last argument");
|
||||
assert.ok(
|
||||
!body.some((x) => x.includes("run-prompts") || x.includes('cat "$1"')),
|
||||
"no sh reading a host file"
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(TMP, "data", "run-prompts")), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's planted link is never followed with the dashboard's rights", () => {
|
||||
it("the memory view reads only the member's own CLAUDE.md", async () => {
|
||||
const dir = path.join(MIA, "mem");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const lane = addLane(dir, "mia");
|
||||
fs.writeFileSync(path.join(dir, "AGENTS.md"), "mia's own notes\n");
|
||||
fs.symlinkSync(path.join(HOSTDIR, "secret.txt"), path.join(dir, "CLAUDE.md"));
|
||||
const mine = await call("mia", "GET", `/api/lanes/${lane}/memory`);
|
||||
assert.equal(mine.status, 200);
|
||||
assert.ok(JSON.stringify(mine.body).includes("mia's own notes"));
|
||||
await asForeign(async () => {
|
||||
const r = await call("mia", "GET", `/api/lanes/${lane}/memory`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(!JSON.stringify(r.body).includes("HOST-SECRET"), JSON.stringify(r.body));
|
||||
});
|
||||
});
|
||||
|
||||
it("the transcript snapshot copies only the member's own files", async () => {
|
||||
const { snapshotTranscript } = require("../../scripts/import-history");
|
||||
const { getTranscriptSnapshotDir } = require("../lib/claude-home");
|
||||
const proj = path.join(MIA, ".claude", "projects", "-p");
|
||||
const sid = "aaaaaaaa-1111-4222-8333-444444444444";
|
||||
fs.mkdirSync(path.join(proj, sid, "subagents"), { recursive: true });
|
||||
fs.writeFileSync(path.join(proj, `${sid}.jsonl`), '{"own":1}\n');
|
||||
fs.writeFileSync(path.join(proj, sid, "subagents", "agent-own.jsonl"), '{"own":2}\n');
|
||||
snapshotTranscript(path.join(proj, `${sid}.jsonl`), sid);
|
||||
const snap = getTranscriptSnapshotDir();
|
||||
assert.equal(fs.readFileSync(path.join(snap, `${sid}.jsonl`), "utf8"), '{"own":1}\n');
|
||||
assert.ok(fs.existsSync(path.join(snap, sid, "subagents", "agent-own.jsonl")));
|
||||
|
||||
const sid2 = "bbbbbbbb-1111-4222-8333-444444444444";
|
||||
fs.mkdirSync(path.join(proj, sid2, "subagents"), { recursive: true });
|
||||
fs.writeFileSync(path.join(proj, `${sid2}.jsonl`), '{"own":3}\n');
|
||||
fs.symlinkSync(
|
||||
path.join(HOSTDIR, "secret.txt"),
|
||||
path.join(proj, sid2, "subagents", "agent-x.jsonl")
|
||||
);
|
||||
await asForeign(() => snapshotTranscript(path.join(proj, `${sid2}.jsonl`), sid2));
|
||||
assert.equal(fs.existsSync(path.join(snap, sid2, "subagents", "agent-x.jsonl")), false);
|
||||
});
|
||||
|
||||
it("the startup AGENTS.md cleanup never touches a member's folder", () => {
|
||||
const { removeStageDoc } = require("../lib/lane-agents");
|
||||
const doc = "keep\n\n<!-- agenthub:stage:start -->\nx\n<!-- agenthub:stage:end -->\n";
|
||||
const target = path.join(HOSTDIR, "victim.txt");
|
||||
fs.writeFileSync(target, doc);
|
||||
const dir = path.join(MIA, "cx");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.symlinkSync(target, path.join(dir, "AGENTS.md"));
|
||||
assert.equal(removeStageDoc(dir), false);
|
||||
assert.equal(fs.readFileSync(target, "utf8"), doc);
|
||||
// A host folder is still cleaned as before.
|
||||
const host = path.join(HOSTDIR, "lane");
|
||||
fs.mkdirSync(host);
|
||||
fs.writeFileSync(path.join(host, "AGENTS.md"), doc);
|
||||
assert.equal(removeStageDoc(host), true);
|
||||
assert.equal(fs.readFileSync(path.join(host, "AGENTS.md"), "utf8"), "keep\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the config readers", () => {
|
||||
it("take a non-admin's ?cwd= only inside their own workspaces", async () => {
|
||||
addLane(path.join(ANA, "proj"), "ana");
|
||||
const q = (cwd, p) =>
|
||||
`/api/cc-config/file?cwd=${encodeURIComponent(cwd)}&path=${encodeURIComponent(p)}`;
|
||||
const bobs = await call("ana", "GET", q(BOB, path.join(BOB, ".claude", ".credentials.json")));
|
||||
assert.equal(bobs.status, 403);
|
||||
assert.equal(bobs.body.error.code, "EFOLDER");
|
||||
// A link in a folder of the caller's own: `/tmp/a/.claude -> /`.
|
||||
const out = path.join(TMP, "a");
|
||||
fs.mkdirSync(out);
|
||||
fs.symlinkSync("/", path.join(out, ".claude"));
|
||||
const root = await call("ana", "GET", q(out, path.join(out, ".claude", "etc", "hostname")));
|
||||
assert.equal(root.status, 403);
|
||||
const codex = await call(
|
||||
"ana",
|
||||
"GET",
|
||||
`/api/codex-config/memory?cwd=${encodeURIComponent(BOB)}`
|
||||
);
|
||||
assert.equal(codex.status, 403);
|
||||
const own = await call(
|
||||
"ana",
|
||||
"GET",
|
||||
`/api/cc-config/hooks?cwd=${encodeURIComponent(path.join(ANA, "proj"))}`
|
||||
);
|
||||
assert.equal(own.status, 200);
|
||||
});
|
||||
|
||||
it("serve no credential file by name, to anyone", async () => {
|
||||
const home = process.env.CLAUDE_HOME;
|
||||
fs.mkdirSync(path.join(home, "agenthub-proxies"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, ".credentials.json"), '{"t":"HOST-TOKEN"}');
|
||||
fs.writeFileSync(path.join(home, "agenthub-proxies", "gw.json"), '{"apiKey":"sk-x"}');
|
||||
fs.writeFileSync(path.join(home, "settings.json"), '{"ok":true}');
|
||||
const get = (p) => call("boss", "GET", `/api/cc-config/file?path=${encodeURIComponent(p)}`);
|
||||
for (const f of [".credentials.json", path.join("agenthub-proxies", "gw.json")]) {
|
||||
const r = await get(path.join(home, f));
|
||||
assert.equal(r.status, 400, f);
|
||||
assert.ok(!JSON.stringify(r.body).includes("HOST-TOKEN"));
|
||||
}
|
||||
assert.equal((await get(path.join(home, "settings.json"))).status, 200);
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(path.join(process.env.CODEX_HOME, "auth.json"), '{"t":"CODEX-TOKEN"}');
|
||||
const cx = await call(
|
||||
"boss",
|
||||
"GET",
|
||||
`/api/codex-config/file?path=${encodeURIComponent(path.join(process.env.CODEX_HOME, "auth.json"))}`
|
||||
);
|
||||
assert.equal(cx.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's quiet Codex session", () => {
|
||||
it("keeps the name from the member's own index", async () => {
|
||||
const { ingestCodexUsage, settleActive } = require("../lib/codex-usage");
|
||||
const tid = "cccccccc-1111-4222-8333-444444444444";
|
||||
const codexHome = path.join(MIA, ".codex");
|
||||
const day = path.join(codexHome, "sessions", "2026", "10", "09");
|
||||
fs.mkdirSync(day, { recursive: true });
|
||||
const file = path.join(day, `rollout-2026-10-09T00-00-00-${tid}.jsonl`);
|
||||
const ts = new Date().toISOString();
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
{
|
||||
timestamp: ts,
|
||||
type: "session_meta",
|
||||
payload: { id: tid, timestamp: ts, cwd: path.join(MIA, "proj") },
|
||||
},
|
||||
{ timestamp: ts, type: "event_msg", payload: { type: "user_message", message: "hello" } },
|
||||
]
|
||||
.map((l) => JSON.stringify(l))
|
||||
.join("\n") + "\n"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, "session_index.jsonl"),
|
||||
`${JSON.stringify({ id: tid, thread_name: "Mia's named thread" })}\n`
|
||||
);
|
||||
const now = Date.now();
|
||||
await ingestCodexUsage({ home: codexHome, now });
|
||||
const row = () => db.prepare("SELECT name, status FROM sessions WHERE id = ?").get(tid);
|
||||
assert.deepEqual(row(), { name: "Mia's named thread", status: "active" });
|
||||
settleActive(() => {}, now + 60 * 60_000);
|
||||
assert.deepEqual(row(), { name: "Mia's named thread", status: "completed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub users provision --all", () => {
|
||||
it("goes on past a user it cannot provision", () => {
|
||||
const bin = path.join(TMP, "fakebin");
|
||||
fs.mkdirSync(bin);
|
||||
// Stands in for `sudo -n /opt/agenthub/sbin/agenthub-provision create <name>`.
|
||||
fs.writeFileSync(path.join(bin, "sudo"), '#!/bin/sh\necho "made $4"\n', { mode: 0o755 });
|
||||
const r = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(__dirname, "..", "..", "bin", "agenthub.js"), "users", "provision", "--all"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
// AGENTHUB_FILE_KEYS: what this process loaded from a settings file,
|
||||
// which the CLI would take back out of ITS env (AGENTHUB_MODE among them).
|
||||
env: {
|
||||
...process.env,
|
||||
AGENTHUB_FILE_KEYS: "",
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(r.status, 1, r.stderr);
|
||||
assert.match(r.stderr, /Old\.Name/);
|
||||
for (const n of ["boss", "mia", "bob", "ana"]) assert.match(r.stdout, new RegExp(`made ${n}`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* @file server-s3-review5.test.js
|
||||
* @description S3 plan, fifth review: a member's Antigravity conversation is
|
||||
* read from a private copy (a FIFO beside it blocks nothing); a developer's
|
||||
* extra workspace folder is refused outside their home before it is probed;
|
||||
* the config reader never serves the data dir (pane tokens, push keys, the
|
||||
* database); a stopped or flooding member tmux server and a hung sudo never
|
||||
* block the dashboard nor blind it to other members' panes; a one-line 512 MB
|
||||
* file, a huge member file and a sparse transcript neither crash the server
|
||||
* nor fill the host's disk; an async route that throws answers 500; a
|
||||
* non-admin owns no host-folder workspace and a pane's cwd is its
|
||||
* workspace's account's; a pane token works only from its pane's uid; an
|
||||
* analyst's GET path stays in their own folder; a drop goes to the pane's
|
||||
* account. A fake passwd (every member = this test's uid) and server.conf
|
||||
* make the members provisioned; fake sudo runs their commands as this
|
||||
* account — and a fake `sudo` on PATH that never returns stands in for a
|
||||
* stalled member, in a child process.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-review5-")));
|
||||
const USERS = path.join(TMP, "users");
|
||||
const MIA = path.join(USERS, "mia");
|
||||
const BOB = path.join(USERS, "bob");
|
||||
// The real layout: the data dir (and its database) inside CLAUDE_HOME.
|
||||
const DATA = path.join(TMP, "host-claude", "agenthub");
|
||||
process.env.DASHBOARD_DATA_DIR = DATA;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(DATA, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "host-claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, "host-codex");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
process.env.AGENTHUB_USERS_ROOT = USERS;
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const cookies = {};
|
||||
|
||||
const call = (who, method, p, body) =>
|
||||
fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", cookie: cookies[who] },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
|
||||
const addLane = (cwd, owner) =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')")
|
||||
.run(cwd, idOf(owner)).lastInsertRowid
|
||||
);
|
||||
|
||||
const UID = process.getuid();
|
||||
let tmuxCalls = [];
|
||||
let liveRuns = new Set();
|
||||
const fakeTmux = (args) => {
|
||||
tmuxCalls.push(args);
|
||||
if (args[0] === "-V") return "tmux 3.4";
|
||||
if (args.includes("has-session")) {
|
||||
if (liveRuns.has(String(args.at(-1)).replace(/^=/, ""))) return "";
|
||||
throw Object.assign(new Error("no"), { status: 1 });
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
for (const d of [path.join(MIA, "proj"), path.join(MIA, "extra"), path.join(BOB, "private")])
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_PASSWD,
|
||||
["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n"
|
||||
);
|
||||
runAs.__resetCache();
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||
);
|
||||
linux.__resetConfCache();
|
||||
linux.__setRunImpl(() => "ok");
|
||||
runAs.__setExecImpl(fakeSudo().exec);
|
||||
tmux.__setExecImpl(fakeTmux);
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
for (const [n, role] of [
|
||||
["mia", "developer"],
|
||||
["bob", "developer"],
|
||||
["ana", "analyst"],
|
||||
["boss", "admin"],
|
||||
]) {
|
||||
await auth.createUser(n, `${n}-pass-123`, role);
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
|
||||
});
|
||||
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__reset();
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_USERS_ROOT;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a member's Antigravity conversation", () => {
|
||||
it("is read from a private copy: a FIFO beside it blocks nothing", () => {
|
||||
const Database = require("better-sqlite3");
|
||||
const dir = path.join(MIA, ".gemini", "antigravity-cli", "conversations");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "7481e37e-9157-4145-b985-2ea9a2117f52.db");
|
||||
const c = new Database(file);
|
||||
c.exec(`CREATE TABLE gen_metadata (idx integer, data blob, PRIMARY KEY (idx));
|
||||
CREATE TABLE trajectory_metadata_blob (id text, data blob, PRIMARY KEY (id));`);
|
||||
c.close();
|
||||
// A "hot journal" SQLite would open by name — and block on, as a FIFO.
|
||||
assert.equal(spawnSync("mkfifo", [`${file}-journal`]).status, 0);
|
||||
// In a child: the old open blocked its whole thread, which no test timeout interrupts.
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`const r = require(${JSON.stringify(path.join(__dirname, "..", "lib", "antigravity-usage"))}).readConversation(${JSON.stringify(file)});
|
||||
process.stdout.write(JSON.stringify(r));`,
|
||||
],
|
||||
{ env: { ...process.env, AGENTHUB_MODE: "server" }, timeout: 15_000, encoding: "utf8" }
|
||||
);
|
||||
assert.equal(child.signal, null, "the read never blocked");
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.deepEqual(JSON.parse(child.stdout), { cwd: null, usage: {}, model: null });
|
||||
assert.ok(fs.statSync(`${file}-journal`).isFIFO(), "the member's files are left alone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a developer's extra workspace folder", () => {
|
||||
it("is refused outside their own home before anything probes it", async () => {
|
||||
const lane = addLane(path.join(MIA, "proj"), "mia");
|
||||
const add = (p) => call("mia", "POST", `/api/lanes/${lane}/sources/folder`, { path: p });
|
||||
// Bob's folder answers exactly like a folder that does not exist.
|
||||
for (const p of [path.join(BOB, "private"), path.join(BOB, "nope")]) {
|
||||
const r = await add(p);
|
||||
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
|
||||
assert.equal(r.body.error.code, "EFOLDER");
|
||||
}
|
||||
// A link in her own home that points out is refused too.
|
||||
fs.symlinkSync(path.join(BOB, "private"), path.join(MIA, "to-bob"));
|
||||
const linked = await add(path.join(MIA, "to-bob"));
|
||||
assert.equal(linked.status, 403, JSON.stringify(linked.body));
|
||||
// Her own folder is fine.
|
||||
const mine = await add(path.join(MIA, "extra"));
|
||||
assert.equal(mine.status, 201, JSON.stringify(mine.body));
|
||||
assert.deepEqual(
|
||||
mine.body.sources.map((s) => s.path),
|
||||
[path.join(MIA, "extra")]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Claude config file reader", () => {
|
||||
it("never serves AgentHub's data dir: pane tokens, push keys, the database", async () => {
|
||||
fs.mkdirSync(path.join(DATA, "proxy-settings"), { recursive: true });
|
||||
const token = path.join(DATA, "proxy-settings", "gw--agenthub-lane-4.json");
|
||||
fs.writeFileSync(token, '{"env":{"ANTHROPIC_AUTH_TOKEN":"ahpane_SECRET"}}');
|
||||
fs.writeFileSync(path.join(DATA, "vapid-keys.json"), '{"privateKey":"VAPID-SECRET"}');
|
||||
// Through a link elsewhere in CLAUDE_HOME too.
|
||||
fs.symlinkSync(token, path.join(TMP, "host-claude", "innocent.json"));
|
||||
for (const who of ["ana", "boss"]) {
|
||||
for (const f of [
|
||||
token,
|
||||
path.join(DATA, "vapid-keys.json"),
|
||||
path.join(DATA, "dashboard.db"),
|
||||
path.join(TMP, "host-claude", "innocent.json"),
|
||||
]) {
|
||||
const r = await call(who, "GET", `/api/cc-config/file?path=${encodeURIComponent(f)}`);
|
||||
assert.equal(r.status, 400, `${who} ${f}: ${JSON.stringify(r.body).slice(0, 200)}`);
|
||||
assert.ok(!/SECRET|SQLite/.test(JSON.stringify(r.body)));
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(TMP, "host-claude", "settings.json"), '{"ok":true}');
|
||||
const ok = await call(
|
||||
"ana",
|
||||
"GET",
|
||||
`/api/cc-config/file?path=${encodeURIComponent(path.join(TMP, "host-claude", "settings.json"))}`
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Robustness: what a member can do to the dashboard's one thread ─────────
|
||||
|
||||
const LIB = path.join(__dirname, "..", "lib");
|
||||
/** Run `code` in a child (the old code hung or crashed the whole process). */
|
||||
const inChild = (code, env = {}) =>
|
||||
spawnSync(process.execPath, ["-e", code], {
|
||||
env: { ...process.env, AGENTHUB_MODE: "server", ...env },
|
||||
timeout: 40_000,
|
||||
encoding: "utf8",
|
||||
});
|
||||
/** A sparse file of `size` bytes (costs no disk), optionally with text at both ends. */
|
||||
function sparse(file, size, { head = "", tail = "" } = {}) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, head);
|
||||
fs.truncateSync(file, size - Buffer.byteLength(tail));
|
||||
if (tail) fs.appendFileSync(file, tail);
|
||||
}
|
||||
|
||||
describe("a member's stopped tmux server or planted FIFO", () => {
|
||||
it("never blocks the dashboard: every sudo into a member is bounded", () => {
|
||||
const lane = addLane(path.join(MIA, "tm"), "mia");
|
||||
const net = require("node:net");
|
||||
const sock = path.join(MIA, ".agenthub", "tmux", "sock");
|
||||
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
||||
fs.rmSync(sock, { force: true });
|
||||
const srv = net.createServer().listen(sock);
|
||||
// sudo that never returns: a tmux client talking to a SIGSTOPped server.
|
||||
const bin = path.join(TMP, "fakebin");
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
fs.writeFileSync(path.join(bin, "sudo"), "#!/bin/sh\nexec sleep 60\n", { mode: 0o755 });
|
||||
try {
|
||||
const child = inChild(
|
||||
`const runAs = require(${JSON.stringify(`${LIB}/run-as`)});
|
||||
const tmux = require(${JSON.stringify(`${LIB}/tmux`)});
|
||||
const proj = ${JSON.stringify(path.join(MIA, "proj"))};
|
||||
const time = (f) => { const t = Date.now(); try { f(); return { ms: Date.now() - t }; }
|
||||
catch (e) { return { code: e.code, ms: Date.now() - t }; } };
|
||||
let live;
|
||||
const first = time(() => (live = tmux.liveSessions()));
|
||||
const again = time(() => tmux.liveSessions());
|
||||
process.stdout.write(JSON.stringify({
|
||||
isSet: live instanceof Set,
|
||||
keeps: live.has("agenthub-lane-${lane}"),
|
||||
other: live.has("agenthub-lane-999999"),
|
||||
first, again,
|
||||
git: time(() => runAs.execIn(proj, "git", ["status"])),
|
||||
write: time(() => runAs.writeAs(runAs.accountForPath(proj), proj + "/f.txt", "", "x")),
|
||||
}));`,
|
||||
{ PATH: `${bin}:${process.env.PATH}` }
|
||||
);
|
||||
assert.equal(child.signal, null, "nothing hung");
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
const r = JSON.parse(child.stdout);
|
||||
// Her panes are UNKNOWN, not dead: nothing of hers is ended for a stall.
|
||||
assert.equal(r.isSet, true, "the host's answer still counts");
|
||||
assert.equal(r.keeps, true);
|
||||
assert.equal(r.other, false);
|
||||
assert.ok(r.first.ms < 10_000, `first list: ${r.first.ms} ms`);
|
||||
assert.ok(r.again.ms < 500, `backed off: ${r.again.ms} ms`);
|
||||
assert.equal(r.git.code, "ETIMEDOUT");
|
||||
assert.equal(r.write.code, "ETIMEDOUT");
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("one member's failing server never blinds the dashboard to everyone else's", () => {
|
||||
const net = require("node:net");
|
||||
const sock = path.join(MIA, ".agenthub", "tmux", "sock");
|
||||
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
||||
fs.rmSync(sock, { force: true });
|
||||
const srv = net.createServer().listen(sock);
|
||||
const lane = addLane(path.join(MIA, "proj2"), "mia");
|
||||
let memberCalls = 0;
|
||||
let hostFails = false;
|
||||
tmux.__reset();
|
||||
tmux.__setExecImpl(() => {
|
||||
if (hostFails) throw Object.assign(new Error("fork"), { code: "EAGAIN" });
|
||||
return "agenthub-lane-1\n";
|
||||
});
|
||||
tmux.__setAccountImpl(
|
||||
(name) => runAs.accountForSession(name),
|
||||
() => {
|
||||
memberCalls += 1;
|
||||
throw Object.assign(new Error("spawnSync sudo ENOBUFS"), { code: "ENOBUFS" });
|
||||
}
|
||||
);
|
||||
try {
|
||||
const live = tmux.liveSessions();
|
||||
assert.ok(live, "not null for everyone");
|
||||
assert.equal(live.has("agenthub-lane-1"), true);
|
||||
assert.equal(live.has(`agenthub-lane-${lane}`), true, "hers: unknown, kept");
|
||||
assert.equal(live.has("agenthub-lane-999999"), false);
|
||||
tmux.__setExecImpl(() => "agenthub-lane-1\n");
|
||||
tmux.liveSessions();
|
||||
assert.equal(memberCalls, 1, "a flooding server is not asked again at once");
|
||||
hostFails = true;
|
||||
tmux.__setExecImpl(() => {
|
||||
throw Object.assign(new Error("fork"), { code: "EAGAIN" });
|
||||
});
|
||||
assert.equal(tmux.liveSessions(), null, "our own failure still proves nothing");
|
||||
} finally {
|
||||
srv.close();
|
||||
tmux.__reset();
|
||||
tmux.__setExecImpl(fakeTmux);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's huge or sparse files", () => {
|
||||
it("a 600 MB line without a newline never crashes the transcript reader", () => {
|
||||
const big = path.join(TMP, "one-line.jsonl");
|
||||
sparse(big, 600 * 1024 * 1024, { head: '{"type":"user"' });
|
||||
try {
|
||||
const child = inChild(
|
||||
`require(${JSON.stringify(path.join(__dirname, "..", "..", "scripts", "import-history"))})
|
||||
.parseSessionFile(${JSON.stringify(big)})
|
||||
.then(() => process.stdout.write("survived"));`
|
||||
);
|
||||
assert.equal(child.status, 0, child.stderr.slice(-400));
|
||||
assert.equal(child.stdout, "survived");
|
||||
} finally {
|
||||
fs.rmSync(big, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("readLines keeps line numbers and never builds an over-long line", async () => {
|
||||
const f = path.join(TMP, "lines.jsonl");
|
||||
fs.writeFileSync(f, `a\r\n\n${"x".repeat(3 << 20)}\nlast`);
|
||||
const out = [];
|
||||
for await (const l of runAs.readLines(f, { maxLine: 1 << 20 })) out.push(l);
|
||||
assert.deepEqual(out, ["a", "", "", "last"]);
|
||||
fs.rmSync(f);
|
||||
});
|
||||
|
||||
it("a member's file is read only up to its cap", async () => {
|
||||
const f = path.join(MIA, ".codex", "auth.json");
|
||||
sparse(f, 70 * 1024 * 1024);
|
||||
assert.throws(() => runAs.readFileSafe(f, "utf8"), { code: "ETOOBIG" });
|
||||
await assert.rejects(runAs.readFileSafeAsync(f), { code: "ETOOBIG" });
|
||||
fs.writeFileSync(f, `{"pad":"${"x".repeat(2 << 20)}"}`);
|
||||
assert.throws(() => runAs.readFileSafe(f, "utf8", { max: 1 << 20 }), { code: "ETOOBIG" });
|
||||
fs.writeFileSync(f, '{"ok":1}');
|
||||
assert.equal(runAs.readFileSafe(f, "utf8"), '{"ok":1}');
|
||||
// A member transcript past the cap is not read on the event loop.
|
||||
const t = path.join(MIA, ".claude", "projects", "p", "big.jsonl");
|
||||
sparse(t, 300 * 1024 * 1024);
|
||||
const real = fs.readSync;
|
||||
let reads = 0;
|
||||
fs.readSync = (...a) => {
|
||||
reads += 1;
|
||||
return real(...a);
|
||||
};
|
||||
try {
|
||||
const TranscriptCache = require("../lib/transcript-cache");
|
||||
new TranscriptCache()._streamRange(t, 0, fs.statSync(t).size);
|
||||
} finally {
|
||||
fs.readSync = real;
|
||||
}
|
||||
assert.equal(reads, 0);
|
||||
fs.rmSync(t);
|
||||
});
|
||||
|
||||
it("the transcript snapshot never fills the host's disk from a sparse file", () => {
|
||||
const { snapshotTranscript } = require("../../scripts/import-history");
|
||||
const { getTranscriptSnapshotDir } = require("../lib/claude-home");
|
||||
const proj = path.join(MIA, ".claude", "projects", "-p");
|
||||
const huge = "11111111-2222-4333-8444-555555555555";
|
||||
sparse(path.join(proj, `${huge}.jsonl`), 300 * 1024 * 1024);
|
||||
snapshotTranscript(path.join(proj, `${huge}.jsonl`), huge);
|
||||
assert.equal(fs.existsSync(path.join(getTranscriptSnapshotDir(), `${huge}.jsonl`)), false);
|
||||
// Under the cap: copied, and its hole stays a hole.
|
||||
const holey = "11111111-2222-4333-8444-666666666666";
|
||||
sparse(path.join(proj, `${holey}.jsonl`), 32 * 1024 * 1024, {
|
||||
head: '{"a":1}\n',
|
||||
tail: '{"z":1}\n',
|
||||
});
|
||||
snapshotTranscript(path.join(proj, `${holey}.jsonl`), holey);
|
||||
const copy = path.join(getTranscriptSnapshotDir(), `${holey}.jsonl`);
|
||||
const st = fs.statSync(copy);
|
||||
assert.equal(st.size, 32 * 1024 * 1024);
|
||||
assert.ok(st.blocks * 512 < 4 * 1024 * 1024, `copy allocated ${st.blocks * 512} bytes`);
|
||||
const text = fs.readFileSync(copy);
|
||||
assert.equal(text.subarray(0, 8).toString(), '{"a":1}\n');
|
||||
assert.equal(text.subarray(-8).toString(), '{"z":1}\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe("a request that makes an async route throw", () => {
|
||||
it("answers an error instead of taking the server down", async () => {
|
||||
const r = await call("mia", "POST", "/api/lanes/worktree", {
|
||||
sourceRepo: path.join(USERS, "ghost", "x"),
|
||||
branch: "b",
|
||||
});
|
||||
assert.equal(r.status, 403, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "EMEMBERLANE");
|
||||
// Any async handler that rejects: a 500, not an exit.
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
app.get("/boom", async () => {
|
||||
await null;
|
||||
throw new Error("boom");
|
||||
});
|
||||
const s = await new Promise((ok) => {
|
||||
const h = app.listen(0, () => ok(h));
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${s.address().port}/boom`);
|
||||
assert.equal(res.status, 500);
|
||||
} finally {
|
||||
s.close();
|
||||
}
|
||||
assert.equal((await call("mia", "GET", "/api/lanes")).status, 200, "still serving");
|
||||
});
|
||||
});
|
||||
|
||||
describe("who a pane runs as", () => {
|
||||
const pty = require("../lib/pty-run");
|
||||
const HOST = path.join(TMP, "hostdir");
|
||||
before(() => fs.mkdirSync(HOST, { recursive: true }));
|
||||
|
||||
it("a non-admin owns no workspace outside their own home: no host pane for them", () => {
|
||||
const lane = addLane(HOST, "boss");
|
||||
assert.throws(() => auth.assignWorkspace(lane, "mia"), { code: "EOWNERFOLDER" });
|
||||
assert.throws(() => auth.assignWorkspace(lane, "ana"), { code: "EOWNERFOLDER" });
|
||||
// One that got there anyway (assigned before this rule, or a demoted admin's).
|
||||
const theirs = addLane(path.join(HOST, "x"), "mia");
|
||||
assert.throws(() => pty.spawnRun({ laneId: theirs, cwd: path.join(HOST, "x"), tool: "claude" }), {
|
||||
code: "EOWNERFOLDER",
|
||||
});
|
||||
assert.throws(() => pty.spawnShell({ laneId: theirs, cwd: path.join(HOST, "x") }), {
|
||||
code: "EOWNERFOLDER",
|
||||
});
|
||||
fs.mkdirSync(path.join(HOST, "y"), { recursive: true });
|
||||
const viaRoute = addLane(path.join(HOST, "y"), "mia");
|
||||
return call("mia", "POST", "/api/run", { laneId: viaRoute, cwd: path.join(HOST, "y") }).then(
|
||||
(r) => {
|
||||
assert.equal(r.status, 409, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "EOWNERFOLDER");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("demoting or disabling a user ends their panes that run as the host", () => {
|
||||
const lane = addLane(path.join(HOST, "z"), "boss");
|
||||
const run = pty.runIdForLane(lane, 0);
|
||||
liveRuns = new Set([run]);
|
||||
tmuxCalls = [];
|
||||
try {
|
||||
auth.setRole("boss", "developer");
|
||||
assert.ok(
|
||||
tmuxCalls.some((a) => a[0] === "kill-session" && String(a.at(-1)).includes(run)),
|
||||
"the host pane was killed"
|
||||
);
|
||||
} finally {
|
||||
auth.setRole("boss", "admin");
|
||||
liveRuns = new Set();
|
||||
}
|
||||
});
|
||||
|
||||
it("a pane's cwd must be its workspace's account's (POST /api/run names the cwd)", async () => {
|
||||
const mine = addLane(path.join(MIA, "own"), "mia");
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: mine, cwd: path.join(BOB, "private"), tool: "claude" }),
|
||||
{ code: "ECWDACCOUNT" }
|
||||
);
|
||||
const host = addLane(path.join(HOST, "h"), "boss");
|
||||
const r = await call("boss", "POST", "/api/run", { laneId: host, cwd: path.join(MIA, "proj") });
|
||||
assert.equal(r.status, 409, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "ECWDACCOUNT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pane token", () => {
|
||||
const hookAuth = require("../lib/hook-auth");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
it("is accepted only from the Linux account its pane runs as", async () => {
|
||||
const lane = addLane(path.join(MIA, "pt"), "mia");
|
||||
const run = `agenthub-lane-${lane}`;
|
||||
const token = "ahpane_review5-token-value-0123456789";
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
|
||||
VALUES (?, 'interactive', ?, 'running', ?, ?)`
|
||||
).run(run, path.join(MIA, "pt"), lane, crypto.createHash("sha256").update(token).digest("hex"));
|
||||
tmux.__setAccountImpl(
|
||||
(name) => runAs.accountForSession(name),
|
||||
(acct, args) => fakeTmux(args)
|
||||
);
|
||||
liveRuns = new Set([run]);
|
||||
const asPane = () =>
|
||||
fetch(`${BASE}/api/sessions`, { headers: { "x-agenthub-pane-token": token } }).then(
|
||||
(r) => r.status
|
||||
);
|
||||
try {
|
||||
hookAuth.__setPeerUid(() => UID); // mia's uid in this test's passwd
|
||||
assert.equal(await asPane(), 200);
|
||||
hookAuth.__setPeerUid(() => UID + 7); // another local account
|
||||
assert.equal(await asPane(), 401);
|
||||
hookAuth.__setPeerUid(() => null); // a peer the kernel table does not show
|
||||
assert.equal(await asPane(), 401);
|
||||
// The AI Proxy gateway takes the same token: refused from another uid.
|
||||
hookAuth.__setPeerUid(() => UID + 7);
|
||||
const gw = await fetch(`${BASE}/api/proxy-gw/x/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(gw.status, 401);
|
||||
} finally {
|
||||
hookAuth.__setPeerUid(null);
|
||||
liveRuns = new Set();
|
||||
db.prepare("DELETE FROM dashboard_runs WHERE id = ?").run(run);
|
||||
tmux.__reset();
|
||||
tmux.__setExecImpl(fakeTmux);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("an analyst's GET that names a path", () => {
|
||||
it("stays in their own folder or workspaces, like any non-admin's request", async () => {
|
||||
fs.mkdirSync(path.join(USERS, "ana", "work"), { recursive: true });
|
||||
const q = encodeURIComponent(path.join(BOB, "private"));
|
||||
for (const p of [
|
||||
`/api/lanes/browse?path=${q}`,
|
||||
`/api/lanes/branches?repo=${q}`,
|
||||
`/api/run/files?cwd=${q}`,
|
||||
]) {
|
||||
const r = await call("ana", "GET", p);
|
||||
assert.equal(r.status, 403, `${p}: ${JSON.stringify(r.body)}`);
|
||||
assert.equal(r.body.error.code, "EFOLDER");
|
||||
}
|
||||
const own = await call(
|
||||
"ana",
|
||||
"GET",
|
||||
`/api/lanes/browse?path=${encodeURIComponent(path.join(USERS, "ana"))}`
|
||||
);
|
||||
assert.equal(own.status, 200, JSON.stringify(own.body));
|
||||
assert.equal((await call("boss", "GET", `/api/lanes/browse?path=${q}`)).status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a dropped file", () => {
|
||||
const drop = (who, laneId) =>
|
||||
fetch(`${BASE}/api/run/drop-file?name=a.txt${laneId ? `&laneId=${laneId}` : ""}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream", cookie: cookies[who] },
|
||||
body: Buffer.from("hello"),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
|
||||
it("goes where the workspace's panes can read it, not to the requester's account", async () => {
|
||||
fs.mkdirSync(path.join(MIA, ".agenthub", "handoff"), { recursive: true });
|
||||
const miaLane = addLane(path.join(MIA, "drop"), "mia");
|
||||
const byAdmin = await drop("boss", miaLane);
|
||||
assert.equal(byAdmin.status, 200, JSON.stringify(byAdmin.body));
|
||||
assert.ok(
|
||||
byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep),
|
||||
byAdmin.body.path
|
||||
);
|
||||
// A host workspace's pane runs as the host: the host's own 0700 drops dir.
|
||||
const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss");
|
||||
const host = await drop("boss", hostLane);
|
||||
assert.equal(host.status, 200, JSON.stringify(host.body));
|
||||
assert.equal(path.dirname(host.body.path), path.join(DATA, "drops"));
|
||||
// Another member's workspace is not one to name.
|
||||
assert.equal((await drop("bob", miaLane)).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @file server-s3-system.test.js
|
||||
* @description S3 plan, P2 (one Linux user per account), with every sudo and
|
||||
* systemctl call faked: the system-unit service manager (host-owned env file,
|
||||
* sudo for start/stop/restart, pure-fs staleness), service-manager picking it,
|
||||
* Quit stopping it through sudo, the Linux-account layer (server.conf, name
|
||||
* rule, the exact provision argv), user create provisioning FIRST, disable
|
||||
* ending the logins first (and locking only an account that exists), and a
|
||||
* member's bootstrap doing nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = 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 TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-system-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.AGENTHUB_SYSTEM_UNIT_DIR = path.join(TMP, "units");
|
||||
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||
// `lin` has a Linux account; `pre` (made before S3) and `Old.Name` do not.
|
||||
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||
fs.writeFileSync(process.env.AGENTHUB_PASSWD, "ah-lin:x:5101:5101::/:/bin/sh\n");
|
||||
|
||||
const sys = require("../../scripts/systemd-system-service");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const auth = require("../lib/auth");
|
||||
const { db } = require("../db");
|
||||
|
||||
const UNIT = path.join(process.env.AGENTHUB_SYSTEM_UNIT_DIR, "agenthub.service");
|
||||
const writeUnit = () => {
|
||||
fs.mkdirSync(path.dirname(UNIT), { recursive: true });
|
||||
fs.writeFileSync(UNIT, `[Unit]\n${sys.MARKER}\n`);
|
||||
};
|
||||
const writeConf = (uid = 4242) => {
|
||||
linux.__resetConfCache();
|
||||
fs.writeFileSync(
|
||||
process.env.AGENTHUB_SERVER_CONF,
|
||||
`HOST_USER=host\nHOST_UID=${uid}\nUSERS_ROOT=/srv/agenthub/users\nPORT=4820\n`
|
||||
);
|
||||
};
|
||||
|
||||
after(() => {
|
||||
sys.__reset();
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("the system-unit service manager", () => {
|
||||
let calls;
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
fs.rmSync(path.dirname(UNIT), { recursive: true, force: true });
|
||||
fs.rmSync(sys.envPath(), { force: true });
|
||||
sys.__setExecImpl(
|
||||
(args) => {
|
||||
calls.push(["systemctl", ...args]);
|
||||
return { status: 0, stdout: args[0] === "is-active" ? "active\n" : "enabled\n" };
|
||||
},
|
||||
(args) => {
|
||||
calls.push(["sudo", "-n", "systemctl", ...args]);
|
||||
return { status: 0 };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("is unavailable, and changes nothing, without its unit", () => {
|
||||
assert.equal(sys.unitIsOurs("agenthub"), false);
|
||||
assert.equal(sys.systemctlAvailable(), false);
|
||||
assert.equal(sys.writeUserService({ argv: ["/n", "/r/server/index.js"] }), "unavailable");
|
||||
assert.equal(sys.restartUserService("agenthub"), "unavailable");
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
it("repoints by rewriting the host-owned env file, never the unit", () => {
|
||||
writeUnit();
|
||||
const unitBefore = fs.readFileSync(UNIT, "utf8");
|
||||
const unit = {
|
||||
argv: ["/usr/bin/node", "/plugins/v2/server/index.js"],
|
||||
env: { NODE_ENV: "production" },
|
||||
};
|
||||
assert.equal(sys.writeUserService(unit), "written");
|
||||
assert.equal(sys.writeUserService(unit), "unchanged");
|
||||
const env = fs.readFileSync(sys.envPath(), "utf8");
|
||||
assert.match(env, /^AGENTHUB_NODE=\/usr\/bin\/node$/m);
|
||||
assert.match(env, /^AGENTHUB_ROOT=\/plugins\/v2$/m);
|
||||
assert.match(env, /^NODE_ENV=production$/m);
|
||||
assert.equal(fs.statSync(sys.envPath()).mode & 0o777, 0o600);
|
||||
assert.equal(fs.readFileSync(UNIT, "utf8"), unitBefore);
|
||||
assert.equal(sys.unitRoot("agenthub"), "/plugins/v2");
|
||||
assert.equal(sys.unitIsStale("agenthub", "/plugins/v2"), false);
|
||||
assert.equal(sys.unitIsStale("agenthub", "/plugins/v3"), true);
|
||||
assert.ok(!calls.some((c) => c[0] === "sudo"), "no root needed to repoint");
|
||||
});
|
||||
|
||||
it("restarts, starts and enables only through sudo, restart without blocking", () => {
|
||||
writeUnit();
|
||||
assert.equal(sys.restartUserService("agenthub"), "restarted");
|
||||
assert.equal(sys.setUnitEnabled("agenthub", false), "ok");
|
||||
sys.installUserService({ name: "agenthub", argv: ["/n", "/r/server/index.js"] });
|
||||
const sudo = calls.filter((c) => c[0] === "sudo").map((c) => c.slice(3).join(" "));
|
||||
assert.deepEqual(sudo, [
|
||||
"restart --no-block agenthub.service",
|
||||
"disable agenthub.service",
|
||||
"start --no-block agenthub.service",
|
||||
]);
|
||||
assert.deepEqual(sys.stopArgv("agenthub"), [
|
||||
"sudo",
|
||||
["-n", "systemctl", "stop", "--no-block", "agenthub.service"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("is what service-manager picks while the unit exists", () => {
|
||||
const load = () => {
|
||||
delete require.cache[require.resolve("../../scripts/service-manager")];
|
||||
return require("../../scripts/service-manager");
|
||||
};
|
||||
if (process.platform !== "linux") return;
|
||||
fs.rmSync(UNIT, { force: true });
|
||||
assert.notEqual(load().kind, "systemd-system");
|
||||
writeUnit();
|
||||
assert.equal(load().kind, "systemd-system");
|
||||
});
|
||||
|
||||
it("lets Quit stop it through sudo, never by SIGTERM", () => {
|
||||
const { stopServer } = require("../lib/quit");
|
||||
const spawned = [];
|
||||
let killed = false;
|
||||
stopServer({
|
||||
svc: {
|
||||
...sys,
|
||||
systemctlAvailable: () => true,
|
||||
unitIsOurs: () => true,
|
||||
unitIsActive: () => true,
|
||||
},
|
||||
spawn: (cmd, args) => {
|
||||
spawned.push([cmd, ...args]);
|
||||
return { unref() {} };
|
||||
},
|
||||
kill: () => (killed = true),
|
||||
});
|
||||
assert.deepEqual(spawned, [
|
||||
["sudo", "-n", "systemctl", "stop", "--no-block", "agenthub.service"],
|
||||
]);
|
||||
assert.equal(killed, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Linux accounts", () => {
|
||||
let argvs;
|
||||
let failNext;
|
||||
before(() => {
|
||||
linux.__setRunImpl((argv) => {
|
||||
argvs.push(argv);
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
throw Object.assign(new Error("exit 4"), { stderr: "agenthub-provision: ah-x exists\n" });
|
||||
}
|
||||
return `ok ${argv[1]} ${argv[2]}\n`;
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
argvs = [];
|
||||
failNext = false;
|
||||
writeConf();
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
});
|
||||
|
||||
it("is off outside server mode or without server.conf", () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
assert.equal(linux.s3Enabled(), false);
|
||||
assert.equal(linux.provision("create", "anyone"), null);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
fs.rmSync(process.env.AGENTHUB_SERVER_CONF);
|
||||
linux.__resetConfCache();
|
||||
assert.equal(linux.s3Enabled(), false);
|
||||
assert.equal(linux.accountFor("mia"), null);
|
||||
assert.deepEqual(argvs, []);
|
||||
});
|
||||
|
||||
it("reads server.conf and maps a user to ah-<name> under USERS_ROOT", () => {
|
||||
assert.deepEqual(linux.serverConf(), {
|
||||
hostUser: "host",
|
||||
hostUid: 4242,
|
||||
usersRoot: "/srv/agenthub/users",
|
||||
port: 4820,
|
||||
bins: {},
|
||||
});
|
||||
assert.deepEqual(linux.accountFor("mia"), {
|
||||
login: "ah-mia",
|
||||
home: "/srv/agenthub/users/mia",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a name Linux cannot take, before any sudo", () => {
|
||||
for (const bad of ["Mia", "1mia", "mi.a", "-x", "a".repeat(30), "root;id", "../x"]) {
|
||||
assert.throws(() => linux.provision("create", bad), { code: "EBADUSER" }, bad);
|
||||
}
|
||||
assert.throws(() => linux.provision("delete", "mia"), /unknown provision action/);
|
||||
assert.deepEqual(argvs, []);
|
||||
});
|
||||
|
||||
it("calls exactly the provision helper with an action and a name", () => {
|
||||
linux.provision("create", "mia");
|
||||
linux.provision("disable", "mia");
|
||||
assert.deepEqual(argvs, [
|
||||
["/opt/agenthub/sbin/agenthub-provision", "create", "mia"],
|
||||
["/opt/agenthub/sbin/agenthub-provision", "disable", "mia"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the Linux account BEFORE the user, and no user when that fails", async () => {
|
||||
const made = await auth.createUser("lin", "member-pass-1", "developer");
|
||||
assert.equal(made.username, "lin");
|
||||
assert.deepEqual(argvs[0], ["/opt/agenthub/sbin/agenthub-provision", "create", "lin"]);
|
||||
failNext = true;
|
||||
await assert.rejects(auth.createUser("lux", "member-pass-1", "developer"), {
|
||||
code: "EPROVISION",
|
||||
message: /ah-x exists/,
|
||||
});
|
||||
assert.equal(db.prepare("SELECT 1 FROM users WHERE username = 'lux'").get(), undefined);
|
||||
await assert.rejects(auth.createUser("Lux", "member-pass-1", "developer"), {
|
||||
code: "EBADUSER",
|
||||
});
|
||||
});
|
||||
|
||||
it("ends the logins first on disable, then locks the Linux account", () => {
|
||||
argvs = [];
|
||||
failNext = true;
|
||||
// A failed lock is still reported — but the dashboard access is gone.
|
||||
assert.throws(() => auth.setDisabled("lin", true), { code: "EPROVISION" });
|
||||
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 1);
|
||||
auth.setDisabled("lin", false);
|
||||
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 0);
|
||||
auth.setDisabled("lin", true);
|
||||
assert.equal(db.prepare("SELECT disabled FROM users WHERE username = 'lin'").get().disabled, 1);
|
||||
assert.deepEqual(
|
||||
argvs.map((a) => a[1]),
|
||||
["disable", "enable", "disable"]
|
||||
);
|
||||
});
|
||||
|
||||
it("disables and enables a user who has no Linux account, with no sudo", () => {
|
||||
argvs = [];
|
||||
for (const n of ["pre", "Old.Name"]) {
|
||||
db.prepare("INSERT INTO users (username, password_hash) VALUES (?, 'x')").run(n);
|
||||
const id = db.prepare("SELECT id FROM users WHERE username = ?").get(n).id;
|
||||
db.prepare(
|
||||
"INSERT INTO auth_sessions (token_hash, user_id, expires_at) VALUES (?, ?, '2999-01-01')"
|
||||
).run(`h-${n}`, id);
|
||||
auth.setDisabled(n, true);
|
||||
const row = db.prepare("SELECT disabled FROM users WHERE id = ?").get(id);
|
||||
assert.equal(row.disabled, 1, n);
|
||||
assert.equal(
|
||||
db.prepare("SELECT 1 FROM auth_sessions WHERE user_id = ?").get(id),
|
||||
undefined,
|
||||
`${n}'s logins end`
|
||||
);
|
||||
auth.setDisabled(n, false);
|
||||
assert.equal(db.prepare("SELECT disabled FROM users WHERE id = ?").get(id).disabled, 0, n);
|
||||
}
|
||||
assert.deepEqual(argvs, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a member's plugin bootstrap", () => {
|
||||
it("does nothing under another uid than the host's", () => {
|
||||
const { notTheHost, bootstrap } = require("../../scripts/plugin-bootstrap");
|
||||
writeConf(process.getuid() + 1);
|
||||
assert.equal(notTheHost(), true);
|
||||
assert.equal(bootstrap(path.join(TMP, "member-rt")), "not-host");
|
||||
assert.equal(fs.existsSync(path.join(TMP, "member-rt")), false);
|
||||
writeConf(process.getuid());
|
||||
assert.equal(notTheHost(), false);
|
||||
fs.rmSync(process.env.AGENTHUB_SERVER_CONF);
|
||||
assert.equal(notTheHost(), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @file server-s3-tools.test.js
|
||||
* @description S3/S4 plan, P5: what a publish copies under the shared tools
|
||||
* root — a native executable alone, an npm package's whole folder, a script's
|
||||
* own version folder — and never a shared `bin` folder; the `bin/<command>`
|
||||
* link; an unchanged tool is not copied twice; everything readable by every
|
||||
* account; the app scripts and the `agenthub` launcher for members' panes.
|
||||
* @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 path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-tools-")));
|
||||
process.env.AGENTHUB_TOOLS_ROOT = path.join(TMP, "opt", "tools");
|
||||
process.env.AGENTHUB_APP_ROOT = path.join(TMP, "opt", "app");
|
||||
|
||||
const shared = require("../lib/shared-tools");
|
||||
|
||||
const write = (p, body, mode = 0o755) => {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, body, { mode });
|
||||
return p;
|
||||
};
|
||||
const ELF = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(64)]);
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("what a publish copies", () => {
|
||||
it("a native executable: that file alone", () => {
|
||||
const f = write(path.join(TMP, "home", ".local", "share", "x", "versions", "1.0"), ELF);
|
||||
assert.deepEqual(shared.planFor(f), { src: f, entry: "" });
|
||||
});
|
||||
|
||||
it("a file in an npm package: the package folder (scoped too)", () => {
|
||||
const plain = write(
|
||||
path.join(TMP, "npm", "lib", "node_modules", "opencode-ai", "bin", "oc.js"),
|
||||
"#!/usr/bin/env node\n"
|
||||
);
|
||||
assert.deepEqual(shared.planFor(plain), {
|
||||
src: path.join(TMP, "npm", "lib", "node_modules", "opencode-ai"),
|
||||
entry: path.join("bin", "oc.js"),
|
||||
});
|
||||
const scoped = write(
|
||||
path.join(TMP, "npm", "lib", "node_modules", "@github", "copilot", "index.js"),
|
||||
"x"
|
||||
);
|
||||
assert.equal(
|
||||
shared.planFor(scoped).src,
|
||||
path.join(TMP, "npm", "lib", "node_modules", "@github", "copilot")
|
||||
);
|
||||
});
|
||||
|
||||
it("a script in a version folder: that folder; in a shared bin: refused", () => {
|
||||
const s = write(
|
||||
path.join(TMP, "home", ".local", "share", "cursor-agent", "versions", "v1", "cursor-agent"),
|
||||
"#!/bin/sh\n"
|
||||
);
|
||||
assert.deepEqual(shared.planFor(s), { src: path.dirname(s), entry: "cursor-agent" });
|
||||
const b = write(path.join(TMP, "home", ".local", "bin", "tool.sh"), "#!/bin/sh\n");
|
||||
assert.throws(() => shared.planFor(b), { code: "ESHAREDLAYOUT" });
|
||||
// ~/.tool/tool: its login usually sits beside it — never the whole folder.
|
||||
const realHome = process.env.HOME;
|
||||
process.env.HOME = path.join(TMP, "home");
|
||||
try {
|
||||
const dot = write(path.join(TMP, "home", ".tool", "tool"), "#!/bin/sh\n");
|
||||
assert.throws(() => shared.planFor(dot), { code: "ESHAREDLAYOUT" });
|
||||
} finally {
|
||||
process.env.HOME = realHome;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishing an installed tool", () => {
|
||||
let real;
|
||||
before(() => {
|
||||
real = write(
|
||||
path.join(TMP, "home", ".local", "share", "claude", "versions", "2.1.0"),
|
||||
ELF,
|
||||
0o700
|
||||
);
|
||||
write(path.join(TMP, "home", ".local", "bin", "claude"), ""); // replaced by a link below
|
||||
fs.rmSync(path.join(TMP, "home", ".local", "bin", "claude"));
|
||||
fs.symlinkSync(real, path.join(TMP, "home", ".local", "bin", "claude"));
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
aiTools.detect = () => ({
|
||||
installed: true,
|
||||
path: path.join(TMP, "home", ".local", "bin", "claude"),
|
||||
});
|
||||
aiTools.get = (id) => (id === "claude" ? { id: "claude", detect: ["claude"] } : null);
|
||||
});
|
||||
|
||||
it("copies it once, links bin/<command>, readable by everyone", async () => {
|
||||
const r = await shared.publish("claude");
|
||||
assert.equal(r.copied, true);
|
||||
const link = path.join(process.env.AGENTHUB_TOOLS_ROOT, "bin", "claude");
|
||||
assert.equal(fs.realpathSync(link), r.path);
|
||||
assert.equal(shared.sharedPath("claude"), link);
|
||||
assert.equal(fs.statSync(r.path).mode & 0o777, 0o755);
|
||||
const again = await shared.publish("claude");
|
||||
assert.equal(again.copied, false);
|
||||
assert.equal(again.path, r.path);
|
||||
});
|
||||
|
||||
it("never copies a file whose name says it holds a login", async () => {
|
||||
const dir = path.join(TMP, "home", ".local", "share", "cur", "versions", "v1");
|
||||
const exe = write(path.join(dir, "cur"), "#!/bin/sh\n");
|
||||
write(path.join(dir, "lib.js"), "x", 0o644);
|
||||
write(path.join(dir, "auth.json"), "{}", 0o600);
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const [get, detect] = [aiTools.get, aiTools.detect];
|
||||
aiTools.get = (id) => (id === "cur" ? { id: "cur", detect: ["cur"] } : get(id));
|
||||
aiTools.detect = (id, o) => (id === "cur" ? { installed: true, path: exe } : detect(id, o));
|
||||
try {
|
||||
const r = await shared.publish("cur");
|
||||
const copy = path.dirname(r.path);
|
||||
assert.ok(fs.existsSync(path.join(copy, "lib.js")));
|
||||
assert.ok(!fs.existsSync(path.join(copy, "auth.json")));
|
||||
} finally {
|
||||
[aiTools.get, aiTools.detect] = [get, detect];
|
||||
}
|
||||
});
|
||||
|
||||
it("never names a command outside a plain file name", () => {
|
||||
assert.equal(shared.sharedPath("../etc/passwd"), null);
|
||||
assert.equal(shared.sharedPath("nope"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the app scripts members run", () => {
|
||||
it("are copied with an agenthub launcher", async () => {
|
||||
const root = path.join(TMP, "install");
|
||||
write(path.join(root, "scripts", "hook-handler.js"), "// hook\n", 0o600);
|
||||
write(path.join(root, "bin", "agenthub.js"), "// cli\n", 0o700);
|
||||
await shared.publishApp(root);
|
||||
const hook = path.join(process.env.AGENTHUB_APP_ROOT, "scripts", "hook-handler.js");
|
||||
assert.equal(fs.readFileSync(hook, "utf8"), "// hook\n");
|
||||
assert.equal(fs.statSync(hook).mode & 0o777, 0o644);
|
||||
const launcher = fs.readFileSync(
|
||||
path.join(process.env.AGENTHUB_TOOLS_ROOT, "bin", "agenthub"),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(launcher, /bin\/node .*\/bin\/agenthub\.js "\$@"/);
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1768,9 +1768,9 @@ db.exec(`
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
-- One account's own UI preferences (server/routes/me.js). \`local\` in local
|
||||
-- mode, \`u<id>\` per server-mode account. The same table as the server-s3
|
||||
-- work's (which adds theme, language… to PREF_KEYS) — keep the two identical.
|
||||
-- One account's own UI preferences (theme, language, pet, …) and whether it
|
||||
-- went through the first-run setup (server/routes/me.js). \`local\` in local
|
||||
-- mode, \`u<id>\` per server-mode account.
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
user_key TEXT PRIMARY KEY,
|
||||
prefs TEXT NOT NULL DEFAULT '{}',
|
||||
|
||||
+73
-2
@@ -6,6 +6,9 @@
|
||||
// First, before any module destructures child_process: no console window per
|
||||
// git/npm call on Windows (server/lib/windows-hide.js).
|
||||
require("./lib/windows-hide").install();
|
||||
// S3 system unit: children never inherit its ambient CAP_DAC_READ_SEARCH
|
||||
// (server/lib/drop-caps.js) — same "before child_process is read" rule.
|
||||
require("./lib/drop-caps").install();
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
||||
|
||||
// A server started from inside a Claude Code session (the bootstrap fallback,
|
||||
@@ -50,6 +53,25 @@ if (require.main === module) {
|
||||
if (require.main === module) require("./lib/claude-home").migrateLegacyDataDir();
|
||||
|
||||
const express = require("express");
|
||||
|
||||
// Express 4 ignores the promise an async handler returns: a rejection (a
|
||||
// throw after the first await) became an unhandled rejection, and Node exits
|
||||
// on one — any request that made a handler throw took the whole server down.
|
||||
// Every handler's rejection goes to `next(err)` (a 500) instead, the way a
|
||||
// synchronous throw already did. Express 5 does this itself.
|
||||
{
|
||||
const Layer = require("express/lib/router/layer");
|
||||
Layer.prototype.handle_request = function handle(req, res, next) {
|
||||
const fn = this.handle;
|
||||
if (fn.length > 3) return next(); // an error handler: not for a request
|
||||
try {
|
||||
const r = fn(req, res, next);
|
||||
if (r && typeof r.then === "function") r.then(undefined, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
const cors = require("cors");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -124,6 +146,10 @@ function createApp() {
|
||||
// allowlist (anti DNS-rebinding), and an optional bearer-token gate on /api/*.
|
||||
app.use(cors(corsOptions()));
|
||||
app.use(hostGuard);
|
||||
// Server mode's AI Proxy key gateway (server/lib/proxy-gateway.js): before
|
||||
// the body parser (it streams the raw body) and the token/login gates (a
|
||||
// pane authenticates with its own pane token, loopback only).
|
||||
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use("/api", tokenGuard);
|
||||
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
|
||||
@@ -296,6 +322,25 @@ function startServer(app, port) {
|
||||
// server even when it bound a non-default port (the desktop app falls
|
||||
// back off 4820 when that port is already taken).
|
||||
writeServerInfo(port);
|
||||
// The AI Proxy gateway URL a server-mode pane is given names this port.
|
||||
require("./lib/proxy-gateway").setListenPort(server.address().port);
|
||||
// S3: what members' panes run — this install's scripts and every
|
||||
// installed AI tool, copied under /opt/agenthub (idle, in the background).
|
||||
if (require("./lib/linux-accounts").s3Enabled()) {
|
||||
const shared = require("./lib/shared-tools");
|
||||
const t = setTimeout(() => {
|
||||
shared
|
||||
.publishApp()
|
||||
.then(() => shared.publishAll())
|
||||
.then((r) => {
|
||||
const bad = r.filter((x) => x.error);
|
||||
if (bad.length)
|
||||
console.warn("[shared-tools]", bad.map((x) => `${x.id}: ${x.error}`).join("; "));
|
||||
})
|
||||
.catch((e) => console.warn("[shared-tools]", e.message));
|
||||
}, 5000);
|
||||
if (t.unref) t.unref();
|
||||
}
|
||||
// A server that starts ends a Quit: the bootstrap may start it again.
|
||||
require("./lib/quit").clearQuit();
|
||||
const sharedDbPeers = peersSharingDataDir();
|
||||
@@ -705,6 +750,13 @@ function startBackgroundServices() {
|
||||
OR EXISTS (SELECT 1 FROM dashboard_runs WHERE status IN ('running', 'spawning')) AS any`
|
||||
);
|
||||
const healTimer = setInterval(() => {
|
||||
try {
|
||||
// A Codex pool login a member's pane refreshed goes back to the pool
|
||||
// (also after its pane ended), so the next pane starts from it.
|
||||
require("./lib/codex-pool").syncBack();
|
||||
} catch (err) {
|
||||
console.warn("codex pool sync failed:", err.message);
|
||||
}
|
||||
try {
|
||||
if (!anyRun.get().any) return; // nothing to heal: no tmux spawn at all
|
||||
const live = liveSessions();
|
||||
@@ -909,7 +961,7 @@ function startWorkflowPoll(broadcast) {
|
||||
function startSessionSync(broadcast) {
|
||||
const fs = require("fs");
|
||||
const dbModule = require("./db");
|
||||
const { getProjectsDir } = require("./lib/claude-home");
|
||||
const { getProjectsDir, allProjectsDirs } = require("./lib/claude-home");
|
||||
const { syncDefaultProjects } = require("../scripts/import-history");
|
||||
|
||||
const projectsDir = getProjectsDir();
|
||||
@@ -923,7 +975,26 @@ function startSessionSync(broadcast) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
syncDefaultProjects(dbModule, { mtimeCache })
|
||||
// This account's projects dir, then every member's (S3: their sessions
|
||||
// write under their own home) — one after another, same mtime cache.
|
||||
// In the chain: a throw here (S3's member lookup) must not leave
|
||||
// `running` set and stop the sync for good.
|
||||
Promise.resolve()
|
||||
.then(allProjectsDirs)
|
||||
.then((dirs) =>
|
||||
dirs.reduce(
|
||||
(prev, dir) =>
|
||||
prev.then(async (acc) => {
|
||||
const { changed } = await syncDefaultProjects(dbModule, {
|
||||
mtimeCache,
|
||||
projectsDir: dir,
|
||||
});
|
||||
return acc.concat(changed);
|
||||
}),
|
||||
Promise.resolve([])
|
||||
)
|
||||
)
|
||||
.then((changed) => ({ changed }))
|
||||
.then(({ changed }) => {
|
||||
for (const { sessionId, isNew } of changed) {
|
||||
let row;
|
||||
|
||||
+77
-34
@@ -21,6 +21,14 @@
|
||||
* so **no route ever returns key material** — `listProxies` reports `hasKey`
|
||||
* and nothing else about it.
|
||||
*
|
||||
* **Shared and personal.** The store's top level holds the SHARED profiles —
|
||||
* the host's in local mode, the admin's in server mode. Each user may also keep
|
||||
* PERSONAL ones under `users/<user id>/`, named `u<id>_<slug>`: `slugify`
|
||||
* never emits `_`, so a personal name can never equal a shared one, and the
|
||||
* name alone (stored in `dashboard_runs.proxy`) says whose it is. Who may see,
|
||||
* write or start a session on which is decided by the routes and by
|
||||
* `proxy-apply.resolveProxy`, from `ownerOfName`.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it has no
|
||||
* endpoint and no model of its own (the session's model picker decides), so
|
||||
* "use the vendor's own API" is a choice like any profile. It cannot be
|
||||
@@ -97,11 +105,25 @@ function proxiesDir() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A personal profile's name: `u<owner id>_<slug>`. */
|
||||
const PERSONAL_RE = /^u([1-9][0-9]{0,8})_[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** The user id a personal profile belongs to, or null for a shared one. */
|
||||
function ownerOfName(name) {
|
||||
const m = typeof name === "string" ? PERSONAL_RE.exec(name) : null;
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** Where one owner's profiles live: the top level is the shared store. */
|
||||
function storeDir(owner) {
|
||||
return owner == null ? proxiesDir() : path.join(proxiesDir(), "users", String(owner));
|
||||
}
|
||||
|
||||
function proxyFile(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(proxiesDir(), `${name}.json`);
|
||||
return path.join(storeDir(ownerOfName(name)), `${name}.json`);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
@@ -138,15 +160,17 @@ function slugify(text) {
|
||||
return NAME_RE.test(base) ? base : "proxy";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||
function freeName(base) {
|
||||
let name = base;
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(proxiesDir(), `${name}.json`));
|
||||
) {
|
||||
name = `${base}-${n}`;
|
||||
n += 1;
|
||||
/**
|
||||
* `base`, or `base-2`, `base-3`… — the first that is neither taken nor built
|
||||
* in — prefixed `u<owner>_` for a personal profile (the slug shortened so the
|
||||
* whole name still fits NAME_RE's 40 characters).
|
||||
*/
|
||||
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) {
|
||||
name = `${prefix}${stem}-${n}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -243,35 +267,49 @@ function readProxy(name) {
|
||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||
function publicView(profile) {
|
||||
const { apiKey, ...rest } = profile;
|
||||
return { ...rest, hasKey: Boolean(apiKey) };
|
||||
return {
|
||||
...rest,
|
||||
scope: ownerOfName(profile.name) == null ? "shared" : "personal",
|
||||
hasKey: Boolean(apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
/** The profile names saved in one owner's store, sorted. */
|
||||
function namesIn(owner) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(storeDir(owner))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n) && n !== BUILTIN_NAME && ownerOfName(n) === owner)
|
||||
.sort();
|
||||
} catch {
|
||||
return []; // nothing saved yet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every saved profile, with the built-in native one first.
|
||||
* The profiles `viewer` may choose: the built-in native one first, then the
|
||||
* shared ones, then the viewer's own personal ones (none for `null` — local
|
||||
* mode, or a request with no user). Nobody lists another user's personal
|
||||
* profiles, an admin included.
|
||||
* @param {number|null} [viewer] user id
|
||||
* @returns `{ items }` — key material is never included.
|
||||
*/
|
||||
function listProxies() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(proxiesDir())
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
/* nothing saved yet */
|
||||
}
|
||||
|
||||
function listProxies(viewer = null) {
|
||||
const items = [publicView(NATIVE)];
|
||||
for (const name of names.sort()) {
|
||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
const owners = viewer == null ? [null] : [null, viewer];
|
||||
for (const owner of owners) {
|
||||
for (const name of namesIn(owner)) {
|
||||
let stored;
|
||||
try {
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
@@ -290,9 +328,10 @@ function getProxy(name) {
|
||||
/**
|
||||
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
||||
* models?:Record<string,string>, behavesAs?:string}} input
|
||||
* @param {number|null} [owner] user id for a personal profile, null = shared
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createProxy(input = {}) {
|
||||
function createProxy(input = {}, owner = null) {
|
||||
const label = normalizeLabel(input.label);
|
||||
const slug = slugify(label);
|
||||
assertNotBuiltin(slug);
|
||||
@@ -308,7 +347,7 @@ function createProxy(input = {}) {
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const name = freeName(slug);
|
||||
const name = freeName(slug, owner);
|
||||
writeJson(proxyFile(name), profile);
|
||||
return publicView({ ...profile, name, builtin: false });
|
||||
}
|
||||
@@ -376,6 +415,9 @@ async function probe(url, apiKey, headers, body, model) {
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
// A redirect is an answer, never followed: fetch drops `authorization`
|
||||
// on a cross-origin hop but would carry `x-api-key` to the new host.
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (e) {
|
||||
// Unreachable, DNS failure, TLS refusal, timeout — a real answer about the
|
||||
@@ -461,4 +503,5 @@ module.exports = {
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
ownerOfName,
|
||||
};
|
||||
|
||||
@@ -214,6 +214,17 @@ async function finish(j, code) {
|
||||
if (code === 0 && j.action === "install" && aiTools.detect(j.tool).installed) {
|
||||
aiTools.setEnabled(j.tool, true);
|
||||
}
|
||||
// S3: members run the shared copy under /opt/agenthub/tools — refresh it.
|
||||
if (code === 0 && require("./linux-accounts").s3Enabled()) {
|
||||
const shared = require("./shared-tools");
|
||||
const done = j.tool ? [await shared.publish(j.tool)] : await shared.publishAll();
|
||||
for (const r of done) {
|
||||
append(
|
||||
j,
|
||||
`\n[agenthub] ${r.error ? `${r.id}: not shared — ${r.error}` : `shared for every account: ${r.path}`}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
append(j, `\n[agenthub] ${err.message}\n`);
|
||||
} finally {
|
||||
|
||||
@@ -142,10 +142,20 @@ function firstFileUri(buf, depth = 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transcriptFiles = (id) => {
|
||||
const dir = path.join(getAgyCliDir(), "brain", id, ".system_generated", "logs");
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
};
|
||||
/** This account's agy dir, then every member's (S3: their own home). */
|
||||
const agyDirs = () => [
|
||||
getAgyCliDir(),
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".gemini", "antigravity-cli")),
|
||||
];
|
||||
|
||||
// A conversation id is a UUID, so its transcript is wherever it exists.
|
||||
const transcriptFiles = (id) =>
|
||||
agyDirs().flatMap((cli) => {
|
||||
const dir = path.join(cli, "brain", id, ".system_generated", "logs");
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
/** The transcript's last write, 0 when it has none. */
|
||||
function transcriptMtime(id) {
|
||||
@@ -164,7 +174,7 @@ function transcriptFacts(id) {
|
||||
for (const file of transcriptFiles(id)) {
|
||||
let text;
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 1024 * 1024));
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
@@ -200,31 +210,55 @@ function transcriptFacts(id) {
|
||||
|
||||
/** Everything AgentHub reads from one conversation file. */
|
||||
function readConversation(file) {
|
||||
const conn = new Database(file, { readonly: true, fileMustExist: true });
|
||||
// S3: SQLite opens by NAME, and its unchecked `-journal`/`-wal` beside it
|
||||
// (a member's FIFO there blocks this thread forever). A member's file and
|
||||
// WAL are read through the guard into a private copy, opened writable so
|
||||
// SQLite can replay the WAL. The host's own is opened in place.
|
||||
const { ownerForRead, readFileSafe } = require("./run-as");
|
||||
let tmp = null;
|
||||
try {
|
||||
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
|
||||
const usage = {};
|
||||
let model = null;
|
||||
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
|
||||
const u = data && generationUsage(data);
|
||||
if (!u) continue;
|
||||
model = u.model;
|
||||
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
for (const k of Object.keys(t)) t[k] += u[k];
|
||||
let open = file;
|
||||
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
|
||||
tmp = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "agenthub-agy-"));
|
||||
open = path.join(tmp, "conversation.db");
|
||||
fs.writeFileSync(open, readFileSafe(file, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
|
||||
try {
|
||||
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: 256 << 20 }));
|
||||
} catch {
|
||||
/* no WAL (or not theirs): what is in the main file */
|
||||
}
|
||||
}
|
||||
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
|
||||
try {
|
||||
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
|
||||
const usage = {};
|
||||
let model = null;
|
||||
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
|
||||
const u = data && generationUsage(data);
|
||||
if (!u) continue;
|
||||
model = u.model;
|
||||
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
for (const k of Object.keys(t)) t[k] += u[k];
|
||||
}
|
||||
return {
|
||||
cwd: blob?.data ? firstFileUri(blob.data) : null,
|
||||
usage,
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
conn.close();
|
||||
}
|
||||
return {
|
||||
cwd: blob?.data ? firstFileUri(blob.data) : null,
|
||||
usage,
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
conn.close();
|
||||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The conversation files, each with the newer mtime of the file and its non-empty `-wal`. */
|
||||
function conversationFiles() {
|
||||
const dir = path.join(getAgyCliDir(), "conversations");
|
||||
return agyDirs().flatMap((cli) => conversationFilesIn(path.join(cli, "conversations")));
|
||||
}
|
||||
|
||||
function conversationFilesIn(dir) {
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
@@ -328,6 +362,8 @@ function writeSession(f, conv, now) {
|
||||
const id = f.id;
|
||||
const existing = stmts.getSession.get(id);
|
||||
if (existing && existing.tool !== "antigravity") return null;
|
||||
// S3: a member's conversation names only their own folder and session.
|
||||
if (!require("./run-as").sameOwner(f.path, conv.cwd, existing?.cwd)) return null;
|
||||
const facts = transcriptFacts(id);
|
||||
const a = activity(id, f.mtime);
|
||||
const lastWrite = new Date(a.last).toISOString();
|
||||
@@ -484,6 +520,7 @@ module.exports = {
|
||||
pbFields,
|
||||
generationUsage,
|
||||
firstFileUri,
|
||||
readConversation,
|
||||
ingestAntigravityUsage,
|
||||
startAntigravityUsagePoll,
|
||||
};
|
||||
|
||||
+60
-7
@@ -41,14 +41,16 @@ const ROLES = ["admin", "analyst", "developer"];
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/** API prefixes whose writes change the host account's own setup. */
|
||||
/**
|
||||
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
||||
* profiles are not here: a member writes their own personal ones, and
|
||||
* `routes/ai-proxies.js` keeps the shared ones an admin's.)
|
||||
*/
|
||||
const ADMIN_WRITE_PREFIXES = [
|
||||
"/settings",
|
||||
"/updates",
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
"/ai-proxies",
|
||||
"/ai-routers",
|
||||
"/git-credentials",
|
||||
"/ai-tools",
|
||||
"/import",
|
||||
@@ -85,6 +87,9 @@ const STAFF_ONLY_PREFIXES = [
|
||||
];
|
||||
// The run form's `/` autocomplete lists commands and skills.
|
||||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
||||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||||
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
||||
|
||||
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
|
||||
|
||||
@@ -136,13 +141,22 @@ function findUser(username) {
|
||||
|
||||
async function createUser(username, password, role = "developer") {
|
||||
const name = validateUsername(username);
|
||||
const linux = require("./linux-accounts");
|
||||
linux.assertLinuxName(name);
|
||||
validatePassword(password);
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`);
|
||||
const hash = await hashPassword(password);
|
||||
// S3: the Linux account first — a user who cannot get one is not created.
|
||||
linux.provision("create", name);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||||
).run(name, role, hash);
|
||||
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
|
||||
// the background (each marketplace is a clone) — the user exists already.
|
||||
require("./member-claude")
|
||||
.applyPluginBaseline(name)
|
||||
.catch((e) => console.warn(`[plugin-baseline] ${name}: ${e.message}`));
|
||||
return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid));
|
||||
}
|
||||
|
||||
@@ -162,21 +176,58 @@ async function setPassword(username, password) {
|
||||
|
||||
function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, requireUser(username).id);
|
||||
const u = requireUser(username);
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: end the live panes of `userId`'s workspaces whose folder is no
|
||||
* member's — they run as the HOST account, which only an admin's may (a
|
||||
* new one is refused, EOWNERFOLDER; this ends the ones already running).
|
||||
*/
|
||||
function stopHostPanes(userId) {
|
||||
if (!require("./linux-accounts").s3Enabled()) return;
|
||||
const runAs = require("./run-as");
|
||||
const pty = require("./pty-run");
|
||||
for (const lane of sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(userId)) {
|
||||
if (runAs.ownerForRead(lane.cwd)) continue; // a member's folder: their own account
|
||||
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
|
||||
try {
|
||||
pty.killRun(pty.runIdForLane(lane.id, slot));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Disable (ending every login) or re-enable a user. Users are never deleted:
|
||||
* S2 will hang owned rows off their id. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
const linux = require("./linux-accounts");
|
||||
// S3: lock (and end the processes of) the Linux account too — when there is
|
||||
// one: a user made before S3, or whose name fits no Linux login, has none,
|
||||
// and that must never stop an admin from revoking their dashboard access.
|
||||
const hasAccount = linux.s3Enabled() && require("./run-as").hasLinuxAccount(u.username);
|
||||
if (!disabled && hasAccount) linux.provision("enable", u.username);
|
||||
sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id);
|
||||
if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
// ...and their panes that run as the host (pkill below reaches only theirs).
|
||||
if (disabled) stopHostPanes(u.id);
|
||||
// Disable: the logins end FIRST, unconditionally; a failed lock still throws
|
||||
// (EPROVISION), so the admin learns that the account's agents may still run.
|
||||
if (disabled && hasAccount) linux.provision("disable", u.username);
|
||||
}
|
||||
|
||||
/** Give a workspace (a lane id) to a user — for the ones made before server
|
||||
* mode, which have no owner and so reach no member. */
|
||||
function assignWorkspace(laneId, username) {
|
||||
const u = requireUser(username);
|
||||
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(Number(laneId));
|
||||
// S3: a workspace in a member's home is theirs alone (EOWNERFOLDER).
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id);
|
||||
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
|
||||
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
||||
}
|
||||
@@ -338,10 +389,12 @@ function userFromPaneToken(req) {
|
||||
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
|
||||
if (!isLocalRequest(req)) return null;
|
||||
const row = sql(
|
||||
`SELECT r.id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
`SELECT r.id, r.lane_id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
|
||||
).get(sha256(t));
|
||||
if (!row || row.owner_id == null) return null;
|
||||
// S3: only from the account that pane runs as (any local uid could post it).
|
||||
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
|
||||
if (!require("./tmux").hasSession(row.id)) return null;
|
||||
return (
|
||||
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
|
||||
@@ -396,8 +449,8 @@ function authGuard(req, res, next) {
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
user.role === "developer" &&
|
||||
STAFF_ONLY_PREFIXES.some(under) &&
|
||||
!(isRead && MEMBER_READ_ALLOW.some(under))
|
||||
((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) ||
|
||||
STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
||||
) {
|
||||
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ function isUnder(root, target) {
|
||||
|
||||
function readJson(absPath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(absPath, "utf8");
|
||||
const raw = require("./run-as").readFileSafe(absPath, "utf8");
|
||||
return { ok: true, data: JSON.parse(raw), raw };
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return { ok: false, missing: true };
|
||||
@@ -129,14 +129,14 @@ function safeReadText(absPath) {
|
||||
return {
|
||||
truncated: true,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
||||
text: require("./run-as").readFileSafe(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
truncated: false,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8"),
|
||||
text: require("./run-as").readFileSafe(absPath, "utf8"),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
@@ -837,6 +837,31 @@ function readMemory(opts = {}) {
|
||||
|
||||
// ── Single-file body endpoint (with strict path containment) ───────────
|
||||
|
||||
/**
|
||||
* A credential is never served by name, to any role: Claude's
|
||||
* `.credentials.json`, Codex's `auth.json`, any non-Markdown file named like a
|
||||
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
|
||||
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
|
||||
*/
|
||||
function isSecretConfigPath(resolved) {
|
||||
const base = path.basename(resolved);
|
||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||
const rel = path.relative(getClaudeHome(), resolved);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-"))
|
||||
return true;
|
||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||
// keys and every gateway pane's settings file WITH its live pane token.
|
||||
const { getDataDir, dataDirCandidates } = require("./claude-home");
|
||||
const { current, legacy } = dataDirCandidates();
|
||||
let real = resolved;
|
||||
try {
|
||||
real = fs.realpathSync(resolved);
|
||||
} catch {
|
||||
/* not there: the lexical path decides */
|
||||
}
|
||||
return [getDataDir(), current, legacy].some((d) => isUnder(d, resolved) || isUnder(d, real));
|
||||
}
|
||||
|
||||
function readFileSafe(absPath, opts = {}) {
|
||||
const allowedRoots = [
|
||||
getClaudeHome(),
|
||||
@@ -855,6 +880,7 @@ function readFileSafe(absPath, opts = {}) {
|
||||
) {
|
||||
return { error: "only CLAUDE.md is readable from project root" };
|
||||
}
|
||||
if (isSecretConfigPath(resolved)) return { error: "a credential file is never served" };
|
||||
const r = safeReadText(resolved);
|
||||
if (!r) return { error: "file not readable" };
|
||||
return { ok: true, file: resolved, ...r };
|
||||
@@ -921,6 +947,7 @@ function readOverview(opts = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSecretConfigPath,
|
||||
// Exported so a test can pin it against the file the CLI writes — the two
|
||||
// drifted apart once and the mismatch was invisible from either side alone.
|
||||
getClaudeJsonPath,
|
||||
|
||||
@@ -65,6 +65,15 @@ function reloadableLanes() {
|
||||
for (const run of listRuns()) {
|
||||
if (run.status !== "running") continue;
|
||||
if (run.tool !== "claude") continue;
|
||||
// S3: a member's pane runs on their own ~/.claude — a change to the
|
||||
// host's plugins or skills is not theirs to reload.
|
||||
// EMEMBERGONE (a disabled member's pane) is a member's too: skip it,
|
||||
// never let one throw end the reload for every other pane.
|
||||
try {
|
||||
if (require("./run-as").accountForSession(run.id)) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const cmd = tmux.paneCommand(run.id);
|
||||
if (SHELL_COMMANDS.has(cmd || "")) continue;
|
||||
out.push({ id: run.id, laneId: run.laneId, paneCommand: cmd });
|
||||
|
||||
+64
-20
@@ -103,6 +103,37 @@ function encodeCwd(cwd) {
|
||||
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* The projects dir a session working in `cwd` writes its transcripts to: a
|
||||
* member's own `<home>/.claude/projects` under S3 (one Linux user per account,
|
||||
* server/lib/run-as.js), else this account's. A READ: a disabled or
|
||||
* unprovisioned member's folder is still theirs (`ownerForRead`), never a
|
||||
* throw — this runs inside async routes and the liveness reaper.
|
||||
*/
|
||||
function projectsDirFor(cwd) {
|
||||
const acct = cwd ? require("./run-as").ownerForRead(cwd) : null;
|
||||
return acct ? path.join(acct.home, ".claude", "projects") : getProjectsDir();
|
||||
}
|
||||
|
||||
/** This account's projects dir, then every member's (S3) — for a scan by id. */
|
||||
function allProjectsDirs() {
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".claude", "projects"));
|
||||
return [getProjectsDir(), ...members];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a by-id scan for ONE session looks: only the projects dir of the
|
||||
* account that owns its folder. Under S3 another member could plant
|
||||
* `<their home>/.claude/projects/x/<id>.jsonl`, a file they own, and a scan of
|
||||
* every home would serve it as this session's. A session with no folder is
|
||||
* this account's (`projectsDirFor(null)`), never every member's.
|
||||
*/
|
||||
function sessionProjectsDirs(cwd) {
|
||||
return [projectsDirFor(cwd)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the main session JSONL file path from sessionId and cwd.
|
||||
* Encoding rule: all non-alphanumeric characters replaced with "-".
|
||||
@@ -111,10 +142,10 @@ function encodeCwd(cwd) {
|
||||
function getTranscriptPath(sessionId, cwd) {
|
||||
if (!cwd) return null;
|
||||
const encoded = encodeCwd(cwd);
|
||||
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
|
||||
const candidate = path.join(projectsDirFor(cwd), encoded, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
// Fallback: scan projects/ subdirectories
|
||||
return findTranscriptPath(sessionId);
|
||||
// Fallback: scan the owner's projects/ subdirectories
|
||||
return findTranscriptPath(sessionId, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,29 +204,31 @@ function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
|
||||
function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
|
||||
if (!cwd) return null;
|
||||
const encoded = encodeCwd(cwd);
|
||||
const subagentsDir = path.join(getProjectsDir(), encoded, sessionId, "subagents");
|
||||
const subagentsDir = path.join(projectsDirFor(cwd), encoded, sessionId, "subagents");
|
||||
const direct = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
|
||||
if (direct) return direct;
|
||||
// Fallback: scan all project directories
|
||||
return findSubagentTranscriptPath(sessionId, agentId, runId);
|
||||
// Fallback: scan the owner's project directories
|
||||
return findSubagentTranscriptPath(sessionId, agentId, runId, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* When cwd is unknown, scan projects/ subdirectories to find the JSONL file for a sessionId.
|
||||
* Scan projects/ subdirectories for a sessionId's JSONL file — the owner's of
|
||||
* `cwd` only (`sessionProjectsDirs`).
|
||||
* Returns the found path or null.
|
||||
*/
|
||||
function findTranscriptPath(sessionId) {
|
||||
const projectsDir = getProjectsDir();
|
||||
if (!fs.existsSync(projectsDir)) return null;
|
||||
try {
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
function findTranscriptPath(sessionId, cwd = null) {
|
||||
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
||||
if (!fs.existsSync(projectsDir)) continue;
|
||||
try {
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
} catch {
|
||||
// Permission or IO error, ignore
|
||||
}
|
||||
} catch {
|
||||
// Permission or IO error, ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -245,8 +278,15 @@ function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
|
||||
* - Exact: agent-<agentId>.jsonl (or workflows/<runId>/agent-<agentId>.jsonl)
|
||||
* - Fuzzy: agent-acompact-*.jsonl (for compaction type)
|
||||
*/
|
||||
function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
|
||||
const projectsDir = getProjectsDir();
|
||||
function findSubagentTranscriptPath(sessionId, agentId, runId = null, cwd = null) {
|
||||
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
||||
const hit = findSubagentIn(projectsDir, sessionId, agentId, runId);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSubagentIn(projectsDir, sessionId, agentId, runId) {
|
||||
if (!fs.existsSync(projectsDir)) return null;
|
||||
try {
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
@@ -303,6 +343,7 @@ module.exports = {
|
||||
getClaudeHome,
|
||||
getProjectsDir,
|
||||
getDataDir,
|
||||
dataDirCandidates,
|
||||
migrateLegacyDataDir,
|
||||
getTranscriptSnapshotDir,
|
||||
getSettingsPath,
|
||||
@@ -313,5 +354,8 @@ module.exports = {
|
||||
getSnapshotSubagentTranscriptPath,
|
||||
findTranscriptPath,
|
||||
findSubagentTranscriptPath,
|
||||
projectsDirFor,
|
||||
allProjectsDirs,
|
||||
sessionProjectsDirs,
|
||||
setClaudeHome,
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ function spendSince(account, sinceHour) {
|
||||
`WITH s AS (
|
||||
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
|
||||
FROM dashboard_runs
|
||||
WHERE account = ? AND session_id IS NOT NULL
|
||||
WHERE tool = 'claude' AND account = ? AND session_id IS NOT NULL
|
||||
GROUP BY session_id)
|
||||
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
@@ -189,7 +189,7 @@ function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS n FROM dashboard_runs WHERE account = ? AND status IN ('running', 'spawning')"
|
||||
"SELECT COUNT(*) AS n FROM dashboard_runs WHERE tool = 'claude' AND account = ? AND status IN ('running', 'spawning')"
|
||||
)
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
@@ -329,6 +329,9 @@ function readFileSafe(absPath, opts = {}) {
|
||||
) {
|
||||
return { error: "only AGENTS.md is readable from project root" };
|
||||
}
|
||||
if (require("./cc-discovery").isSecretConfigPath(resolved)) {
|
||||
return { error: "a credential file is never served" };
|
||||
}
|
||||
|
||||
const r = safeReadText(resolved);
|
||||
if (!r) return { notFound: true };
|
||||
|
||||
@@ -65,11 +65,17 @@ function allRolloutFiles(home = getCodexHome()) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The newest MAX_FILES rollout journals, newest mtime first. */
|
||||
/** The newest MAX_FILES rollout journals of each home, newest mtime first. */
|
||||
function rolloutFiles() {
|
||||
return allRolloutFiles()
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
.slice(0, MAX_FILES);
|
||||
// This account's CODEX_HOME and every member's (S3) — capped PER home, so
|
||||
// one member touching many journals cannot push everyone else's out.
|
||||
const newest = (files) => files.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex"));
|
||||
return [allRolloutFiles(), ...members.map((h) => allRolloutFiles(h))]
|
||||
.flatMap(newest)
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
|
||||
const cwdCache = new Map(); // rollout path → thread cwd (written once, at thread creation)
|
||||
@@ -79,7 +85,7 @@ function rolloutCwd(file) {
|
||||
if (cwdCache.has(file)) return cwdCache.get(file);
|
||||
let cwd = null;
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const buf = Buffer.alloc(64 * 1024);
|
||||
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
@@ -138,8 +144,11 @@ function ingestCodexOnce({ broadcast } = {}) {
|
||||
for (const file of files) {
|
||||
const cwd = rolloutCwd(file.path);
|
||||
if (!cwd) continue;
|
||||
// A journal speaks only for its own account's lanes (S3): its cwd is what
|
||||
// the journal SAYS, and a member can write any cwd into their own file.
|
||||
const lane = codexLanes
|
||||
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
||||
.filter((l) => require("./run-as").sameOwner(file.path, l.cwd))
|
||||
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
||||
if (!lane) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* @file codex-pool.js
|
||||
* @description The Codex account POOL, modelled on claude-pool.js: accounts an
|
||||
* admin added from the host's shell (`agenthub accounts add <name> --tool
|
||||
* codex`), and the balancer that gives every new Codex pane on no AI Proxy one
|
||||
* of them. An empty pool changes nothing: panes run on their CODEX_HOME's own
|
||||
* login as before. Two kinds:
|
||||
* - `apikey`: an OpenAI API key, any number of panes at once. The key reaches
|
||||
* the pane only through tmux `-e AGENTHUB_POOL_KEY` (a member's launch
|
||||
* file); the argv only adds a `-c` provider whose `env_key` NAMES it.
|
||||
* - `chatgpt`: a ChatGPT login (`auth.json`). Codex reads it from no env
|
||||
* var, so it is written into the MEMBER's own CODEX_HOME (S3) AS the member
|
||||
* (write-as, 0600) just before the pane starts — never into a host pane's
|
||||
* CODEX_HOME (that is the host's own login), never over a login AgentHub
|
||||
* did not place there, and never a different account than the one a live
|
||||
* pane of that member already runs on (one auth.json per home). Refresh
|
||||
* tokens rotate, so `syncBack` copies a member's auth.json back into the
|
||||
* pool whenever its `last_refresh` is newer (newest wins) — only from a
|
||||
* home AgentHub placed that account in, never dated in the future, and
|
||||
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
|
||||
* spike showed an old refresh token still refreshes, so panes sharing an
|
||||
* account do not log each other out.
|
||||
*
|
||||
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
|
||||
* 0700, file 0600); no route returns a key or a token.
|
||||
*
|
||||
* `pickAccount` ranks like the Claude pool: an account whose last reading is
|
||||
* ≥ `BLOCK_PERCENT` goes last, then fewest live Codex panes, then least $ in
|
||||
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
|
||||
* rollouts of the sessions that account's runs started (no network), each
|
||||
* from the CODEX_HOME of the account its workspace runs as.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const KEY_RE = /^sk-[A-Za-z0-9_-]{20,500}$/;
|
||||
const QUOTA_TTL_MS = 60 * 1000;
|
||||
const BLOCK_PERCENT = 90;
|
||||
const PROVIDER = "agenthub_pool";
|
||||
const KEY_ENV = "AGENTHUB_POOL_KEY";
|
||||
const SESSION_IN_NAME = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const runAs = () => require("./run-as");
|
||||
|
||||
const poolDir = () => path.join(getClaudeHome(), "agenthub-codex-accounts");
|
||||
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
|
||||
|
||||
function validName(name) {
|
||||
const n = String(name || "").trim();
|
||||
if (!NAME_RE.test(n))
|
||||
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
|
||||
return n;
|
||||
}
|
||||
|
||||
const isChatgptAuth = (auth) =>
|
||||
["id_token", "access_token", "refresh_token", "account_id"].every(
|
||||
(k) => typeof auth?.tokens?.[k] === "string" && auth.tokens[k]
|
||||
);
|
||||
|
||||
function readAccount(name) {
|
||||
try {
|
||||
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
||||
if (a.kind === "apikey" && typeof a.key === "string") return a;
|
||||
if (a.kind === "chatgpt" && isChatgptAuth(a.auth)) return a;
|
||||
} catch {
|
||||
/* absent or not ours */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function allAccounts() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n))
|
||||
.map(readAccount)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Write a record atomically, 0600. `create` refuses an existing name. */
|
||||
function save(record, { create = false } = {}) {
|
||||
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(poolDir(), 0o700);
|
||||
if (create) {
|
||||
fs.writeFileSync(fileOf(record.name), JSON.stringify(record, null, 2), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ponytail: no cross-process lock — the server's read-compare-write is one
|
||||
// synchronous run on its event loop, and the rename is atomic; a `remove`
|
||||
// from the CLI racing a copy-back can at worst bring the account back.
|
||||
if (!fs.existsSync(fileOf(record.name))) return; // removed meanwhile
|
||||
const tmp = `${fileOf(record.name)}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, fileOf(record.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an account: `{apiKey}`, or `{auth}` = the parsed `auth.json` of a
|
||||
* `codex login` (a ChatGPT login, or one holding only an API key).
|
||||
*/
|
||||
function addAccount(name, { apiKey, auth } = {}) {
|
||||
const n = validName(name);
|
||||
let record;
|
||||
if (isChatgptAuth(auth)) {
|
||||
record = { name: n, kind: "chatgpt", accountId: auth.tokens.account_id, auth };
|
||||
} else {
|
||||
const key = String((auth && auth.OPENAI_API_KEY) || apiKey || "").trim();
|
||||
if (!KEY_RE.test(key)) {
|
||||
throw err(
|
||||
"EBADTOKEN",
|
||||
"not a Codex login — give an OpenAI API key (sk-…) or the auth.json `codex login` wrote"
|
||||
);
|
||||
}
|
||||
record = { name: n, kind: "apikey", key };
|
||||
}
|
||||
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
|
||||
record.addedAt = new Date().toISOString();
|
||||
save(record, { create: true });
|
||||
quotaCache = null;
|
||||
return { name: n, kind: record.kind, addedAt: record.addedAt };
|
||||
}
|
||||
|
||||
function removeAccount(name) {
|
||||
const n = validName(name);
|
||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||
fs.rmSync(fileOf(n));
|
||||
quotaCache = null;
|
||||
}
|
||||
|
||||
// ── What AgentHub recorded per account ─────────────────────────────────────
|
||||
|
||||
function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account = ? AND status IN ('running', 'spawning')`
|
||||
)
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
/**
|
||||
* $ of the Codex sessions this account's runs started since `hours` ago.
|
||||
* ponytail: Codex usage has no hourly table, so a session counts whole once
|
||||
* its run started in the window; add hourly rows if that skews a ranking.
|
||||
*/
|
||||
function spendSince(account, hours) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost } = require("../routes/pricing");
|
||||
const since = new Date(Date.now() - hours * 3600e3).toISOString();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT model, speed, inference_geo, service_tier,
|
||||
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
|
||||
SUM(cache_read_tokens) AS cache_read_tokens,
|
||||
SUM(cache_write_tokens) AS cache_write_tokens
|
||||
FROM token_usage
|
||||
WHERE session_id IN (SELECT session_id FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account = ?
|
||||
AND session_id IS NOT NULL AND started_at >= ?)
|
||||
GROUP BY model, speed, inference_geo, service_tier`
|
||||
)
|
||||
.all(account, since);
|
||||
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
|
||||
}
|
||||
|
||||
// ── Quota, from the account's own rollouts ─────────────────────────────────
|
||||
|
||||
let quotaCache = null; // { at, byName: Map<name, {windows, asOf}> }
|
||||
|
||||
/**
|
||||
* name → the newest `rate_limits` its sessions' rollouts carry. A run's
|
||||
* rollout is read only from the CODEX_HOME of the account its workspace runs
|
||||
* as (a member's own, else the host's), never dated in the future — a member
|
||||
* can speak only for their own session's reading (an accepted ceiling: no
|
||||
* network check), never for another account's run.
|
||||
*/
|
||||
function quotas() {
|
||||
if (quotaCache && Date.now() - quotaCache.at < QUOTA_TTL_MS) return quotaCache.byName;
|
||||
const byName = new Map();
|
||||
const { db } = require("../db");
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT DISTINCT account, session_id, lane_id FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account IS NOT NULL AND session_id IS NOT NULL`
|
||||
)
|
||||
.all();
|
||||
if (runs.length) {
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { lastRateLimits, codexWindow } = require("./quota");
|
||||
const homeOf = (laneId) => {
|
||||
try {
|
||||
const acct = runAs().accountForLane(laneId);
|
||||
return acct ? path.join(acct.home, ".codex") : getCodexHome();
|
||||
} catch {
|
||||
return null; // a gone member: nobody's reading
|
||||
}
|
||||
};
|
||||
// ponytail: one walk of each CODEX_HOME in use per minute; index rollouts
|
||||
// by session id if a sessions/ tree ever makes that slow.
|
||||
const byHome = new Map(); // home → session id → rollout files
|
||||
const filesIn = (home, sid) => {
|
||||
if (!byHome.has(home)) {
|
||||
const bySession = new Map();
|
||||
for (const f of allRolloutFiles(home)) {
|
||||
if (f.mtime > Date.now() + FUTURE_SLACK_MS) continue;
|
||||
const id = SESSION_IN_NAME.exec(path.basename(f.path))?.[1];
|
||||
if (id) bySession.set(id, [...(bySession.get(id) || []), f]);
|
||||
}
|
||||
byHome.set(home, bySession);
|
||||
}
|
||||
return byHome.get(home).get(sid) || [];
|
||||
};
|
||||
const filesOf = new Map();
|
||||
for (const r of runs) {
|
||||
const home = homeOf(r.lane_id);
|
||||
if (!home) continue;
|
||||
filesOf.set(r.account, [...(filesOf.get(r.account) || []), ...filesIn(home, r.session_id)]);
|
||||
}
|
||||
for (const [name, files] of filesOf) {
|
||||
for (const f of files.sort((a, b) => b.mtime - a.mtime)) {
|
||||
const found = lastRateLimits(f);
|
||||
if (!found) continue;
|
||||
const windows = [found.rl.primary, found.rl.secondary]
|
||||
.map((w) => codexWindow(w, found.mtime))
|
||||
.filter(Boolean);
|
||||
byName.set(name, { windows, asOf: new Date(found.mtime).toISOString() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
quotaCache = { at: Date.now(), byName };
|
||||
return byName;
|
||||
}
|
||||
|
||||
function quotaPercent(name) {
|
||||
const q = quotas().get(name);
|
||||
return q && q.windows.length ? Math.max(...q.windows.map((w) => w.usedPercent)) : null;
|
||||
}
|
||||
|
||||
/** Every account with what the balancer sees — never a key or a token. */
|
||||
function poolStatus() {
|
||||
return allAccounts().map((a) => {
|
||||
const percent = quotaPercent(a.name);
|
||||
return {
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
addedAt: a.addedAt,
|
||||
livePanes: livePanes(a.name),
|
||||
spend5h: spendSince(a.name, 5),
|
||||
spend7d: spendSince(a.name, 24 * 7),
|
||||
quota: quotas().get(a.name) || null,
|
||||
quotaPercent: percent,
|
||||
full: percent != null && percent >= BLOCK_PERCENT,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── A member's auth.json ───────────────────────────────────────────────────
|
||||
|
||||
const authFile = (home) => path.join(home, ".codex", "auth.json");
|
||||
|
||||
/** The auth.json in `home`'s CODEX_HOME (read guarded: the member's own file), or null. */
|
||||
function homeAuth(home) {
|
||||
try {
|
||||
return JSON.parse(runAs().readFileSafe(authFile(home), "utf8", { max: 1 << 20 }));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedAt = (auth) => Date.parse(auth?.last_refresh) || 0;
|
||||
const FUTURE_SLACK_MS = 5 * 60 * 1000;
|
||||
|
||||
// Who an id_token says it is: Codex's carries the ChatGPT account under this
|
||||
// claim (and the email); a real refresh keeps both.
|
||||
const AUTH_CLAIM = "https://api.openai.com/auth";
|
||||
function identity(auth) {
|
||||
try {
|
||||
const c = JSON.parse(
|
||||
Buffer.from(String(auth.tokens.id_token).split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
return { email: c.email ?? null, account: c[AUTH_CLAIM]?.chatgpt_account_id ?? null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `disk` (a member's auth.json) may replace pool account `a`: AgentHub
|
||||
* placed `a` in that home itself (`placedIn`), its `last_refresh` is no later
|
||||
* than now, and its id_token names the same account and email as ours. A
|
||||
* member edits their own auth.json at will; without these, one member could
|
||||
* write a forged record over a login every other member's panes get.
|
||||
* ponytail: the id_token is not signature-checked (no network here), so a
|
||||
* member P was placed with can still poison P with a self-consistent forgery
|
||||
* until a real refresh from another home wins it back; a server-side refresh
|
||||
* is the upgrade.
|
||||
*/
|
||||
function mayCopyBack(a, disk, login) {
|
||||
if (!placedHere(a, disk, login)) return false;
|
||||
const t = refreshedAt(disk);
|
||||
return t > refreshedAt(a.auth) && t <= Date.now() + FUTURE_SLACK_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `disk` (a member's auth.json) is pool account `a` that AgentHub
|
||||
* placed in that home: `placedIn`, and the same email and account in the
|
||||
* id_token. `account_id` alone says nothing — it is the ChatGPT WORKSPACE,
|
||||
* shared by every seat of a Team, so a member's own seat would match it.
|
||||
*/
|
||||
function placedHere(a, disk, login) {
|
||||
if (a.kind !== "chatgpt" || !(a.placedIn || []).includes(login)) return false;
|
||||
const ours = identity(a.auth);
|
||||
const theirs = identity(disk);
|
||||
return Boolean(ours && theirs && ours.email === theirs.email && ours.account === theirs.account);
|
||||
}
|
||||
|
||||
/** Copy `home`'s auth.json back into the pool account AgentHub placed there, when it is a newer login of it. */
|
||||
function syncHome(home) {
|
||||
const disk = homeAuth(home);
|
||||
if (!isChatgptAuth(disk)) return;
|
||||
const login = require("./linux-accounts").loginFor(path.basename(home));
|
||||
for (const a of allAccounts()) {
|
||||
if (a.kind !== "chatgpt" || a.accountId !== disk.tokens.account_id) continue;
|
||||
if (mayCopyBack(a, disk, login)) save({ ...a, auth: disk });
|
||||
}
|
||||
}
|
||||
|
||||
/** Every member's CODEX_HOME → the pool, newest login wins (the run-heal beat). */
|
||||
function syncBack() {
|
||||
if (!allAccounts().some((a) => a.kind === "chatgpt")) return;
|
||||
for (const home of runAs().memberHomes()) syncHome(home);
|
||||
}
|
||||
|
||||
// ── Picking ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The full record a new Codex pane should run on, or null. `member` = run-as account. */
|
||||
function pick(member) {
|
||||
let usable = allAccounts();
|
||||
if (usable.length === 0) return null;
|
||||
if (!member) {
|
||||
// A host pane's CODEX_HOME holds the host's own login.
|
||||
usable = usable.filter((a) => a.kind === "apikey");
|
||||
} else {
|
||||
const disk = homeAuth(member.home);
|
||||
const placed = disk ? usable.find((a) => placedHere(a, disk, member.login)) : null;
|
||||
// A login AgentHub did not place there is the member's own: keep it.
|
||||
if (!placed && (isChatgptAuth(disk) || disk?.OPENAI_API_KEY)) {
|
||||
usable = usable.filter((a) => a.kind !== "chatgpt");
|
||||
} else {
|
||||
// One auth.json per home: while a pane of theirs runs on a pool
|
||||
// login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
}
|
||||
}
|
||||
if (usable.length === 0) return null;
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
const percent = quotaPercent(a.name);
|
||||
return {
|
||||
a,
|
||||
key: [
|
||||
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
|
||||
livePanes(a.name),
|
||||
spendSince(a.name, 5),
|
||||
spendSince(a.name, 24 * 7),
|
||||
],
|
||||
};
|
||||
})
|
||||
.sort((x, y) => {
|
||||
for (let i = 0; i < x.key.length; i++) {
|
||||
if (x.key[i] !== y.key[i]) return x.key[i] - y.key[i];
|
||||
}
|
||||
return x.a.name.localeCompare(y.a.name);
|
||||
});
|
||||
return ranked[0].a;
|
||||
}
|
||||
|
||||
function liveInHome(name, member) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT lane_id FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account = ? AND status IN ('running', 'spawning')
|
||||
AND lane_id IS NOT NULL`
|
||||
)
|
||||
.all(name)
|
||||
.some((r) => {
|
||||
try {
|
||||
return runAs().accountForLane(r.lane_id)?.login === member.login;
|
||||
} catch {
|
||||
return false; // a gone member's lane is not this (enabled) member's
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
return a ? { name: a.name, kind: a.kind } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new Codex pane needs from the pool, or null (empty pool, or nothing
|
||||
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
|
||||
* (a member's launch file); `place()` runs just before the pane starts.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
if (!a) return null;
|
||||
if (a.kind === "apikey") {
|
||||
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
|
||||
return {
|
||||
name: a.name,
|
||||
argv: ["-c", `model_providers.${PROVIDER}=${provider}`, "-c", `model_provider="${PROVIDER}"`],
|
||||
env: { [KEY_ENV]: a.key },
|
||||
place: () => {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: a.name,
|
||||
argv: [],
|
||||
env: {},
|
||||
place: () => {
|
||||
syncHome(member.home); // a live pane may hold a newer login than ours
|
||||
const record = readAccount(a.name) || a;
|
||||
runAs().mkdirAs(member, path.dirname(authFile(member.home)));
|
||||
runAs().writeAs(
|
||||
member,
|
||||
authFile(member.home),
|
||||
"private",
|
||||
JSON.stringify(record.auth, null, 2)
|
||||
);
|
||||
// Only a home AgentHub placed this login in may copy a refresh back.
|
||||
if (!(record.placedIn || []).includes(member.login)) {
|
||||
save({ ...record, placedIn: [...(record.placedIn || []), member.login] });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BLOCK_PERCENT,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
syncBack,
|
||||
poolDir,
|
||||
__read: readAccount,
|
||||
resetQuota: () => {
|
||||
quotaCache = null;
|
||||
},
|
||||
};
|
||||
+61
-13
@@ -43,7 +43,6 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
const { getCodexHome, codexHomes } = require("./codex-home");
|
||||
@@ -166,7 +165,7 @@ function parseRolloutChunk(text, state = {}, skip = null) {
|
||||
*/
|
||||
function readChunk(file, offset, size) {
|
||||
const buf = Buffer.alloc(Math.max(0, Math.min(size - offset, MAX_CHUNK)));
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
fs.readSync(fd, buf, 0, buf.length, offset);
|
||||
} finally {
|
||||
@@ -180,8 +179,7 @@ function readChunk(file, offset, size) {
|
||||
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
|
||||
async function parentTotals(file) {
|
||||
const keys = new Set();
|
||||
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
||||
for await (const line of lines) {
|
||||
for await (const line of require("./run-as").readLines(file)) {
|
||||
if (!line.includes('"token_count"')) continue;
|
||||
try {
|
||||
const t = JSON.parse(line).payload?.info?.total_token_usage;
|
||||
@@ -214,7 +212,14 @@ const threadCache = new Map(); // rollout path → {id, sessionId}; a first line
|
||||
function threadOf(file) {
|
||||
if (typeof file !== "string" || !path.isAbsolute(file)) return null;
|
||||
const resolved = path.resolve(file);
|
||||
const under = codexHomes().some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
|
||||
// The host's CODEX_HOME, or a member's (S3: hook-auth holds them to their own).
|
||||
const homes = [
|
||||
...codexHomes(),
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex")),
|
||||
];
|
||||
const under = homes.some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
|
||||
if (!under || !FILE_NAME.test(path.basename(resolved))) return null;
|
||||
if (threadCache.has(resolved)) return threadCache.get(resolved);
|
||||
const meta = firstLineMeta(resolved);
|
||||
@@ -230,7 +235,7 @@ function threadNames(home = getCodexHome()) {
|
||||
const names = new Map();
|
||||
let text = "";
|
||||
try {
|
||||
text = fs.readFileSync(path.join(home, "session_index.jsonl"), "utf8");
|
||||
text = require("./run-as").readFileSafe(path.join(home, "session_index.jsonl"), "utf8", { max: 16 << 20 });
|
||||
} catch {
|
||||
return names;
|
||||
}
|
||||
@@ -257,8 +262,13 @@ const upsertRollout = db.prepare(
|
||||
// or reached through a symlink) replaces its old row instead of counting twice.
|
||||
const dropOtherPaths = db.prepare("DELETE FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
|
||||
const rolloutsOf = db.prepare(
|
||||
"SELECT thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
|
||||
"SELECT path, thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
|
||||
);
|
||||
const threadPaths = db.prepare("SELECT path FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
|
||||
// S3: whose a path is ("" = the host's). A journal in a member's home speaks
|
||||
// only for that member: its session's folder must be theirs, and its ids
|
||||
// never take over another account's rows.
|
||||
const ownerKey = (p) => (p ? require("./run-as").ownerForRead(p)?.name || "" : "");
|
||||
const insertCodexSession = db.prepare(
|
||||
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'codex', ?)`
|
||||
@@ -274,6 +284,7 @@ const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||
// an event no older than the journal's newest write (less ACTIVE_MS). Hooks
|
||||
// removed, or no longer trusted, hand the status back to the journal.
|
||||
const lastEventAt = db.prepare("SELECT MAX(created_at) AS t FROM events WHERE session_id = ?");
|
||||
const anyRollout = db.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? LIMIT 1");
|
||||
const newestRollout = db.prepare(
|
||||
"SELECT MAX(mtime_ms) AS m FROM codex_rollouts WHERE session_id = ?"
|
||||
);
|
||||
@@ -292,9 +303,14 @@ const LOCAL = { source: "local", activeMs: ACTIVE_MS };
|
||||
|
||||
/** Re-derive one root session's row and usage from all its threads. */
|
||||
function writeSession(sessionId, names, now, origin = LOCAL) {
|
||||
const rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
|
||||
if (rows.length === 0) return null;
|
||||
let rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
|
||||
const existing = stmts.getSession.get(sessionId);
|
||||
if (rows.length && require("./linux-accounts").s3Enabled()) {
|
||||
const own = rows.find((r) => r.thread_id === sessionId) || rows[0];
|
||||
const key = existing ? ownerKey(existing.cwd) : ownerKey(own.path);
|
||||
rows = rows.filter((r) => ownerKey(r.path) === key && ownerKey(r.state.cwd) === key);
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
if (existing && existing.tool !== "codex") return null;
|
||||
// Another machine's row (or this machine's, seen from a remote pass).
|
||||
if (existing && (existing.source || "local") !== origin.source) return null;
|
||||
@@ -439,7 +455,13 @@ function ingestCodexUsage({
|
||||
offset + bytes,
|
||||
JSON.stringify(state)
|
||||
);
|
||||
if (state.threadId) dropOtherPaths.run(state.threadId, f.path);
|
||||
if (
|
||||
state.threadId &&
|
||||
threadPaths
|
||||
.all(state.threadId, f.path)
|
||||
.every((o) => ownerKey(o.path) === ownerKey(f.path))
|
||||
)
|
||||
dropOtherPaths.run(state.threadId, f.path);
|
||||
read += 1;
|
||||
if (state.sessionId) touched.add(state.sessionId);
|
||||
} catch (err) {
|
||||
@@ -478,8 +500,24 @@ function startCodexUsagePoll(broadcast) {
|
||||
? Number(process.env.DASHBOARD_CODEX_POLL_MS)
|
||||
: 15_000;
|
||||
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||
// This account's CODEX_HOME, then every member's (S3: their sessions
|
||||
// write under their own home), one after another.
|
||||
const homes = () => [
|
||||
undefined,
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex")),
|
||||
];
|
||||
const ingestAll = async () => {
|
||||
const total = { sessions: 0 };
|
||||
for (const home of homes()) {
|
||||
const r = await ingestCodexUsage({ broadcast, home });
|
||||
total.sessions += r?.sessions || 0;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
const tick = () => {
|
||||
ingestCodexUsage({ broadcast })
|
||||
ingestAll()
|
||||
.then((r) => {
|
||||
settleActive(broadcast);
|
||||
// A pass that wrote sessions may have brought a model with no rate
|
||||
@@ -510,10 +548,20 @@ function settleActive(
|
||||
)
|
||||
.all(source, now - activeMs, now + FUTURE_SLACK_MS);
|
||||
if (quiet.length === 0) return;
|
||||
const names = threadNames(home);
|
||||
// Each session is named from its OWN home's index: S3 names a member's
|
||||
// thread in their CODEX_HOME, and the host's would rename it back to its
|
||||
// first prompt. A remote pass names its own home.
|
||||
const byHome = new Map();
|
||||
const namesOf = (id) => {
|
||||
const member =
|
||||
home === undefined ? require("./run-as").ownerForRead(anyRollout.get(id)?.path || "") : null;
|
||||
const h = member ? path.join(member.home, ".codex") : home;
|
||||
if (!byHome.has(h)) byHome.set(h, threadNames(h));
|
||||
return byHome.get(h);
|
||||
};
|
||||
for (const { id } of quiet) {
|
||||
if (hooksCurrent(id, newestRollout.get(id)?.m || 0)) continue;
|
||||
if (writeSession(id, names, now, { source, activeMs }))
|
||||
if (writeSession(id, namesOf(id), now, { source, activeMs }))
|
||||
broadcast("session_updated", stmts.getSession.get(id));
|
||||
}
|
||||
}
|
||||
|
||||
+32
-20
@@ -39,31 +39,32 @@ function getCursorDir() {
|
||||
|
||||
const norm = (s) => s.replace(/[^A-Za-z0-9]/g, "-");
|
||||
|
||||
// A walk enters at most this many directories: a member can build a tree (or
|
||||
// a symlink loop) whose matching paths grow exponentially with the slug.
|
||||
const MAX_VISITS = 500;
|
||||
|
||||
/**
|
||||
* The existing directory whose path slugs to `slug`, or null. Walks from `/`,
|
||||
* trying every entry whose own slug is a prefix of what is left.
|
||||
* trying every real directory (never a symlink) whose own slug is a prefix of
|
||||
* what is left; gives up (null) after MAX_VISITS directories.
|
||||
*/
|
||||
function cwdFromSlug(slug, root = "/") {
|
||||
let visits = 0;
|
||||
const walk = (dir, rest, depth) => {
|
||||
if (!rest) {
|
||||
try {
|
||||
return fs.statSync(dir).isDirectory() ? dir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (depth > 40) return null;
|
||||
let names;
|
||||
if (!rest) return dir;
|
||||
if (depth > 40 || ++visits > MAX_VISITS) return null;
|
||||
let entries;
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const name of names) {
|
||||
const n = norm(name);
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const n = norm(e.name);
|
||||
if (rest !== n && !rest.startsWith(`${n}-`)) continue;
|
||||
const hit = walk(path.join(dir, name), rest.slice(n.length + 1), depth + 1);
|
||||
if (hit) return hit;
|
||||
const hit = walk(path.join(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
||||
if (hit || visits > MAX_VISITS) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -75,7 +76,7 @@ function cwdFromSlug(slug, root = "/") {
|
||||
function firstPrompt(file) {
|
||||
let text;
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, HEAD_BYTES));
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
@@ -104,9 +105,17 @@ function firstPrompt(file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every agent transcript, with its project slug, birth and mtime. */
|
||||
/** Every agent transcript, with its project slug, birth and mtime — this
|
||||
* account's, then every member's (S3: their own home). */
|
||||
function transcriptFiles() {
|
||||
const projects = path.join(getCursorDir(), "projects");
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".cursor"));
|
||||
return [getCursorDir(), ...members].flatMap((dir) => transcriptFilesIn(dir));
|
||||
}
|
||||
|
||||
function transcriptFilesIn(cursorDir) {
|
||||
const projects = path.join(cursorDir, "projects");
|
||||
let slugs;
|
||||
try {
|
||||
slugs = fs.readdirSync(projects);
|
||||
@@ -164,6 +173,9 @@ const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACT
|
||||
function writeSession(f, now) {
|
||||
const existing = stmts.getSession.get(f.id);
|
||||
if (existing && existing.tool !== "cursor") return null;
|
||||
// S3: a member's transcript names only their own folder and session.
|
||||
const cwd = existing?.cwd ? null : cwdFromSlug(f.slug);
|
||||
if (!require("./run-as").sameOwner(f.path, existing?.cwd || cwd)) return null;
|
||||
const lastWrite = new Date(f.mtime).toISOString();
|
||||
const active = fresh(f.mtime, now);
|
||||
const status = active ? "active" : "completed";
|
||||
@@ -175,7 +187,7 @@ function writeSession(f, now) {
|
||||
status,
|
||||
endedAt,
|
||||
lastWrite,
|
||||
existing.cwd ? null : cwdFromSlug(f.slug),
|
||||
cwd,
|
||||
f.id
|
||||
);
|
||||
return "session_updated";
|
||||
@@ -184,7 +196,7 @@ function writeSession(f, now) {
|
||||
f.id,
|
||||
name,
|
||||
status,
|
||||
cwdFromSlug(f.slug),
|
||||
cwd,
|
||||
new Date(Math.min(f.birth, f.mtime)).toISOString(),
|
||||
endedAt,
|
||||
lastWrite,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file drop-caps.js
|
||||
* @description Server mode, one Linux user per account (S3): the system unit
|
||||
* gives the dashboard CAP_DAC_READ_SEARCH as an AMBIENT capability (it reads
|
||||
* members' 0600 transcripts), and Linux passes an ambient capability on to
|
||||
* every program a process execs — the host's tmux server and every agent in
|
||||
* a host pane, git and its hooks, npm installs. None of them may read
|
||||
* everything. So, when this process holds ambient capabilities, every child
|
||||
* starts through `setpriv --inh-caps=-all --ambient-caps=-all --`, which
|
||||
* clears them before the real program runs. Patched like windows-hide.js:
|
||||
* `ChildProcess.prototype.spawn` for every async form, the sync exports by
|
||||
* hand (they never reach that prototype). sudo needs no help (setuid clears
|
||||
* them). node-pty forks natively, outside child_process: its callers take the
|
||||
* argv from `ptyArgv` (pty-attach.js — a host `tmux attach` client could
|
||||
* otherwise run `detach-client -E <cmd>` with the capability). A no-op
|
||||
* anywhere else.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const DONE = Symbol.for("agenthub.dropCaps");
|
||||
const SETPRIV = "/usr/bin/setpriv";
|
||||
const FLAGS = ["--inh-caps=-all", "--ambient-caps=-all", "--"];
|
||||
let active = false; // install() patched child_process: wrap native spawns too
|
||||
|
||||
function hasAmbientCaps(status = null) {
|
||||
try {
|
||||
const text = status ?? fs.readFileSync("/proc/self/status", "utf8");
|
||||
const m = /^CapAmb:\s*([0-9a-f]+)$/m.exec(text);
|
||||
return !!m && /[1-9a-f]/.test(m[1]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Node's own shell rule (child_process normalizeSpawnArguments), for the sync forms. */
|
||||
function viaShell(file, args, shell) {
|
||||
const sh = typeof shell === "string" ? shell : "/bin/sh";
|
||||
return [sh, ["-c", [file, ...args].join(" ")]];
|
||||
}
|
||||
|
||||
function install(cp = require("node:child_process"), status = null) {
|
||||
if (process.platform !== "linux" || cp[DONE] || !hasAmbientCaps(status)) return false;
|
||||
if (!fs.existsSync(SETPRIV)) {
|
||||
console.warn("[drop-caps] setpriv is missing: children inherit CAP_DAC_READ_SEARCH");
|
||||
return false;
|
||||
}
|
||||
cp[DONE] = true;
|
||||
active = true;
|
||||
|
||||
const spawn = cp.ChildProcess.prototype.spawn;
|
||||
cp.ChildProcess.prototype.spawn = function (options) {
|
||||
// options.args[0] is argv0; setpriv execs the file under its own path.
|
||||
options.args = ["setpriv", ...FLAGS, options.file, ...options.args.slice(1)];
|
||||
options.file = SETPRIV;
|
||||
return spawn.call(this, options);
|
||||
};
|
||||
|
||||
// spawnSync / execFileSync (file, args?, opts?) and execSync (cmd, opts?).
|
||||
const wrap = (fn) =>
|
||||
function (file, args, opts) {
|
||||
if (!Array.isArray(args)) [args, opts] = [[], args];
|
||||
let [f, a] = [file, args || []];
|
||||
if (opts?.shell) [f, a] = viaShell(f, a, opts.shell);
|
||||
return fn.call(this, SETPRIV, [...FLAGS, f, ...a], opts ? { ...opts, shell: false } : opts);
|
||||
};
|
||||
cp.spawnSync = wrap(cp.spawnSync);
|
||||
cp.execFileSync = wrap(cp.execFileSync);
|
||||
const execFileSync = cp.execFileSync;
|
||||
cp.execSync = function (cmd, opts) {
|
||||
return execFileSync.call(this, cmd, [], { ...(opts || {}), shell: opts?.shell || true });
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `[file, args]` for a spawn that bypasses child_process (node-pty): via setpriv once installed. */
|
||||
function ptyArgv(file, args = []) {
|
||||
return active ? [SETPRIV, [...FLAGS, file, ...args]] : [file, args];
|
||||
}
|
||||
|
||||
module.exports = { install, hasAmbientCaps, ptyArgv };
|
||||
+3
-1
@@ -23,7 +23,9 @@ const execFileAsync = promisify(execFile);
|
||||
*/
|
||||
async function gh(cwd, args) {
|
||||
try {
|
||||
const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 });
|
||||
// As the folder's member under S3 (their own `gh auth`, their repository).
|
||||
const [cmd, argv] = require("./run-as").commandIn(cwd, "gh", args);
|
||||
const result = await execFileAsync(cmd, argv, { cwd, maxBuffer: 8 * 1024 * 1024 });
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (err) {
|
||||
const error = new Error(`gh ${args[0]} failed`);
|
||||
|
||||
@@ -153,6 +153,18 @@ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
* remote hosts — a key typed for one server is never sent to another.
|
||||
*/
|
||||
async function gitConfigFor(dir, once) {
|
||||
// S3: a member's git runs as the member (sudo), which can neither read this
|
||||
// host-owned store nor carry a typed password (sudo logs its command line).
|
||||
// Their own git login applies (`git credential`, `gh auth` in their terminal).
|
||||
if (require("./run-as").accountForPath(dir)) {
|
||||
if (once) {
|
||||
throw err(
|
||||
"EMEMBERCRED",
|
||||
"log in to this git host from your own terminal (git credential store or gh auth login)"
|
||||
);
|
||||
}
|
||||
return { args: [], env: {} };
|
||||
}
|
||||
const hosts = await remoteHosts(dir);
|
||||
const env = {};
|
||||
if (once) {
|
||||
|
||||
+40
-8
@@ -98,6 +98,38 @@ function isSecret(rel) {
|
||||
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: whether `abs` is a dot entry at the TOP of a member's home — `.claude`,
|
||||
* `.codex`, `.config/gh`, `.ssh`, `.git-credentials`, `.agenthub/handoff`…:
|
||||
* where that member's logins and pane tokens live. A workspace is a folder
|
||||
* under the home, so this never hides project files; it closes a workspace
|
||||
* rooted AT the home (made before that was refused, or a symlink) to anyone
|
||||
* reading it, analysts included. Not by basename: `auth`/`token` patterns
|
||||
* would hide `AuthGate.tsx` in every member workspace.
|
||||
*/
|
||||
function inMemberDotDir(abs) {
|
||||
const runAs = require("./run-as");
|
||||
const acct = require("./linux-accounts").s3Enabled() ? runAs.ownerForRead(abs) : null;
|
||||
if (!acct) return false;
|
||||
const rel = path.relative(acct.home, abs);
|
||||
return Boolean(rel) && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* The real path of a workspace folder for a filesystem read or write (S3:
|
||||
* refused, EFOREIGN, when it resolves into another account's files). The
|
||||
* account a read or write runs as comes from the PATH (run-as.js), so a
|
||||
* member who swaps their workspace folder for a link to another member's
|
||||
* folder, the host's home or /etc must not have the dashboard follow it.
|
||||
*/
|
||||
async function laneRoot(dir) {
|
||||
const root = await fs.promises.realpath(dir);
|
||||
if (!require("./run-as").sameOwner(path.resolve(dir), root)) {
|
||||
throw badRequest("EFOREIGN", "the workspace folder resolves outside its owner's files");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
|
||||
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
|
||||
@@ -107,7 +139,7 @@ function isSecret(rel) {
|
||||
* path that does not exist on disk is judged by its name alone.
|
||||
*/
|
||||
async function isSecretOnDisk(dir, file) {
|
||||
if (isSecret(file)) return true;
|
||||
if (isSecret(file) || inMemberDotDir(path.resolve(dir, file))) return true;
|
||||
let root;
|
||||
let real;
|
||||
try {
|
||||
@@ -116,7 +148,7 @@ async function isSecretOnDisk(dir, file) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (isSecret(path.relative(root, real))) return true;
|
||||
if (isSecret(path.relative(root, real)) || inMemberDotDir(real)) return true;
|
||||
try {
|
||||
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
|
||||
} catch {
|
||||
@@ -162,7 +194,7 @@ async function resolveInLane(dir, file) {
|
||||
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
|
||||
throw badRequest("EBADPATH", "path escapes the workspace directory");
|
||||
}
|
||||
const root = await fs.promises.realpath(dir);
|
||||
const root = await laneRoot(dir);
|
||||
let real;
|
||||
try {
|
||||
real = await fs.promises.realpath(path.resolve(root, file));
|
||||
@@ -406,7 +438,7 @@ async function untrackedFacts(dir, file) {
|
||||
const abs = await resolveInLane(dir, file);
|
||||
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
|
||||
return { lines: null, binary: true };
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
const buf = await require("./run-as").readFileSafeAsync(abs);
|
||||
if (looksBinary(buf)) return { lines: null, binary: true };
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return { lines: 0, binary: false };
|
||||
@@ -502,7 +534,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
async function synthesizeAddedPatch(dir, file) {
|
||||
let buf;
|
||||
try {
|
||||
buf = await fs.promises.readFile(await resolveInLane(dir, file));
|
||||
buf = await require("./run-as").readFileSafeAsync(await resolveInLane(dir, file));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -763,7 +795,7 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
|
||||
// whole to answer with its first megabyte. The extra byte is what
|
||||
// tells the cap below the file was longer.
|
||||
const fh = await fs.promises.open(abs, "r");
|
||||
const fh = await require("./run-as").openSafeAsync(abs);
|
||||
try {
|
||||
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
|
||||
buf = Buffer.alloc(size);
|
||||
@@ -848,7 +880,7 @@ async function readImageAt(dir, requested, rev = "work") {
|
||||
}
|
||||
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
|
||||
return { type, buf: await fs.promises.readFile(abs) };
|
||||
return { type, buf: await require("./run-as").readFileSafeAsync(abs) };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
|
||||
@@ -937,7 +969,7 @@ async function addIgnored(dir, sub, dirNames, files) {
|
||||
const added = new Set();
|
||||
let dirents;
|
||||
try {
|
||||
const root = await fs.promises.realpath(dir);
|
||||
const root = await laneRoot(dir);
|
||||
const real = await fs.promises.realpath(path.join(root, sub));
|
||||
const r = path.relative(root, real);
|
||||
// A symlinked folder pointing out of the workspace lists nothing.
|
||||
|
||||
+35
-17
@@ -8,7 +8,6 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -36,23 +35,37 @@ const REPO_VARS = [
|
||||
* @param {string[]} relPaths e.g. [".mcp.json", ".claude/.cc-config-backups/"]
|
||||
*/
|
||||
function excludeFromGit(laneDir, relPaths) {
|
||||
const runAs = require("./run-as");
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
for (const name of REPO_VARS) delete env[name];
|
||||
const [commonDir, prefix = ""] = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--git-common-dir", "--show-prefix"],
|
||||
{ cwd: laneDir, env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
// As the folder's member under S3 (git refuses another uid's repository).
|
||||
const [commonDir, prefix = ""] = String(
|
||||
runAs.execIn(laneDir, "git", ["rev-parse", "--git-common-dir", "--show-prefix"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
).split("\n");
|
||||
// Under S3 the member's git printed this path (a `.git` file or a
|
||||
// `commondir` can name any directory): it must resolve inside the lane
|
||||
// owner's own files, or the dashboard would read and write wherever the
|
||||
// member pointed it — as the host, outside the users root.
|
||||
const common = fs.realpathSync(path.resolve(laneDir, commonDir));
|
||||
if (!runAs.sameOwner(path.resolve(laneDir), common)) return;
|
||||
const lines = relPaths.map((rel) => `/${prefix}${rel}`);
|
||||
const excludePath = path.join(path.resolve(laneDir, commonDir), "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const excludePath = path.join(common, "info", "exclude");
|
||||
// readFileSafe: a member's FIFO or link to another account's file is refused.
|
||||
let existing = fs.existsSync(excludePath) ? runAs.readFileSafe(excludePath, "utf8") : "";
|
||||
const present = new Set(existing.split("\n"));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
if (missing.length === 0) return;
|
||||
if (existing && !existing.endsWith("\n")) existing += "\n";
|
||||
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
// `info/` exists in every repo git made; under S3 only its member may write it.
|
||||
if (!runAs.accountForPath(excludePath)) {
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
}
|
||||
runAs.writeFileAs(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
} catch {
|
||||
/* not a repo, or unwritable — see above */
|
||||
}
|
||||
@@ -61,19 +74,24 @@ function excludeFromGit(laneDir, relPaths) {
|
||||
/**
|
||||
* The same, anchored at the REPOSITORY root rather than `dir` — for paths a
|
||||
* tool creates at the root whatever subdirectory it was started in (Claude
|
||||
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`.
|
||||
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`. The root
|
||||
* the (member's) git names must be the same account's as `dir`.
|
||||
*/
|
||||
function excludeFromRepoRoot(dir, relPaths) {
|
||||
const runAs = require("./run-as");
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
for (const name of REPO_VARS) delete env[name];
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: dir,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (root) excludeFromGit(root, relPaths);
|
||||
const root = String(
|
||||
runAs.execIn(dir, "git", ["rev-parse", "--show-toplevel"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
).trim();
|
||||
if (!root) return;
|
||||
const real = fs.realpathSync(root);
|
||||
if (runAs.sameOwner(path.resolve(dir), real)) excludeFromGit(real, relPaths);
|
||||
} catch {
|
||||
/* not a repo */
|
||||
}
|
||||
|
||||
+22
-25
@@ -50,7 +50,6 @@
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
git: rawGit,
|
||||
@@ -384,7 +383,10 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// are still in it (the same check `resolve` makes).
|
||||
for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) {
|
||||
const file = path.join(dir, p.path);
|
||||
if (fs.existsSync(file) && MARKER_RE.test(await fs.promises.readFile(file, "utf8"))) {
|
||||
if (
|
||||
fs.existsSync(file) &&
|
||||
MARKER_RE.test(await require("./run-as").readFileSafeAsync(file, "utf8"))
|
||||
) {
|
||||
throw bad("EMARKERS", `${p.path} still has conflict markers`);
|
||||
}
|
||||
}
|
||||
@@ -455,20 +457,14 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-hunk-"));
|
||||
const file = path.join(tmp, "hunk.patch");
|
||||
try {
|
||||
await fs.promises.writeFile(file, patch);
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
|
||||
// reversed, off the working tree. A hunk the index or tree no longer
|
||||
// matches fails in git's own words (409), never half-applies.
|
||||
const args =
|
||||
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
|
||||
return done(action, await git(top, [...args, file]));
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
|
||||
// reversed, off the working tree. A hunk the index or tree no longer
|
||||
// matches fails in git's own words (409), never half-applies. The patch
|
||||
// goes in on stdin — no file to write (S3: never one in a member's home).
|
||||
const args =
|
||||
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
|
||||
return done(action, await git(top, [...args, "-"], { input: patch }));
|
||||
}
|
||||
|
||||
if (action === "commit") {
|
||||
@@ -572,7 +568,7 @@ async function mergeAction(dir, action, body) {
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
}
|
||||
if (side === "done" && fs.existsSync(path.join(dir, p))) {
|
||||
if (MARKER_RE.test(await fs.promises.readFile(path.join(dir, p), "utf8"))) {
|
||||
if (MARKER_RE.test(await require("./run-as").readFileSafeAsync(path.join(dir, p), "utf8"))) {
|
||||
throw bad("EMARKERS", `${p} still has conflict markers`);
|
||||
}
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
@@ -833,22 +829,22 @@ async function commitSelection(dir, args, body) {
|
||||
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
||||
);
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-commit-"));
|
||||
const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
|
||||
// A temporary index git itself creates (S3: as the member, in their own
|
||||
// handoff dir — the dashboard never writes there).
|
||||
const scratch = require("./run-as").scratchFile(top, "agenthub-index-");
|
||||
const env = { GIT_INDEX_FILE: scratch.file };
|
||||
try {
|
||||
await git(dir, (await hasHead(dir)) ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], {
|
||||
env,
|
||||
});
|
||||
if (wholePaths.length) await git(dir, ["add", "-A", "--", ...wholePaths], { env });
|
||||
const file = path.join(tmp, "part.patch");
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
for (const [p, list] of picks) {
|
||||
const patch = await selectionPatch(dir, p, body.context, list);
|
||||
if (!patch)
|
||||
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
|
||||
await fs.promises.writeFile(file, patch);
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
await git(top, ["apply", "--cached", ...zero, file], { env });
|
||||
await git(top, ["apply", "--cached", ...zero, "-"], { env, input: patch });
|
||||
}
|
||||
const out = await git(dir, ["commit", ...args], { env });
|
||||
await git(dir, ["reset", "-q", "--", ...wholePaths, ...part.map((p) => p.path)]).catch(
|
||||
@@ -856,7 +852,7 @@ async function commitSelection(dir, args, body) {
|
||||
);
|
||||
return out;
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
scratch.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -882,12 +878,13 @@ async function ignorePaths(dir, body) {
|
||||
const file = path.join(dir, ".gitignore");
|
||||
const st = await fs.promises.lstat(file).catch(() => null);
|
||||
if (st && !st.isFile()) throw bad("EBADPATH", ".gitignore is not a regular file");
|
||||
const old = st ? await fs.promises.readFile(file, "utf8") : "";
|
||||
const old = st ? await require("./run-as").readFileSafeAsync(file, "utf8") : "";
|
||||
const have = new Set(old.split(/\r?\n/));
|
||||
const lines = [...new Set(paths.map((p) => ignoreLine(p.path)))].filter((l) => !have.has(l));
|
||||
if (lines.length) {
|
||||
const sep = old && !old.endsWith("\n") ? "\n" : "";
|
||||
await fs.promises.appendFile(file, `${sep}${lines.join("\n")}\n`);
|
||||
// As the folder's member under S3 (the dashboard cannot write there).
|
||||
require("./run-as").writeFileAs(file, `${sep}${lines.join("\n")}\n`, { append: true });
|
||||
}
|
||||
return done("ignore", { stdout: lines.map((l) => `${l}\n`).join(""), stderr: "" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file hook-auth.js
|
||||
* @description Server mode, one Linux user per account (S3 plan, P3d): who
|
||||
* posted a hook. Hooks come over loopback HTTP, and once members have their
|
||||
* own shells on this machine, loopback alone proves nothing — any account
|
||||
* could post a forged event for any session. The kernel knows the answer: the
|
||||
* peer of a loopback TCP connection is a socket in `/proc/net/tcp*`, and its
|
||||
* row carries the owning uid. No token, no change to any hook script.
|
||||
*
|
||||
* - the host account (or root): trusted, as before;
|
||||
* - a member (`ah-<name>`, enabled): may report only work inside their own
|
||||
* home — the hook's `cwd` must be there, a session or a pane it names must
|
||||
* be theirs, and a transcript path outside the home is dropped;
|
||||
* - anyone else: refused.
|
||||
*
|
||||
* Off (everything is "host") unless S3 is on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Test seam: the /proc/net/tcp* text.
|
||||
let readTables = () =>
|
||||
["/proc/net/tcp", "/proc/net/tcp6"].map((f) => {
|
||||
try {
|
||||
return fs.readFileSync(f, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
function __setTables(fn) {
|
||||
readTables = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The uid owning the CLIENT end of this loopback connection, or null: the row
|
||||
* whose local port is the client's port and whose remote port is ours,
|
||||
* ESTABLISHED (`01`).
|
||||
*/
|
||||
function peerUid(socket) {
|
||||
const client = socket?.remotePort;
|
||||
const server = socket?.localPort;
|
||||
if (!client || !server) return null;
|
||||
const hex = (n) => n.toString(16).toUpperCase().padStart(4, "0");
|
||||
for (const table of readTables()) {
|
||||
for (const line of table.split("\n").slice(1)) {
|
||||
const f = line.trim().split(/\s+/);
|
||||
if (f.length < 8 || f[3] !== "01") continue;
|
||||
// Both ends on loopback: a connection of this machine's to another
|
||||
// host's port that happens to match is somebody else's row.
|
||||
const [la, lp] = f[1].split(":");
|
||||
const [ra, rp] = f[2].split(":");
|
||||
if (!LOOPBACK.has(la) || !LOOPBACK.has(ra)) continue;
|
||||
if (lp === hex(client) && rp === hex(server)) return Number(f[7]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 127.0.0.1, ::1 and ::ffff:127.0.0.1 as /proc/net/tcp{,6} print them.
|
||||
const LOOPBACK = new Set([
|
||||
"0100007F",
|
||||
"00000000000000000000000001000000",
|
||||
"0000000000000000FFFF00000100007F",
|
||||
]);
|
||||
|
||||
const inside = (home, p) => {
|
||||
if (typeof p !== "string" || !path.isAbsolute(p)) return false;
|
||||
const rel = path.relative(home, path.resolve(p));
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
};
|
||||
|
||||
/**
|
||||
* Who posted `req`: `{kind:"host"}`, `{kind:"member", name, home}` or
|
||||
* `{kind:"refused"}`. "host" outside S3.
|
||||
*/
|
||||
let peerImpl = peerUid; // test seam
|
||||
function __setPeerUid(fn) {
|
||||
peerImpl = fn || peerUid;
|
||||
}
|
||||
|
||||
function hookPeer(req) {
|
||||
if (!require("./linux-accounts").s3Enabled()) return { kind: "host" };
|
||||
const uid = peerImpl(req.socket);
|
||||
if (uid === null) return { kind: "refused" };
|
||||
if (uid === 0 || uid === process.getuid()) return { kind: "host" };
|
||||
const runAs = require("./run-as");
|
||||
const name = runAs.memberNameForUid(uid);
|
||||
const conf = require("./linux-accounts").serverConf();
|
||||
let acct = null;
|
||||
try {
|
||||
acct = name ? runAs.accountForPath(path.join(conf.usersRoot, name)) : null;
|
||||
} catch {
|
||||
/* disabled or gone: refused below */
|
||||
}
|
||||
return acct && acct.uid === uid ? { kind: "member", name, home: acct.home } : { kind: "refused" };
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: whether `req` came from the Linux account the panes of workspace
|
||||
* `laneId` run as — its member's, else the host's (this process's uid). A
|
||||
* pane token is a bearer secret: whoever holds it acts as the workspace's
|
||||
* owner, so it is accepted only from the account its pane runs as. An
|
||||
* unknown peer is refused. Always true outside S3.
|
||||
*/
|
||||
function peerIsPaneAccount(req, laneId) {
|
||||
if (!require("./linux-accounts").s3Enabled()) return true;
|
||||
const uid = peerImpl(req.socket);
|
||||
if (uid === null) return false;
|
||||
let acct;
|
||||
try {
|
||||
acct = require("./run-as").accountForLane(laneId);
|
||||
} catch {
|
||||
return false; // its member is gone: nobody's pane any more
|
||||
}
|
||||
return uid === (acct ? acct.uid : process.getuid());
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's hook, checked and trimmed in place (`body.data`, `body.run_id`).
|
||||
* Returns null when it may go on, else `{status, code, message}`.
|
||||
* `data` is the hook's payload after the tool mapping (Claude shape).
|
||||
*/
|
||||
function checkMemberHook(peer, data, body, { getSession, getRun }) {
|
||||
if (peer.kind !== "member") return null;
|
||||
const foreign = (message) => ({ status: 403, code: "EFOREIGN", message });
|
||||
if (!inside(peer.home, data.cwd))
|
||||
return foreign("a hook may only report work in your own folder");
|
||||
const existing = typeof data.session_id === "string" ? getSession(data.session_id) : null;
|
||||
if (existing && !inside(peer.home, existing.cwd)) return foreign("that session is not yours");
|
||||
if (body.run_id != null) {
|
||||
const run = getRun(String(body.run_id));
|
||||
if (!run || !inside(peer.home, run.cwd)) delete body.run_id;
|
||||
}
|
||||
dropForeignPaths(peer, data);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A member's hook loses every transcript path outside their home — run
|
||||
* BEFORE a tool mapping reads one (Codex's `threadOf`). */
|
||||
function dropForeignPaths(peer, data) {
|
||||
if (peer.kind !== "member" || !data || typeof data !== "object") return;
|
||||
for (const key of ["transcript_path", "agent_transcript_path", "transcriptPath"]) {
|
||||
if (key in data && !inside(peer.home, data[key])) delete data[key];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
peerUid,
|
||||
hookPeer,
|
||||
peerIsPaneAccount,
|
||||
checkMemberHook,
|
||||
dropForeignPaths,
|
||||
inside,
|
||||
__setTables,
|
||||
__setPeerUid,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Cleans up what older builds wrote into a lane: `removeStageDoc` takes
|
||||
* AgentHub's stage block back out of a codex lane's AGENTS.md. Plain file I/O.
|
||||
* AgentHub's stage block back out of a codex lane's AGENTS.md. Plain file I/O,
|
||||
* so never in an S3 member's folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,6 +23,10 @@ const STAGE_BLOCK_END = "<!-- agenthub:stage:end -->";
|
||||
function removeStageDoc(cwd) {
|
||||
const file = path.join(cwd, "AGENTS.md");
|
||||
try {
|
||||
// S3: never in a member's folder — the host would read, rewrite or delete
|
||||
// through any link the member planted there (with CAP_DAC_READ_SEARCH).
|
||||
// Builds that wrote the block predate S3; a member removes it themselves.
|
||||
if (require("./run-as").ownerForRead(cwd)) return false;
|
||||
const doc = fs.readFileSync(file, "utf8");
|
||||
const start = doc.indexOf(STAGE_BLOCK_START);
|
||||
const end = doc.indexOf(STAGE_BLOCK_END, start);
|
||||
|
||||
@@ -52,7 +52,7 @@ async function writeLaneFile(dir, requested, content, baseSha) {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
if (!stat.isFile()) throw bad("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file is too large to edit here");
|
||||
current = await fs.promises.readFile(abs);
|
||||
current = await require("./run-as").readFileSafeAsync(abs);
|
||||
} catch (err) {
|
||||
if (err.code === "ETOOBIG" || err.code === "ENOFILE") throw err;
|
||||
throw bad("ENOFILE", "no such file in the working tree");
|
||||
@@ -70,7 +70,8 @@ async function writeLaneFile(dir, requested, content, baseSha) {
|
||||
if (eol === "\r\n") out = out.replace(/\n/g, "\r\n");
|
||||
if (text.endsWith("\n") && out && !out.endsWith(eol)) out += eol;
|
||||
|
||||
await fs.promises.writeFile(abs, out);
|
||||
// As the folder's member under S3 (the dashboard reads, never writes, there).
|
||||
require("./run-as").writeFileAs(abs, out);
|
||||
return { path: file };
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ function insertSource(laneId, kind, p, name, size) {
|
||||
return db.prepare("SELECT * FROM lane_sources WHERE id = ?").get(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
function addFolder(laneId, input) {
|
||||
/** `allowed(realPath)`: the caller's own boundary (a developer's home). */
|
||||
function addFolder(laneId, input, { allowed = () => true } = {}) {
|
||||
const lane = laneRow(laneId);
|
||||
if (typeof input !== "string" || !input.trim()) throw fail("EBADPATH", "path is required");
|
||||
let resolved;
|
||||
@@ -96,6 +97,7 @@ function addFolder(laneId, input) {
|
||||
} catch {
|
||||
/* a vanished workspace folder still has its recorded path */
|
||||
}
|
||||
if (!allowed(resolved)) throw fail("EFOLDER", "an extra folder must be inside your own folder");
|
||||
if (resolved === laneCwd) throw fail("EDUP", "that is the workspace's own folder");
|
||||
const dup = db
|
||||
.prepare("SELECT 1 FROM lane_sources WHERE lane_id = ? AND kind = 'folder' AND path = ?")
|
||||
@@ -196,8 +198,23 @@ function launchExtras(laneId, tool) {
|
||||
}
|
||||
};
|
||||
const folders = rows.filter((r) => r.kind === "folder" && isDir(r.path)).map((r) => r.path);
|
||||
const files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
|
||||
const addDirs = [...folders, ...(files.length ? [contextDir(laneId)] : [])];
|
||||
let files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
|
||||
// S3: a member's pane cannot reach the dashboard's data dir — the context
|
||||
// files and the prompt are copied into the member's handoff dir instead.
|
||||
const member = require("./run-as").accountForLane(laneId);
|
||||
let ctx = contextDir(laneId);
|
||||
if (member && files.length) {
|
||||
// Written AS the member (`write-as`): the dashboard never writes in a home.
|
||||
const runAs = require("./run-as");
|
||||
const sub = `context-${Number(laneId)}`;
|
||||
ctx = path.join(runAs.handoffDir(member), sub);
|
||||
runAs.removeAs(member, ctx);
|
||||
runAs.mkdirAs(member, ctx);
|
||||
files = files.map((f) =>
|
||||
runAs.handoffWrite(member, path.join(sub, path.basename(f)), fs.readFileSync(f))
|
||||
);
|
||||
}
|
||||
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
|
||||
const parts = [];
|
||||
if (lane.instructions) parts.push(lane.instructions);
|
||||
if (files.length) {
|
||||
@@ -216,6 +233,14 @@ function launchExtras(laneId, tool) {
|
||||
// Claude's, Codex's past the inline budget), never its start — same as a
|
||||
// vanished source.
|
||||
try {
|
||||
if (member) {
|
||||
const file = require("./run-as").handoffWrite(
|
||||
member,
|
||||
`prompt-${Number(laneId)}.md`,
|
||||
systemPrompt
|
||||
);
|
||||
return { addDirs, systemPrompt, systemPromptFile: file };
|
||||
}
|
||||
const dir = path.join(getDataDir(), "workspace-prompts");
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`);
|
||||
@@ -245,11 +270,15 @@ function codexInstructions(systemPrompt, systemPromptFile) {
|
||||
|
||||
function readMemoryFile(label, file) {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile()) return null;
|
||||
const truncated = st.size > MAX_MEMORY_BYTES;
|
||||
const fd = fs.openSync(file, "r");
|
||||
if (!fs.statSync(file).isFile()) return null; // no blocking open on a FIFO
|
||||
// S3: a file in a member's folder is read only when the OPEN file is a
|
||||
// regular file that member owns — a link they planted to /etc/shadow or
|
||||
// another member's login is never read with the dashboard's rights.
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const st = fs.fstatSync(fd);
|
||||
if (!st.isFile()) return null;
|
||||
const truncated = st.size > MAX_MEMORY_BYTES;
|
||||
const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES));
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
return {
|
||||
|
||||
@@ -219,6 +219,11 @@ function createLane({
|
||||
validateKind(kind);
|
||||
validatePipeline(pipeline);
|
||||
validateTool(tool);
|
||||
// S3: only a member owns a workspace in their own home (EOWNERFOLDER).
|
||||
require("./run-as").assertOwnerFits(
|
||||
canonicalCwd(cwd),
|
||||
Number.isInteger(owner_id) ? owner_id : null
|
||||
);
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file linux-accounts.js
|
||||
* @description Server mode, one Linux user per account (S3 plan, P2). Each
|
||||
* AgentHub user `alice` maps to the Linux user `ah-alice`, whose home is
|
||||
* `<USERS_ROOT>/alice` — the same folder `scope.folderAllowed` keeps her
|
||||
* workspaces in.
|
||||
*
|
||||
* S3 is ON when server mode is on AND `/etc/agenthub/server.conf` exists
|
||||
* (written by `sudo agenthub server install-system`, root-owned). Without it
|
||||
* everything here is a no-op and server mode behaves as in S2.
|
||||
*
|
||||
* `provision` is the dashboard's only way to create, disable or enable a Linux
|
||||
* account: `sudo -n /opt/agenthub/sbin/agenthub-provision <action> <name>`,
|
||||
* the one root command sudoers lets the host run. Only an action from a fixed
|
||||
* set and a name that passed `LINUX_NAME_RE` ever reach its argv.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
/** Lower case, a letter first, ≤ 29 so `ah-<name>` fits Linux's 32. */
|
||||
const LINUX_NAME_RE = /^[a-z][a-z0-9_-]{0,28}$/;
|
||||
const PROVISION = "/opt/agenthub/sbin/agenthub-provision";
|
||||
const ACTIONS = new Set(["create", "disable", "enable"]);
|
||||
|
||||
const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
|
||||
|
||||
// server.conf is read on every tmux call's account lookup — keep it 5 s.
|
||||
let confCache = { at: 0, path: null, value: null };
|
||||
|
||||
/** `{hostUser, hostUid, usersRoot, port, bins}` from server.conf, or null. */
|
||||
function serverConf() {
|
||||
const file = confPath();
|
||||
if (confCache.path === file && Date.now() - confCache.at < 5000) return confCache.value;
|
||||
confCache = { at: Date.now(), path: file, value: readConf(file) };
|
||||
return confCache.value;
|
||||
}
|
||||
|
||||
function readConf(file) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
|
||||
const usersRoot = get("USERS_ROOT");
|
||||
if (!path.isAbsolute(usersRoot)) return null;
|
||||
return {
|
||||
hostUser: get("HOST_USER"),
|
||||
hostUid: Number(get("HOST_UID")),
|
||||
usersRoot,
|
||||
port: Number(get("PORT")) || null,
|
||||
// The absolute paths sudoers lists (sudo would resolve a bare name on its
|
||||
// own secure_path, which may not be them).
|
||||
bins: Object.fromEntries(
|
||||
[
|
||||
["tmux", get("TMUX_BIN")],
|
||||
["git", get("GIT_BIN")],
|
||||
["gh", get("GH_BIN")],
|
||||
].filter(([, v]) => path.isAbsolute(v))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Is S3 on: server mode plus an installed system layout. */
|
||||
function s3Enabled() {
|
||||
return require("./auth").isServerMode() && serverConf() !== null;
|
||||
}
|
||||
|
||||
const loginFor = (username) => `ah-${username}`;
|
||||
|
||||
/** The Linux side of an AgentHub user, or null outside S3. */
|
||||
function accountFor(username) {
|
||||
const conf = s3Enabled() ? serverConf() : null;
|
||||
if (!conf || !LINUX_NAME_RE.test(String(username || ""))) return null;
|
||||
return { login: loginFor(username), home: path.join(conf.usersRoot, username) };
|
||||
}
|
||||
|
||||
/** Throws EBADUSER unless `username` can be a Linux account (S3 only). */
|
||||
function assertLinuxName(username) {
|
||||
if (s3Enabled() && !LINUX_NAME_RE.test(String(username || ""))) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
"username: with one Linux user per account it must be lower case — a letter, then up to 28 letters, digits, '_' or '-'"
|
||||
),
|
||||
{ code: "EBADUSER" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seam: the sudo call itself.
|
||||
let runImpl = (argv) =>
|
||||
execFileSync("sudo", ["-n", ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
function __setRunImpl(fn) {
|
||||
runImpl = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create / disable / enable the Linux account of `username`. A no-op outside
|
||||
* S3 (returns null). Throws EPROVISION with the helper's own words on failure.
|
||||
*/
|
||||
function provision(action, username) {
|
||||
if (!s3Enabled()) return null;
|
||||
if (!ACTIONS.has(action)) throw new Error(`unknown provision action: ${action}`);
|
||||
assertLinuxName(username);
|
||||
try {
|
||||
return String(runImpl([PROVISION, action, username]) || "").trim();
|
||||
} catch (e) {
|
||||
const why = String(e.stderr || e.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}`), {
|
||||
code: "EPROVISION",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function __resetConfCache() {
|
||||
confCache = { at: 0, path: null, value: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
__resetConfCache,
|
||||
confPath,
|
||||
LINUX_NAME_RE,
|
||||
serverConf,
|
||||
s3Enabled,
|
||||
accountFor,
|
||||
assertLinuxName,
|
||||
provision,
|
||||
loginFor,
|
||||
__setRunImpl,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file member-claude.js
|
||||
* @description Server mode, one Linux user per account (S3): what AgentHub
|
||||
* keeps in step inside a MEMBER's own Claude Code config.
|
||||
*
|
||||
* - `setTheme`: the `theme` in `~/.claude/settings.json`, following the
|
||||
* dark/light mode the member picked in AgentHub. Read through run-as's
|
||||
* guarded reader (the file must be theirs) and written AS the member
|
||||
* (`write-as`, merged, never replacing their other settings). A file that
|
||||
* is not JSON is left alone.
|
||||
* - `applyPluginBaseline` (P6 fallback, opt-in): when the admin keeps a
|
||||
* `plugin-baseline.json` beside `server.conf` (`/etc/agenthub/`, root's),
|
||||
* runs `claude plugin marketplace add <source>` for each of its
|
||||
* `marketplaces`, then `claude plugin install <name@marketplace>` for each
|
||||
* of its `plugins`, AS the member (`sudo -u`, the shared `claude`), in the
|
||||
* background. No file, nothing runs. An entry that could be read as an
|
||||
* option is skipped, and `-y` is never passed, so a plugin that installs by
|
||||
* running a marketplace-declared command fails rather than runs unseen.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { promisify } = require("node:util");
|
||||
const runAs = require("./run-as");
|
||||
const linux = require("./linux-accounts");
|
||||
|
||||
function setTheme(acct, theme) {
|
||||
const dir = path.join(acct.home, ".claude");
|
||||
const file = path.join(dir, "settings.json");
|
||||
let settings = {};
|
||||
try {
|
||||
settings = JSON.parse(runAs.readFileSafe(file, "utf8", { max: 1 << 20 }));
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") return false; // theirs to fix, not ours to overwrite
|
||||
runAs.mkdirAs(acct, dir);
|
||||
}
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return false;
|
||||
if (settings.theme === theme) return true;
|
||||
settings.theme = theme;
|
||||
runAs.writeFileAs(file, `${JSON.stringify(settings, null, 2)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// owner/repo, a git or https URL, an absolute path — never a leading `-`.
|
||||
const MARKETPLACE_RE = /^[A-Za-z0-9/][^\s]{0,499}$/;
|
||||
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
|
||||
const baselinePath = () => path.join(path.dirname(linux.confPath()), "plugin-baseline.json");
|
||||
|
||||
// Test seam: async, a marketplace add is a git clone.
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the admin's plugin baseline for `username`. Resolves to null when
|
||||
* there is nothing to do (outside S3, no baseline file, empty lists), else to
|
||||
* `[{item, ok, error?}]`, one per entry, in order. Throws on a baseline file
|
||||
* that is not JSON, or ENOTSHARED when `claude` is not in the shared tools.
|
||||
*/
|
||||
async function applyPluginBaseline(username) {
|
||||
const acct = linux.accountFor(username);
|
||||
if (!acct) return null;
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(baselinePath(), "utf8");
|
||||
} catch {
|
||||
return null; // opt-in: no file, no baseline
|
||||
}
|
||||
const base = JSON.parse(raw);
|
||||
const list = (v) => (Array.isArray(v) ? v.map(String) : []);
|
||||
const steps = [
|
||||
...list(base?.marketplaces).map((s) => [
|
||||
s,
|
||||
MARKETPLACE_RE,
|
||||
["plugin", "marketplace", "add", s],
|
||||
]),
|
||||
...list(base?.plugins).map((p) => [p, PLUGIN_RE, ["plugin", "install", "--scope", "user", p]]),
|
||||
];
|
||||
if (!steps.length) return null;
|
||||
const claude = require("./shared-tools").sharedPath("claude");
|
||||
if (!claude) {
|
||||
throw Object.assign(new Error("claude is not in the shared tools yet"), { code: "ENOTSHARED" });
|
||||
}
|
||||
const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" };
|
||||
const results = [];
|
||||
for (const [item, re, args] of steps) {
|
||||
if (!re.test(item)) {
|
||||
results.push({ item, ok: false, error: "not a valid entry" });
|
||||
continue;
|
||||
}
|
||||
const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env);
|
||||
try {
|
||||
await execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" });
|
||||
results.push({ item, ok: true });
|
||||
} catch (e) {
|
||||
const why = String(e.stderr || e.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
results.push({ item, ok: false, error: why });
|
||||
}
|
||||
}
|
||||
for (const r of results) {
|
||||
const line = `[plugin-baseline] ${acct.login}: ${r.item} ${r.ok ? "ok" : `failed: ${r.error}`}`;
|
||||
(r.ok ? console.log : console.warn)(line);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { setTheme, applyPluginBaseline, __setExecImpl };
|
||||
+140
-25
@@ -27,6 +27,12 @@
|
||||
* rewrites them on an edit (a live Claude session reads its file again);
|
||||
* `removeProfileFiles` deletes them with the profile.
|
||||
*
|
||||
* **Server mode never hands a pane the key** (S3 plan, P1b): both files then
|
||||
* name AgentHub's own loopback gateway (`proxy-gateway.js`) as the endpoint and
|
||||
* carry no key; the pane's own token is its key, through tmux `-e`
|
||||
* (`ANTHROPIC_AUTH_TOKEN` for Claude, `AGENTHUB_PROXY_KEY` for Codex), and the
|
||||
* gateway swaps in the real one. Local mode is unchanged.
|
||||
*
|
||||
* Workspaces once carried a proxy (then called a router) of their own in
|
||||
* `lanes.router_profile` and `<cwd>/.claude/settings.local.json`;
|
||||
* `clearLaneProxies` takes that out once.
|
||||
@@ -41,6 +47,10 @@ const proxies = require("./ai-proxies");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { PROXY_CAPABLE } = require("./ai-tools");
|
||||
const { gatewayBase } = require("./proxy-gateway");
|
||||
|
||||
/** Server mode: panes reach a proxy only through the key gateway. */
|
||||
const viaGateway = () => require("./auth").isServerMode();
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the proxy key from. One fixed name rather
|
||||
@@ -49,11 +59,16 @@ const { PROXY_CAPABLE } = require("./ai-tools");
|
||||
*/
|
||||
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
|
||||
|
||||
/** The eight env keys a proxy sets for Claude Code. */
|
||||
/**
|
||||
* The eight env keys a proxy sets for Claude Code. Through the gateway the
|
||||
* base URL is the gateway's and the token is left out (the pane's own token
|
||||
* comes through tmux `-e`).
|
||||
*/
|
||||
function envForProfile(profile) {
|
||||
const gw = viaGateway();
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
|
||||
ANTHROPIC_BASE_URL: gw ? gatewayBase(profile.name) : profile.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: gw ? "" : profile.apiKey,
|
||||
ANTHROPIC_MODEL: profile.model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
|
||||
@@ -94,12 +109,29 @@ function modelPicker(profile) {
|
||||
return options.length ? { modelPicker: { options } } : {};
|
||||
}
|
||||
|
||||
function writeClaudeSettings(profile) {
|
||||
const file = claudeSettingsPath(profile.name);
|
||||
/**
|
||||
* The `--settings` file of a Claude pane. Through the gateway (server mode)
|
||||
* it is the PANE's own (`<name>--<run id>.json`) and carries the pane token
|
||||
* as ANTHROPIC_AUTH_TOKEN: the command-line layer outranks the user's and the
|
||||
* project's settings, so an `env.ANTHROPIC_*` there cannot replace it (the
|
||||
* process env could be). A member's goes in their handoff dir, as them, 0600.
|
||||
*/
|
||||
function writeClaudeSettings(profile, member = null, pane = null) {
|
||||
const name = pane ? `${profile.name}--${pane.runId}` : profile.name;
|
||||
const file = member
|
||||
? path.join(require("./run-as").handoffDir(member), `proxy-${name}.json`)
|
||||
: claudeSettingsPath(name);
|
||||
// Empty values are left out: a blank key in the command-line layer would
|
||||
// blank the user's own setting, not leave it alone.
|
||||
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
|
||||
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries({
|
||||
...envForProfile(profile),
|
||||
...(pane ? { ANTHROPIC_AUTH_TOKEN: pane.token } : {}),
|
||||
}).filter(([, v]) => v)
|
||||
);
|
||||
const body = `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`;
|
||||
if (member) return require("./run-as").handoffWrite(member, path.basename(file), body, "private");
|
||||
writeSecret(file, body);
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -114,6 +146,7 @@ function tomlString(value) {
|
||||
*/
|
||||
function writeCodexLayer(profile) {
|
||||
const file = codexLayerPath(profile.name);
|
||||
const gw = viaGateway();
|
||||
const body =
|
||||
`# Written by AgentHub — AI Proxy profile ${profile.label}.\n` +
|
||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||
@@ -121,11 +154,11 @@ function writeCodexLayer(profile) {
|
||||
`model_provider = ${tomlString(profile.name)}\n\n` +
|
||||
`[model_providers.${profile.name}]\n` +
|
||||
`name = ${tomlString(profile.label)}\n` +
|
||||
`base_url = ${tomlString(`${profile.baseUrl}/v1`)}\n` +
|
||||
`base_url = ${tomlString(`${gw ? gatewayBase(profile.name) : profile.baseUrl}/v1`)}\n` +
|
||||
// `env_key` only when there is a key to name. A local proxy (the 9Router
|
||||
// reference config is exactly this) needs none, and naming a variable that
|
||||
// is never set makes Codex refuse to start.
|
||||
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
(gw || profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
|
||||
// no longer supported`, a hard config error before the first request.
|
||||
`wire_api = "responses"\n`;
|
||||
@@ -133,19 +166,40 @@ function writeCodexLayer(profile) {
|
||||
return file;
|
||||
}
|
||||
|
||||
/** The Codex layer's settings as `-c` overrides (a member's pane; server mode). */
|
||||
function codexOverrides(profile) {
|
||||
const provider =
|
||||
`{name=${tomlString(profile.label)},` +
|
||||
`base_url=${tomlString(`${gatewayBase(profile.name)}/v1`)},` +
|
||||
`env_key=${tomlString(CODEX_KEY_ENV)},wire_api="responses"}`;
|
||||
return [
|
||||
...["-c", `model_providers.${profile.name}=${provider}`],
|
||||
...["-c", `model_provider=${tomlString(profile.name)}`],
|
||||
...["-c", `model=${tomlString(profile.model)}`],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a new session asked for, checked against its tool. `null`,
|
||||
* `""` and `"native"` mean the vendor's own API and resolve to null.
|
||||
* @throws EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
|
||||
* proxy on a tool that has no endpoint to point at one.
|
||||
* The profile a new session asked for, checked against its tool and its
|
||||
* workspace's owner. `null`, `""` and `"native"` mean the vendor's own API and
|
||||
* resolve to null. A personal profile resolves only for a workspace its owner
|
||||
* owns; for anyone else it does not exist.
|
||||
* @param {number|null} [ownerId] the workspace's `lanes.owner_id`
|
||||
* @throws EBADPROXY for a profile the store does not have (or this owner may
|
||||
* not use), EUNSUPPORTED for a proxy on a tool that has no endpoint to point
|
||||
* at one.
|
||||
*/
|
||||
function resolveProxy(tool, name) {
|
||||
function resolveProxy(tool, name, ownerId = null) {
|
||||
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
|
||||
if (typeof name !== "string") {
|
||||
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
|
||||
}
|
||||
let profile;
|
||||
try {
|
||||
const owner = proxies.ownerOfName(name);
|
||||
if (owner != null && owner !== ownerId) {
|
||||
throw Object.assign(new Error("not this owner's"), { code: "ENOTFOUND" });
|
||||
}
|
||||
profile = proxies.getProxy(name);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOTFOUND") throw e;
|
||||
@@ -164,12 +218,15 @@ function resolveProxy(tool, name) {
|
||||
|
||||
/**
|
||||
* The proxy a resume that names none goes back to: the one its session last
|
||||
* ran on, while that profile still exists — a deleted one resumes on native.
|
||||
* ran on, while that profile still exists and the workspace's owner may use
|
||||
* it — a deleted one, or another user's personal one, resumes on native.
|
||||
*/
|
||||
function resumeProxy(sessionId) {
|
||||
function resumeProxy(sessionId, ownerId = null) {
|
||||
if (!sessionId) return null;
|
||||
const name = require("./dashboard-runs").proxyForSession(sessionId);
|
||||
if (!name) return null;
|
||||
const owner = proxies.ownerOfName(name);
|
||||
if (owner != null && owner !== ownerId) return null;
|
||||
try {
|
||||
proxies.getProxy(name);
|
||||
return name;
|
||||
@@ -180,31 +237,87 @@ function resumeProxy(sessionId) {
|
||||
|
||||
/**
|
||||
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
|
||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file. In
|
||||
* server mode the "key" is the pane's own token (`paneToken`), which only the
|
||||
* gateway accepts.
|
||||
* @returns {{argv: string[], env: Record<string,string>}}
|
||||
*/
|
||||
function proxyForSpawn(tool, profile) {
|
||||
function proxyForSpawn(tool, profile, paneToken = null, member = null, runId = null) {
|
||||
if (!profile) return { argv: [], env: {} };
|
||||
const gw = viaGateway();
|
||||
if (gw && !paneToken) {
|
||||
// Server mode always mints one (auth.newPaneToken); without it the pane
|
||||
// could only be given the real key, which this mode never does.
|
||||
throw Object.assign(new Error("no pane token for the AI Proxy gateway"), {
|
||||
code: "EPROXY",
|
||||
});
|
||||
}
|
||||
if (tool === "codex") {
|
||||
const key = gw ? paneToken : profile.apiKey;
|
||||
const env = key ? { [CODEX_KEY_ENV]: key } : {};
|
||||
// S3: a member's CODEX_HOME is theirs (no layer file of ours there): the
|
||||
// same provider as `-c` overrides, through the gateway, no key.
|
||||
if (member) return { argv: codexOverrides(profile), env };
|
||||
writeCodexLayer(profile);
|
||||
return {
|
||||
argv: ["-p", profile.name],
|
||||
env: profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {},
|
||||
};
|
||||
return { argv: ["-p", profile.name], env };
|
||||
}
|
||||
// `=` form: one argv element, never read as an option of its own.
|
||||
return { argv: [`--settings=${writeClaudeSettings(profile)}`], env: {} };
|
||||
const pane = gw ? { token: paneToken, runId: runId || "pane" } : null;
|
||||
return {
|
||||
argv: [`--settings=${writeClaudeSettings(profile, member, pane)}`],
|
||||
env: gw ? { ANTHROPIC_AUTH_TOKEN: paneToken } : {},
|
||||
};
|
||||
}
|
||||
|
||||
/** A gateway pane's own settings files of profile `name`: `[{file, runId}]`. */
|
||||
function paneSettingsFiles(name) {
|
||||
const dir = path.dirname(claudeSettingsPath(name));
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.startsWith(`${name}--`) && f.endsWith(".json"))
|
||||
.map((f) => ({ file: path.join(dir, f), runId: f.slice(name.length + 2, -5) }));
|
||||
} catch {
|
||||
return []; // no dir yet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane token a pane's settings file holds, while that pane still runs on
|
||||
* it (`dashboard_runs`: same run, same token hash, running) — else null.
|
||||
*/
|
||||
function livePaneToken({ file, runId }) {
|
||||
try {
|
||||
const token = JSON.parse(fs.readFileSync(file, "utf8")).env?.ANTHROPIC_AUTH_TOKEN;
|
||||
if (typeof token !== "string" || !token) return null;
|
||||
const hash = require("node:crypto").createHash("sha256").update(token).digest("hex");
|
||||
const live = require("../db")
|
||||
.db.prepare(
|
||||
"SELECT 1 FROM dashboard_runs WHERE id = ? AND pane_token_hash = ? AND status IN ('running', 'spawning')"
|
||||
)
|
||||
.get(runId, hash);
|
||||
return live ? token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After an edit: rewrite whichever of the profile's files exist, so a live
|
||||
* Claude session and the next Codex one see the new key/endpoint. Files that
|
||||
* do not exist yet are written at the next spawn.
|
||||
* do not exist yet are written at the next spawn. A gateway pane's own file
|
||||
* (server mode) is rewritten with ITS token while the pane runs, and deleted
|
||||
* once it does not: its token is dead, and nothing else would remove it.
|
||||
*/
|
||||
function refreshProfileFiles(name) {
|
||||
const profile = proxies.getProxy(name);
|
||||
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
||||
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
||||
for (const pane of paneSettingsFiles(name)) {
|
||||
const token = livePaneToken(pane);
|
||||
if (token) writeClaudeSettings(profile, null, { token, runId: pane.runId });
|
||||
else fs.rmSync(pane.file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +327,9 @@ function refreshProfileFiles(name) {
|
||||
*/
|
||||
function removeProfileFiles(name) {
|
||||
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
|
||||
// A gateway pane's own copies (`<name>--<run id>.json`) go too.
|
||||
const panes = paneSettingsFiles(name).map((p) => p.file);
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy, ...panes]) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @file proxy-gateway.js
|
||||
* @description Server mode's AI Proxy key gateway (S3 plan, P1b). A pane in
|
||||
* server mode is never given a proxy profile's API key: its Claude Code / Codex
|
||||
* talks to `http://127.0.0.1:<port>/api/proxy-gw/<profile>/v1/…` with its own
|
||||
* pane token as the key, and this handler forwards the request to the
|
||||
* profile's `baseUrl` with the real key, streaming the body and the answer
|
||||
* (SSE included) through untouched.
|
||||
*
|
||||
* A request is forwarded only when all hold:
|
||||
* - server mode (in local mode the key is the user's own and the pane gets it
|
||||
* as before — this route answers 404);
|
||||
* - a loopback socket with no forwarding header (a pane runs on this host);
|
||||
* - the bearer / `x-api-key` token is the pane token of a LIVE dashboard run
|
||||
* (`dashboard_runs.pane_token_hash`, tmux session alive), and that run was
|
||||
* started on THIS profile (`dashboard_runs.proxy`) — so a token never opens
|
||||
* a profile its pane was not given, nor outlives its pane;
|
||||
* - the path is under `/v1/` of the profile's own base URL (no `..` escape).
|
||||
*
|
||||
* The token and any client-sent key are dropped; the upstream sees only the
|
||||
* profile's key (both `Authorization: Bearer` and `x-api-key`, as
|
||||
* `testProxy` sends them). Nothing here logs a key or a token.
|
||||
*
|
||||
* Ceiling: an agent in the pane can still SPEND through the profile while its
|
||||
* pane lives — it just cannot carry the key away.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
|
||||
/** A streamed answer may go quiet while the model thinks; not for this long. */
|
||||
const IDLE_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Request headers never passed upstream: hop-by-hop, the pane's token, cookies. */
|
||||
const DROP_REQUEST = new Set([
|
||||
"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",
|
||||
]);
|
||||
|
||||
/** Answer headers node sets itself. */
|
||||
const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding"]);
|
||||
|
||||
let listenPort = null;
|
||||
|
||||
/** The port this dashboard listens on — set by `startServer` once bound. */
|
||||
function setListenPort(port) {
|
||||
listenPort = port;
|
||||
}
|
||||
|
||||
/** This dashboard's port (the bound one once listening). */
|
||||
function currentPort() {
|
||||
return listenPort || parseInt(process.env.DASHBOARD_PORT || "4820", 10);
|
||||
}
|
||||
|
||||
/** The gateway endpoint a pane uses for `name`: Claude's base URL (Codex adds `/v1`). */
|
||||
function gatewayBase(name) {
|
||||
return `http://127.0.0.1:${currentPort()}/api/proxy-gw/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
||||
|
||||
const fail = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
|
||||
function bearer(req) {
|
||||
const fromAuth = /^Bearer\s+(\S+)$/i.exec(String(req.headers.authorization || ""))?.[1];
|
||||
const fromKey = req.headers["x-api-key"];
|
||||
return [fromAuth, fromKey].find((t) => typeof t === "string" && t.startsWith("ahpane_")) || null;
|
||||
}
|
||||
|
||||
/** The live run this pane token belongs to, or null. */
|
||||
function liveRun(token, req) {
|
||||
if (!token || token.length > 200) return null;
|
||||
const { db } = require("../db");
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, proxy, lane_id FROM dashboard_runs
|
||||
WHERE pane_token_hash = ? AND status IN ('running', 'spawning') AND instr(id, '@') = 0`
|
||||
)
|
||||
.get(sha256(token));
|
||||
if (!row) return null;
|
||||
// S3: only from the account that pane runs as (any local uid could post it).
|
||||
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
|
||||
if (!require("./tmux").hasSession(row.id)) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Express handler, mounted at `/api/proxy-gw` BEFORE the body parsers. */
|
||||
function proxyGateway(req, res) {
|
||||
const { isServerMode } = require("./auth");
|
||||
const { isLocalRequest } = require("./same-origin");
|
||||
if (!isServerMode()) return fail(res, 404, "ENOTFOUND", "not found");
|
||||
if (!isLocalRequest(req)) {
|
||||
return fail(res, 403, "ELOCALONLY", "the proxy gateway is for this machine's panes only");
|
||||
}
|
||||
|
||||
// `/<name>/v1/<rest>?<query>`, relative to the mount point.
|
||||
const m = /^\/([^/?]+)(\/v1\/[^?]*)(\?.*)?$/.exec(req.url);
|
||||
if (!m) return fail(res, 400, "EBADPATH", "expected /api/proxy-gw/<profile>/v1/…");
|
||||
let name;
|
||||
try {
|
||||
name = decodeURIComponent(m[1]);
|
||||
} catch {
|
||||
return fail(res, 400, "EBADPATH", "bad profile name");
|
||||
}
|
||||
|
||||
const run = liveRun(bearer(req), req);
|
||||
if (!run) return fail(res, 401, "EAUTH", "not a live pane's token");
|
||||
if (run.proxy !== name) {
|
||||
return fail(res, 403, "EPROXY", "this pane was not started on that AI Proxy");
|
||||
}
|
||||
|
||||
let profile;
|
||||
try {
|
||||
profile = require("./ai-proxies").getProxy(name);
|
||||
} catch {
|
||||
return fail(res, 404, "ENOTFOUND", `no AI Proxy profile named '${name}'`);
|
||||
}
|
||||
if (profile.builtin) return fail(res, 404, "ENOTFOUND", "native has no endpoint");
|
||||
|
||||
// The upstream URL stays under the profile's own `/v1/` — a `..` in the
|
||||
// path is resolved here and refused if it climbs out.
|
||||
const base = new URL(profile.baseUrl);
|
||||
const prefix = `${base.pathname.replace(/\/+$/, "")}/v1/`;
|
||||
const target = new URL(`${base.origin}${prefix}${m[2].slice("/v1/".length)}${m[3] || ""}`);
|
||||
if (target.origin !== base.origin || !target.pathname.startsWith(prefix)) {
|
||||
return fail(res, 400, "EBADPATH", "path leaves the profile's /v1/");
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (!DROP_REQUEST.has(k.toLowerCase())) headers[k] = v;
|
||||
}
|
||||
if (profile.apiKey) {
|
||||
headers.authorization = `Bearer ${profile.apiKey}`;
|
||||
headers["x-api-key"] = profile.apiKey;
|
||||
}
|
||||
|
||||
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);
|
||||
answer.pipe(res);
|
||||
});
|
||||
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();
|
||||
});
|
||||
// The pane hung up (Ctrl-C mid-stream): stop the upstream call too.
|
||||
res.on("close", () => {
|
||||
if (!res.writableFinished) up.destroy();
|
||||
});
|
||||
req.pipe(up);
|
||||
}
|
||||
|
||||
module.exports = { proxyGateway, gatewayBase, setListenPort, listenPort: currentPort };
|
||||
@@ -104,9 +104,16 @@ function attach(ws, runId, { cols, rows }) {
|
||||
let group = groups.get(runId);
|
||||
if (!group) groups.set(runId, (group = { driver: null, viewers: new Set() }));
|
||||
const lead = group.driver;
|
||||
// A developer's pane is on that account's own tmux server, attached AS that
|
||||
// account (S3: never a host client on a socket the account controls).
|
||||
// node-pty forks natively, so drop-caps' child_process patch never sees it:
|
||||
// the client must not keep the unit's CAP_DAC_READ_SEARCH (S3).
|
||||
const [file, args] = require("./drop-caps").ptyArgv(
|
||||
...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]])
|
||||
);
|
||||
// A follower attaches at the driver's size: tmux sizes the window to the
|
||||
// newest client, so its own size would resize the driver's pane first.
|
||||
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", tmux.session(runId)], {
|
||||
const pty = spawnImpl(file, args, {
|
||||
name: "xterm-256color",
|
||||
cols: lead ? lead.cols : cols || 80,
|
||||
rows: lead ? lead.rows : rows || 24,
|
||||
|
||||
+68
-5
@@ -275,6 +275,18 @@ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt
|
||||
function buildArgv(args, entry) {
|
||||
if (entry.launcher === "codex") return buildCodexArgv(args);
|
||||
if (entry.launcher === "claude") return buildClaudeArgv(args);
|
||||
// S3: a member's pane cannot enter the host's home — it runs the copy every
|
||||
// account shares (server/lib/shared-tools.js).
|
||||
if (require("./run-as").accountForLane(args.laneId)) {
|
||||
const shared = require("./shared-tools").sharedPath(entry.detect[0]);
|
||||
if (!shared) {
|
||||
throw makeErr(
|
||||
"ENOTSHARED",
|
||||
`${entry.label || entry.id} is not installed for every account yet — an admin runs \`agenthub server tools publish\``
|
||||
);
|
||||
}
|
||||
return buildCatalogArgv(entry, shared, args);
|
||||
}
|
||||
const d = aiTools.detect(entry.id);
|
||||
// By absolute path when found; the bare name otherwise, so a CLI missing
|
||||
// from PATH dies in the pane with the shell's own "not found".
|
||||
@@ -306,6 +318,14 @@ function buildArgv(args, entry) {
|
||||
* name only, its key and endpoint come from the store; null/"native" = the
|
||||
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
|
||||
*/
|
||||
/** S3: a pane's cwd must be its workspace's account's (both null outside S3). */
|
||||
function assertCwdAccount(laneId, cwd) {
|
||||
const runAs = require("./run-as");
|
||||
if (runAs.accountForPath(cwd)?.login !== runAs.accountForLane(laneId)?.login) {
|
||||
throw makeErr("ECWDACCOUNT", "that folder belongs to another account than this workspace");
|
||||
}
|
||||
}
|
||||
|
||||
function spawnRun(args) {
|
||||
const {
|
||||
laneId,
|
||||
@@ -387,12 +407,28 @@ function spawnRun(args) {
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
|
||||
// A personal AI Proxy resolves only for its owner's workspace.
|
||||
const lane = require("./lanes").getLane(laneId);
|
||||
const laneOwner = lane?.owner_id ?? null;
|
||||
// S3, before anything is written for this pane: it runs as its workspace
|
||||
// FOLDER's account with its OWNER's token, so the two must agree (live
|
||||
// role: a non-admin owns no host folder), and the cwd must be that same
|
||||
// account's — else the host would run in a member's folder (their hooks,
|
||||
// their .mcp.json) or a member in someone else's.
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
|
||||
// own key, and an empty pool leaves the machine's login in charge.
|
||||
const poolAccount =
|
||||
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
|
||||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||||
const codexPool =
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
@@ -430,7 +466,13 @@ function spawnRun(args) {
|
||||
laneId,
|
||||
tool
|
||||
);
|
||||
const proxySpawn = proxyForSpawn(tool, proxyProfile);
|
||||
const proxySpawn = proxyForSpawn(
|
||||
tool,
|
||||
proxyProfile,
|
||||
paneCred?.token,
|
||||
require("./run-as").accountForLane(laneId),
|
||||
id
|
||||
);
|
||||
// A new Claude session gets its id from us, so the run knows the session it
|
||||
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
||||
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||||
@@ -438,6 +480,7 @@ function spawnRun(args) {
|
||||
entry.launcher === "claude" && !resumeSessionId ? require("crypto").randomUUID() : null;
|
||||
const argv = buildArgv(
|
||||
{
|
||||
laneId,
|
||||
addDirs,
|
||||
systemPrompt,
|
||||
systemPromptFile,
|
||||
@@ -454,15 +497,20 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
initialPrompt,
|
||||
proxyArgv: proxySpawn.argv,
|
||||
proxyArgv: [...proxySpawn.argv, ...(codexPool ? codexPool.argv : [])],
|
||||
},
|
||||
entry
|
||||
);
|
||||
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
|
||||
// S3: a member's pane runs as them (tmux.js starts it on their server).
|
||||
const member = require("./run-as").accountForLane(laneId);
|
||||
// Every launcher puts the prompt LAST; Windows has no sh to read it back.
|
||||
// A member's pane cannot read the host's 0700 run-prompts dir — and needs
|
||||
// no file: its whole argv already travels in its launch file, not tmux's.
|
||||
const paneCmd =
|
||||
initialPrompt &&
|
||||
!pasteLater &&
|
||||
!member &&
|
||||
process.platform !== "win32" &&
|
||||
Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES
|
||||
? promptFileArgv(id, argv)
|
||||
@@ -489,7 +537,7 @@ function spawnRun(args) {
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
worktree: worktree === true || inWorktree === true,
|
||||
proxy: proxyProfile ? proxyProfile.name : null,
|
||||
account: poolAccount ? poolAccount.name : null,
|
||||
account: (poolAccount || codexPool)?.name ?? null,
|
||||
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
@@ -535,11 +583,19 @@ function spawnRun(args) {
|
||||
// The pool account's setup-token: outranks the machine's `/login` for
|
||||
// this process only. Through tmux `-e`, never typed into the pane.
|
||||
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
|
||||
...(codexPool ? codexPool.env : {}),
|
||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
// S3: a member's pane gets their home, the shared tools and none of the
|
||||
// host's desktop.
|
||||
if (member) {
|
||||
const own = require("./run-as").memberPaneEnv(member, env, { codex: tool === "codex" });
|
||||
for (const k of Object.keys(env)) delete env[k];
|
||||
Object.assign(env, own);
|
||||
}
|
||||
|
||||
// `after-start` tools take no prompt on their command line: paste it once
|
||||
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||
@@ -557,6 +613,7 @@ function spawnRun(args) {
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
codexPool?.place();
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
|
||||
record();
|
||||
schedulePaste();
|
||||
@@ -567,6 +624,7 @@ function spawnRun(args) {
|
||||
// `--resume` and an initial prompt would never be typed, while the API
|
||||
// still answered 200. Run the argv in the pane the user already sees
|
||||
// instead of erroring or opening a second session.
|
||||
codexPool?.place();
|
||||
tmux.sendCommand(id, paneCmd, env, cwd);
|
||||
record();
|
||||
schedulePaste();
|
||||
@@ -612,9 +670,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
...desktopEnv(),
|
||||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||
};
|
||||
const lane = require("./lanes").getLane(laneId);
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, lane.owner_id ?? null); // as spawnRun
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT, as spawnRun
|
||||
const member = require("./run-as").accountForLane(laneId);
|
||||
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env });
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env: paneEnv });
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user