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:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+77 -34
View File
@@ -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,
};
+11
View File
@@ -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 {
+59 -22
View File
@@ -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
View File
@@ -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");
}
+30 -3
View File
@@ -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,
+9
View File
@@ -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
View File
@@ -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,
};
+2 -2
View File
@@ -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;
}
+3
View File
@@ -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 };
+14 -5
View File
@@ -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;
+472
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+82
View File
@@ -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
View File
@@ -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`);
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: "" });
}
+158
View File
@@ -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,
};
+6 -1
View File
@@ -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);
+3 -2
View File
@@ -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 };
}
+36 -7
View 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 {
+5
View File
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+137
View File
@@ -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,
};
+113
View File
@@ -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
View File
@@ -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 {
+176
View File
@@ -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 };
+8 -1
View File
@@ -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
View File
@@ -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,
+3 -2
View File
@@ -47,8 +47,9 @@ function stopServer({
const owned =
svc.systemctlAvailable() && svc.unitIsOurs("agenthub") && svc.unitIsActive("agenthub");
if (!owned) return kill();
const argv =
svc.kind === "launchd"
const argv = svc.stopArgv
? svc.stopArgv("agenthub") // the server-mode system unit: through sudo
: svc.kind === "launchd"
? ["launchctl", ["bootout", `gui/${process.getuid()}/io.smartgift.agenthub`]]
: ["systemctl", ["--user", "stop", "--no-block", "agenthub.service"]];
spawn(argv[0], argv[1], { detached: true, stdio: "ignore" }).unref();
+112 -38
View File
@@ -57,6 +57,13 @@
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
* read, never verified).
*
* Server mode with S3 (`quotas({home})`): a member's plan is read from THEIR
* Linux account's home only — `.claude`, `.codex`, `.config/cursor` +
* `.cursor`, `.grok`, `.kimi-code`, `.local/share/opencode`, never the
* host's env overrides — through `run-as`'s owner-checked reads (F3). No
* saved Claude profiles (the Profiles modal is the host's) and no agy: its
* `/usage` needs the self-updating binary, so it stays host-only.
*
* Only percentages, window lengths, reset/expiry times, the plan's name and
* the account's email leave this module — never a token.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -74,6 +81,8 @@ const { fetchUsage, fetchOwner } = require("./cc-usage");
const { allRolloutFiles } = require("./codex-ingest");
const { getCursorDir } = require("./cursor-usage");
const { Database } = require("../db");
const { readFileSafe, openSafe, ownerForRead } = require("./run-as");
const { s3Enabled } = require("./linux-accounts");
const CLAUDE_TTL_MS = 5 * 60_000;
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
@@ -137,11 +146,20 @@ async function usageFor(token, uuid) {
return claudeWindows(usage);
}
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
function memberClaudeAuth(home) {
const creds = readJson(path.join(home, ".claude", ".credentials.json"));
const cfg = [path.join(home, ".claude.json"), path.join(home, ".claude", ".claude.json")]
.map(readJson)
.find((c) => c?.oauthAccount);
return { oauth: creds?.claudeAiOauth || null, account: cfg?.oauthAccount || null };
}
/** The account the CLI is logged into, with the token it holds right now. */
async function liveClaude() {
async function liveClaude(readAuth = readActiveAuth) {
let who = {};
try {
const { oauth, account } = readActiveAuth();
const { oauth, account } = readAuth();
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
who = {
account: account?.emailAddress || account?.displayName || null,
@@ -204,7 +222,15 @@ function expireReset(w) {
}
/** The live account first, then every other saved profile (by label). */
async function claudeQuotas(force) {
async function claudeQuotas(force, home) {
if (home) {
const live = await cached(`${home}|live`, force, () =>
liveClaude(() => memberClaudeAuth(home))
);
if (!live) return [];
const { ownerUuid: _uuid, ...shown } = live;
return [{ id: "claude", active: true, label: null, ...shown }];
}
let items = [];
try {
items = listProfiles().items;
@@ -235,7 +261,7 @@ async function claudeQuotas(force) {
function lastRateLimits(file) {
let text;
try {
const fd = fs.openSync(file.path, "r");
const fd = openSafe(file.path);
try {
const len = Math.min(file.size, TAIL_BYTES);
const buf = Buffer.alloc(len);
@@ -282,9 +308,9 @@ function jwtClaims(token) {
}
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
function codexAccount() {
function codexAccount(codexHome = getCodexHome()) {
try {
const { tokens } = JSON.parse(fs.readFileSync(path.join(getCodexHome(), "auth.json"), "utf8"));
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }));
const { exp } = jwtClaims(tokens.access_token);
const id = jwtClaims(tokens.id_token);
return {
@@ -297,8 +323,8 @@ function codexAccount() {
}
}
function codexQuota() {
const files = allRolloutFiles()
function codexQuota(codexHome = getCodexHome()) {
const files = allRolloutFiles(codexHome)
.sort((a, b) => b.mtime - a.mtime)
.slice(0, CODEX_FILES);
for (const f of files) {
@@ -310,7 +336,7 @@ function codexQuota() {
return {
id: "codex",
active: true,
...codexAccount(),
...codexAccount(codexHome),
windows,
asOf: new Date(found.mtime).toISOString(),
};
@@ -318,18 +344,35 @@ function codexQuota() {
return null;
}
/** A JSON file, or null; a member's only if it is theirs (`readFileSafe`). */
function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
return JSON.parse(readFileSafe(file, "utf8", { max: 1 << 20 }));
} catch {
return null;
}
}
/**
* Where each tool keeps its login: the host's (env overrides honoured), or a
* member's home at the tools' defaults — a member's env is not the host's.
*/
function toolDirs(home) {
const h = home || os.homedir();
const env = (k) => (home ? null : process.env[k]);
return {
codex: home ? path.join(home, ".codex") : getCodexHome(),
cursorConfig: path.join(env("XDG_CONFIG_HOME") || path.join(h, ".config"), "cursor"),
cursor: home ? path.join(home, ".cursor") : getCursorDir(),
grok: env("GROK_HOME") || path.join(h, ".grok"),
kimi: env("KIMI_CODE_HOME") || path.join(h, ".kimi-code"),
opencode: path.join(env("XDG_DATA_HOME") || path.join(h, ".local", "share"), "opencode"),
};
}
/** The Cursor CLI's login and the account's plan + billing-cycle usage. */
async function cursorQuota() {
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
const token = readJson(path.join(configHome, "cursor", "auth.json"))?.accessToken;
async function cursorQuota(dirs = toolDirs()) {
const token = readJson(path.join(dirs.cursorConfig, "auth.json"))?.accessToken;
if (typeof token !== "string" || !token) return null; // not logged in
let claims = {};
try {
@@ -337,7 +380,7 @@ async function cursorQuota() {
} catch {
/* not a JWT: no expiry to check, cursor.com decides */
}
const info = readJson(path.join(getCursorDir(), "cli-config.json"))?.authInfo || {};
const info = readJson(path.join(dirs.cursor, "cli-config.json"))?.authInfo || {};
const who = {
id: "cursor",
active: true,
@@ -460,9 +503,8 @@ async function bearerWindows(who, url, token, headers, toWindows) {
}
/** Grok Build's `auth.x.ai` session and its billing view. */
async function grokQuota() {
const home = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
const sessions = readJson(path.join(home, "auth.json"));
async function grokQuota(dirs = toolDirs()) {
const sessions = readJson(path.join(dirs.grok, "auth.json"));
if (!sessions || typeof sessions !== "object") return null;
const xai = Object.entries(sessions)
.filter(([issuer, s]) => /^https:\/\/auth\.x\.ai(::|$)/.test(issuer) && s?.key)
@@ -508,9 +550,8 @@ async function grokQuota() {
}
/** Kimi Code's OAuth token and its managed-plan usage. */
async function kimiQuota() {
const home = process.env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code");
const cred = readJson(path.join(home, "credentials", "kimi-code.json"));
async function kimiQuota(dirs = toolDirs()) {
const cred = readJson(path.join(dirs.kimi, "credentials", "kimi-code.json"));
if (typeof cred?.access_token !== "string" || !cred.access_token) return null;
const exp = Number(cred.expires_at) * 1000;
const who = { id: "kimi", active: true, expiresAt: exp ? iso(exp) : null };
@@ -530,17 +571,36 @@ async function kimiQuota() {
}));
}
/**
* A member's `opencode.db`, copied (with its WAL) from owner-checked reads
* into a private temp dir — SQLite opens by NAME, which the member could swap
* between a check and the open. The host's own is opened in place.
*/
function openCodeDbPath(dir, tmp) {
const db = path.join(dir, "opencode.db");
if (!s3Enabled() || !ownerForRead(db)) return db;
const copy = path.join(tmp(), "opencode.db");
fs.writeFileSync(copy, readFileSafe(db, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
try {
fs.writeFileSync(`${copy}-wal`, readFileSafe(`${db}-wal`, null, { max: 256 << 20 }));
} catch {
/* no WAL: everything is in the main file */
}
return copy;
}
/** opencode's `opencode-go` key: `auth.json` (v1), else `opencode.db` (v2). */
function openCodeGoKey() {
const dir = path.join(
process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
"opencode"
);
function openCodeGoKey(dirs = toolDirs()) {
const dir = dirs.opencode;
const entry = readJson(path.join(dir, "auth.json"))?.["opencode-go"];
if (entry?.type === "api" && typeof entry.key === "string" && entry.key) return entry.key;
let conn;
let tmpDir = null;
const tmp = () => (tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-")));
try {
conn = new Database(path.join(dir, "opencode.db"), { readonly: true, fileMustExist: true });
const file = openCodeDbPath(dir, tmp);
// The private copy is writable so SQLite can replay its WAL.
conn = new Database(file, { readonly: !tmpDir, fileMustExist: true });
const rows = conn
.prepare(
"SELECT value FROM credential WHERE integration_id = 'opencode-go' ORDER BY active DESC, time_created DESC LIMIT 8"
@@ -554,12 +614,13 @@ function openCodeGoKey() {
/* no OpenCode 2 database, or no credential table (OpenCode 1) */
} finally {
conn?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
}
return null;
}
async function openCodeGoQuota() {
const key = openCodeGoKey();
async function openCodeGoQuota(dirs = toolDirs()) {
const key = openCodeGoKey(dirs);
if (!key) return null;
const who = { id: "opencode", active: true, plan: "Go" };
return bearerWindows(who, OPENCODE_GO_URL, key, {}, (body) => ({
@@ -573,18 +634,30 @@ async function openCodeGoQuota() {
}));
}
async function quotas({ force = false } = {}) {
/**
* Every tool's quota row. `home`: a member's Linux home (S3) — that account's
* logins only, and no agy; omitted, the host's.
*/
async function quotas({ force = false, home = null } = {}) {
const dirs = toolDirs(home);
const key = (k) => (home ? `${home}|${k}` : k);
const [claude, cursor, antigravity, grok, kimi, opencode] = await Promise.all([
claudeQuotas(force),
cached("cursor", force, cursorQuota),
cached("antigravity", force, antigravityQuota),
cached("grok", force, grokQuota),
cached("kimi", force, kimiQuota),
cached("opencode", force, openCodeGoQuota),
claudeQuotas(force, home),
cached(key("cursor"), force, () => cursorQuota(dirs)),
home ? null : cached("antigravity", force, antigravityQuota),
cached(key("grok"), force, () => grokQuota(dirs)),
cached(key("kimi"), force, () => kimiQuota(dirs)),
cached(key("opencode"), force, () => openCodeGoQuota(dirs)),
]);
const tools = [...claude, codexQuota(), antigravity, cursor, grok, kimi, opencode].filter(
Boolean
);
const tools = [
...claude,
codexQuota(dirs.codex),
antigravity,
cursor,
grok,
kimi,
opencode,
].filter(Boolean);
return { tools, checkedAt: new Date().toISOString() };
}
@@ -596,6 +669,7 @@ function __resetCache() {
module.exports = {
quotas,
codexWindow,
lastRateLimits,
codexAccount,
windowLabel,
agyVersionOk,
+533
View File
@@ -0,0 +1,533 @@
/**
* @file run-as.js
* @description Server mode, one Linux user per account (S3 plan, P3): which
* Linux account a pane, a git call or a file write must run as, and the argv
* that does it.
*
* A path decides it: everything under `<USERS_ROOT>/<name>/` belongs to the
* AgentHub user `<name>` (Linux `ah-<name>`), the same folder rule
* `scope.folderAllowed` keeps a member's workspaces in. A pane is decided by
* its workspace's folder. Anything else — local mode, S3 not installed, a
* folder outside the users root — is `null`: the host account runs it,
* exactly as before. A folder under the users root whose user is unknown,
* disabled or not provisioned is an ERROR (`EMEMBERGONE`), never "the host
* then": the host account must not run anything in a member's home.
*
* The argv is always `sudo -n -u ah-<name> [VAR=value…] <command> …`; the
* sudoers file install-system wrote allows only a fixed command list for
* `%agenthub-members`, and `SETENV` for the variables. The dashboard never
* writes, chmods or deletes inside a member's home itself — the member can
* swap any name there, and the host's write would follow the swap — so every
* such change runs AS the member through `write-as`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const fs = require("node:fs");
const linux = require("./linux-accounts");
const gone = (name) =>
Object.assign(new Error(`${name}: no enabled, provisioned account owns this folder`), {
code: "EMEMBERGONE",
});
/**
* The account owning `p`, or null (host). `{name, login, home, uid}`.
* @param {string} p an absolute path
* @throws EMEMBERGONE for a folder under the users root no usable account owns
*/
function accountForPath(p) {
if (typeof p !== "string" || !path.isAbsolute(p) || !linux.s3Enabled()) return null;
const conf = linux.serverConf();
const rel = path.relative(conf.usersRoot, path.resolve(p));
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
const name = rel.split(path.sep)[0];
if (!linux.LINUX_NAME_RE.test(name)) throw gone(name);
const { db } = require("../db");
const user = db.prepare("SELECT disabled FROM users WHERE username = ?").get(name);
if (!user || user.disabled) throw gone(name);
const login = linux.loginFor(name);
const uid = uidOf(login);
if (uid == null) throw gone(name); // not provisioned yet: nothing to run as
return { name, login, home: path.join(conf.usersRoot, name), uid };
}
/** `accountForPath` for a READ: a gone member's file is still theirs, never the host's. */
function ownerForRead(p) {
try {
return accountForPath(p);
} catch {
const conf = linux.serverConf();
const name = path.relative(conf.usersRoot, path.resolve(p)).split(path.sep)[0];
const uid = uidOf(linux.loginFor(name));
// No uid at all: nobody's file may be read as theirs (-1 matches no owner).
return {
name,
login: linux.loginFor(name),
home: path.join(conf.usersRoot, name),
uid: uid ?? -1,
};
}
}
/** The account a workspace's panes run as (by its folder), or null. */
function accountForLane(laneId) {
if (!linux.s3Enabled()) return null; // no DB read outside S3
const lane = require("./lanes").getLane(Number(laneId));
return lane ? accountForPath(lane.cwd) : null;
}
/**
* S3: a workspace's panes run as the account of its FOLDER and carry its
* OWNER's pane token (and personal AI Proxy), so the two must agree. In a
* member's folder only that member may own it — any other owner would hand
* their API identity to the folder's member. A folder outside the users root
* runs as the HOST account, so only an admin (by the LIVE role) may own one:
* a developer's or analyst's pane there would be a shell as the host. No
* owner (a workspace from before server mode) is not this rule's business.
* @throws EOWNERFOLDER
*/
function assertOwnerFits(cwd, ownerId) {
if (ownerId == null || !linux.s3Enabled()) return;
const name = ownerForRead(cwd)?.name;
const { db } = require("../db");
const owner = db.prepare("SELECT username, role FROM users WHERE id = ?").get(ownerId);
if (name ? owner?.username === name : owner?.role === "admin") return;
throw Object.assign(
new Error(
name
? `${cwd} is in ${name}'s home: only ${name} may own a workspace there`
: `${cwd} runs as the host account: only an admin may own a workspace there`
),
{ code: "EOWNERFOLDER" }
);
}
/** `agenthub-lane-<id>[-<slot>]` → its workspace's account, or null. */
function accountForSession(name) {
if (!linux.s3Enabled()) return null; // every tmux call asks: no DB read outside S3
const m = /^agenthub-lane-(\d+)(?:-\d)?$/.exec(String(name || ""));
return m ? accountForLane(Number(m[1])) : null;
}
/** The member's tmux socket (only ever reached AS the member, through sudo). */
const tmuxSocket = (acct) => path.join(acct.home, ".agenthub", "tmux", "sock");
/** Where the files a member's pane reads are written (as the member, `write-as`). */
const handoffDir = (acct) => path.join(acct.home, ".agenthub", "handoff");
/** The home of every enabled, provisioned member (S3), else []. */
function memberHomes() {
if (!linux.s3Enabled()) return [];
const conf = linux.serverConf();
const { db } = require("../db");
return db
.prepare("SELECT username FROM users WHERE disabled = 0")
.all()
.map((u) => u.username)
.filter((n) => linux.LINUX_NAME_RE.test(n) && uidOf(linux.loginFor(n)) != null)
.map((n) => path.join(conf.usersRoot, n));
}
/** Every provisioned member whose tmux socket exists — for listing all panes. */
function membersWithTmux() {
return memberHomes()
.map((home) => accountForPath(home))
.filter((acct) => {
try {
return fs.statSync(tmuxSocket(acct)).isSocket();
} catch {
return false;
}
});
}
// uid by login, from /etc/passwd (world-readable; node has no getpwnam).
let passwdCache = { at: 0, byLogin: new Map(), byUid: new Map() };
function passwd() {
if (Date.now() - passwdCache.at < 30_000) return passwdCache;
const byLogin = new Map();
const byUid = new Map();
try {
const file = process.env.AGENTHUB_PASSWD || "/etc/passwd"; // a test's own
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
const [login, , uid] = line.split(":");
if (login && /^\d+$/.test(uid || "")) {
byLogin.set(login, Number(uid));
byUid.set(Number(uid), login);
}
}
} catch {
/* no passwd: nobody is a member */
}
passwdCache = { at: Date.now(), byLogin, byUid };
return passwdCache;
}
const uidOf = (login) => passwd().byLogin.get(login) ?? null;
/** Does AgentHub user `username` have a Linux account (a fitting name in passwd)? */
const hasLinuxAccount = (username) =>
linux.LINUX_NAME_RE.test(String(username || "")) && uidOf(linux.loginFor(username)) != null;
/** The AgentHub username behind a Linux uid (`ah-<name>`), or null. */
function memberNameForUid(uid) {
const login = passwd().byUid.get(Number(uid));
const m = login ? /^ah-([a-z][a-z0-9_-]{0,28})$/.exec(login) : null;
return m ? m[1] : null;
}
/** A member pane's PATH: the shared tools (`/opt/agenthub/tools/bin`) first. */
const MEMBER_PATH = "/opt/agenthub/tools/bin:/usr/local/bin:/usr/bin:/bin";
/** The host's desktop session — a member's pane has none of it. */
const HOST_ONLY = [
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
];
/**
* A member pane's environment, over the one a host pane would get: the
* member's own home and identity (so Claude/Codex keep their config, logins
* and transcripts there), the shared tools first on PATH, self-updates off
* (the binaries are shared), the dashboard's port for hooks and the CLI, and
* none of the host's desktop.
*/
function memberPaneEnv(acct, base, { codex = false } = {}) {
const env = { ...base };
for (const k of HOST_ONLY) delete env[k];
Object.assign(env, {
HOME: acct.home,
USER: acct.login,
LOGNAME: acct.login,
SHELL: "/bin/bash",
PATH: MEMBER_PATH,
DISABLE_AUTOUPDATER: "1",
CLAUDE_DASHBOARD_PORT: String(require("./proxy-gateway").listenPort()),
});
if (codex) env.CODEX_HOME = path.join(acct.home, ".codex");
return env;
}
/**
* `sudo -n -u <login> [VAR=value…] <command> <args…>` — the sudoers file allows
* exactly the commands install-system listed, by absolute path (server.conf
* records the ones it listed), with `SETENV` for `env`.
*/
function sudoArgv(acct, command, args = [], env = {}) {
const assigns = Object.entries(env).map(([k, v]) => {
// A key with `=` or a leading `-` would be read by sudo as something else.
if (!/^[A-Z_][A-Z0-9_]*$/.test(k)) throw new Error(`sudoArgv: bad variable name ${k}`);
return `${k}=${v}`;
});
const bin = linux.serverConf()?.bins?.[command] || command;
return ["sudo", ["-n", "-u", acct.login, ...assigns, bin, ...args]];
}
const WRITE_AS = "/opt/agenthub/libexec/write-as";
// Test seam: how a command runs as a member (sync, with stdin).
let execImpl = (cmd, argv, opts) => require("node:child_process").execFileSync(cmd, argv, opts);
function __setExecImpl(fn) {
execImpl = fn;
}
// Every synchronous call that runs something AS a member is bounded: it
// blocks the event loop, and the member can leave a FIFO where write-as
// writes or where their git reads its config (`include.path`). SIGKILL: the
// command may ignore anything gentler.
const MEMBER_EXEC = { timeout: 5000, killSignal: "SIGKILL" };
/** Run `write-as <file> [mode]` as `acct`, `content` on stdin. */
function writeAs(acct, file, mode, content = "") {
const [cmd, argv] = sudoArgv(acct, WRITE_AS, mode ? [file, mode] : [file]);
execImpl(cmd, argv, { ...MEMBER_EXEC, input: content, stdio: ["pipe", "ignore", "pipe"] });
}
/**
* Save `content` to `file` — as the member who owns the folder (S3), else
* directly. In place, keeping the file's mode; `append` adds to the end.
*/
function writeFileAs(file, content, { append = false } = {}) {
const acct = accountForPath(file);
if (!acct) {
if (append) fs.appendFileSync(file, content);
else fs.writeFileSync(file, content);
return;
}
writeAs(acct, file, append ? "append" : "", content);
}
/**
* A scratch FILE name a git command in `cwd` may create (a temporary index):
* in the host's tmp, or — for a member's folder — a fresh name in the
* member's handoff dir that the member's own git creates. `remove()` deletes
* it (and git's `.lock`) as whoever owns it.
*/
function scratchFile(cwd, prefix) {
const name = `${prefix}${require("node:crypto").randomBytes(8).toString("hex")}`;
const acct = accountForPath(cwd);
if (!acct) {
const dir = fs.mkdtempSync(path.join(require("node:os").tmpdir(), prefix));
return {
file: path.join(dir, name),
remove: () => fs.rmSync(dir, { recursive: true, force: true }),
};
}
const file = path.join(handoffDir(acct), name);
return {
file,
remove: () => {
for (const f of [file, `${file}.lock`]) {
try {
writeAs(acct, f, "remove");
} catch {
/* best effort: a stray scratch file is the member's own */
}
}
},
};
}
/** A user's account by AgentHub username (S3, provisioned), or null. */
function accountForUser(username) {
const conf = linux.s3Enabled() ? linux.serverConf() : null;
if (!conf || !linux.LINUX_NAME_RE.test(String(username || ""))) return null;
return accountForPath(path.join(conf.usersRoot, username));
}
/**
* Write `content` as a file a member's pane reads, in their handoff dir, AS
* the member (`replace`: a new 0644 file renamed over the name; `private`:
* 0600). Returns the path.
*/
function handoffWrite(acct, name, content, mode = "replace") {
const file = path.join(handoffDir(acct), name);
writeAs(acct, file, mode, content);
return file;
}
const PANE_LAUNCH = "/opt/agenthub/libexec/pane-launch";
/**
* The argv a member's tmux session starts with: `pane-launch <file>`, where
* the file (written AS the member, 0600, deleted by pane-launch as it starts)
* holds the pane's environment and command — sudo logs the session's command
* line, and these carry a pane token, a pool account's token and the prompt.
* NUL-separated: the number of variables, `NAME=value` each, then argv.
*/
function launchArgv(acct, env = {}, argv = []) {
const vars = Object.entries(env || {}).map(([k, v]) => `${k}=${v}`);
const body = [String(vars.length), ...vars, ...argv].map((x) => `${x}\0`).join("");
const name = `launch-${require("node:crypto").randomBytes(12).toString("hex")}`;
return [PANE_LAUNCH, handoffWrite(acct, name, body, "private")];
}
// ── Reading a member's files ───────────────────────────────────────────────
// The dashboard reads members' files as itself, with CAP_DAC_READ_SEARCH (it
// must: Claude writes transcripts 0600) — so a name the member swapped for a
// link to another member's file, the host's or root's would be read too. A
// path in a member's home is therefore opened FIRST and kept only when the
// open file is a regular file that member owns (`fstat` on the descriptor:
// no window to swap it after the check). O_NONBLOCK: a FIFO cannot hang the
// event loop on open. Anything else is plain `fs`, as before.
const foreign = (file) =>
Object.assign(new Error(`${file}: not a file of this member's`), { code: "EFOREIGNFILE" });
const OPEN_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0);
function memberOwnerOf(file) {
return typeof file === "string" && linux.s3Enabled() ? ownerForRead(file) : null;
}
/** `fs.openSync(file, "r")`, guarded (above). */
function openSafe(file) {
const acct = memberOwnerOf(file);
if (!acct) return fs.openSync(file, "r");
const fd = fs.openSync(file, OPEN_FLAGS);
const st = fs.fstatSync(fd);
if (!st.isFile() || st.uid !== acct.uid) {
fs.closeSync(fd);
throw foreign(file);
}
return fd;
}
// A member's file is read only up to a cap (theirs to make 1.5 GB, sparse,
// at no cost): MEMBER_READ_MAX unless the caller passes its own `max`, and
// never past the size `fstat` gave — a file growing under the read is cut.
const MEMBER_READ_MAX = 64 * 1024 * 1024;
const tooBig = (file) =>
Object.assign(new Error(`${file}: too big to read`), { code: "ETOOBIG" });
/** `fs.readFileSync(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
function readFileSafe(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (!memberOwnerOf(file)) return fs.readFileSync(file, enc);
const fd = openSafe(file);
try {
const size = fs.fstatSync(fd).size;
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
for (let n; off < size && (n = fs.readSync(fd, buf, off, size - off, off)) > 0; ) off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
fs.closeSync(fd);
}
}
/** `fs.promises.open(file, "r")`, guarded. */
async function openSafeAsync(file) {
const acct = memberOwnerOf(file);
if (!acct) return fs.promises.open(file, "r");
const fh = await fs.promises.open(file, OPEN_FLAGS);
const st = await fh.stat();
if (!st.isFile() || st.uid !== acct.uid) {
await fh.close();
throw foreign(file);
}
return fh;
}
/** `fs.promises.readFile(file, enc)`, guarded (a member's file: ≤ `max` bytes). */
async function readFileSafeAsync(file, enc, { max = MEMBER_READ_MAX } = {}) {
if (!memberOwnerOf(file)) return fs.promises.readFile(file, enc);
const fh = await openSafeAsync(file);
try {
const { size } = await fh.stat();
if (size > max) throw tooBig(file);
const buf = Buffer.alloc(size);
let off = 0;
for (let n; off < size && (n = (await fh.read(buf, off, size - off, off)).bytesRead) > 0; )
off += n;
return enc ? buf.toString(enc, 0, off) : buf.subarray(0, off);
} finally {
await fh.close();
}
}
/** `fs.createReadStream(file, opts)`, guarded (throws at once, like a missing file would on open). */
function readStreamSafe(file, opts = {}) {
if (!memberOwnerOf(file)) return fs.createReadStream(file, opts);
return fs.createReadStream(file, { ...opts, fd: openSafe(file) });
}
/**
* The lines of `file` (opened like `openSafeAsync`), read in 1 MiB chunks.
* A line longer than `maxLine` bytes is never built: it comes out as "", so
* line numbers stay right. readline builds every line whole, and one 512 MB
* line (a member's sparse file costs them nothing) throws a RangeError inside
* the stream that no try/catch around the loop catches — the server exits.
* A trailing "\r" is dropped, like readline's `crlfDelay: Infinity`.
*/
async function* readLines(file, { maxLine = 64 * 1024 * 1024 } = {}) {
const fh = await openSafeAsync(file);
const text = (b) => {
const line = b.toString("utf8");
return line.endsWith("\r") ? line.slice(0, -1) : line;
};
try {
const buf = Buffer.allocUnsafe(1 << 20);
let parts = [];
let partLen = 0;
let skipping = false; // inside a line already over maxLine
for (;;) {
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
if (!bytesRead) break;
const view = buf.subarray(0, bytesRead);
let start = 0;
for (let i = view.indexOf(0x0a); i !== -1; i = view.indexOf(0x0a, start)) {
if (skipping || partLen + (i - start) > maxLine) yield "";
else yield text(partLen ? Buffer.concat([...parts, view.subarray(start, i)]) : view.subarray(start, i));
parts = [];
partLen = 0;
skipping = false;
start = i + 1;
}
if (start < bytesRead && !skipping) {
if (partLen + (bytesRead - start) > maxLine) {
skipping = true;
parts = [];
partLen = 0;
} else {
parts.push(Buffer.from(view.subarray(start)));
partLen += bytesRead - start;
}
}
}
if (skipping) yield "";
else if (partLen) yield text(Buffer.concat(parts));
} finally {
await fh.close();
}
}
/** Whether `file` is a member's (S3): read with that member's caps. */
const isMemberFile = (file) => Boolean(memberOwnerOf(file));
/**
* Whether every path given belongs to one account (S3; always true outside
* it) — a member's journal speaks only for that member's folders and rows.
*/
function sameOwner(...paths) {
if (!linux.s3Enabled()) return true;
const keys = paths.filter(Boolean).map((p) => ownerForRead(p)?.name || "");
return keys.every((k) => k === keys[0]);
}
/** `mkdir -p` / `rm -rf` of a path in a member's home, AS the member. */
const mkdirAs = (acct, dir) => writeAs(acct, dir, "mkdir");
const removeAs = (acct, p) => writeAs(acct, p, "remove");
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
function commandIn(cwd, command, args, env = {}) {
const acct = accountForPath(cwd);
return acct ? sudoArgv(acct, command, args, { HOME: acct.home, ...env }) : [command, args];
}
/** Run `command args` in `cwd` (sync): as its member under S3, else as is. */
function execIn(cwd, command, args, opts = {}) {
const [cmd, argv] = commandIn(cwd, command, args);
return execImpl(cmd, argv, { ...MEMBER_EXEC, cwd, ...opts });
}
function __resetCache() {
passwdCache = { at: 0, byLogin: new Map(), byUid: new Map() };
}
module.exports = {
accountForPath,
accountForLane,
accountForSession,
assertOwnerFits,
hasLinuxAccount,
membersWithTmux,
memberHomes,
ownerForRead,
tmuxSocket,
handoffDir,
memberNameForUid,
sudoArgv,
memberPaneEnv,
MEMBER_PATH,
writeFileAs,
scratchFile,
accountForUser,
handoffWrite,
writeAs,
mkdirAs,
removeAs,
launchArgv,
sameOwner,
openSafe,
readFileSafe,
openSafeAsync,
readFileSafeAsync,
readStreamSafe,
readLines,
isMemberFile,
commandIn,
execIn,
__setExecImpl,
__resetCache,
};
+74 -7
View File
@@ -285,21 +285,36 @@ function audienceIncludes(audience, user) {
return Boolean(audience.owners && audience.owners.has(user.id));
}
/**
* A non-admin's own folder, `<AGENTHUB_USERS_ROOT>/<username>`, or null (no
* root set, or an admin). Whatever the method: an analyst reads every user's
* ROWS, but a path named in a request is read with the dashboard's own rights
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
*/
function ownFolder(req) {
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (!req?.user || req.user.role === "admin" || !root) return null;
const path = require("node:path");
return path.join(path.resolve(root), req.user.username);
}
/**
* The folder a member's workspace may use: anywhere outside server mode or for
* an admin; for a member, only inside `<AGENTHUB_USERS_ROOT>/<username>` when
* that root is set. `dir` must already be absolute and canonical. Until
* accounts are Linux users (S3) this keeps workspaces apart, not agents: a
* terminal can still reach any folder the host account can.
* terminal can still reach any folder the host account can. Under S3 the
* home ITSELF is no workspace (its dot folders hold the member's logins and
* pane tokens, which a workspace's file views would serve) — `allowHome`
* is for browsing to a folder, not for making one a workspace.
*/
function folderAllowed(req, dir) {
if (memberId(req) == null) return true;
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
if (!root) return true;
function folderAllowed(req, dir, { allowHome = false } = {}) {
const home = ownFolder(req);
if (!home) return true;
const path = require("node:path");
const home = path.join(path.resolve(root), req.user.username);
const rel = path.relative(home, path.resolve(dir));
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
if (rel === "") return allowHome || !require("./linux-accounts").s3Enabled();
return rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel);
}
/**
@@ -322,7 +337,58 @@ function folderTakenByOther(req, dir) {
.some((r) => typeof r.cwd === "string" && (related(r.cwd, mine) || related(mine, r.cwd)));
}
/**
* Server mode: whether a non-admin may name `dir` — only inside one of their
* own workspaces' folders, compared by real path so a symlink in their folder
* cannot point the check elsewhere. An analyst's GET too (a path is read with
* the dashboard's rights, see `ownFolder`). Always true for an admin.
*/
function insideOwnWorkspace(req, dir) {
const u = req?.user;
if (!u || u.role === "admin") return true;
return insideLanes(
sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(u.id)
.map((r) => r.id),
dir
);
}
/**
* Server mode: Express middleware for the config readers (`/cc-config`,
* `/codex-config`) — a non-admin's `?cwd=` (an analyst's GET included) must be
* inside one of THEIR OWN workspaces, else 403 EFOLDER. A caller-chosen
* folder is a path the dashboard reads as itself: under S3, with
* CAP_DAC_READ_SEARCH, one planted link (`/tmp/a/.claude -> /`) made it a
* whole-machine read. An admin, and local mode, pass.
*/
function ownCwdQuery(req, res, next) {
const asked = typeof req.query?.cwd === "string" && req.query.cwd;
if (!asked || !req.user || req.user.role === "admin") return next();
const own = sql("SELECT id FROM lanes WHERE owner_id = ?")
.all(req.user.id)
.map((r) => r.id);
if (require("node:path").isAbsolute(asked) && insideLanes(own, asked)) return next();
res.status(403).json({
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
});
}
function insideLanes(ids, dir) {
const path = require("node:path");
const lanesLib = require("./lanes");
const real = lanesLib.canonicalCwd(dir);
return ids.some((id) => {
const cwd = lanesLib.getLane(id)?.cwd;
if (!cwd) return false;
const rel = path.relative(cwd, real);
return rel === "" || (rel.split(path.sep)[0] !== ".." && !path.isAbsolute(rel));
});
}
module.exports = {
insideOwnWorkspace,
ownCwdQuery,
folderTakenByOther,
scopeGuard,
broadcastAudience,
@@ -332,6 +398,7 @@ module.exports = {
canSeeRowSession,
canSeeRunRow,
folderAllowed,
ownFolder,
memberId,
ownerFor,
sessionWhere,
+193
View File
@@ -0,0 +1,193 @@
/**
* @file shared-tools.js
* @description Server mode with one Linux user per account (S3/S4): the AI
* tools every account runs, installed once under `/opt/agenthub/tools`
* (owned by the dashboard's account, `r-x` for everyone). A member's pane has
* `/opt/agenthub/tools/bin` first on its PATH (`run-as.memberPaneEnv`) and
* cannot enter the host's home, where the AI Tools page installs them — so
* every installed tool is PUBLISHED there: after an install or update, at
* server start, and by `agenthub server tools publish`.
*
* What a publish copies, from the tool's real (symlink-resolved) path:
* - a native executable (ELF): that one file;
* - a file inside an npm package (`…/node_modules/<pkg>/…`): the package
* folder (its dependencies live inside it for a global install);
* - a script in a folder of its own (`…/versions/<v>/cursor-agent`): that
* folder — never a shared `bin` folder, nor a dot-folder right under the
* home (`~/.tool/tool`: its login usually sits beside it), both refused.
* A file whose name says it holds a login (`CONFIG_SECRET_RE`) is never
* copied: the copy is readable by every account.
* Each copy lands in `<root>/<id>/<size>-<mtime>/` (so an unchanged tool is not
* copied twice) and `<root>/bin/<command>` is pointed at it. Old copies are
* kept (a running pane may still execute one). Self-updates in a member's
* pane cannot touch them: the files are not the member's.
*
* The dashboard's own scripts members need (hook handler, `agenthub` CLI,
* MCP server, statusline) are published to `/opt/agenthub/app` the same way,
* with an `agenthub` launcher in `<root>/bin`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const toolsRoot = () => process.env.AGENTHUB_TOOLS_ROOT || "/opt/agenthub/tools";
const appRoot = () => process.env.AGENTHUB_APP_ROOT || "/opt/agenthub/app";
const err = (code, message) => Object.assign(new Error(message), { code });
/** `<root>/bin/<command>` when it exists and runs, else null. */
function sharedPath(command) {
if (typeof command !== "string" || !/^[A-Za-z0-9._-]+$/.test(command)) return null;
const p = path.join(toolsRoot(), "bin", command);
try {
fs.accessSync(p, fs.constants.X_OK);
return p;
} catch {
return null;
}
}
function isElf(file) {
const fd = fs.openSync(file, "r");
try {
const buf = Buffer.alloc(4);
fs.readSync(fd, buf, 0, 4, 0);
return buf.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
} finally {
fs.closeSync(fd);
}
}
/**
* What to copy for an executable at `real`: `{src, entry}` — `src` a file or
* a folder, `entry` the executable's path relative to the copy ("" = src).
*/
function planFor(real) {
const parts = real.split(path.sep);
const nm = parts.lastIndexOf("node_modules");
if (nm !== -1 && nm + 1 < parts.length - 1) {
const end = parts[nm + 1].startsWith("@") ? nm + 3 : nm + 2;
const src = parts.slice(0, end).join(path.sep);
return { src, entry: path.relative(src, real) };
}
if (isElf(real)) return { src: real, entry: "" };
const dir = path.dirname(real);
const home = require("node:os").homedir();
if (path.basename(dir) === "bin" || dir === path.dirname(dir) || path.dirname(dir) === home) {
throw err(
"ESHAREDLAYOUT",
`${real} is a script in a shared folder — install this tool under ${toolsRoot()} by hand`
);
}
return { src: dir, entry: path.basename(real) };
}
const secretName = (f) => require("./ai-tools").CONFIG_SECRET_RE.test(path.basename(f));
/** Point `link` at `target` (relative), replacing what was there in one rename. */
function relink(link, target) {
const tmp = `${link}.agenthub-tmp`;
fs.rmSync(tmp, { force: true });
fs.symlinkSync(path.relative(path.dirname(link), target), tmp);
fs.renameSync(tmp, link);
}
/**
* Publish one installed catalog tool. Resolves `{id, command, path, copied}`.
* @throws ENOTINSTALLED, ESHAREDLAYOUT
*/
async function publish(id) {
const aiTools = require("./ai-tools");
const entry = aiTools.get(id);
const found = entry && aiTools.detect(id, { fresh: true });
if (!found?.installed || !found.path) throw err("ENOTINSTALLED", `${id} is not installed`);
const command = entry.detect[0];
const real = fs.realpathSync(found.path);
if (real.startsWith(toolsRoot() + path.sep)) {
return { id, command, path: real, copied: false }; // already a shared copy
}
const plan = planFor(real);
const stat = fs.statSync(real);
const dest = path.join(toolsRoot(), id, `${stat.size}-${Math.floor(stat.mtimeMs)}`);
const copy = plan.entry ? path.join(dest, path.basename(plan.src)) : path.join(dest, command);
let copied = false;
if (!fs.existsSync(dest)) {
const tmp = `${dest}.agenthub-tmp`;
await fs.promises.rm(tmp, { recursive: true, force: true });
await fs.promises.mkdir(tmp, { recursive: true });
const into = plan.entry ? path.join(tmp, path.basename(plan.src)) : path.join(tmp, command);
await fs.promises.cp(plan.src, into, {
recursive: true,
verbatimSymlinks: true,
filter: (f) => !secretName(f),
});
await chmodReadable(tmp);
await fs.promises.rename(tmp, dest);
copied = true;
}
fs.mkdirSync(path.join(toolsRoot(), "bin"), { recursive: true, mode: 0o755 });
const exe = plan.entry ? path.join(copy, plan.entry) : copy;
relink(path.join(toolsRoot(), "bin", command), exe);
return { id, command, path: exe, copied };
}
/** `a+rX` over a tree: every account may read and run it, nobody but us write. */
async function chmodReadable(p) {
const st = await fs.promises.lstat(p);
if (st.isSymbolicLink()) return;
const mode = st.isDirectory() || st.mode & 0o111 ? 0o755 : 0o644;
await fs.promises.chmod(p, mode);
if (st.isDirectory()) {
for (const name of await fs.promises.readdir(p)) await chmodReadable(path.join(p, name));
}
}
/** Every installed catalog tool; one that cannot be published is reported, not thrown. */
async function publishAll() {
const aiTools = require("./ai-tools");
const out = [];
for (const t of aiTools.list()) {
if (!aiTools.detect(t.id, { fresh: true }).installed) continue;
try {
out.push(await publish(t.id));
} catch (e) {
out.push({ id: t.id, error: e.message });
}
}
return out;
}
const APP_DIRS = ["scripts", "server/lib", "bin", "mcp", "statusline"];
/**
* The dashboard's own scripts a member's pane runs (hook handler, CLI, MCP
* server, statusline), from this install, plus the `agenthub` launcher.
*/
async function publishApp(root = path.join(__dirname, "..", "..")) {
for (const dir of APP_DIRS) {
const src = path.join(root, dir);
if (!fs.existsSync(src)) continue;
const dest = path.join(appRoot(), dir);
await fs.promises.mkdir(dest, { recursive: true });
await fs.promises.cp(src, dest, { recursive: true, force: true });
await chmodReadable(dest);
}
// The CLI reads its version from here.
if (fs.existsSync(path.join(root, "package.json"))) {
await fs.promises.copyFile(
path.join(root, "package.json"),
path.join(appRoot(), "package.json")
);
await fs.promises.chmod(path.join(appRoot(), "package.json"), 0o644);
}
const launcher = path.join(toolsRoot(), "bin", "agenthub");
const body =
"#!/bin/sh\n# AgentHub CLI for members' panes (published by the dashboard).\n" +
`exec ${path.join(toolsRoot(), "bin", "node")} ${path.join(appRoot(), "bin", "agenthub.js")} "$@"\n`;
fs.mkdirSync(path.dirname(launcher), { recursive: true });
fs.writeFileSync(`${launcher}.tmp`, body, { mode: 0o755 });
fs.renameSync(`${launcher}.tmp`, launcher);
}
module.exports = { sharedPath, planFor, publish, publishAll, publishApp, toolsRoot };
+211 -48
View File
@@ -12,6 +12,18 @@
* out of the pane's rendered text, its scrollback and the user's shell history.
* `sendText` is the one primitive that does send keystrokes — a pane running an
* interactive chat program that reads stdin as a message, not a shell.
*
* Server mode, one Linux user per account (S3): a member's panes live on THAT
* member's tmux server, socket `<home>/.agenthub/tmux/sock` (`run-as.js`), so
* they run as the member. Every call naming a session goes there, resolved
* from the session's workspace, and runs AS the member (`sudo -u`, tmux by
* the absolute path sudoers lists) — never as a host client: the member owns
* that socket's folder, and a host client on it could be handed a fake server
* or replaced by a member's command (`detach-client -E`). sudo logs its
* command line, so a session's environment and argv travel in a launch file
* (`run-as.launchArgv`) and a paste on stdin. Listing covers every server.
* With no member (local mode, S3 not installed, the host's own workspaces)
* nothing changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,7 +36,7 @@ const { execFileSync } = require("node:child_process");
// "error connecting to ..." noise to the process's stderr just spams the
// journal on every poll while no tmux server is running yet.
const EXEC_OPTS = { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] };
let execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
let execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
// Linux: the tmux server lives in the cgroup of whatever started it — the
// dashboard's systemd unit, or the terminal/IDE scope a dev server runs in —
@@ -50,6 +62,61 @@ const realScopeImpl = (args) =>
);
let scopeImpl = realScopeImpl;
// S3: the member account a session belongs to (null = the host's own server),
// and how a tmux command runs AS that member on their server (`input` = its
// stdin). Seams, so tests never sudo.
let accountImpl = (name) => require("./run-as").accountForSession(name);
// A member's server is theirs: they can stop it (SIGSTOP) or fill it with
// sessions, and every call here blocks the event loop. So each call on it is
// bounded (time, output), and a server that timed out or flooded us is not
// asked again for MEMBER_BACKOFF_MS — its panes read as UNKNOWN meanwhile.
// ponytail: a frozen member still costs one MEMBER_TIMEOUT_MS block per
// backoff; move member calls to async execFile if that ever shows.
const MEMBER_TIMEOUT_MS = 3000;
const MEMBER_BACKOFF_MS = 60_000;
const MEMBER_OPTS = { ...EXEC_OPTS, timeout: MEMBER_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: 1 << 20 };
const realMemberImpl = (acct, args, input) => {
const runAs = require("./run-as");
const [cmd, argv] = runAs.sudoArgv(acct, "tmux", ["-S", runAs.tmuxSocket(acct), ...args]);
return execFileSync(cmd, argv, input == null ? MEMBER_OPTS : { ...MEMBER_OPTS, input });
};
let memberImpl = realMemberImpl;
let unreachable = new Map(); // member login → backoff end
function memberCall(acct, args, input) {
if ((unreachable.get(acct.login) || 0) > Date.now()) {
throw Object.assign(new Error(`${acct.login}: tmux server not answering`), {
code: "ETMUXHUNG",
});
}
try {
return memberImpl(acct, args, input);
} catch (err) {
if (err.code === "ETIMEDOUT" || err.code === "ENOBUFS") {
unreachable.set(acct.login, Date.now() + MEMBER_BACKOFF_MS);
}
throw err;
}
}
function __setAccountImpl(fn, member) {
accountImpl = fn;
if (member) memberImpl = member;
}
function accountOf(name) {
try {
return accountImpl(name);
} catch (err) {
// A member's workspace with no usable account never falls back to the
// host's server: that would run the pane as the host in their folder.
if (err.code === "EMEMBERGONE") throw err;
return null; // no DB yet, a malformed name: the host's own server
}
}
/** Run a tmux command on the server that holds session `name`. */
function onServerOf(name, args, input) {
const acct = accountOf(name);
return acct ? memberCall(acct, args, input) : execImpl(args);
}
// Whether this tmux takes `=name` targets: true for tmux itself, false for
// psmux (the WinGet `tmux` on Windows), null until a live session has shown
// which. A fake exec in a test is real tmux unless it says otherwise.
@@ -60,16 +127,19 @@ function __setExecImpl(fn, { exact = true } = {}) {
scopeImpl = null; // a fake tmux never starts a real scope
exactTargets = exact;
listed = null;
listed = new Map();
}
function __setScopeImpl(fn) {
scopeImpl = fn;
}
function __reset() {
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
execImpl = (args, opts = EXEC_OPTS) => execFileSync("tmux", args, opts);
scopeImpl = realScopeImpl;
accountImpl = (name) => require("./run-as").accountForSession(name);
memberImpl = realMemberImpl;
exactTargets = null;
listed = null;
listed = new Map();
unreachable = new Map();
}
// The `list-sessions` answer, shared for LIST_TTL_MS. Every open dashboard
@@ -79,22 +149,53 @@ function __reset() {
// own can read as live for up to a second. "No server" (exit 1) is an answer
// too and is shared; any other failure is not.
const LIST_TTL_MS = 1000;
let listed = null; // { at, out } | { at, err }
function listOutput() {
if (listed && Date.now() - listed.at < LIST_TTL_MS) {
if (listed.err) throw listed.err;
return listed.out;
let listed = new Map(); // member login ("" = ours) → { at, out } | { at, err }
function listOutput(acct = null) {
const key = acct ? acct.login : "";
const hit = listed.get(key);
if (hit && Date.now() - hit.at < LIST_TTL_MS) {
if (hit.err) throw hit.err;
return hit.out;
}
try {
const out = String(execImpl(["list-sessions", "-F", "#{session_name}"]));
listed = { at: Date.now(), out };
const list = ["list-sessions", "-F", "#{session_name}"];
let out = String(acct ? memberCall(acct, list) : execImpl(list));
// A member's server answers for their own workspaces only: a name it
// lists that belongs to anyone else (a server the member runs to lie
// about another pane) is not believed.
if (acct)
out = out
.split("\n")
.filter((n) => ownedBy(n.trim(), acct))
.join("\n");
listed.set(key, { at: Date.now(), out });
return out;
} catch (err) {
listed = err.status === 1 || err.code === "ENOENT" ? { at: Date.now(), err } : null;
// A member's failure is shared too: a broken server of theirs is not
// asked again on every poll.
if (acct || err.status === 1 || err.code === "ENOENT") listed.set(key, { at: Date.now(), err });
else listed.delete(key);
throw err;
}
}
function ownedBy(name, acct) {
try {
return accountImpl(name)?.login === acct.login;
} catch {
return false;
}
}
/** Our own server first, then every member's (S3). */
function allServers() {
try {
return [null, ...require("./run-as").membersWithTmux()];
} catch {
return [null];
}
}
/**
* Probe once, on a session that exists: `=name` failing where the bare name
* answers means this tmux has no exact-match syntax (psmux answers
@@ -136,14 +237,14 @@ const pane = (name) => (exact() ? `=${name}:` : name);
function hasSession(name) {
try {
execImpl(["has-session", "-t", session(name)]);
onServerOf(name, ["has-session", "-t", session(name)]);
return true;
} catch {
return false;
}
}
/** The running tmux server's pid, or null when none answers. */
/** Our running tmux server's pid, or null when none answers. */
function serverPid() {
try {
const pid = String(execImpl(["display-message", "-p", "#{pid}"])).trim();
@@ -172,9 +273,15 @@ function envFlags(env) {
function newSession({ name, cwd, argv, env }) {
// No argv = tmux's own default shell (a Windows Terminal pane).
const cmd = argv.length ? ["--", ...argv] : [];
listed = null;
listed = new Map();
const args = ["new-session", "-d", "-s", name, "-c", cwd, ...envFlags(env), ...cmd];
if (scopeImpl && process.platform === "linux" && !serverPid()) {
const acct = accountOf(name);
if (acct) {
// As the member (the first call starts their server); env and argv in
// the launch file, never on sudo's logged command line.
const launch = require("./run-as").launchArgv(acct, env, argv);
memberCall(acct, ["new-session", "-d", "-s", name, "-c", cwd, "--", ...launch]);
} else if (scopeImpl && process.platform === "linux" && !serverPid()) {
try {
scopeImpl(args);
} catch {
@@ -215,8 +322,12 @@ function enableMouse(name) {
// so the options every tmux knows go first, in their own call. psmux (no
// `=name` targets) is not known to chain, so it gets one call per option.
const run = (...cmds) => {
if (exact()) return execImpl(cmds.flatMap((c, i) => (i ? [";", ...c] : c)));
for (const c of cmds) execImpl(c);
if (exact())
return onServerOf(
name,
cmds.flatMap((c, i) => (i ? [";", ...c] : c))
);
for (const c of cmds) onServerOf(name, c);
};
run(
["set-option", "-t", pane(name), "mouse", "on"],
@@ -254,8 +365,13 @@ function enableMouse(name) {
function paneCommand(name) {
try {
return (
execImpl(["display-message", "-p", "-t", pane(name), "#{pane_current_command}"]).trim() ||
null
onServerOf(name, [
"display-message",
"-p",
"-t",
pane(name),
"#{pane_current_command}",
]).trim() || null
);
} catch {
return null;
@@ -276,7 +392,21 @@ function paneCommand(name) {
* for, so the whole class of quoting bugs goes with it.
*/
function sendCommand(name, argv, env, cwd) {
execImpl([
const acct = accountOf(name);
if (acct) {
const launch = require("./run-as").launchArgv(acct, env, argv);
memberCall(acct, [
"respawn-pane",
"-k",
"-t",
pane(name),
...(cwd ? ["-c", cwd] : []),
"--",
...launch,
]);
return;
}
onServerOf(name, [
"respawn-pane",
"-k",
"-t",
@@ -298,8 +428,8 @@ function sendCommand(name, argv, env, cwd) {
* only the first line and leave the rest sitting unsent in the input box.
*/
function sendText(name, text) {
execImpl(["send-keys", "-t", pane(name), "-l", text]);
execImpl(["send-keys", "-t", pane(name), "Enter"]);
onServerOf(name, ["send-keys", "-t", pane(name), "-l", text]);
onServerOf(name, ["send-keys", "-t", pane(name), "Enter"]);
}
/**
@@ -310,16 +440,18 @@ function sendText(name, text) {
*/
function pasteText(name, text) {
const buf = `agenthub-paste-${name}`;
execImpl(["set-buffer", "-b", buf, "--", text]);
execImpl(["paste-buffer", "-p", "-d", "-b", buf, "-t", pane(name)]);
execImpl(["send-keys", "-t", pane(name), "Enter"]);
// A member's prompt goes in on stdin: sudo logs argv.
if (accountOf(name)) onServerOf(name, ["load-buffer", "-b", buf, "-"], text);
else onServerOf(name, ["set-buffer", "-b", buf, "--", text]);
onServerOf(name, ["paste-buffer", "-p", "-d", "-b", buf, "-t", pane(name)]);
onServerOf(name, ["send-keys", "-t", pane(name), "Enter"]);
}
/** Idempotent — a session that's already gone is not an error. */
function killSession(name) {
listed = null;
listed = new Map();
try {
execImpl(["kill-session", "-t", session(name)]);
onServerOf(name, ["kill-session", "-t", session(name)]);
} catch {
/* already gone */
}
@@ -327,37 +459,66 @@ function killSession(name) {
/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */
function listSessions(prefix) {
let out;
try {
out = listOutput();
} catch {
return [];
const names = [];
for (const acct of allServers()) {
try {
names.push(...listOutput(acct).split("\n"));
} catch {
/* no server there */
}
}
return out
.split("\n")
.map((s) => s.trim())
.filter((s) => s && s.startsWith(prefix));
return names.map((s) => s.trim()).filter((s) => s && s.startsWith(prefix));
}
/**
* Every live session name from ONE `list-sessions` — what a poll asks instead
* of a synchronous has-session per name, each of which blocks the event loop
* (and so every terminal keystroke) for a whole tmux spawn. Empty when no
* server runs (tmux exits 1) or tmux is not installed; null when the call
* server runs (tmux exits 1) or tmux is not installed; null when OUR call
* itself failed (EAGAIN/ENOMEM under memory pressure), which proves nothing
* about the panes — a caller must not read that as "every pane is gone".
* about the panes — a caller must not read that as "every pane is gone". A
* MEMBER's server that fails (stopped, flooding, gone wrong) proves nothing
* either, but only about that member's panes: `has` answers true for them,
* so nobody's runs end and everyone else's are still checked.
*/
function liveSessions() {
try {
return new Set(
listOutput()
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
);
} catch (err) {
return err.status === 1 || err.code === "ENOENT" ? new Set() : null;
const live = new Set();
const unknown = new Set(); // member logins whose server did not answer
for (const acct of allServers()) {
try {
for (const s of listOutput(acct).split("\n")) if (s.trim()) live.add(s.trim());
} catch (err) {
if (err.status === 1 || err.code === "ENOENT") continue;
if (!acct) return null;
unknown.add(acct.login);
}
}
if (unknown.size) {
const has = live.has.bind(live);
live.has = (name) => has(name) || unknown.has(accountOfQuiet(name)?.login);
}
return live;
}
function accountOfQuiet(name) {
try {
return accountImpl(name);
} catch {
return null;
}
}
/**
* `[file, args]` attaching a terminal to a MEMBER's session (as the member,
* sudo), or null for one on our own server (pty-attach runs our binary).
*/
function memberAttach(name) {
const acct = accountOf(name);
if (!acct) return null;
const runAs = require("./run-as");
return runAs.sudoArgv(acct, "tmux", [
...["-S", runAs.tmuxSocket(acct), "attach-session", "-t", session(name)],
]);
}
function isTmuxAvailable() {
@@ -403,6 +564,8 @@ module.exports = {
clientVersion,
serverVersion,
serverPid,
memberAttach,
__setExecImpl,
__setAccountImpl,
__reset,
};
+10 -1
View File
@@ -15,6 +15,7 @@ const {
} = require("./token-usage");
const MAX_CACHE_ENTRIES = 200;
const MEMBER_TRANSCRIPT_MAX = 256 * 1024 * 1024; // S3: bytes read per pass of a member's transcript
/** `YYYY-MM-DDTHH` (UTC) of a transcript timestamp, or null when it has none. */
function utcHour(ts) {
@@ -295,10 +296,18 @@ class TranscriptCache {
let fd;
try {
try {
fd = fs.openSync(filePath, "r");
fd = require("./run-as").openSafe(filePath);
} catch {
return this._finalizeState(state);
}
// A member's transcript is read on this thread only up to a cap: a
// sparse file the size of a disk costs them nothing.
if (
endOffset - startOffset > MEMBER_TRANSCRIPT_MAX &&
require("./run-as").isMemberFile(filePath)
) {
return this._finalizeState(state);
}
while (pos < endOffset) {
const want = Math.min(CHUNK, endOffset - pos);
+2 -2
View File
@@ -227,7 +227,7 @@ function agentsDirForRun(sessionDir, runId) {
function parseWorkflowJournal(journalPath) {
let raw;
try {
raw = fs.readFileSync(journalPath, "utf8");
raw = require("./run-as").readFileSafe(journalPath, "utf8", { max: 16 << 20 });
} catch {
return null;
}
@@ -425,7 +425,7 @@ async function ingestLiveWorkflow(dbModule, sessionId, sessionDir, runId, script
try {
const jj = path.join(dir, "journal.jsonl");
if (fs.existsSync(jj)) {
for (const line of fs.readFileSync(jj, "utf8").split("\n")) {
for (const line of require("./run-as").readFileSafe(jj, "utf8", { max: 16 << 20 }).split("\n")) {
if (!line.trim()) continue;
let o;
try {
+26 -1
View File
@@ -40,6 +40,8 @@ async function git(
env: extraEnv = {},
// "buffer" for a binary read (an image at a revision).
encoding = "utf8",
// stdin (a patch for `apply -`): never a file the dashboard writes.
input = null,
} = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
@@ -74,8 +76,31 @@ async function git(
// Caller-supplied last: the git credential helper's inputs (git-credentials.js).
Object.assign(env, extraEnv);
// S3: in a member's folder git runs AS the member — the dashboard cannot
// write there, and git refuses a repository another uid owns. Only these
// variables cross sudo, and never a secret: sudo logs its command line
// (git-credentials.js gives a member's folder no helper of ours).
const member = require("./run-as").accountForPath(cwd);
let cmd = "git";
let argv = args;
if (member) {
const pass = { GIT_TERMINAL_PROMPT: "0", HOME: member.home, ...extraEnv };
if (literal) pass.GIT_LITERAL_PATHSPECS = "1";
[cmd, argv] = require("./run-as").sudoArgv(member, "git", args, pass);
}
try {
const result = await execFileAsync("git", args, {
const run =
input == null
? execFileAsync
: (c, a, o) =>
new Promise((resolve, reject) => {
const child = execFile(c, a, o, (err, stdout, stderr) =>
err ? reject(Object.assign(err, { stdout, stderr })) : resolve({ stdout, stderr })
);
child.stdin.end(input);
});
const result = await run(cmd, argv, {
cwd,
env,
// A read with its own size cap passes a bigger buffer and treats an