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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user