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:
+77
-34
@@ -21,6 +21,14 @@
|
||||
* so **no route ever returns key material** — `listProxies` reports `hasKey`
|
||||
* and nothing else about it.
|
||||
*
|
||||
* **Shared and personal.** The store's top level holds the SHARED profiles —
|
||||
* the host's in local mode, the admin's in server mode. Each user may also keep
|
||||
* PERSONAL ones under `users/<user id>/`, named `u<id>_<slug>`: `slugify`
|
||||
* never emits `_`, so a personal name can never equal a shared one, and the
|
||||
* name alone (stored in `dashboard_runs.proxy`) says whose it is. Who may see,
|
||||
* write or start a session on which is decided by the routes and by
|
||||
* `proxy-apply.resolveProxy`, from `ownerOfName`.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it has no
|
||||
* endpoint and no model of its own (the session's model picker decides), so
|
||||
* "use the vendor's own API" is a choice like any profile. It cannot be
|
||||
@@ -97,11 +105,25 @@ function proxiesDir() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A personal profile's name: `u<owner id>_<slug>`. */
|
||||
const PERSONAL_RE = /^u([1-9][0-9]{0,8})_[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** The user id a personal profile belongs to, or null for a shared one. */
|
||||
function ownerOfName(name) {
|
||||
const m = typeof name === "string" ? PERSONAL_RE.exec(name) : null;
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** Where one owner's profiles live: the top level is the shared store. */
|
||||
function storeDir(owner) {
|
||||
return owner == null ? proxiesDir() : path.join(proxiesDir(), "users", String(owner));
|
||||
}
|
||||
|
||||
function proxyFile(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(proxiesDir(), `${name}.json`);
|
||||
return path.join(storeDir(ownerOfName(name)), `${name}.json`);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
@@ -138,15 +160,17 @@ function slugify(text) {
|
||||
return NAME_RE.test(base) ? base : "proxy";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||
function freeName(base) {
|
||||
let name = base;
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(proxiesDir(), `${name}.json`));
|
||||
) {
|
||||
name = `${base}-${n}`;
|
||||
n += 1;
|
||||
/**
|
||||
* `base`, or `base-2`, `base-3`… — the first that is neither taken nor built
|
||||
* in — prefixed `u<owner>_` for a personal profile (the slug shortened so the
|
||||
* whole name still fits NAME_RE's 40 characters).
|
||||
*/
|
||||
function freeName(base, owner = null) {
|
||||
const prefix = owner == null ? "" : `u${owner}_`;
|
||||
const stem = base.slice(0, 40 - prefix.length - 4).replace(/-+$/, "") || "proxy";
|
||||
let name = prefix + stem;
|
||||
for (let n = 2; name === BUILTIN_NAME || fs.existsSync(proxyFile(name)); n += 1) {
|
||||
name = `${prefix}${stem}-${n}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -243,35 +267,49 @@ function readProxy(name) {
|
||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||
function publicView(profile) {
|
||||
const { apiKey, ...rest } = profile;
|
||||
return { ...rest, hasKey: Boolean(apiKey) };
|
||||
return {
|
||||
...rest,
|
||||
scope: ownerOfName(profile.name) == null ? "shared" : "personal",
|
||||
hasKey: Boolean(apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
/** The profile names saved in one owner's store, sorted. */
|
||||
function namesIn(owner) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(storeDir(owner))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n) && n !== BUILTIN_NAME && ownerOfName(n) === owner)
|
||||
.sort();
|
||||
} catch {
|
||||
return []; // nothing saved yet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every saved profile, with the built-in native one first.
|
||||
* The profiles `viewer` may choose: the built-in native one first, then the
|
||||
* shared ones, then the viewer's own personal ones (none for `null` — local
|
||||
* mode, or a request with no user). Nobody lists another user's personal
|
||||
* profiles, an admin included.
|
||||
* @param {number|null} [viewer] user id
|
||||
* @returns `{ items }` — key material is never included.
|
||||
*/
|
||||
function listProxies() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(proxiesDir())
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
/* nothing saved yet */
|
||||
}
|
||||
|
||||
function listProxies(viewer = null) {
|
||||
const items = [publicView(NATIVE)];
|
||||
for (const name of names.sort()) {
|
||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
const owners = viewer == null ? [null] : [null, viewer];
|
||||
for (const owner of owners) {
|
||||
for (const name of namesIn(owner)) {
|
||||
let stored;
|
||||
try {
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
@@ -290,9 +328,10 @@ function getProxy(name) {
|
||||
/**
|
||||
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
||||
* models?:Record<string,string>, behavesAs?:string}} input
|
||||
* @param {number|null} [owner] user id for a personal profile, null = shared
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createProxy(input = {}) {
|
||||
function createProxy(input = {}, owner = null) {
|
||||
const label = normalizeLabel(input.label);
|
||||
const slug = slugify(label);
|
||||
assertNotBuiltin(slug);
|
||||
@@ -308,7 +347,7 @@ function createProxy(input = {}) {
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const name = freeName(slug);
|
||||
const name = freeName(slug, owner);
|
||||
writeJson(proxyFile(name), profile);
|
||||
return publicView({ ...profile, name, builtin: false });
|
||||
}
|
||||
@@ -376,6 +415,9 @@ async function probe(url, apiKey, headers, body, model) {
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
// A redirect is an answer, never followed: fetch drops `authorization`
|
||||
// on a cross-origin hop but would carry `x-api-key` to the new host.
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (e) {
|
||||
// Unreachable, DNS failure, TLS refusal, timeout — a real answer about the
|
||||
@@ -461,4 +503,5 @@ module.exports = {
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
ownerOfName,
|
||||
};
|
||||
|
||||
@@ -214,6 +214,17 @@ async function finish(j, code) {
|
||||
if (code === 0 && j.action === "install" && aiTools.detect(j.tool).installed) {
|
||||
aiTools.setEnabled(j.tool, true);
|
||||
}
|
||||
// S3: members run the shared copy under /opt/agenthub/tools — refresh it.
|
||||
if (code === 0 && require("./linux-accounts").s3Enabled()) {
|
||||
const shared = require("./shared-tools");
|
||||
const done = j.tool ? [await shared.publish(j.tool)] : await shared.publishAll();
|
||||
for (const r of done) {
|
||||
append(
|
||||
j,
|
||||
`\n[agenthub] ${r.error ? `${r.id}: not shared — ${r.error}` : `shared for every account: ${r.path}`}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
append(j, `\n[agenthub] ${err.message}\n`);
|
||||
} finally {
|
||||
|
||||
@@ -142,10 +142,20 @@ function firstFileUri(buf, depth = 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transcriptFiles = (id) => {
|
||||
const dir = path.join(getAgyCliDir(), "brain", id, ".system_generated", "logs");
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
};
|
||||
/** This account's agy dir, then every member's (S3: their own home). */
|
||||
const agyDirs = () => [
|
||||
getAgyCliDir(),
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".gemini", "antigravity-cli")),
|
||||
];
|
||||
|
||||
// A conversation id is a UUID, so its transcript is wherever it exists.
|
||||
const transcriptFiles = (id) =>
|
||||
agyDirs().flatMap((cli) => {
|
||||
const dir = path.join(cli, "brain", id, ".system_generated", "logs");
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
/** The transcript's last write, 0 when it has none. */
|
||||
function transcriptMtime(id) {
|
||||
@@ -164,7 +174,7 @@ function transcriptFacts(id) {
|
||||
for (const file of transcriptFiles(id)) {
|
||||
let text;
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, 1024 * 1024));
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
@@ -200,31 +210,55 @@ function transcriptFacts(id) {
|
||||
|
||||
/** Everything AgentHub reads from one conversation file. */
|
||||
function readConversation(file) {
|
||||
const conn = new Database(file, { readonly: true, fileMustExist: true });
|
||||
// S3: SQLite opens by NAME, and its unchecked `-journal`/`-wal` beside it
|
||||
// (a member's FIFO there blocks this thread forever). A member's file and
|
||||
// WAL are read through the guard into a private copy, opened writable so
|
||||
// SQLite can replay the WAL. The host's own is opened in place.
|
||||
const { ownerForRead, readFileSafe } = require("./run-as");
|
||||
let tmp = null;
|
||||
try {
|
||||
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
|
||||
const usage = {};
|
||||
let model = null;
|
||||
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
|
||||
const u = data && generationUsage(data);
|
||||
if (!u) continue;
|
||||
model = u.model;
|
||||
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
for (const k of Object.keys(t)) t[k] += u[k];
|
||||
let open = file;
|
||||
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
|
||||
tmp = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "agenthub-agy-"));
|
||||
open = path.join(tmp, "conversation.db");
|
||||
fs.writeFileSync(open, readFileSafe(file, null, { max: 256 << 20 })); // ponytail: whole file in memory, capped
|
||||
try {
|
||||
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: 256 << 20 }));
|
||||
} catch {
|
||||
/* no WAL (or not theirs): what is in the main file */
|
||||
}
|
||||
}
|
||||
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
|
||||
try {
|
||||
const blob = conn.prepare("SELECT data FROM trajectory_metadata_blob LIMIT 1").get();
|
||||
const usage = {};
|
||||
let model = null;
|
||||
for (const { data } of conn.prepare("SELECT data FROM gen_metadata ORDER BY idx").iterate()) {
|
||||
const u = data && generationUsage(data);
|
||||
if (!u) continue;
|
||||
model = u.model;
|
||||
const t = (usage[u.model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
for (const k of Object.keys(t)) t[k] += u[k];
|
||||
}
|
||||
return {
|
||||
cwd: blob?.data ? firstFileUri(blob.data) : null,
|
||||
usage,
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
conn.close();
|
||||
}
|
||||
return {
|
||||
cwd: blob?.data ? firstFileUri(blob.data) : null,
|
||||
usage,
|
||||
model,
|
||||
};
|
||||
} finally {
|
||||
conn.close();
|
||||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** The conversation files, each with the newer mtime of the file and its non-empty `-wal`. */
|
||||
function conversationFiles() {
|
||||
const dir = path.join(getAgyCliDir(), "conversations");
|
||||
return agyDirs().flatMap((cli) => conversationFilesIn(path.join(cli, "conversations")));
|
||||
}
|
||||
|
||||
function conversationFilesIn(dir) {
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
@@ -328,6 +362,8 @@ function writeSession(f, conv, now) {
|
||||
const id = f.id;
|
||||
const existing = stmts.getSession.get(id);
|
||||
if (existing && existing.tool !== "antigravity") return null;
|
||||
// S3: a member's conversation names only their own folder and session.
|
||||
if (!require("./run-as").sameOwner(f.path, conv.cwd, existing?.cwd)) return null;
|
||||
const facts = transcriptFacts(id);
|
||||
const a = activity(id, f.mtime);
|
||||
const lastWrite = new Date(a.last).toISOString();
|
||||
@@ -484,6 +520,7 @@ module.exports = {
|
||||
pbFields,
|
||||
generationUsage,
|
||||
firstFileUri,
|
||||
readConversation,
|
||||
ingestAntigravityUsage,
|
||||
startAntigravityUsagePoll,
|
||||
};
|
||||
|
||||
+60
-7
@@ -41,14 +41,16 @@ const ROLES = ["admin", "analyst", "developer"];
|
||||
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/** API prefixes whose writes change the host account's own setup. */
|
||||
/**
|
||||
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
||||
* profiles are not here: a member writes their own personal ones, and
|
||||
* `routes/ai-proxies.js` keeps the shared ones an admin's.)
|
||||
*/
|
||||
const ADMIN_WRITE_PREFIXES = [
|
||||
"/settings",
|
||||
"/updates",
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
"/ai-proxies",
|
||||
"/ai-routers",
|
||||
"/git-credentials",
|
||||
"/ai-tools",
|
||||
"/import",
|
||||
@@ -85,6 +87,9 @@ const STAFF_ONLY_PREFIXES = [
|
||||
];
|
||||
// The run form's `/` autocomplete lists commands and skills.
|
||||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
||||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||||
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
||||
|
||||
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
|
||||
|
||||
@@ -136,13 +141,22 @@ function findUser(username) {
|
||||
|
||||
async function createUser(username, password, role = "developer") {
|
||||
const name = validateUsername(username);
|
||||
const linux = require("./linux-accounts");
|
||||
linux.assertLinuxName(name);
|
||||
validatePassword(password);
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
if (findUser(name)) throw err("EEXISTS", `user ${name} already exists`);
|
||||
const hash = await hashPassword(password);
|
||||
// S3: the Linux account first — a user who cannot get one is not created.
|
||||
linux.provision("create", name);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||||
).run(name, role, hash);
|
||||
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
|
||||
// the background (each marketplace is a clone) — the user exists already.
|
||||
require("./member-claude")
|
||||
.applyPluginBaseline(name)
|
||||
.catch((e) => console.warn(`[plugin-baseline] ${name}: ${e.message}`));
|
||||
return publicUser(sql("SELECT * FROM users WHERE id = ?").get(lastInsertRowid));
|
||||
}
|
||||
|
||||
@@ -162,21 +176,58 @@ async function setPassword(username, password) {
|
||||
|
||||
function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, requireUser(username).id);
|
||||
const u = requireUser(username);
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: end the live panes of `userId`'s workspaces whose folder is no
|
||||
* member's — they run as the HOST account, which only an admin's may (a
|
||||
* new one is refused, EOWNERFOLDER; this ends the ones already running).
|
||||
*/
|
||||
function stopHostPanes(userId) {
|
||||
if (!require("./linux-accounts").s3Enabled()) return;
|
||||
const runAs = require("./run-as");
|
||||
const pty = require("./pty-run");
|
||||
for (const lane of sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(userId)) {
|
||||
if (runAs.ownerForRead(lane.cwd)) continue; // a member's folder: their own account
|
||||
for (let slot = 0; slot <= pty.MAX_PANE_SLOT; slot++) {
|
||||
try {
|
||||
pty.killRun(pty.runIdForLane(lane.id, slot));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Disable (ending every login) or re-enable a user. Users are never deleted:
|
||||
* S2 will hang owned rows off their id. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
const linux = require("./linux-accounts");
|
||||
// S3: lock (and end the processes of) the Linux account too — when there is
|
||||
// one: a user made before S3, or whose name fits no Linux login, has none,
|
||||
// and that must never stop an admin from revoking their dashboard access.
|
||||
const hasAccount = linux.s3Enabled() && require("./run-as").hasLinuxAccount(u.username);
|
||||
if (!disabled && hasAccount) linux.provision("enable", u.username);
|
||||
sql("UPDATE users SET disabled = ? WHERE id = ?").run(disabled ? 1 : 0, u.id);
|
||||
if (disabled) sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
// ...and their panes that run as the host (pkill below reaches only theirs).
|
||||
if (disabled) stopHostPanes(u.id);
|
||||
// Disable: the logins end FIRST, unconditionally; a failed lock still throws
|
||||
// (EPROVISION), so the admin learns that the account's agents may still run.
|
||||
if (disabled && hasAccount) linux.provision("disable", u.username);
|
||||
}
|
||||
|
||||
/** Give a workspace (a lane id) to a user — for the ones made before server
|
||||
* mode, which have no owner and so reach no member. */
|
||||
function assignWorkspace(laneId, username) {
|
||||
const u = requireUser(username);
|
||||
const lane = sql("SELECT cwd FROM lanes WHERE id = ?").get(Number(laneId));
|
||||
// S3: a workspace in a member's home is theirs alone (EOWNERFOLDER).
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id);
|
||||
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
|
||||
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
||||
}
|
||||
@@ -338,10 +389,12 @@ function userFromPaneToken(req) {
|
||||
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
|
||||
if (!isLocalRequest(req)) return null;
|
||||
const row = sql(
|
||||
`SELECT r.id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
`SELECT r.id, r.lane_id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
|
||||
).get(sha256(t));
|
||||
if (!row || row.owner_id == null) return null;
|
||||
// S3: only from the account that pane runs as (any local uid could post it).
|
||||
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
|
||||
if (!require("./tmux").hasSession(row.id)) return null;
|
||||
return (
|
||||
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
|
||||
@@ -396,8 +449,8 @@ function authGuard(req, res, next) {
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
user.role === "developer" &&
|
||||
STAFF_ONLY_PREFIXES.some(under) &&
|
||||
!(isRead && MEMBER_READ_ALLOW.some(under))
|
||||
((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) ||
|
||||
STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
||||
) {
|
||||
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ function isUnder(root, target) {
|
||||
|
||||
function readJson(absPath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(absPath, "utf8");
|
||||
const raw = require("./run-as").readFileSafe(absPath, "utf8");
|
||||
return { ok: true, data: JSON.parse(raw), raw };
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return { ok: false, missing: true };
|
||||
@@ -129,14 +129,14 @@ function safeReadText(absPath) {
|
||||
return {
|
||||
truncated: true,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
||||
text: require("./run-as").readFileSafe(absPath, "utf8").slice(0, MAX_FILE_BYTES),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
truncated: false,
|
||||
size: stat.size,
|
||||
text: fs.readFileSync(absPath, "utf8"),
|
||||
text: require("./run-as").readFileSafe(absPath, "utf8"),
|
||||
mtime: stat.mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
@@ -837,6 +837,31 @@ function readMemory(opts = {}) {
|
||||
|
||||
// ── Single-file body endpoint (with strict path containment) ───────────
|
||||
|
||||
/**
|
||||
* A credential is never served by name, to any role: Claude's
|
||||
* `.credentials.json`, Codex's `auth.json`, any non-Markdown file named like a
|
||||
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
|
||||
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
|
||||
*/
|
||||
function isSecretConfigPath(resolved) {
|
||||
const base = path.basename(resolved);
|
||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||
const rel = path.relative(getClaudeHome(), resolved);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-"))
|
||||
return true;
|
||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||
// keys and every gateway pane's settings file WITH its live pane token.
|
||||
const { getDataDir, dataDirCandidates } = require("./claude-home");
|
||||
const { current, legacy } = dataDirCandidates();
|
||||
let real = resolved;
|
||||
try {
|
||||
real = fs.realpathSync(resolved);
|
||||
} catch {
|
||||
/* not there: the lexical path decides */
|
||||
}
|
||||
return [getDataDir(), current, legacy].some((d) => isUnder(d, resolved) || isUnder(d, real));
|
||||
}
|
||||
|
||||
function readFileSafe(absPath, opts = {}) {
|
||||
const allowedRoots = [
|
||||
getClaudeHome(),
|
||||
@@ -855,6 +880,7 @@ function readFileSafe(absPath, opts = {}) {
|
||||
) {
|
||||
return { error: "only CLAUDE.md is readable from project root" };
|
||||
}
|
||||
if (isSecretConfigPath(resolved)) return { error: "a credential file is never served" };
|
||||
const r = safeReadText(resolved);
|
||||
if (!r) return { error: "file not readable" };
|
||||
return { ok: true, file: resolved, ...r };
|
||||
@@ -921,6 +947,7 @@ function readOverview(opts = {}) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSecretConfigPath,
|
||||
// Exported so a test can pin it against the file the CLI writes — the two
|
||||
// drifted apart once and the mismatch was invisible from either side alone.
|
||||
getClaudeJsonPath,
|
||||
|
||||
@@ -65,6 +65,15 @@ function reloadableLanes() {
|
||||
for (const run of listRuns()) {
|
||||
if (run.status !== "running") continue;
|
||||
if (run.tool !== "claude") continue;
|
||||
// S3: a member's pane runs on their own ~/.claude — a change to the
|
||||
// host's plugins or skills is not theirs to reload.
|
||||
// EMEMBERGONE (a disabled member's pane) is a member's too: skip it,
|
||||
// never let one throw end the reload for every other pane.
|
||||
try {
|
||||
if (require("./run-as").accountForSession(run.id)) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const cmd = tmux.paneCommand(run.id);
|
||||
if (SHELL_COMMANDS.has(cmd || "")) continue;
|
||||
out.push({ id: run.id, laneId: run.laneId, paneCommand: cmd });
|
||||
|
||||
+64
-20
@@ -103,6 +103,37 @@ function encodeCwd(cwd) {
|
||||
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* The projects dir a session working in `cwd` writes its transcripts to: a
|
||||
* member's own `<home>/.claude/projects` under S3 (one Linux user per account,
|
||||
* server/lib/run-as.js), else this account's. A READ: a disabled or
|
||||
* unprovisioned member's folder is still theirs (`ownerForRead`), never a
|
||||
* throw — this runs inside async routes and the liveness reaper.
|
||||
*/
|
||||
function projectsDirFor(cwd) {
|
||||
const acct = cwd ? require("./run-as").ownerForRead(cwd) : null;
|
||||
return acct ? path.join(acct.home, ".claude", "projects") : getProjectsDir();
|
||||
}
|
||||
|
||||
/** This account's projects dir, then every member's (S3) — for a scan by id. */
|
||||
function allProjectsDirs() {
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".claude", "projects"));
|
||||
return [getProjectsDir(), ...members];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a by-id scan for ONE session looks: only the projects dir of the
|
||||
* account that owns its folder. Under S3 another member could plant
|
||||
* `<their home>/.claude/projects/x/<id>.jsonl`, a file they own, and a scan of
|
||||
* every home would serve it as this session's. A session with no folder is
|
||||
* this account's (`projectsDirFor(null)`), never every member's.
|
||||
*/
|
||||
function sessionProjectsDirs(cwd) {
|
||||
return [projectsDirFor(cwd)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the main session JSONL file path from sessionId and cwd.
|
||||
* Encoding rule: all non-alphanumeric characters replaced with "-".
|
||||
@@ -111,10 +142,10 @@ function encodeCwd(cwd) {
|
||||
function getTranscriptPath(sessionId, cwd) {
|
||||
if (!cwd) return null;
|
||||
const encoded = encodeCwd(cwd);
|
||||
const candidate = path.join(getProjectsDir(), encoded, `${sessionId}.jsonl`);
|
||||
const candidate = path.join(projectsDirFor(cwd), encoded, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
// Fallback: scan projects/ subdirectories
|
||||
return findTranscriptPath(sessionId);
|
||||
// Fallback: scan the owner's projects/ subdirectories
|
||||
return findTranscriptPath(sessionId, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,29 +204,31 @@ function resolveAgentTranscriptInDir(subagentsDir, agentId, runId = null) {
|
||||
function getSubagentTranscriptPath(sessionId, cwd, agentId, runId = null) {
|
||||
if (!cwd) return null;
|
||||
const encoded = encodeCwd(cwd);
|
||||
const subagentsDir = path.join(getProjectsDir(), encoded, sessionId, "subagents");
|
||||
const subagentsDir = path.join(projectsDirFor(cwd), encoded, sessionId, "subagents");
|
||||
const direct = resolveAgentTranscriptInDir(subagentsDir, agentId, runId);
|
||||
if (direct) return direct;
|
||||
// Fallback: scan all project directories
|
||||
return findSubagentTranscriptPath(sessionId, agentId, runId);
|
||||
// Fallback: scan the owner's project directories
|
||||
return findSubagentTranscriptPath(sessionId, agentId, runId, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* When cwd is unknown, scan projects/ subdirectories to find the JSONL file for a sessionId.
|
||||
* Scan projects/ subdirectories for a sessionId's JSONL file — the owner's of
|
||||
* `cwd` only (`sessionProjectsDirs`).
|
||||
* Returns the found path or null.
|
||||
*/
|
||||
function findTranscriptPath(sessionId) {
|
||||
const projectsDir = getProjectsDir();
|
||||
if (!fs.existsSync(projectsDir)) return null;
|
||||
try {
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
function findTranscriptPath(sessionId, cwd = null) {
|
||||
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
||||
if (!fs.existsSync(projectsDir)) continue;
|
||||
try {
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
for (const d of dirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const candidate = path.join(projectsDir, d.name, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
} catch {
|
||||
// Permission or IO error, ignore
|
||||
}
|
||||
} catch {
|
||||
// Permission or IO error, ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -245,8 +278,15 @@ function getSnapshotSubagentTranscriptPath(sessionId, agentId, runId = null) {
|
||||
* - Exact: agent-<agentId>.jsonl (or workflows/<runId>/agent-<agentId>.jsonl)
|
||||
* - Fuzzy: agent-acompact-*.jsonl (for compaction type)
|
||||
*/
|
||||
function findSubagentTranscriptPath(sessionId, agentId, runId = null) {
|
||||
const projectsDir = getProjectsDir();
|
||||
function findSubagentTranscriptPath(sessionId, agentId, runId = null, cwd = null) {
|
||||
for (const projectsDir of sessionProjectsDirs(cwd)) {
|
||||
const hit = findSubagentIn(projectsDir, sessionId, agentId, runId);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSubagentIn(projectsDir, sessionId, agentId, runId) {
|
||||
if (!fs.existsSync(projectsDir)) return null;
|
||||
try {
|
||||
const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
|
||||
@@ -303,6 +343,7 @@ module.exports = {
|
||||
getClaudeHome,
|
||||
getProjectsDir,
|
||||
getDataDir,
|
||||
dataDirCandidates,
|
||||
migrateLegacyDataDir,
|
||||
getTranscriptSnapshotDir,
|
||||
getSettingsPath,
|
||||
@@ -313,5 +354,8 @@ module.exports = {
|
||||
getSnapshotSubagentTranscriptPath,
|
||||
findTranscriptPath,
|
||||
findSubagentTranscriptPath,
|
||||
projectsDirFor,
|
||||
allProjectsDirs,
|
||||
sessionProjectsDirs,
|
||||
setClaudeHome,
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ function spendSince(account, sinceHour) {
|
||||
`WITH s AS (
|
||||
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
|
||||
FROM dashboard_runs
|
||||
WHERE account = ? AND session_id IS NOT NULL
|
||||
WHERE tool = 'claude' AND account = ? AND session_id IS NOT NULL
|
||||
GROUP BY session_id)
|
||||
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
|
||||
@@ -189,7 +189,7 @@ function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS n FROM dashboard_runs WHERE account = ? AND status IN ('running', 'spawning')"
|
||||
"SELECT COUNT(*) AS n FROM dashboard_runs WHERE tool = 'claude' AND account = ? AND status IN ('running', 'spawning')"
|
||||
)
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
@@ -329,6 +329,9 @@ function readFileSafe(absPath, opts = {}) {
|
||||
) {
|
||||
return { error: "only AGENTS.md is readable from project root" };
|
||||
}
|
||||
if (require("./cc-discovery").isSecretConfigPath(resolved)) {
|
||||
return { error: "a credential file is never served" };
|
||||
}
|
||||
|
||||
const r = safeReadText(resolved);
|
||||
if (!r) return { notFound: true };
|
||||
|
||||
@@ -65,11 +65,17 @@ function allRolloutFiles(home = getCodexHome()) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The newest MAX_FILES rollout journals, newest mtime first. */
|
||||
/** The newest MAX_FILES rollout journals of each home, newest mtime first. */
|
||||
function rolloutFiles() {
|
||||
return allRolloutFiles()
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
.slice(0, MAX_FILES);
|
||||
// This account's CODEX_HOME and every member's (S3) — capped PER home, so
|
||||
// one member touching many journals cannot push everyone else's out.
|
||||
const newest = (files) => files.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES);
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex"));
|
||||
return [allRolloutFiles(), ...members.map((h) => allRolloutFiles(h))]
|
||||
.flatMap(newest)
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
|
||||
const cwdCache = new Map(); // rollout path → thread cwd (written once, at thread creation)
|
||||
@@ -79,7 +85,7 @@ function rolloutCwd(file) {
|
||||
if (cwdCache.has(file)) return cwdCache.get(file);
|
||||
let cwd = null;
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const buf = Buffer.alloc(64 * 1024);
|
||||
const read = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
@@ -138,8 +144,11 @@ function ingestCodexOnce({ broadcast } = {}) {
|
||||
for (const file of files) {
|
||||
const cwd = rolloutCwd(file.path);
|
||||
if (!cwd) continue;
|
||||
// A journal speaks only for its own account's lanes (S3): its cwd is what
|
||||
// the journal SAYS, and a member can write any cwd into their own file.
|
||||
const lane = codexLanes
|
||||
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
||||
.filter((l) => require("./run-as").sameOwner(file.path, l.cwd))
|
||||
.sort((a, b) => b.cwd.length - a.cwd.length)[0];
|
||||
if (!lane) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* @file codex-pool.js
|
||||
* @description The Codex account POOL, modelled on claude-pool.js: accounts an
|
||||
* admin added from the host's shell (`agenthub accounts add <name> --tool
|
||||
* codex`), and the balancer that gives every new Codex pane on no AI Proxy one
|
||||
* of them. An empty pool changes nothing: panes run on their CODEX_HOME's own
|
||||
* login as before. Two kinds:
|
||||
* - `apikey`: an OpenAI API key, any number of panes at once. The key reaches
|
||||
* the pane only through tmux `-e AGENTHUB_POOL_KEY` (a member's launch
|
||||
* file); the argv only adds a `-c` provider whose `env_key` NAMES it.
|
||||
* - `chatgpt`: a ChatGPT login (`auth.json`). Codex reads it from no env
|
||||
* var, so it is written into the MEMBER's own CODEX_HOME (S3) AS the member
|
||||
* (write-as, 0600) just before the pane starts — never into a host pane's
|
||||
* CODEX_HOME (that is the host's own login), never over a login AgentHub
|
||||
* did not place there, and never a different account than the one a live
|
||||
* pane of that member already runs on (one auth.json per home). Refresh
|
||||
* tokens rotate, so `syncBack` copies a member's auth.json back into the
|
||||
* pool whenever its `last_refresh` is newer (newest wins) — only from a
|
||||
* home AgentHub placed that account in, never dated in the future, and
|
||||
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
|
||||
* spike showed an old refresh token still refreshes, so panes sharing an
|
||||
* account do not log each other out.
|
||||
*
|
||||
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
|
||||
* 0700, file 0600); no route returns a key or a token.
|
||||
*
|
||||
* `pickAccount` ranks like the Claude pool: an account whose last reading is
|
||||
* ≥ `BLOCK_PERCENT` goes last, then fewest live Codex panes, then least $ in
|
||||
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
|
||||
* rollouts of the sessions that account's runs started (no network), each
|
||||
* from the CODEX_HOME of the account its workspace runs as.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
const KEY_RE = /^sk-[A-Za-z0-9_-]{20,500}$/;
|
||||
const QUOTA_TTL_MS = 60 * 1000;
|
||||
const BLOCK_PERCENT = 90;
|
||||
const PROVIDER = "agenthub_pool";
|
||||
const KEY_ENV = "AGENTHUB_POOL_KEY";
|
||||
const SESSION_IN_NAME = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const runAs = () => require("./run-as");
|
||||
|
||||
const poolDir = () => path.join(getClaudeHome(), "agenthub-codex-accounts");
|
||||
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
|
||||
|
||||
function validName(name) {
|
||||
const n = String(name || "").trim();
|
||||
if (!NAME_RE.test(n))
|
||||
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
|
||||
return n;
|
||||
}
|
||||
|
||||
const isChatgptAuth = (auth) =>
|
||||
["id_token", "access_token", "refresh_token", "account_id"].every(
|
||||
(k) => typeof auth?.tokens?.[k] === "string" && auth.tokens[k]
|
||||
);
|
||||
|
||||
function readAccount(name) {
|
||||
try {
|
||||
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
||||
if (a.kind === "apikey" && typeof a.key === "string") return a;
|
||||
if (a.kind === "chatgpt" && isChatgptAuth(a.auth)) return a;
|
||||
} catch {
|
||||
/* absent or not ours */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function allAccounts() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n))
|
||||
.map(readAccount)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Write a record atomically, 0600. `create` refuses an existing name. */
|
||||
function save(record, { create = false } = {}) {
|
||||
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(poolDir(), 0o700);
|
||||
if (create) {
|
||||
fs.writeFileSync(fileOf(record.name), JSON.stringify(record, null, 2), {
|
||||
mode: 0o600,
|
||||
flag: "wx",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ponytail: no cross-process lock — the server's read-compare-write is one
|
||||
// synchronous run on its event loop, and the rename is atomic; a `remove`
|
||||
// from the CLI racing a copy-back can at worst bring the account back.
|
||||
if (!fs.existsSync(fileOf(record.name))) return; // removed meanwhile
|
||||
const tmp = `${fileOf(record.name)}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, fileOf(record.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an account: `{apiKey}`, or `{auth}` = the parsed `auth.json` of a
|
||||
* `codex login` (a ChatGPT login, or one holding only an API key).
|
||||
*/
|
||||
function addAccount(name, { apiKey, auth } = {}) {
|
||||
const n = validName(name);
|
||||
let record;
|
||||
if (isChatgptAuth(auth)) {
|
||||
record = { name: n, kind: "chatgpt", accountId: auth.tokens.account_id, auth };
|
||||
} else {
|
||||
const key = String((auth && auth.OPENAI_API_KEY) || apiKey || "").trim();
|
||||
if (!KEY_RE.test(key)) {
|
||||
throw err(
|
||||
"EBADTOKEN",
|
||||
"not a Codex login — give an OpenAI API key (sk-…) or the auth.json `codex login` wrote"
|
||||
);
|
||||
}
|
||||
record = { name: n, kind: "apikey", key };
|
||||
}
|
||||
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
|
||||
record.addedAt = new Date().toISOString();
|
||||
save(record, { create: true });
|
||||
quotaCache = null;
|
||||
return { name: n, kind: record.kind, addedAt: record.addedAt };
|
||||
}
|
||||
|
||||
function removeAccount(name) {
|
||||
const n = validName(name);
|
||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||
fs.rmSync(fileOf(n));
|
||||
quotaCache = null;
|
||||
}
|
||||
|
||||
// ── What AgentHub recorded per account ─────────────────────────────────────
|
||||
|
||||
function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account = ? AND status IN ('running', 'spawning')`
|
||||
)
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
/**
|
||||
* $ of the Codex sessions this account's runs started since `hours` ago.
|
||||
* ponytail: Codex usage has no hourly table, so a session counts whole once
|
||||
* its run started in the window; add hourly rows if that skews a ranking.
|
||||
*/
|
||||
function spendSince(account, hours) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost } = require("../routes/pricing");
|
||||
const since = new Date(Date.now() - hours * 3600e3).toISOString();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT model, speed, inference_geo, service_tier,
|
||||
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
|
||||
SUM(cache_read_tokens) AS cache_read_tokens,
|
||||
SUM(cache_write_tokens) AS cache_write_tokens
|
||||
FROM token_usage
|
||||
WHERE session_id IN (SELECT session_id FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account = ?
|
||||
AND session_id IS NOT NULL AND started_at >= ?)
|
||||
GROUP BY model, speed, inference_geo, service_tier`
|
||||
)
|
||||
.all(account, since);
|
||||
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
|
||||
}
|
||||
|
||||
// ── Quota, from the account's own rollouts ─────────────────────────────────
|
||||
|
||||
let quotaCache = null; // { at, byName: Map<name, {windows, asOf}> }
|
||||
|
||||
/**
|
||||
* name → the newest `rate_limits` its sessions' rollouts carry. A run's
|
||||
* rollout is read only from the CODEX_HOME of the account its workspace runs
|
||||
* as (a member's own, else the host's), never dated in the future — a member
|
||||
* can speak only for their own session's reading (an accepted ceiling: no
|
||||
* network check), never for another account's run.
|
||||
*/
|
||||
function quotas() {
|
||||
if (quotaCache && Date.now() - quotaCache.at < QUOTA_TTL_MS) return quotaCache.byName;
|
||||
const byName = new Map();
|
||||
const { db } = require("../db");
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT DISTINCT account, session_id, lane_id FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account IS NOT NULL AND session_id IS NOT NULL`
|
||||
)
|
||||
.all();
|
||||
if (runs.length) {
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { lastRateLimits, codexWindow } = require("./quota");
|
||||
const homeOf = (laneId) => {
|
||||
try {
|
||||
const acct = runAs().accountForLane(laneId);
|
||||
return acct ? path.join(acct.home, ".codex") : getCodexHome();
|
||||
} catch {
|
||||
return null; // a gone member: nobody's reading
|
||||
}
|
||||
};
|
||||
// ponytail: one walk of each CODEX_HOME in use per minute; index rollouts
|
||||
// by session id if a sessions/ tree ever makes that slow.
|
||||
const byHome = new Map(); // home → session id → rollout files
|
||||
const filesIn = (home, sid) => {
|
||||
if (!byHome.has(home)) {
|
||||
const bySession = new Map();
|
||||
for (const f of allRolloutFiles(home)) {
|
||||
if (f.mtime > Date.now() + FUTURE_SLACK_MS) continue;
|
||||
const id = SESSION_IN_NAME.exec(path.basename(f.path))?.[1];
|
||||
if (id) bySession.set(id, [...(bySession.get(id) || []), f]);
|
||||
}
|
||||
byHome.set(home, bySession);
|
||||
}
|
||||
return byHome.get(home).get(sid) || [];
|
||||
};
|
||||
const filesOf = new Map();
|
||||
for (const r of runs) {
|
||||
const home = homeOf(r.lane_id);
|
||||
if (!home) continue;
|
||||
filesOf.set(r.account, [...(filesOf.get(r.account) || []), ...filesIn(home, r.session_id)]);
|
||||
}
|
||||
for (const [name, files] of filesOf) {
|
||||
for (const f of files.sort((a, b) => b.mtime - a.mtime)) {
|
||||
const found = lastRateLimits(f);
|
||||
if (!found) continue;
|
||||
const windows = [found.rl.primary, found.rl.secondary]
|
||||
.map((w) => codexWindow(w, found.mtime))
|
||||
.filter(Boolean);
|
||||
byName.set(name, { windows, asOf: new Date(found.mtime).toISOString() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
quotaCache = { at: Date.now(), byName };
|
||||
return byName;
|
||||
}
|
||||
|
||||
function quotaPercent(name) {
|
||||
const q = quotas().get(name);
|
||||
return q && q.windows.length ? Math.max(...q.windows.map((w) => w.usedPercent)) : null;
|
||||
}
|
||||
|
||||
/** Every account with what the balancer sees — never a key or a token. */
|
||||
function poolStatus() {
|
||||
return allAccounts().map((a) => {
|
||||
const percent = quotaPercent(a.name);
|
||||
return {
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
addedAt: a.addedAt,
|
||||
livePanes: livePanes(a.name),
|
||||
spend5h: spendSince(a.name, 5),
|
||||
spend7d: spendSince(a.name, 24 * 7),
|
||||
quota: quotas().get(a.name) || null,
|
||||
quotaPercent: percent,
|
||||
full: percent != null && percent >= BLOCK_PERCENT,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── A member's auth.json ───────────────────────────────────────────────────
|
||||
|
||||
const authFile = (home) => path.join(home, ".codex", "auth.json");
|
||||
|
||||
/** The auth.json in `home`'s CODEX_HOME (read guarded: the member's own file), or null. */
|
||||
function homeAuth(home) {
|
||||
try {
|
||||
return JSON.parse(runAs().readFileSafe(authFile(home), "utf8", { max: 1 << 20 }));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedAt = (auth) => Date.parse(auth?.last_refresh) || 0;
|
||||
const FUTURE_SLACK_MS = 5 * 60 * 1000;
|
||||
|
||||
// Who an id_token says it is: Codex's carries the ChatGPT account under this
|
||||
// claim (and the email); a real refresh keeps both.
|
||||
const AUTH_CLAIM = "https://api.openai.com/auth";
|
||||
function identity(auth) {
|
||||
try {
|
||||
const c = JSON.parse(
|
||||
Buffer.from(String(auth.tokens.id_token).split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
return { email: c.email ?? null, account: c[AUTH_CLAIM]?.chatgpt_account_id ?? null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `disk` (a member's auth.json) may replace pool account `a`: AgentHub
|
||||
* placed `a` in that home itself (`placedIn`), its `last_refresh` is no later
|
||||
* than now, and its id_token names the same account and email as ours. A
|
||||
* member edits their own auth.json at will; without these, one member could
|
||||
* write a forged record over a login every other member's panes get.
|
||||
* ponytail: the id_token is not signature-checked (no network here), so a
|
||||
* member P was placed with can still poison P with a self-consistent forgery
|
||||
* until a real refresh from another home wins it back; a server-side refresh
|
||||
* is the upgrade.
|
||||
*/
|
||||
function mayCopyBack(a, disk, login) {
|
||||
if (!placedHere(a, disk, login)) return false;
|
||||
const t = refreshedAt(disk);
|
||||
return t > refreshedAt(a.auth) && t <= Date.now() + FUTURE_SLACK_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `disk` (a member's auth.json) is pool account `a` that AgentHub
|
||||
* placed in that home: `placedIn`, and the same email and account in the
|
||||
* id_token. `account_id` alone says nothing — it is the ChatGPT WORKSPACE,
|
||||
* shared by every seat of a Team, so a member's own seat would match it.
|
||||
*/
|
||||
function placedHere(a, disk, login) {
|
||||
if (a.kind !== "chatgpt" || !(a.placedIn || []).includes(login)) return false;
|
||||
const ours = identity(a.auth);
|
||||
const theirs = identity(disk);
|
||||
return Boolean(ours && theirs && ours.email === theirs.email && ours.account === theirs.account);
|
||||
}
|
||||
|
||||
/** Copy `home`'s auth.json back into the pool account AgentHub placed there, when it is a newer login of it. */
|
||||
function syncHome(home) {
|
||||
const disk = homeAuth(home);
|
||||
if (!isChatgptAuth(disk)) return;
|
||||
const login = require("./linux-accounts").loginFor(path.basename(home));
|
||||
for (const a of allAccounts()) {
|
||||
if (a.kind !== "chatgpt" || a.accountId !== disk.tokens.account_id) continue;
|
||||
if (mayCopyBack(a, disk, login)) save({ ...a, auth: disk });
|
||||
}
|
||||
}
|
||||
|
||||
/** Every member's CODEX_HOME → the pool, newest login wins (the run-heal beat). */
|
||||
function syncBack() {
|
||||
if (!allAccounts().some((a) => a.kind === "chatgpt")) return;
|
||||
for (const home of runAs().memberHomes()) syncHome(home);
|
||||
}
|
||||
|
||||
// ── Picking ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The full record a new Codex pane should run on, or null. `member` = run-as account. */
|
||||
function pick(member) {
|
||||
let usable = allAccounts();
|
||||
if (usable.length === 0) return null;
|
||||
if (!member) {
|
||||
// A host pane's CODEX_HOME holds the host's own login.
|
||||
usable = usable.filter((a) => a.kind === "apikey");
|
||||
} else {
|
||||
const disk = homeAuth(member.home);
|
||||
const placed = disk ? usable.find((a) => placedHere(a, disk, member.login)) : null;
|
||||
// A login AgentHub did not place there is the member's own: keep it.
|
||||
if (!placed && (isChatgptAuth(disk) || disk?.OPENAI_API_KEY)) {
|
||||
usable = usable.filter((a) => a.kind !== "chatgpt");
|
||||
} else {
|
||||
// One auth.json per home: while a pane of theirs runs on a pool
|
||||
// login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
}
|
||||
}
|
||||
if (usable.length === 0) return null;
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
const percent = quotaPercent(a.name);
|
||||
return {
|
||||
a,
|
||||
key: [
|
||||
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
|
||||
livePanes(a.name),
|
||||
spendSince(a.name, 5),
|
||||
spendSince(a.name, 24 * 7),
|
||||
],
|
||||
};
|
||||
})
|
||||
.sort((x, y) => {
|
||||
for (let i = 0; i < x.key.length; i++) {
|
||||
if (x.key[i] !== y.key[i]) return x.key[i] - y.key[i];
|
||||
}
|
||||
return x.a.name.localeCompare(y.a.name);
|
||||
});
|
||||
return ranked[0].a;
|
||||
}
|
||||
|
||||
function liveInHome(name, member) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT lane_id FROM dashboard_runs
|
||||
WHERE tool = 'codex' AND account = ? AND status IN ('running', 'spawning')
|
||||
AND lane_id IS NOT NULL`
|
||||
)
|
||||
.all(name)
|
||||
.some((r) => {
|
||||
try {
|
||||
return runAs().accountForLane(r.lane_id)?.login === member.login;
|
||||
} catch {
|
||||
return false; // a gone member's lane is not this (enabled) member's
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
return a ? { name: a.name, kind: a.kind } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new Codex pane needs from the pool, or null (empty pool, or nothing
|
||||
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
|
||||
* (a member's launch file); `place()` runs just before the pane starts.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
if (!a) return null;
|
||||
if (a.kind === "apikey") {
|
||||
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
|
||||
return {
|
||||
name: a.name,
|
||||
argv: ["-c", `model_providers.${PROVIDER}=${provider}`, "-c", `model_provider="${PROVIDER}"`],
|
||||
env: { [KEY_ENV]: a.key },
|
||||
place: () => {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: a.name,
|
||||
argv: [],
|
||||
env: {},
|
||||
place: () => {
|
||||
syncHome(member.home); // a live pane may hold a newer login than ours
|
||||
const record = readAccount(a.name) || a;
|
||||
runAs().mkdirAs(member, path.dirname(authFile(member.home)));
|
||||
runAs().writeAs(
|
||||
member,
|
||||
authFile(member.home),
|
||||
"private",
|
||||
JSON.stringify(record.auth, null, 2)
|
||||
);
|
||||
// Only a home AgentHub placed this login in may copy a refresh back.
|
||||
if (!(record.placedIn || []).includes(member.login)) {
|
||||
save({ ...record, placedIn: [...(record.placedIn || []), member.login] });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BLOCK_PERCENT,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
syncBack,
|
||||
poolDir,
|
||||
__read: readAccount,
|
||||
resetQuota: () => {
|
||||
quotaCache = null;
|
||||
},
|
||||
};
|
||||
+61
-13
@@ -43,7 +43,6 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
const { getCodexHome, codexHomes } = require("./codex-home");
|
||||
@@ -166,7 +165,7 @@ function parseRolloutChunk(text, state = {}, skip = null) {
|
||||
*/
|
||||
function readChunk(file, offset, size) {
|
||||
const buf = Buffer.alloc(Math.max(0, Math.min(size - offset, MAX_CHUNK)));
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
fs.readSync(fd, buf, 0, buf.length, offset);
|
||||
} finally {
|
||||
@@ -180,8 +179,7 @@ function readChunk(file, offset, size) {
|
||||
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
|
||||
async function parentTotals(file) {
|
||||
const keys = new Set();
|
||||
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
||||
for await (const line of lines) {
|
||||
for await (const line of require("./run-as").readLines(file)) {
|
||||
if (!line.includes('"token_count"')) continue;
|
||||
try {
|
||||
const t = JSON.parse(line).payload?.info?.total_token_usage;
|
||||
@@ -214,7 +212,14 @@ const threadCache = new Map(); // rollout path → {id, sessionId}; a first line
|
||||
function threadOf(file) {
|
||||
if (typeof file !== "string" || !path.isAbsolute(file)) return null;
|
||||
const resolved = path.resolve(file);
|
||||
const under = codexHomes().some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
|
||||
// The host's CODEX_HOME, or a member's (S3: hook-auth holds them to their own).
|
||||
const homes = [
|
||||
...codexHomes(),
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex")),
|
||||
];
|
||||
const under = homes.some((h) => resolved.startsWith(path.join(h, "sessions") + path.sep));
|
||||
if (!under || !FILE_NAME.test(path.basename(resolved))) return null;
|
||||
if (threadCache.has(resolved)) return threadCache.get(resolved);
|
||||
const meta = firstLineMeta(resolved);
|
||||
@@ -230,7 +235,7 @@ function threadNames(home = getCodexHome()) {
|
||||
const names = new Map();
|
||||
let text = "";
|
||||
try {
|
||||
text = fs.readFileSync(path.join(home, "session_index.jsonl"), "utf8");
|
||||
text = require("./run-as").readFileSafe(path.join(home, "session_index.jsonl"), "utf8", { max: 16 << 20 });
|
||||
} catch {
|
||||
return names;
|
||||
}
|
||||
@@ -257,8 +262,13 @@ const upsertRollout = db.prepare(
|
||||
// or reached through a symlink) replaces its old row instead of counting twice.
|
||||
const dropOtherPaths = db.prepare("DELETE FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
|
||||
const rolloutsOf = db.prepare(
|
||||
"SELECT thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
|
||||
"SELECT path, thread_id, mtime_ms, state FROM codex_rollouts WHERE session_id = ?"
|
||||
);
|
||||
const threadPaths = db.prepare("SELECT path FROM codex_rollouts WHERE thread_id = ? AND path <> ?");
|
||||
// S3: whose a path is ("" = the host's). A journal in a member's home speaks
|
||||
// only for that member: its session's folder must be theirs, and its ids
|
||||
// never take over another account's rows.
|
||||
const ownerKey = (p) => (p ? require("./run-as").ownerForRead(p)?.name || "" : "");
|
||||
const insertCodexSession = db.prepare(
|
||||
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'codex', ?)`
|
||||
@@ -274,6 +284,7 @@ const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||
// an event no older than the journal's newest write (less ACTIVE_MS). Hooks
|
||||
// removed, or no longer trusted, hand the status back to the journal.
|
||||
const lastEventAt = db.prepare("SELECT MAX(created_at) AS t FROM events WHERE session_id = ?");
|
||||
const anyRollout = db.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? LIMIT 1");
|
||||
const newestRollout = db.prepare(
|
||||
"SELECT MAX(mtime_ms) AS m FROM codex_rollouts WHERE session_id = ?"
|
||||
);
|
||||
@@ -292,9 +303,14 @@ const LOCAL = { source: "local", activeMs: ACTIVE_MS };
|
||||
|
||||
/** Re-derive one root session's row and usage from all its threads. */
|
||||
function writeSession(sessionId, names, now, origin = LOCAL) {
|
||||
const rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
|
||||
if (rows.length === 0) return null;
|
||||
let rows = rolloutsOf.all(sessionId).map((r) => ({ ...r, state: JSON.parse(r.state) }));
|
||||
const existing = stmts.getSession.get(sessionId);
|
||||
if (rows.length && require("./linux-accounts").s3Enabled()) {
|
||||
const own = rows.find((r) => r.thread_id === sessionId) || rows[0];
|
||||
const key = existing ? ownerKey(existing.cwd) : ownerKey(own.path);
|
||||
rows = rows.filter((r) => ownerKey(r.path) === key && ownerKey(r.state.cwd) === key);
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
if (existing && existing.tool !== "codex") return null;
|
||||
// Another machine's row (or this machine's, seen from a remote pass).
|
||||
if (existing && (existing.source || "local") !== origin.source) return null;
|
||||
@@ -439,7 +455,13 @@ function ingestCodexUsage({
|
||||
offset + bytes,
|
||||
JSON.stringify(state)
|
||||
);
|
||||
if (state.threadId) dropOtherPaths.run(state.threadId, f.path);
|
||||
if (
|
||||
state.threadId &&
|
||||
threadPaths
|
||||
.all(state.threadId, f.path)
|
||||
.every((o) => ownerKey(o.path) === ownerKey(f.path))
|
||||
)
|
||||
dropOtherPaths.run(state.threadId, f.path);
|
||||
read += 1;
|
||||
if (state.sessionId) touched.add(state.sessionId);
|
||||
} catch (err) {
|
||||
@@ -478,8 +500,24 @@ function startCodexUsagePoll(broadcast) {
|
||||
? Number(process.env.DASHBOARD_CODEX_POLL_MS)
|
||||
: 15_000;
|
||||
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||
// This account's CODEX_HOME, then every member's (S3: their sessions
|
||||
// write under their own home), one after another.
|
||||
const homes = () => [
|
||||
undefined,
|
||||
...require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".codex")),
|
||||
];
|
||||
const ingestAll = async () => {
|
||||
const total = { sessions: 0 };
|
||||
for (const home of homes()) {
|
||||
const r = await ingestCodexUsage({ broadcast, home });
|
||||
total.sessions += r?.sessions || 0;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
const tick = () => {
|
||||
ingestCodexUsage({ broadcast })
|
||||
ingestAll()
|
||||
.then((r) => {
|
||||
settleActive(broadcast);
|
||||
// A pass that wrote sessions may have brought a model with no rate
|
||||
@@ -510,10 +548,20 @@ function settleActive(
|
||||
)
|
||||
.all(source, now - activeMs, now + FUTURE_SLACK_MS);
|
||||
if (quiet.length === 0) return;
|
||||
const names = threadNames(home);
|
||||
// Each session is named from its OWN home's index: S3 names a member's
|
||||
// thread in their CODEX_HOME, and the host's would rename it back to its
|
||||
// first prompt. A remote pass names its own home.
|
||||
const byHome = new Map();
|
||||
const namesOf = (id) => {
|
||||
const member =
|
||||
home === undefined ? require("./run-as").ownerForRead(anyRollout.get(id)?.path || "") : null;
|
||||
const h = member ? path.join(member.home, ".codex") : home;
|
||||
if (!byHome.has(h)) byHome.set(h, threadNames(h));
|
||||
return byHome.get(h);
|
||||
};
|
||||
for (const { id } of quiet) {
|
||||
if (hooksCurrent(id, newestRollout.get(id)?.m || 0)) continue;
|
||||
if (writeSession(id, names, now, { source, activeMs }))
|
||||
if (writeSession(id, namesOf(id), now, { source, activeMs }))
|
||||
broadcast("session_updated", stmts.getSession.get(id));
|
||||
}
|
||||
}
|
||||
|
||||
+32
-20
@@ -39,31 +39,32 @@ function getCursorDir() {
|
||||
|
||||
const norm = (s) => s.replace(/[^A-Za-z0-9]/g, "-");
|
||||
|
||||
// A walk enters at most this many directories: a member can build a tree (or
|
||||
// a symlink loop) whose matching paths grow exponentially with the slug.
|
||||
const MAX_VISITS = 500;
|
||||
|
||||
/**
|
||||
* The existing directory whose path slugs to `slug`, or null. Walks from `/`,
|
||||
* trying every entry whose own slug is a prefix of what is left.
|
||||
* trying every real directory (never a symlink) whose own slug is a prefix of
|
||||
* what is left; gives up (null) after MAX_VISITS directories.
|
||||
*/
|
||||
function cwdFromSlug(slug, root = "/") {
|
||||
let visits = 0;
|
||||
const walk = (dir, rest, depth) => {
|
||||
if (!rest) {
|
||||
try {
|
||||
return fs.statSync(dir).isDirectory() ? dir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (depth > 40) return null;
|
||||
let names;
|
||||
if (!rest) return dir;
|
||||
if (depth > 40 || ++visits > MAX_VISITS) return null;
|
||||
let entries;
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const name of names) {
|
||||
const n = norm(name);
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const n = norm(e.name);
|
||||
if (rest !== n && !rest.startsWith(`${n}-`)) continue;
|
||||
const hit = walk(path.join(dir, name), rest.slice(n.length + 1), depth + 1);
|
||||
if (hit) return hit;
|
||||
const hit = walk(path.join(dir, e.name), rest.slice(n.length + 1), depth + 1);
|
||||
if (hit || visits > MAX_VISITS) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -75,7 +76,7 @@ function cwdFromSlug(slug, root = "/") {
|
||||
function firstPrompt(file) {
|
||||
let text;
|
||||
try {
|
||||
const fd = fs.openSync(file, "r");
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, HEAD_BYTES));
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
@@ -104,9 +105,17 @@ function firstPrompt(file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every agent transcript, with its project slug, birth and mtime. */
|
||||
/** Every agent transcript, with its project slug, birth and mtime — this
|
||||
* account's, then every member's (S3: their own home). */
|
||||
function transcriptFiles() {
|
||||
const projects = path.join(getCursorDir(), "projects");
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".cursor"));
|
||||
return [getCursorDir(), ...members].flatMap((dir) => transcriptFilesIn(dir));
|
||||
}
|
||||
|
||||
function transcriptFilesIn(cursorDir) {
|
||||
const projects = path.join(cursorDir, "projects");
|
||||
let slugs;
|
||||
try {
|
||||
slugs = fs.readdirSync(projects);
|
||||
@@ -164,6 +173,9 @@ const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACT
|
||||
function writeSession(f, now) {
|
||||
const existing = stmts.getSession.get(f.id);
|
||||
if (existing && existing.tool !== "cursor") return null;
|
||||
// S3: a member's transcript names only their own folder and session.
|
||||
const cwd = existing?.cwd ? null : cwdFromSlug(f.slug);
|
||||
if (!require("./run-as").sameOwner(f.path, existing?.cwd || cwd)) return null;
|
||||
const lastWrite = new Date(f.mtime).toISOString();
|
||||
const active = fresh(f.mtime, now);
|
||||
const status = active ? "active" : "completed";
|
||||
@@ -175,7 +187,7 @@ function writeSession(f, now) {
|
||||
status,
|
||||
endedAt,
|
||||
lastWrite,
|
||||
existing.cwd ? null : cwdFromSlug(f.slug),
|
||||
cwd,
|
||||
f.id
|
||||
);
|
||||
return "session_updated";
|
||||
@@ -184,7 +196,7 @@ function writeSession(f, now) {
|
||||
f.id,
|
||||
name,
|
||||
status,
|
||||
cwdFromSlug(f.slug),
|
||||
cwd,
|
||||
new Date(Math.min(f.birth, f.mtime)).toISOString(),
|
||||
endedAt,
|
||||
lastWrite,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file drop-caps.js
|
||||
* @description Server mode, one Linux user per account (S3): the system unit
|
||||
* gives the dashboard CAP_DAC_READ_SEARCH as an AMBIENT capability (it reads
|
||||
* members' 0600 transcripts), and Linux passes an ambient capability on to
|
||||
* every program a process execs — the host's tmux server and every agent in
|
||||
* a host pane, git and its hooks, npm installs. None of them may read
|
||||
* everything. So, when this process holds ambient capabilities, every child
|
||||
* starts through `setpriv --inh-caps=-all --ambient-caps=-all --`, which
|
||||
* clears them before the real program runs. Patched like windows-hide.js:
|
||||
* `ChildProcess.prototype.spawn` for every async form, the sync exports by
|
||||
* hand (they never reach that prototype). sudo needs no help (setuid clears
|
||||
* them). node-pty forks natively, outside child_process: its callers take the
|
||||
* argv from `ptyArgv` (pty-attach.js — a host `tmux attach` client could
|
||||
* otherwise run `detach-client -E <cmd>` with the capability). A no-op
|
||||
* anywhere else.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const DONE = Symbol.for("agenthub.dropCaps");
|
||||
const SETPRIV = "/usr/bin/setpriv";
|
||||
const FLAGS = ["--inh-caps=-all", "--ambient-caps=-all", "--"];
|
||||
let active = false; // install() patched child_process: wrap native spawns too
|
||||
|
||||
function hasAmbientCaps(status = null) {
|
||||
try {
|
||||
const text = status ?? fs.readFileSync("/proc/self/status", "utf8");
|
||||
const m = /^CapAmb:\s*([0-9a-f]+)$/m.exec(text);
|
||||
return !!m && /[1-9a-f]/.test(m[1]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Node's own shell rule (child_process normalizeSpawnArguments), for the sync forms. */
|
||||
function viaShell(file, args, shell) {
|
||||
const sh = typeof shell === "string" ? shell : "/bin/sh";
|
||||
return [sh, ["-c", [file, ...args].join(" ")]];
|
||||
}
|
||||
|
||||
function install(cp = require("node:child_process"), status = null) {
|
||||
if (process.platform !== "linux" || cp[DONE] || !hasAmbientCaps(status)) return false;
|
||||
if (!fs.existsSync(SETPRIV)) {
|
||||
console.warn("[drop-caps] setpriv is missing: children inherit CAP_DAC_READ_SEARCH");
|
||||
return false;
|
||||
}
|
||||
cp[DONE] = true;
|
||||
active = true;
|
||||
|
||||
const spawn = cp.ChildProcess.prototype.spawn;
|
||||
cp.ChildProcess.prototype.spawn = function (options) {
|
||||
// options.args[0] is argv0; setpriv execs the file under its own path.
|
||||
options.args = ["setpriv", ...FLAGS, options.file, ...options.args.slice(1)];
|
||||
options.file = SETPRIV;
|
||||
return spawn.call(this, options);
|
||||
};
|
||||
|
||||
// spawnSync / execFileSync (file, args?, opts?) and execSync (cmd, opts?).
|
||||
const wrap = (fn) =>
|
||||
function (file, args, opts) {
|
||||
if (!Array.isArray(args)) [args, opts] = [[], args];
|
||||
let [f, a] = [file, args || []];
|
||||
if (opts?.shell) [f, a] = viaShell(f, a, opts.shell);
|
||||
return fn.call(this, SETPRIV, [...FLAGS, f, ...a], opts ? { ...opts, shell: false } : opts);
|
||||
};
|
||||
cp.spawnSync = wrap(cp.spawnSync);
|
||||
cp.execFileSync = wrap(cp.execFileSync);
|
||||
const execFileSync = cp.execFileSync;
|
||||
cp.execSync = function (cmd, opts) {
|
||||
return execFileSync.call(this, cmd, [], { ...(opts || {}), shell: opts?.shell || true });
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `[file, args]` for a spawn that bypasses child_process (node-pty): via setpriv once installed. */
|
||||
function ptyArgv(file, args = []) {
|
||||
return active ? [SETPRIV, [...FLAGS, file, ...args]] : [file, args];
|
||||
}
|
||||
|
||||
module.exports = { install, hasAmbientCaps, ptyArgv };
|
||||
+3
-1
@@ -23,7 +23,9 @@ const execFileAsync = promisify(execFile);
|
||||
*/
|
||||
async function gh(cwd, args) {
|
||||
try {
|
||||
const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 });
|
||||
// As the folder's member under S3 (their own `gh auth`, their repository).
|
||||
const [cmd, argv] = require("./run-as").commandIn(cwd, "gh", args);
|
||||
const result = await execFileAsync(cmd, argv, { cwd, maxBuffer: 8 * 1024 * 1024 });
|
||||
return { stdout: result.stdout, stderr: result.stderr };
|
||||
} catch (err) {
|
||||
const error = new Error(`gh ${args[0]} failed`);
|
||||
|
||||
@@ -153,6 +153,18 @@ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
* remote hosts — a key typed for one server is never sent to another.
|
||||
*/
|
||||
async function gitConfigFor(dir, once) {
|
||||
// S3: a member's git runs as the member (sudo), which can neither read this
|
||||
// host-owned store nor carry a typed password (sudo logs its command line).
|
||||
// Their own git login applies (`git credential`, `gh auth` in their terminal).
|
||||
if (require("./run-as").accountForPath(dir)) {
|
||||
if (once) {
|
||||
throw err(
|
||||
"EMEMBERCRED",
|
||||
"log in to this git host from your own terminal (git credential store or gh auth login)"
|
||||
);
|
||||
}
|
||||
return { args: [], env: {} };
|
||||
}
|
||||
const hosts = await remoteHosts(dir);
|
||||
const env = {};
|
||||
if (once) {
|
||||
|
||||
+40
-8
@@ -98,6 +98,38 @@ function isSecret(rel) {
|
||||
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: whether `abs` is a dot entry at the TOP of a member's home — `.claude`,
|
||||
* `.codex`, `.config/gh`, `.ssh`, `.git-credentials`, `.agenthub/handoff`…:
|
||||
* where that member's logins and pane tokens live. A workspace is a folder
|
||||
* under the home, so this never hides project files; it closes a workspace
|
||||
* rooted AT the home (made before that was refused, or a symlink) to anyone
|
||||
* reading it, analysts included. Not by basename: `auth`/`token` patterns
|
||||
* would hide `AuthGate.tsx` in every member workspace.
|
||||
*/
|
||||
function inMemberDotDir(abs) {
|
||||
const runAs = require("./run-as");
|
||||
const acct = require("./linux-accounts").s3Enabled() ? runAs.ownerForRead(abs) : null;
|
||||
if (!acct) return false;
|
||||
const rel = path.relative(acct.home, abs);
|
||||
return Boolean(rel) && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* The real path of a workspace folder for a filesystem read or write (S3:
|
||||
* refused, EFOREIGN, when it resolves into another account's files). The
|
||||
* account a read or write runs as comes from the PATH (run-as.js), so a
|
||||
* member who swaps their workspace folder for a link to another member's
|
||||
* folder, the host's home or /etc must not have the dashboard follow it.
|
||||
*/
|
||||
async function laneRoot(dir) {
|
||||
const root = await fs.promises.realpath(dir);
|
||||
if (!require("./run-as").sameOwner(path.resolve(dir), root)) {
|
||||
throw badRequest("EFOREIGN", "the workspace folder resolves outside its owner's files");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
|
||||
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
|
||||
@@ -107,7 +139,7 @@ function isSecret(rel) {
|
||||
* path that does not exist on disk is judged by its name alone.
|
||||
*/
|
||||
async function isSecretOnDisk(dir, file) {
|
||||
if (isSecret(file)) return true;
|
||||
if (isSecret(file) || inMemberDotDir(path.resolve(dir, file))) return true;
|
||||
let root;
|
||||
let real;
|
||||
try {
|
||||
@@ -116,7 +148,7 @@ async function isSecretOnDisk(dir, file) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (isSecret(path.relative(root, real))) return true;
|
||||
if (isSecret(path.relative(root, real)) || inMemberDotDir(real)) return true;
|
||||
try {
|
||||
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
|
||||
} catch {
|
||||
@@ -162,7 +194,7 @@ async function resolveInLane(dir, file) {
|
||||
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
|
||||
throw badRequest("EBADPATH", "path escapes the workspace directory");
|
||||
}
|
||||
const root = await fs.promises.realpath(dir);
|
||||
const root = await laneRoot(dir);
|
||||
let real;
|
||||
try {
|
||||
real = await fs.promises.realpath(path.resolve(root, file));
|
||||
@@ -406,7 +438,7 @@ async function untrackedFacts(dir, file) {
|
||||
const abs = await resolveInLane(dir, file);
|
||||
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
|
||||
return { lines: null, binary: true };
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
const buf = await require("./run-as").readFileSafeAsync(abs);
|
||||
if (looksBinary(buf)) return { lines: null, binary: true };
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return { lines: 0, binary: false };
|
||||
@@ -502,7 +534,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
async function synthesizeAddedPatch(dir, file) {
|
||||
let buf;
|
||||
try {
|
||||
buf = await fs.promises.readFile(await resolveInLane(dir, file));
|
||||
buf = await require("./run-as").readFileSafeAsync(await resolveInLane(dir, file));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -763,7 +795,7 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
|
||||
// whole to answer with its first megabyte. The extra byte is what
|
||||
// tells the cap below the file was longer.
|
||||
const fh = await fs.promises.open(abs, "r");
|
||||
const fh = await require("./run-as").openSafeAsync(abs);
|
||||
try {
|
||||
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
|
||||
buf = Buffer.alloc(size);
|
||||
@@ -848,7 +880,7 @@ async function readImageAt(dir, requested, rev = "work") {
|
||||
}
|
||||
if (!stat.isFile()) throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_IMAGE_BYTES) throw tooBig();
|
||||
return { type, buf: await fs.promises.readFile(abs) };
|
||||
return { type, buf: await require("./run-as").readFileSafeAsync(abs) };
|
||||
}
|
||||
try {
|
||||
const { stdout } = await git(dir, ["show", `${rev}:./${file}`], {
|
||||
@@ -937,7 +969,7 @@ async function addIgnored(dir, sub, dirNames, files) {
|
||||
const added = new Set();
|
||||
let dirents;
|
||||
try {
|
||||
const root = await fs.promises.realpath(dir);
|
||||
const root = await laneRoot(dir);
|
||||
const real = await fs.promises.realpath(path.join(root, sub));
|
||||
const r = path.relative(root, real);
|
||||
// A symlinked folder pointing out of the workspace lists nothing.
|
||||
|
||||
+35
-17
@@ -8,7 +8,6 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -36,23 +35,37 @@ const REPO_VARS = [
|
||||
* @param {string[]} relPaths e.g. [".mcp.json", ".claude/.cc-config-backups/"]
|
||||
*/
|
||||
function excludeFromGit(laneDir, relPaths) {
|
||||
const runAs = require("./run-as");
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
for (const name of REPO_VARS) delete env[name];
|
||||
const [commonDir, prefix = ""] = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--git-common-dir", "--show-prefix"],
|
||||
{ cwd: laneDir, env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
// As the folder's member under S3 (git refuses another uid's repository).
|
||||
const [commonDir, prefix = ""] = String(
|
||||
runAs.execIn(laneDir, "git", ["rev-parse", "--git-common-dir", "--show-prefix"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
).split("\n");
|
||||
// Under S3 the member's git printed this path (a `.git` file or a
|
||||
// `commondir` can name any directory): it must resolve inside the lane
|
||||
// owner's own files, or the dashboard would read and write wherever the
|
||||
// member pointed it — as the host, outside the users root.
|
||||
const common = fs.realpathSync(path.resolve(laneDir, commonDir));
|
||||
if (!runAs.sameOwner(path.resolve(laneDir), common)) return;
|
||||
const lines = relPaths.map((rel) => `/${prefix}${rel}`);
|
||||
const excludePath = path.join(path.resolve(laneDir, commonDir), "info", "exclude");
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
let existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
|
||||
const excludePath = path.join(common, "info", "exclude");
|
||||
// readFileSafe: a member's FIFO or link to another account's file is refused.
|
||||
let existing = fs.existsSync(excludePath) ? runAs.readFileSafe(excludePath, "utf8") : "";
|
||||
const present = new Set(existing.split("\n"));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
if (missing.length === 0) return;
|
||||
if (existing && !existing.endsWith("\n")) existing += "\n";
|
||||
fs.writeFileSync(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
// `info/` exists in every repo git made; under S3 only its member may write it.
|
||||
if (!runAs.accountForPath(excludePath)) {
|
||||
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
|
||||
}
|
||||
runAs.writeFileAs(excludePath, `${existing}${missing.join("\n")}\n`);
|
||||
} catch {
|
||||
/* not a repo, or unwritable — see above */
|
||||
}
|
||||
@@ -61,19 +74,24 @@ function excludeFromGit(laneDir, relPaths) {
|
||||
/**
|
||||
* The same, anchored at the REPOSITORY root rather than `dir` — for paths a
|
||||
* tool creates at the root whatever subdirectory it was started in (Claude
|
||||
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`.
|
||||
* Code's `.claude/worktrees/`). Best effort, like `excludeFromGit`. The root
|
||||
* the (member's) git names must be the same account's as `dir`.
|
||||
*/
|
||||
function excludeFromRepoRoot(dir, relPaths) {
|
||||
const runAs = require("./run-as");
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
for (const name of REPO_VARS) delete env[name];
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: dir,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (root) excludeFromGit(root, relPaths);
|
||||
const root = String(
|
||||
runAs.execIn(dir, "git", ["rev-parse", "--show-toplevel"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
).trim();
|
||||
if (!root) return;
|
||||
const real = fs.realpathSync(root);
|
||||
if (runAs.sameOwner(path.resolve(dir), real)) excludeFromGit(real, relPaths);
|
||||
} catch {
|
||||
/* not a repo */
|
||||
}
|
||||
|
||||
+22
-25
@@ -50,7 +50,6 @@
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
git: rawGit,
|
||||
@@ -384,7 +383,10 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// are still in it (the same check `resolve` makes).
|
||||
for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) {
|
||||
const file = path.join(dir, p.path);
|
||||
if (fs.existsSync(file) && MARKER_RE.test(await fs.promises.readFile(file, "utf8"))) {
|
||||
if (
|
||||
fs.existsSync(file) &&
|
||||
MARKER_RE.test(await require("./run-as").readFileSafeAsync(file, "utf8"))
|
||||
) {
|
||||
throw bad("EMARKERS", `${p.path} still has conflict markers`);
|
||||
}
|
||||
}
|
||||
@@ -455,20 +457,14 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-hunk-"));
|
||||
const file = path.join(tmp, "hunk.patch");
|
||||
try {
|
||||
await fs.promises.writeFile(file, patch);
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
|
||||
// reversed, off the working tree. A hunk the index or tree no longer
|
||||
// matches fails in git's own words (409), never half-applies.
|
||||
const args =
|
||||
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
|
||||
return done(action, await git(top, [...args, file]));
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
|
||||
// reversed, off the working tree. A hunk the index or tree no longer
|
||||
// matches fails in git's own words (409), never half-applies. The patch
|
||||
// goes in on stdin — no file to write (S3: never one in a member's home).
|
||||
const args =
|
||||
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
|
||||
return done(action, await git(top, [...args, "-"], { input: patch }));
|
||||
}
|
||||
|
||||
if (action === "commit") {
|
||||
@@ -572,7 +568,7 @@ async function mergeAction(dir, action, body) {
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
}
|
||||
if (side === "done" && fs.existsSync(path.join(dir, p))) {
|
||||
if (MARKER_RE.test(await fs.promises.readFile(path.join(dir, p), "utf8"))) {
|
||||
if (MARKER_RE.test(await require("./run-as").readFileSafeAsync(path.join(dir, p), "utf8"))) {
|
||||
throw bad("EMARKERS", `${p} still has conflict markers`);
|
||||
}
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
@@ -833,22 +829,22 @@ async function commitSelection(dir, args, body) {
|
||||
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
||||
);
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-commit-"));
|
||||
const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
|
||||
// A temporary index git itself creates (S3: as the member, in their own
|
||||
// handoff dir — the dashboard never writes there).
|
||||
const scratch = require("./run-as").scratchFile(top, "agenthub-index-");
|
||||
const env = { GIT_INDEX_FILE: scratch.file };
|
||||
try {
|
||||
await git(dir, (await hasHead(dir)) ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], {
|
||||
env,
|
||||
});
|
||||
if (wholePaths.length) await git(dir, ["add", "-A", "--", ...wholePaths], { env });
|
||||
const file = path.join(tmp, "part.patch");
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
for (const [p, list] of picks) {
|
||||
const patch = await selectionPatch(dir, p, body.context, list);
|
||||
if (!patch)
|
||||
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
|
||||
await fs.promises.writeFile(file, patch);
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
await git(top, ["apply", "--cached", ...zero, file], { env });
|
||||
await git(top, ["apply", "--cached", ...zero, "-"], { env, input: patch });
|
||||
}
|
||||
const out = await git(dir, ["commit", ...args], { env });
|
||||
await git(dir, ["reset", "-q", "--", ...wholePaths, ...part.map((p) => p.path)]).catch(
|
||||
@@ -856,7 +852,7 @@ async function commitSelection(dir, args, body) {
|
||||
);
|
||||
return out;
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
scratch.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -882,12 +878,13 @@ async function ignorePaths(dir, body) {
|
||||
const file = path.join(dir, ".gitignore");
|
||||
const st = await fs.promises.lstat(file).catch(() => null);
|
||||
if (st && !st.isFile()) throw bad("EBADPATH", ".gitignore is not a regular file");
|
||||
const old = st ? await fs.promises.readFile(file, "utf8") : "";
|
||||
const old = st ? await require("./run-as").readFileSafeAsync(file, "utf8") : "";
|
||||
const have = new Set(old.split(/\r?\n/));
|
||||
const lines = [...new Set(paths.map((p) => ignoreLine(p.path)))].filter((l) => !have.has(l));
|
||||
if (lines.length) {
|
||||
const sep = old && !old.endsWith("\n") ? "\n" : "";
|
||||
await fs.promises.appendFile(file, `${sep}${lines.join("\n")}\n`);
|
||||
// As the folder's member under S3 (the dashboard cannot write there).
|
||||
require("./run-as").writeFileAs(file, `${sep}${lines.join("\n")}\n`, { append: true });
|
||||
}
|
||||
return done("ignore", { stdout: lines.map((l) => `${l}\n`).join(""), stderr: "" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file hook-auth.js
|
||||
* @description Server mode, one Linux user per account (S3 plan, P3d): who
|
||||
* posted a hook. Hooks come over loopback HTTP, and once members have their
|
||||
* own shells on this machine, loopback alone proves nothing — any account
|
||||
* could post a forged event for any session. The kernel knows the answer: the
|
||||
* peer of a loopback TCP connection is a socket in `/proc/net/tcp*`, and its
|
||||
* row carries the owning uid. No token, no change to any hook script.
|
||||
*
|
||||
* - the host account (or root): trusted, as before;
|
||||
* - a member (`ah-<name>`, enabled): may report only work inside their own
|
||||
* home — the hook's `cwd` must be there, a session or a pane it names must
|
||||
* be theirs, and a transcript path outside the home is dropped;
|
||||
* - anyone else: refused.
|
||||
*
|
||||
* Off (everything is "host") unless S3 is on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Test seam: the /proc/net/tcp* text.
|
||||
let readTables = () =>
|
||||
["/proc/net/tcp", "/proc/net/tcp6"].map((f) => {
|
||||
try {
|
||||
return fs.readFileSync(f, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
function __setTables(fn) {
|
||||
readTables = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The uid owning the CLIENT end of this loopback connection, or null: the row
|
||||
* whose local port is the client's port and whose remote port is ours,
|
||||
* ESTABLISHED (`01`).
|
||||
*/
|
||||
function peerUid(socket) {
|
||||
const client = socket?.remotePort;
|
||||
const server = socket?.localPort;
|
||||
if (!client || !server) return null;
|
||||
const hex = (n) => n.toString(16).toUpperCase().padStart(4, "0");
|
||||
for (const table of readTables()) {
|
||||
for (const line of table.split("\n").slice(1)) {
|
||||
const f = line.trim().split(/\s+/);
|
||||
if (f.length < 8 || f[3] !== "01") continue;
|
||||
// Both ends on loopback: a connection of this machine's to another
|
||||
// host's port that happens to match is somebody else's row.
|
||||
const [la, lp] = f[1].split(":");
|
||||
const [ra, rp] = f[2].split(":");
|
||||
if (!LOOPBACK.has(la) || !LOOPBACK.has(ra)) continue;
|
||||
if (lp === hex(client) && rp === hex(server)) return Number(f[7]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 127.0.0.1, ::1 and ::ffff:127.0.0.1 as /proc/net/tcp{,6} print them.
|
||||
const LOOPBACK = new Set([
|
||||
"0100007F",
|
||||
"00000000000000000000000001000000",
|
||||
"0000000000000000FFFF00000100007F",
|
||||
]);
|
||||
|
||||
const inside = (home, p) => {
|
||||
if (typeof p !== "string" || !path.isAbsolute(p)) return false;
|
||||
const rel = path.relative(home, path.resolve(p));
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
};
|
||||
|
||||
/**
|
||||
* Who posted `req`: `{kind:"host"}`, `{kind:"member", name, home}` or
|
||||
* `{kind:"refused"}`. "host" outside S3.
|
||||
*/
|
||||
let peerImpl = peerUid; // test seam
|
||||
function __setPeerUid(fn) {
|
||||
peerImpl = fn || peerUid;
|
||||
}
|
||||
|
||||
function hookPeer(req) {
|
||||
if (!require("./linux-accounts").s3Enabled()) return { kind: "host" };
|
||||
const uid = peerImpl(req.socket);
|
||||
if (uid === null) return { kind: "refused" };
|
||||
if (uid === 0 || uid === process.getuid()) return { kind: "host" };
|
||||
const runAs = require("./run-as");
|
||||
const name = runAs.memberNameForUid(uid);
|
||||
const conf = require("./linux-accounts").serverConf();
|
||||
let acct = null;
|
||||
try {
|
||||
acct = name ? runAs.accountForPath(path.join(conf.usersRoot, name)) : null;
|
||||
} catch {
|
||||
/* disabled or gone: refused below */
|
||||
}
|
||||
return acct && acct.uid === uid ? { kind: "member", name, home: acct.home } : { kind: "refused" };
|
||||
}
|
||||
|
||||
/**
|
||||
* S3: whether `req` came from the Linux account the panes of workspace
|
||||
* `laneId` run as — its member's, else the host's (this process's uid). A
|
||||
* pane token is a bearer secret: whoever holds it acts as the workspace's
|
||||
* owner, so it is accepted only from the account its pane runs as. An
|
||||
* unknown peer is refused. Always true outside S3.
|
||||
*/
|
||||
function peerIsPaneAccount(req, laneId) {
|
||||
if (!require("./linux-accounts").s3Enabled()) return true;
|
||||
const uid = peerImpl(req.socket);
|
||||
if (uid === null) return false;
|
||||
let acct;
|
||||
try {
|
||||
acct = require("./run-as").accountForLane(laneId);
|
||||
} catch {
|
||||
return false; // its member is gone: nobody's pane any more
|
||||
}
|
||||
return uid === (acct ? acct.uid : process.getuid());
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's hook, checked and trimmed in place (`body.data`, `body.run_id`).
|
||||
* Returns null when it may go on, else `{status, code, message}`.
|
||||
* `data` is the hook's payload after the tool mapping (Claude shape).
|
||||
*/
|
||||
function checkMemberHook(peer, data, body, { getSession, getRun }) {
|
||||
if (peer.kind !== "member") return null;
|
||||
const foreign = (message) => ({ status: 403, code: "EFOREIGN", message });
|
||||
if (!inside(peer.home, data.cwd))
|
||||
return foreign("a hook may only report work in your own folder");
|
||||
const existing = typeof data.session_id === "string" ? getSession(data.session_id) : null;
|
||||
if (existing && !inside(peer.home, existing.cwd)) return foreign("that session is not yours");
|
||||
if (body.run_id != null) {
|
||||
const run = getRun(String(body.run_id));
|
||||
if (!run || !inside(peer.home, run.cwd)) delete body.run_id;
|
||||
}
|
||||
dropForeignPaths(peer, data);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A member's hook loses every transcript path outside their home — run
|
||||
* BEFORE a tool mapping reads one (Codex's `threadOf`). */
|
||||
function dropForeignPaths(peer, data) {
|
||||
if (peer.kind !== "member" || !data || typeof data !== "object") return;
|
||||
for (const key of ["transcript_path", "agent_transcript_path", "transcriptPath"]) {
|
||||
if (key in data && !inside(peer.home, data[key])) delete data[key];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
peerUid,
|
||||
hookPeer,
|
||||
peerIsPaneAccount,
|
||||
checkMemberHook,
|
||||
dropForeignPaths,
|
||||
inside,
|
||||
__setTables,
|
||||
__setPeerUid,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Cleans up what older builds wrote into a lane: `removeStageDoc` takes
|
||||
* AgentHub's stage block back out of a codex lane's AGENTS.md. Plain file I/O.
|
||||
* AgentHub's stage block back out of a codex lane's AGENTS.md. Plain file I/O,
|
||||
* so never in an S3 member's folder.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,6 +23,10 @@ const STAGE_BLOCK_END = "<!-- agenthub:stage:end -->";
|
||||
function removeStageDoc(cwd) {
|
||||
const file = path.join(cwd, "AGENTS.md");
|
||||
try {
|
||||
// S3: never in a member's folder — the host would read, rewrite or delete
|
||||
// through any link the member planted there (with CAP_DAC_READ_SEARCH).
|
||||
// Builds that wrote the block predate S3; a member removes it themselves.
|
||||
if (require("./run-as").ownerForRead(cwd)) return false;
|
||||
const doc = fs.readFileSync(file, "utf8");
|
||||
const start = doc.indexOf(STAGE_BLOCK_START);
|
||||
const end = doc.indexOf(STAGE_BLOCK_END, start);
|
||||
|
||||
@@ -52,7 +52,7 @@ async function writeLaneFile(dir, requested, content, baseSha) {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
if (!stat.isFile()) throw bad("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file is too large to edit here");
|
||||
current = await fs.promises.readFile(abs);
|
||||
current = await require("./run-as").readFileSafeAsync(abs);
|
||||
} catch (err) {
|
||||
if (err.code === "ETOOBIG" || err.code === "ENOFILE") throw err;
|
||||
throw bad("ENOFILE", "no such file in the working tree");
|
||||
@@ -70,7 +70,8 @@ async function writeLaneFile(dir, requested, content, baseSha) {
|
||||
if (eol === "\r\n") out = out.replace(/\n/g, "\r\n");
|
||||
if (text.endsWith("\n") && out && !out.endsWith(eol)) out += eol;
|
||||
|
||||
await fs.promises.writeFile(abs, out);
|
||||
// As the folder's member under S3 (the dashboard reads, never writes, there).
|
||||
require("./run-as").writeFileAs(abs, out);
|
||||
return { path: file };
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@ function insertSource(laneId, kind, p, name, size) {
|
||||
return db.prepare("SELECT * FROM lane_sources WHERE id = ?").get(info.lastInsertRowid);
|
||||
}
|
||||
|
||||
function addFolder(laneId, input) {
|
||||
/** `allowed(realPath)`: the caller's own boundary (a developer's home). */
|
||||
function addFolder(laneId, input, { allowed = () => true } = {}) {
|
||||
const lane = laneRow(laneId);
|
||||
if (typeof input !== "string" || !input.trim()) throw fail("EBADPATH", "path is required");
|
||||
let resolved;
|
||||
@@ -96,6 +97,7 @@ function addFolder(laneId, input) {
|
||||
} catch {
|
||||
/* a vanished workspace folder still has its recorded path */
|
||||
}
|
||||
if (!allowed(resolved)) throw fail("EFOLDER", "an extra folder must be inside your own folder");
|
||||
if (resolved === laneCwd) throw fail("EDUP", "that is the workspace's own folder");
|
||||
const dup = db
|
||||
.prepare("SELECT 1 FROM lane_sources WHERE lane_id = ? AND kind = 'folder' AND path = ?")
|
||||
@@ -196,8 +198,23 @@ function launchExtras(laneId, tool) {
|
||||
}
|
||||
};
|
||||
const folders = rows.filter((r) => r.kind === "folder" && isDir(r.path)).map((r) => r.path);
|
||||
const files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
|
||||
const addDirs = [...folders, ...(files.length ? [contextDir(laneId)] : [])];
|
||||
let files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
|
||||
// S3: a member's pane cannot reach the dashboard's data dir — the context
|
||||
// files and the prompt are copied into the member's handoff dir instead.
|
||||
const member = require("./run-as").accountForLane(laneId);
|
||||
let ctx = contextDir(laneId);
|
||||
if (member && files.length) {
|
||||
// Written AS the member (`write-as`): the dashboard never writes in a home.
|
||||
const runAs = require("./run-as");
|
||||
const sub = `context-${Number(laneId)}`;
|
||||
ctx = path.join(runAs.handoffDir(member), sub);
|
||||
runAs.removeAs(member, ctx);
|
||||
runAs.mkdirAs(member, ctx);
|
||||
files = files.map((f) =>
|
||||
runAs.handoffWrite(member, path.join(sub, path.basename(f)), fs.readFileSync(f))
|
||||
);
|
||||
}
|
||||
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
|
||||
const parts = [];
|
||||
if (lane.instructions) parts.push(lane.instructions);
|
||||
if (files.length) {
|
||||
@@ -216,6 +233,14 @@ function launchExtras(laneId, tool) {
|
||||
// Claude's, Codex's past the inline budget), never its start — same as a
|
||||
// vanished source.
|
||||
try {
|
||||
if (member) {
|
||||
const file = require("./run-as").handoffWrite(
|
||||
member,
|
||||
`prompt-${Number(laneId)}.md`,
|
||||
systemPrompt
|
||||
);
|
||||
return { addDirs, systemPrompt, systemPromptFile: file };
|
||||
}
|
||||
const dir = path.join(getDataDir(), "workspace-prompts");
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`);
|
||||
@@ -245,11 +270,15 @@ function codexInstructions(systemPrompt, systemPromptFile) {
|
||||
|
||||
function readMemoryFile(label, file) {
|
||||
try {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile()) return null;
|
||||
const truncated = st.size > MAX_MEMORY_BYTES;
|
||||
const fd = fs.openSync(file, "r");
|
||||
if (!fs.statSync(file).isFile()) return null; // no blocking open on a FIFO
|
||||
// S3: a file in a member's folder is read only when the OPEN file is a
|
||||
// regular file that member owns — a link they planted to /etc/shadow or
|
||||
// another member's login is never read with the dashboard's rights.
|
||||
const fd = require("./run-as").openSafe(file);
|
||||
try {
|
||||
const st = fs.fstatSync(fd);
|
||||
if (!st.isFile()) return null;
|
||||
const truncated = st.size > MAX_MEMORY_BYTES;
|
||||
const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES));
|
||||
fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
return {
|
||||
|
||||
@@ -219,6 +219,11 @@ function createLane({
|
||||
validateKind(kind);
|
||||
validatePipeline(pipeline);
|
||||
validateTool(tool);
|
||||
// S3: only a member owns a workspace in their own home (EOWNERFOLDER).
|
||||
require("./run-as").assertOwnerFits(
|
||||
canonicalCwd(cwd),
|
||||
Number.isInteger(owner_id) ? owner_id : null
|
||||
);
|
||||
const info = db
|
||||
.prepare(
|
||||
"INSERT INTO lanes (title, cwd, branch, pipeline, kind, source_repo, base_branch, slug, tool, router_profile, stage_since, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @file linux-accounts.js
|
||||
* @description Server mode, one Linux user per account (S3 plan, P2). Each
|
||||
* AgentHub user `alice` maps to the Linux user `ah-alice`, whose home is
|
||||
* `<USERS_ROOT>/alice` — the same folder `scope.folderAllowed` keeps her
|
||||
* workspaces in.
|
||||
*
|
||||
* S3 is ON when server mode is on AND `/etc/agenthub/server.conf` exists
|
||||
* (written by `sudo agenthub server install-system`, root-owned). Without it
|
||||
* everything here is a no-op and server mode behaves as in S2.
|
||||
*
|
||||
* `provision` is the dashboard's only way to create, disable or enable a Linux
|
||||
* account: `sudo -n /opt/agenthub/sbin/agenthub-provision <action> <name>`,
|
||||
* the one root command sudoers lets the host run. Only an action from a fixed
|
||||
* set and a name that passed `LINUX_NAME_RE` ever reach its argv.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
/** Lower case, a letter first, ≤ 29 so `ah-<name>` fits Linux's 32. */
|
||||
const LINUX_NAME_RE = /^[a-z][a-z0-9_-]{0,28}$/;
|
||||
const PROVISION = "/opt/agenthub/sbin/agenthub-provision";
|
||||
const ACTIONS = new Set(["create", "disable", "enable"]);
|
||||
|
||||
const confPath = () => process.env.AGENTHUB_SERVER_CONF || "/etc/agenthub/server.conf";
|
||||
|
||||
// server.conf is read on every tmux call's account lookup — keep it 5 s.
|
||||
let confCache = { at: 0, path: null, value: null };
|
||||
|
||||
/** `{hostUser, hostUid, usersRoot, port, bins}` from server.conf, or null. */
|
||||
function serverConf() {
|
||||
const file = confPath();
|
||||
if (confCache.path === file && Date.now() - confCache.at < 5000) return confCache.value;
|
||||
confCache = { at: Date.now(), path: file, value: readConf(file) };
|
||||
return confCache.value;
|
||||
}
|
||||
|
||||
function readConf(file) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const get = (k) => new RegExp(`^${k}=(.*)$`, "m").exec(text)?.[1]?.trim() || "";
|
||||
const usersRoot = get("USERS_ROOT");
|
||||
if (!path.isAbsolute(usersRoot)) return null;
|
||||
return {
|
||||
hostUser: get("HOST_USER"),
|
||||
hostUid: Number(get("HOST_UID")),
|
||||
usersRoot,
|
||||
port: Number(get("PORT")) || null,
|
||||
// The absolute paths sudoers lists (sudo would resolve a bare name on its
|
||||
// own secure_path, which may not be them).
|
||||
bins: Object.fromEntries(
|
||||
[
|
||||
["tmux", get("TMUX_BIN")],
|
||||
["git", get("GIT_BIN")],
|
||||
["gh", get("GH_BIN")],
|
||||
].filter(([, v]) => path.isAbsolute(v))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Is S3 on: server mode plus an installed system layout. */
|
||||
function s3Enabled() {
|
||||
return require("./auth").isServerMode() && serverConf() !== null;
|
||||
}
|
||||
|
||||
const loginFor = (username) => `ah-${username}`;
|
||||
|
||||
/** The Linux side of an AgentHub user, or null outside S3. */
|
||||
function accountFor(username) {
|
||||
const conf = s3Enabled() ? serverConf() : null;
|
||||
if (!conf || !LINUX_NAME_RE.test(String(username || ""))) return null;
|
||||
return { login: loginFor(username), home: path.join(conf.usersRoot, username) };
|
||||
}
|
||||
|
||||
/** Throws EBADUSER unless `username` can be a Linux account (S3 only). */
|
||||
function assertLinuxName(username) {
|
||||
if (s3Enabled() && !LINUX_NAME_RE.test(String(username || ""))) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
"username: with one Linux user per account it must be lower case — a letter, then up to 28 letters, digits, '_' or '-'"
|
||||
),
|
||||
{ code: "EBADUSER" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seam: the sudo call itself.
|
||||
let runImpl = (argv) =>
|
||||
execFileSync("sudo", ["-n", ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
function __setRunImpl(fn) {
|
||||
runImpl = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create / disable / enable the Linux account of `username`. A no-op outside
|
||||
* S3 (returns null). Throws EPROVISION with the helper's own words on failure.
|
||||
*/
|
||||
function provision(action, username) {
|
||||
if (!s3Enabled()) return null;
|
||||
if (!ACTIONS.has(action)) throw new Error(`unknown provision action: ${action}`);
|
||||
assertLinuxName(username);
|
||||
try {
|
||||
return String(runImpl([PROVISION, action, username]) || "").trim();
|
||||
} catch (e) {
|
||||
const why = String(e.stderr || e.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
throw Object.assign(new Error(`could not ${action} the Linux account: ${why}`), {
|
||||
code: "EPROVISION",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function __resetConfCache() {
|
||||
confCache = { at: 0, path: null, value: null };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
__resetConfCache,
|
||||
confPath,
|
||||
LINUX_NAME_RE,
|
||||
serverConf,
|
||||
s3Enabled,
|
||||
accountFor,
|
||||
assertLinuxName,
|
||||
provision,
|
||||
loginFor,
|
||||
__setRunImpl,
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file member-claude.js
|
||||
* @description Server mode, one Linux user per account (S3): what AgentHub
|
||||
* keeps in step inside a MEMBER's own Claude Code config.
|
||||
*
|
||||
* - `setTheme`: the `theme` in `~/.claude/settings.json`, following the
|
||||
* dark/light mode the member picked in AgentHub. Read through run-as's
|
||||
* guarded reader (the file must be theirs) and written AS the member
|
||||
* (`write-as`, merged, never replacing their other settings). A file that
|
||||
* is not JSON is left alone.
|
||||
* - `applyPluginBaseline` (P6 fallback, opt-in): when the admin keeps a
|
||||
* `plugin-baseline.json` beside `server.conf` (`/etc/agenthub/`, root's),
|
||||
* runs `claude plugin marketplace add <source>` for each of its
|
||||
* `marketplaces`, then `claude plugin install <name@marketplace>` for each
|
||||
* of its `plugins`, AS the member (`sudo -u`, the shared `claude`), in the
|
||||
* background. No file, nothing runs. An entry that could be read as an
|
||||
* option is skipped, and `-y` is never passed, so a plugin that installs by
|
||||
* running a marketplace-declared command fails rather than runs unseen.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { promisify } = require("node:util");
|
||||
const runAs = require("./run-as");
|
||||
const linux = require("./linux-accounts");
|
||||
|
||||
function setTheme(acct, theme) {
|
||||
const dir = path.join(acct.home, ".claude");
|
||||
const file = path.join(dir, "settings.json");
|
||||
let settings = {};
|
||||
try {
|
||||
settings = JSON.parse(runAs.readFileSafe(file, "utf8", { max: 1 << 20 }));
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") return false; // theirs to fix, not ours to overwrite
|
||||
runAs.mkdirAs(acct, dir);
|
||||
}
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return false;
|
||||
if (settings.theme === theme) return true;
|
||||
settings.theme = theme;
|
||||
runAs.writeFileAs(file, `${JSON.stringify(settings, null, 2)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// owner/repo, a git or https URL, an absolute path — never a leading `-`.
|
||||
const MARKETPLACE_RE = /^[A-Za-z0-9/][^\s]{0,499}$/;
|
||||
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
|
||||
const baselinePath = () => path.join(path.dirname(linux.confPath()), "plugin-baseline.json");
|
||||
|
||||
// Test seam: async, a marketplace add is a git clone.
|
||||
let execImpl = promisify(require("node:child_process").execFile);
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the admin's plugin baseline for `username`. Resolves to null when
|
||||
* there is nothing to do (outside S3, no baseline file, empty lists), else to
|
||||
* `[{item, ok, error?}]`, one per entry, in order. Throws on a baseline file
|
||||
* that is not JSON, or ENOTSHARED when `claude` is not in the shared tools.
|
||||
*/
|
||||
async function applyPluginBaseline(username) {
|
||||
const acct = linux.accountFor(username);
|
||||
if (!acct) return null;
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(baselinePath(), "utf8");
|
||||
} catch {
|
||||
return null; // opt-in: no file, no baseline
|
||||
}
|
||||
const base = JSON.parse(raw);
|
||||
const list = (v) => (Array.isArray(v) ? v.map(String) : []);
|
||||
const steps = [
|
||||
...list(base?.marketplaces).map((s) => [
|
||||
s,
|
||||
MARKETPLACE_RE,
|
||||
["plugin", "marketplace", "add", s],
|
||||
]),
|
||||
...list(base?.plugins).map((p) => [p, PLUGIN_RE, ["plugin", "install", "--scope", "user", p]]),
|
||||
];
|
||||
if (!steps.length) return null;
|
||||
const claude = require("./shared-tools").sharedPath("claude");
|
||||
if (!claude) {
|
||||
throw Object.assign(new Error("claude is not in the shared tools yet"), { code: "ENOTSHARED" });
|
||||
}
|
||||
const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" };
|
||||
const results = [];
|
||||
for (const [item, re, args] of steps) {
|
||||
if (!re.test(item)) {
|
||||
results.push({ item, ok: false, error: "not a valid entry" });
|
||||
continue;
|
||||
}
|
||||
const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env);
|
||||
try {
|
||||
await execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" });
|
||||
results.push({ item, ok: true });
|
||||
} catch (e) {
|
||||
const why = String(e.stderr || e.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
results.push({ item, ok: false, error: why });
|
||||
}
|
||||
}
|
||||
for (const r of results) {
|
||||
const line = `[plugin-baseline] ${acct.login}: ${r.item} ${r.ok ? "ok" : `failed: ${r.error}`}`;
|
||||
(r.ok ? console.log : console.warn)(line);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { setTheme, applyPluginBaseline, __setExecImpl };
|
||||
+140
-25
@@ -27,6 +27,12 @@
|
||||
* rewrites them on an edit (a live Claude session reads its file again);
|
||||
* `removeProfileFiles` deletes them with the profile.
|
||||
*
|
||||
* **Server mode never hands a pane the key** (S3 plan, P1b): both files then
|
||||
* name AgentHub's own loopback gateway (`proxy-gateway.js`) as the endpoint and
|
||||
* carry no key; the pane's own token is its key, through tmux `-e`
|
||||
* (`ANTHROPIC_AUTH_TOKEN` for Claude, `AGENTHUB_PROXY_KEY` for Codex), and the
|
||||
* gateway swaps in the real one. Local mode is unchanged.
|
||||
*
|
||||
* Workspaces once carried a proxy (then called a router) of their own in
|
||||
* `lanes.router_profile` and `<cwd>/.claude/settings.local.json`;
|
||||
* `clearLaneProxies` takes that out once.
|
||||
@@ -41,6 +47,10 @@ const proxies = require("./ai-proxies");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { PROXY_CAPABLE } = require("./ai-tools");
|
||||
const { gatewayBase } = require("./proxy-gateway");
|
||||
|
||||
/** Server mode: panes reach a proxy only through the key gateway. */
|
||||
const viaGateway = () => require("./auth").isServerMode();
|
||||
|
||||
/**
|
||||
* The env var Codex is told to read the proxy key from. One fixed name rather
|
||||
@@ -49,11 +59,16 @@ const { PROXY_CAPABLE } = require("./ai-tools");
|
||||
*/
|
||||
const CODEX_KEY_ENV = "AGENTHUB_PROXY_KEY";
|
||||
|
||||
/** The eight env keys a proxy sets for Claude Code. */
|
||||
/**
|
||||
* The eight env keys a proxy sets for Claude Code. Through the gateway the
|
||||
* base URL is the gateway's and the token is left out (the pane's own token
|
||||
* comes through tmux `-e`).
|
||||
*/
|
||||
function envForProfile(profile) {
|
||||
const gw = viaGateway();
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: profile.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: profile.apiKey,
|
||||
ANTHROPIC_BASE_URL: gw ? gatewayBase(profile.name) : profile.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: gw ? "" : profile.apiKey,
|
||||
ANTHROPIC_MODEL: profile.model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models.sonnet,
|
||||
@@ -94,12 +109,29 @@ function modelPicker(profile) {
|
||||
return options.length ? { modelPicker: { options } } : {};
|
||||
}
|
||||
|
||||
function writeClaudeSettings(profile) {
|
||||
const file = claudeSettingsPath(profile.name);
|
||||
/**
|
||||
* The `--settings` file of a Claude pane. Through the gateway (server mode)
|
||||
* it is the PANE's own (`<name>--<run id>.json`) and carries the pane token
|
||||
* as ANTHROPIC_AUTH_TOKEN: the command-line layer outranks the user's and the
|
||||
* project's settings, so an `env.ANTHROPIC_*` there cannot replace it (the
|
||||
* process env could be). A member's goes in their handoff dir, as them, 0600.
|
||||
*/
|
||||
function writeClaudeSettings(profile, member = null, pane = null) {
|
||||
const name = pane ? `${profile.name}--${pane.runId}` : profile.name;
|
||||
const file = member
|
||||
? path.join(require("./run-as").handoffDir(member), `proxy-${name}.json`)
|
||||
: claudeSettingsPath(name);
|
||||
// Empty values are left out: a blank key in the command-line layer would
|
||||
// blank the user's own setting, not leave it alone.
|
||||
const env = Object.fromEntries(Object.entries(envForProfile(profile)).filter(([, v]) => v));
|
||||
writeSecret(file, `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries({
|
||||
...envForProfile(profile),
|
||||
...(pane ? { ANTHROPIC_AUTH_TOKEN: pane.token } : {}),
|
||||
}).filter(([, v]) => v)
|
||||
);
|
||||
const body = `${JSON.stringify({ env, ...modelPicker(profile) }, null, 2)}\n`;
|
||||
if (member) return require("./run-as").handoffWrite(member, path.basename(file), body, "private");
|
||||
writeSecret(file, body);
|
||||
return file;
|
||||
}
|
||||
|
||||
@@ -114,6 +146,7 @@ function tomlString(value) {
|
||||
*/
|
||||
function writeCodexLayer(profile) {
|
||||
const file = codexLayerPath(profile.name);
|
||||
const gw = viaGateway();
|
||||
const body =
|
||||
`# Written by AgentHub — AI Proxy profile ${profile.label}.\n` +
|
||||
`# Loaded by \`codex -p ${profile.name}\`; the base config.toml is untouched.\n` +
|
||||
@@ -121,11 +154,11 @@ function writeCodexLayer(profile) {
|
||||
`model_provider = ${tomlString(profile.name)}\n\n` +
|
||||
`[model_providers.${profile.name}]\n` +
|
||||
`name = ${tomlString(profile.label)}\n` +
|
||||
`base_url = ${tomlString(`${profile.baseUrl}/v1`)}\n` +
|
||||
`base_url = ${tomlString(`${gw ? gatewayBase(profile.name) : profile.baseUrl}/v1`)}\n` +
|
||||
// `env_key` only when there is a key to name. A local proxy (the 9Router
|
||||
// reference config is exactly this) needs none, and naming a variable that
|
||||
// is never set makes Codex refuse to start.
|
||||
(profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
(gw || profile.apiKey ? `env_key = ${tomlString(CODEX_KEY_ENV)}\n` : "") +
|
||||
// Not "chat": codex-cli 0.149 rejects it outright — `wire_api = "chat" is
|
||||
// no longer supported`, a hard config error before the first request.
|
||||
`wire_api = "responses"\n`;
|
||||
@@ -133,19 +166,40 @@ function writeCodexLayer(profile) {
|
||||
return file;
|
||||
}
|
||||
|
||||
/** The Codex layer's settings as `-c` overrides (a member's pane; server mode). */
|
||||
function codexOverrides(profile) {
|
||||
const provider =
|
||||
`{name=${tomlString(profile.label)},` +
|
||||
`base_url=${tomlString(`${gatewayBase(profile.name)}/v1`)},` +
|
||||
`env_key=${tomlString(CODEX_KEY_ENV)},wire_api="responses"}`;
|
||||
return [
|
||||
...["-c", `model_providers.${profile.name}=${provider}`],
|
||||
...["-c", `model_provider=${tomlString(profile.name)}`],
|
||||
...["-c", `model=${tomlString(profile.model)}`],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a new session asked for, checked against its tool. `null`,
|
||||
* `""` and `"native"` mean the vendor's own API and resolve to null.
|
||||
* @throws EBADPROXY for a profile the store does not have, EUNSUPPORTED for a
|
||||
* proxy on a tool that has no endpoint to point at one.
|
||||
* The profile a new session asked for, checked against its tool and its
|
||||
* workspace's owner. `null`, `""` and `"native"` mean the vendor's own API and
|
||||
* resolve to null. A personal profile resolves only for a workspace its owner
|
||||
* owns; for anyone else it does not exist.
|
||||
* @param {number|null} [ownerId] the workspace's `lanes.owner_id`
|
||||
* @throws EBADPROXY for a profile the store does not have (or this owner may
|
||||
* not use), EUNSUPPORTED for a proxy on a tool that has no endpoint to point
|
||||
* at one.
|
||||
*/
|
||||
function resolveProxy(tool, name) {
|
||||
function resolveProxy(tool, name, ownerId = null) {
|
||||
if (name == null || name === "" || name === proxies.BUILTIN_NAME) return null;
|
||||
if (typeof name !== "string") {
|
||||
throw Object.assign(new Error("proxy must be a profile name"), { code: "EBADPROXY" });
|
||||
}
|
||||
let profile;
|
||||
try {
|
||||
const owner = proxies.ownerOfName(name);
|
||||
if (owner != null && owner !== ownerId) {
|
||||
throw Object.assign(new Error("not this owner's"), { code: "ENOTFOUND" });
|
||||
}
|
||||
profile = proxies.getProxy(name);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOTFOUND") throw e;
|
||||
@@ -164,12 +218,15 @@ function resolveProxy(tool, name) {
|
||||
|
||||
/**
|
||||
* The proxy a resume that names none goes back to: the one its session last
|
||||
* ran on, while that profile still exists — a deleted one resumes on native.
|
||||
* ran on, while that profile still exists and the workspace's owner may use
|
||||
* it — a deleted one, or another user's personal one, resumes on native.
|
||||
*/
|
||||
function resumeProxy(sessionId) {
|
||||
function resumeProxy(sessionId, ownerId = null) {
|
||||
if (!sessionId) return null;
|
||||
const name = require("./dashboard-runs").proxyForSession(sessionId);
|
||||
if (!name) return null;
|
||||
const owner = proxies.ownerOfName(name);
|
||||
if (owner != null && owner !== ownerId) return null;
|
||||
try {
|
||||
proxies.getProxy(name);
|
||||
return name;
|
||||
@@ -180,31 +237,87 @@ function resumeProxy(sessionId) {
|
||||
|
||||
/**
|
||||
* Argv and env one spawn needs for `profile` (from `resolveProxy`): Claude's
|
||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file.
|
||||
* `--settings=<file>`, Codex's `-p <name>` plus the key. Writes the file. In
|
||||
* server mode the "key" is the pane's own token (`paneToken`), which only the
|
||||
* gateway accepts.
|
||||
* @returns {{argv: string[], env: Record<string,string>}}
|
||||
*/
|
||||
function proxyForSpawn(tool, profile) {
|
||||
function proxyForSpawn(tool, profile, paneToken = null, member = null, runId = null) {
|
||||
if (!profile) return { argv: [], env: {} };
|
||||
const gw = viaGateway();
|
||||
if (gw && !paneToken) {
|
||||
// Server mode always mints one (auth.newPaneToken); without it the pane
|
||||
// could only be given the real key, which this mode never does.
|
||||
throw Object.assign(new Error("no pane token for the AI Proxy gateway"), {
|
||||
code: "EPROXY",
|
||||
});
|
||||
}
|
||||
if (tool === "codex") {
|
||||
const key = gw ? paneToken : profile.apiKey;
|
||||
const env = key ? { [CODEX_KEY_ENV]: key } : {};
|
||||
// S3: a member's CODEX_HOME is theirs (no layer file of ours there): the
|
||||
// same provider as `-c` overrides, through the gateway, no key.
|
||||
if (member) return { argv: codexOverrides(profile), env };
|
||||
writeCodexLayer(profile);
|
||||
return {
|
||||
argv: ["-p", profile.name],
|
||||
env: profile.apiKey ? { [CODEX_KEY_ENV]: profile.apiKey } : {},
|
||||
};
|
||||
return { argv: ["-p", profile.name], env };
|
||||
}
|
||||
// `=` form: one argv element, never read as an option of its own.
|
||||
return { argv: [`--settings=${writeClaudeSettings(profile)}`], env: {} };
|
||||
const pane = gw ? { token: paneToken, runId: runId || "pane" } : null;
|
||||
return {
|
||||
argv: [`--settings=${writeClaudeSettings(profile, member, pane)}`],
|
||||
env: gw ? { ANTHROPIC_AUTH_TOKEN: paneToken } : {},
|
||||
};
|
||||
}
|
||||
|
||||
/** A gateway pane's own settings files of profile `name`: `[{file, runId}]`. */
|
||||
function paneSettingsFiles(name) {
|
||||
const dir = path.dirname(claudeSettingsPath(name));
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.startsWith(`${name}--`) && f.endsWith(".json"))
|
||||
.map((f) => ({ file: path.join(dir, f), runId: f.slice(name.length + 2, -5) }));
|
||||
} catch {
|
||||
return []; // no dir yet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pane token a pane's settings file holds, while that pane still runs on
|
||||
* it (`dashboard_runs`: same run, same token hash, running) — else null.
|
||||
*/
|
||||
function livePaneToken({ file, runId }) {
|
||||
try {
|
||||
const token = JSON.parse(fs.readFileSync(file, "utf8")).env?.ANTHROPIC_AUTH_TOKEN;
|
||||
if (typeof token !== "string" || !token) return null;
|
||||
const hash = require("node:crypto").createHash("sha256").update(token).digest("hex");
|
||||
const live = require("../db")
|
||||
.db.prepare(
|
||||
"SELECT 1 FROM dashboard_runs WHERE id = ? AND pane_token_hash = ? AND status IN ('running', 'spawning')"
|
||||
)
|
||||
.get(runId, hash);
|
||||
return live ? token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After an edit: rewrite whichever of the profile's files exist, so a live
|
||||
* Claude session and the next Codex one see the new key/endpoint. Files that
|
||||
* do not exist yet are written at the next spawn.
|
||||
* do not exist yet are written at the next spawn. A gateway pane's own file
|
||||
* (server mode) is rewritten with ITS token while the pane runs, and deleted
|
||||
* once it does not: its token is dead, and nothing else would remove it.
|
||||
*/
|
||||
function refreshProfileFiles(name) {
|
||||
const profile = proxies.getProxy(name);
|
||||
if (fs.existsSync(claudeSettingsPath(name))) writeClaudeSettings(profile);
|
||||
if (fs.existsSync(codexLayerPath(name))) writeCodexLayer(profile);
|
||||
for (const pane of paneSettingsFiles(name)) {
|
||||
const token = livePaneToken(pane);
|
||||
if (token) writeClaudeSettings(profile, null, { token, runId: pane.runId });
|
||||
else fs.rmSync(pane.file, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +327,9 @@ function refreshProfileFiles(name) {
|
||||
*/
|
||||
function removeProfileFiles(name) {
|
||||
const legacy = path.join(getDataDir(), "router-settings", `${name}.json`);
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy]) {
|
||||
// A gateway pane's own copies (`<name>--<run id>.json`) go too.
|
||||
const panes = paneSettingsFiles(name).map((p) => p.file);
|
||||
for (const file of [claudeSettingsPath(name), codexLayerPath(name), legacy, ...panes]) {
|
||||
try {
|
||||
fs.rmSync(file, { force: true });
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @file proxy-gateway.js
|
||||
* @description Server mode's AI Proxy key gateway (S3 plan, P1b). A pane in
|
||||
* server mode is never given a proxy profile's API key: its Claude Code / Codex
|
||||
* talks to `http://127.0.0.1:<port>/api/proxy-gw/<profile>/v1/…` with its own
|
||||
* pane token as the key, and this handler forwards the request to the
|
||||
* profile's `baseUrl` with the real key, streaming the body and the answer
|
||||
* (SSE included) through untouched.
|
||||
*
|
||||
* A request is forwarded only when all hold:
|
||||
* - server mode (in local mode the key is the user's own and the pane gets it
|
||||
* as before — this route answers 404);
|
||||
* - a loopback socket with no forwarding header (a pane runs on this host);
|
||||
* - the bearer / `x-api-key` token is the pane token of a LIVE dashboard run
|
||||
* (`dashboard_runs.pane_token_hash`, tmux session alive), and that run was
|
||||
* started on THIS profile (`dashboard_runs.proxy`) — so a token never opens
|
||||
* a profile its pane was not given, nor outlives its pane;
|
||||
* - the path is under `/v1/` of the profile's own base URL (no `..` escape).
|
||||
*
|
||||
* The token and any client-sent key are dropped; the upstream sees only the
|
||||
* profile's key (both `Authorization: Bearer` and `x-api-key`, as
|
||||
* `testProxy` sends them). Nothing here logs a key or a token.
|
||||
*
|
||||
* Ceiling: an agent in the pane can still SPEND through the profile while its
|
||||
* pane lives — it just cannot carry the key away.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
|
||||
/** A streamed answer may go quiet while the model thinks; not for this long. */
|
||||
const IDLE_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Request headers never passed upstream: hop-by-hop, the pane's token, cookies. */
|
||||
const DROP_REQUEST = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"cookie",
|
||||
"x-agenthub-pane-token",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
]);
|
||||
|
||||
/** Answer headers node sets itself. */
|
||||
const DROP_RESPONSE = new Set(["connection", "keep-alive", "transfer-encoding"]);
|
||||
|
||||
let listenPort = null;
|
||||
|
||||
/** The port this dashboard listens on — set by `startServer` once bound. */
|
||||
function setListenPort(port) {
|
||||
listenPort = port;
|
||||
}
|
||||
|
||||
/** This dashboard's port (the bound one once listening). */
|
||||
function currentPort() {
|
||||
return listenPort || parseInt(process.env.DASHBOARD_PORT || "4820", 10);
|
||||
}
|
||||
|
||||
/** The gateway endpoint a pane uses for `name`: Claude's base URL (Codex adds `/v1`). */
|
||||
function gatewayBase(name) {
|
||||
return `http://127.0.0.1:${currentPort()}/api/proxy-gw/${encodeURIComponent(name)}`;
|
||||
}
|
||||
|
||||
const sha256 = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
||||
|
||||
const fail = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
|
||||
function bearer(req) {
|
||||
const fromAuth = /^Bearer\s+(\S+)$/i.exec(String(req.headers.authorization || ""))?.[1];
|
||||
const fromKey = req.headers["x-api-key"];
|
||||
return [fromAuth, fromKey].find((t) => typeof t === "string" && t.startsWith("ahpane_")) || null;
|
||||
}
|
||||
|
||||
/** The live run this pane token belongs to, or null. */
|
||||
function liveRun(token, req) {
|
||||
if (!token || token.length > 200) return null;
|
||||
const { db } = require("../db");
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, proxy, lane_id FROM dashboard_runs
|
||||
WHERE pane_token_hash = ? AND status IN ('running', 'spawning') AND instr(id, '@') = 0`
|
||||
)
|
||||
.get(sha256(token));
|
||||
if (!row) return null;
|
||||
// S3: only from the account that pane runs as (any local uid could post it).
|
||||
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
|
||||
if (!require("./tmux").hasSession(row.id)) return null;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Express handler, mounted at `/api/proxy-gw` BEFORE the body parsers. */
|
||||
function proxyGateway(req, res) {
|
||||
const { isServerMode } = require("./auth");
|
||||
const { isLocalRequest } = require("./same-origin");
|
||||
if (!isServerMode()) return fail(res, 404, "ENOTFOUND", "not found");
|
||||
if (!isLocalRequest(req)) {
|
||||
return fail(res, 403, "ELOCALONLY", "the proxy gateway is for this machine's panes only");
|
||||
}
|
||||
|
||||
// `/<name>/v1/<rest>?<query>`, relative to the mount point.
|
||||
const m = /^\/([^/?]+)(\/v1\/[^?]*)(\?.*)?$/.exec(req.url);
|
||||
if (!m) return fail(res, 400, "EBADPATH", "expected /api/proxy-gw/<profile>/v1/…");
|
||||
let name;
|
||||
try {
|
||||
name = decodeURIComponent(m[1]);
|
||||
} catch {
|
||||
return fail(res, 400, "EBADPATH", "bad profile name");
|
||||
}
|
||||
|
||||
const run = liveRun(bearer(req), req);
|
||||
if (!run) return fail(res, 401, "EAUTH", "not a live pane's token");
|
||||
if (run.proxy !== name) {
|
||||
return fail(res, 403, "EPROXY", "this pane was not started on that AI Proxy");
|
||||
}
|
||||
|
||||
let profile;
|
||||
try {
|
||||
profile = require("./ai-proxies").getProxy(name);
|
||||
} catch {
|
||||
return fail(res, 404, "ENOTFOUND", `no AI Proxy profile named '${name}'`);
|
||||
}
|
||||
if (profile.builtin) return fail(res, 404, "ENOTFOUND", "native has no endpoint");
|
||||
|
||||
// The upstream URL stays under the profile's own `/v1/` — a `..` in the
|
||||
// path is resolved here and refused if it climbs out.
|
||||
const base = new URL(profile.baseUrl);
|
||||
const prefix = `${base.pathname.replace(/\/+$/, "")}/v1/`;
|
||||
const target = new URL(`${base.origin}${prefix}${m[2].slice("/v1/".length)}${m[3] || ""}`);
|
||||
if (target.origin !== base.origin || !target.pathname.startsWith(prefix)) {
|
||||
return fail(res, 400, "EBADPATH", "path leaves the profile's /v1/");
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (!DROP_REQUEST.has(k.toLowerCase())) headers[k] = v;
|
||||
}
|
||||
if (profile.apiKey) {
|
||||
headers.authorization = `Bearer ${profile.apiKey}`;
|
||||
headers["x-api-key"] = profile.apiKey;
|
||||
}
|
||||
|
||||
const mod = target.protocol === "https:" ? https : http;
|
||||
const up = mod.request(target, { method: req.method, headers }, (answer) => {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(answer.headers)) {
|
||||
if (!DROP_RESPONSE.has(k.toLowerCase())) out[k] = v;
|
||||
}
|
||||
res.writeHead(answer.statusCode || 502, out);
|
||||
answer.pipe(res);
|
||||
});
|
||||
up.setTimeout(IDLE_MS, () => up.destroy(new Error("upstream went quiet")));
|
||||
up.on("error", (e) => {
|
||||
if (!res.headersSent) fail(res, 502, "EUPSTREAM", e.message);
|
||||
else res.destroy();
|
||||
});
|
||||
// The pane hung up (Ctrl-C mid-stream): stop the upstream call too.
|
||||
res.on("close", () => {
|
||||
if (!res.writableFinished) up.destroy();
|
||||
});
|
||||
req.pipe(up);
|
||||
}
|
||||
|
||||
module.exports = { proxyGateway, gatewayBase, setListenPort, listenPort: currentPort };
|
||||
@@ -104,9 +104,16 @@ function attach(ws, runId, { cols, rows }) {
|
||||
let group = groups.get(runId);
|
||||
if (!group) groups.set(runId, (group = { driver: null, viewers: new Set() }));
|
||||
const lead = group.driver;
|
||||
// A developer's pane is on that account's own tmux server, attached AS that
|
||||
// account (S3: never a host client on a socket the account controls).
|
||||
// node-pty forks natively, so drop-caps' child_process patch never sees it:
|
||||
// the client must not keep the unit's CAP_DAC_READ_SEARCH (S3).
|
||||
const [file, args] = require("./drop-caps").ptyArgv(
|
||||
...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]])
|
||||
);
|
||||
// A follower attaches at the driver's size: tmux sizes the window to the
|
||||
// newest client, so its own size would resize the driver's pane first.
|
||||
const pty = spawnImpl(attachBinary(), ["attach-session", "-t", tmux.session(runId)], {
|
||||
const pty = spawnImpl(file, args, {
|
||||
name: "xterm-256color",
|
||||
cols: lead ? lead.cols : cols || 80,
|
||||
rows: lead ? lead.rows : rows || 24,
|
||||
|
||||
+68
-5
@@ -275,6 +275,18 @@ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt
|
||||
function buildArgv(args, entry) {
|
||||
if (entry.launcher === "codex") return buildCodexArgv(args);
|
||||
if (entry.launcher === "claude") return buildClaudeArgv(args);
|
||||
// S3: a member's pane cannot enter the host's home — it runs the copy every
|
||||
// account shares (server/lib/shared-tools.js).
|
||||
if (require("./run-as").accountForLane(args.laneId)) {
|
||||
const shared = require("./shared-tools").sharedPath(entry.detect[0]);
|
||||
if (!shared) {
|
||||
throw makeErr(
|
||||
"ENOTSHARED",
|
||||
`${entry.label || entry.id} is not installed for every account yet — an admin runs \`agenthub server tools publish\``
|
||||
);
|
||||
}
|
||||
return buildCatalogArgv(entry, shared, args);
|
||||
}
|
||||
const d = aiTools.detect(entry.id);
|
||||
// By absolute path when found; the bare name otherwise, so a CLI missing
|
||||
// from PATH dies in the pane with the shell's own "not found".
|
||||
@@ -306,6 +318,14 @@ function buildArgv(args, entry) {
|
||||
* name only, its key and endpoint come from the store; null/"native" = the
|
||||
* vendor's own API. Claude and Codex only (`EUNSUPPORTED`).
|
||||
*/
|
||||
/** S3: a pane's cwd must be its workspace's account's (both null outside S3). */
|
||||
function assertCwdAccount(laneId, cwd) {
|
||||
const runAs = require("./run-as");
|
||||
if (runAs.accountForPath(cwd)?.login !== runAs.accountForLane(laneId)?.login) {
|
||||
throw makeErr("ECWDACCOUNT", "that folder belongs to another account than this workspace");
|
||||
}
|
||||
}
|
||||
|
||||
function spawnRun(args) {
|
||||
const {
|
||||
laneId,
|
||||
@@ -387,12 +407,28 @@ function spawnRun(args) {
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
const proxyProfile = resolveProxy(tool, proxy); // ENOTFOUND, EUNSUPPORTED
|
||||
// A personal AI Proxy resolves only for its owner's workspace.
|
||||
const lane = require("./lanes").getLane(laneId);
|
||||
const laneOwner = lane?.owner_id ?? null;
|
||||
// S3, before anything is written for this pane: it runs as its workspace
|
||||
// FOLDER's account with its OWNER's token, so the two must agree (live
|
||||
// role: a non-admin owns no host folder), and the cwd must be that same
|
||||
// account's — else the host would run in a member's folder (their hooks,
|
||||
// their .mcp.json) or a member in someone else's.
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
|
||||
// own key, and an empty pool leaves the machine's login in charge.
|
||||
const poolAccount =
|
||||
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
|
||||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||||
const codexPool =
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
@@ -430,7 +466,13 @@ function spawnRun(args) {
|
||||
laneId,
|
||||
tool
|
||||
);
|
||||
const proxySpawn = proxyForSpawn(tool, proxyProfile);
|
||||
const proxySpawn = proxyForSpawn(
|
||||
tool,
|
||||
proxyProfile,
|
||||
paneCred?.token,
|
||||
require("./run-as").accountForLane(laneId),
|
||||
id
|
||||
);
|
||||
// A new Claude session gets its id from us, so the run knows the session it
|
||||
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
||||
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||||
@@ -438,6 +480,7 @@ function spawnRun(args) {
|
||||
entry.launcher === "claude" && !resumeSessionId ? require("crypto").randomUUID() : null;
|
||||
const argv = buildArgv(
|
||||
{
|
||||
laneId,
|
||||
addDirs,
|
||||
systemPrompt,
|
||||
systemPromptFile,
|
||||
@@ -454,15 +497,20 @@ function spawnRun(args) {
|
||||
resumeLast,
|
||||
bypass: bypass === true,
|
||||
initialPrompt,
|
||||
proxyArgv: proxySpawn.argv,
|
||||
proxyArgv: [...proxySpawn.argv, ...(codexPool ? codexPool.argv : [])],
|
||||
},
|
||||
entry
|
||||
);
|
||||
const pasteLater = entry.prompt?.mode === "after-start" && initialPrompt ? initialPrompt : null;
|
||||
// S3: a member's pane runs as them (tmux.js starts it on their server).
|
||||
const member = require("./run-as").accountForLane(laneId);
|
||||
// Every launcher puts the prompt LAST; Windows has no sh to read it back.
|
||||
// A member's pane cannot read the host's 0700 run-prompts dir — and needs
|
||||
// no file: its whole argv already travels in its launch file, not tmux's.
|
||||
const paneCmd =
|
||||
initialPrompt &&
|
||||
!pasteLater &&
|
||||
!member &&
|
||||
process.platform !== "win32" &&
|
||||
Buffer.byteLength(argv[argv.length - 1]) > INLINE_PROMPT_BYTES
|
||||
? promptFileArgv(id, argv)
|
||||
@@ -489,7 +537,7 @@ function spawnRun(args) {
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
worktree: worktree === true || inWorktree === true,
|
||||
proxy: proxyProfile ? proxyProfile.name : null,
|
||||
account: poolAccount ? poolAccount.name : null,
|
||||
account: (poolAccount || codexPool)?.name ?? null,
|
||||
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
@@ -535,11 +583,19 @@ function spawnRun(args) {
|
||||
// The pool account's setup-token: outranks the machine's `/login` for
|
||||
// this process only. Through tmux `-e`, never typed into the pane.
|
||||
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
|
||||
...(codexPool ? codexPool.env : {}),
|
||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||
...(bypass === true && entry.bypassEnv ? entry.bypassEnv : {}),
|
||||
};
|
||||
// S3: a member's pane gets their home, the shared tools and none of the
|
||||
// host's desktop.
|
||||
if (member) {
|
||||
const own = require("./run-as").memberPaneEnv(member, env, { codex: tool === "codex" });
|
||||
for (const k of Object.keys(env)) delete env[k];
|
||||
Object.assign(env, own);
|
||||
}
|
||||
|
||||
// `after-start` tools take no prompt on their command line: paste it once
|
||||
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||
@@ -557,6 +613,7 @@ function spawnRun(args) {
|
||||
};
|
||||
|
||||
if (!tmux.hasSession(id)) {
|
||||
codexPool?.place();
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd), env });
|
||||
record();
|
||||
schedulePaste();
|
||||
@@ -567,6 +624,7 @@ function spawnRun(args) {
|
||||
// `--resume` and an initial prompt would never be typed, while the API
|
||||
// still answered 200. Run the argv in the pane the user already sees
|
||||
// instead of erroring or opening a second session.
|
||||
codexPool?.place();
|
||||
tmux.sendCommand(id, paneCmd, env, cwd);
|
||||
record();
|
||||
schedulePaste();
|
||||
@@ -612,9 +670,14 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
...desktopEnv(),
|
||||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||
};
|
||||
const lane = require("./lanes").getLane(laneId);
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, lane.owner_id ?? null); // as spawnRun
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT, as spawnRun
|
||||
const member = require("./run-as").accountForLane(laneId);
|
||||
const paneEnv = member ? require("./run-as").memberPaneEnv(member, env) : env;
|
||||
// Started once the pane has its real size (WAIT_FOR_CLIENT, issue #55), as
|
||||
// the login shell tmux would have run (tmux sets SHELL in the pane).
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env });
|
||||
tmux.newSession({ name: id, cwd, argv: paneArgv(null), env: paneEnv });
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
|
||||
+3
-2
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user