84e2a9f0ac
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>
473 lines
18 KiB
JavaScript
473 lines
18 KiB
JavaScript
/**
|
||
* @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;
|
||
},
|
||
};
|