feat(server-s3): one Linux user per account (S3) — experimental

Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+80 -17
View File
@@ -374,6 +374,9 @@ router.get("/branches", async (req, res) => {
error: { code: "EBADSOURCEREPO", message: "repo must be an existing absolute path" },
});
}
// A developer names only a repo in their own folder: git would otherwise run
// AS whichever member owns the path (S3) and hand back their branches.
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
if (!(await isGitRepo(sourceRepo))) {
return res.status(400).json({
error: { code: "EBADSOURCEREPO", message: "repo is not a git repository" },
@@ -485,6 +488,9 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
// re-read and return THEIR lane rather than reporting a conflict.
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
@@ -501,6 +507,33 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
* on this machine. See server/lib/lane-gc.js for what "orphaned" and
* "oversized" mean.
*/
/**
* S3 (docs/plans/server-mode/S3-PLAN.md): the managed-worktree mode and the
* runtime layer run as the dashboard's own account and write into the
* workspace or LANES_ROOT, so they are not offered for a folder that belongs
* to a Linux account — such a workspace uses Repo mode and the prompt box's
* New worktree. True once it has answered 403 EMEMBERLANE.
*/
function refuseS3Folder(res, folder) {
// ownerForRead: a folder of a gone member is still a member's (accountForPath throws).
if (!require("../lib/run-as").ownerForRead(folder)) return false;
res.status(403).json({
error: {
code: "EMEMBERLANE",
message:
"not available for a workspace in a member's folder — use Repo mode and the prompt box's New worktree",
},
});
return true;
}
/** Route guard: `refuseS3Folder` for the workspace `:id`. */
function s3Guard(req, res, next) {
const lane = lanesLib.getLane(Number(req.params.id));
if (lane && refuseS3Folder(res, lane.cwd)) return;
next();
}
router.post("/gc", sameOriginGuard, (req, res) => {
// Machine-wide, so it cannot be narrowed to one member's workspaces.
if (scope.memberId(req) != null) {
@@ -525,9 +558,16 @@ router.post("/gc", sameOriginGuard, (req, res) => {
* "/:id" so the literal "browse" segment is never captured as a lane id.
*/
router.get("/browse", (req, res) => {
// A developer browses only their own folder (by real path: a link in it
// cannot point the listing elsewhere) — the dashboard lists with its own
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
const own = scope.ownFolder(req);
const raw =
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
const resolved = path.resolve(raw);
typeof req.query.path === "string" && req.query.path.trim()
? req.query.path
: own || os.homedir();
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
let stat;
try {
@@ -555,7 +595,9 @@ router.get("/browse", (req, res) => {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
const top =
path.dirname(resolved) === resolved || (own && resolved === lanesLib.canonicalCwd(own));
const parent = top ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries });
});
@@ -618,6 +660,9 @@ router.post("/", sameOriginGuard, (req, res) => {
) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
@@ -790,7 +835,7 @@ router.get("/:id/proof/:slug/:group/:file", (req, res) => {
res.type(contentType).sendFile(p);
});
router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
router.delete("/:id/proof/:slug", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -812,7 +857,7 @@ router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
}
});
router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
router.post("/:id/proof-link", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -827,7 +872,7 @@ router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
* calls this explicitly. Never touches permissions/settings.local.json —
* see the F1 design spec for why.
*/
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
router.post("/:id/mcp/sync", sameOriginGuard, s3Guard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -850,7 +895,7 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
* a normal 200 outcome, not an error — most lanes won't be auto-detectable
* and that's fine, same as every other optional profile declaration.
*/
router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
router.post("/:id/profile/init", sameOriginGuard, s3Guard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
@@ -1001,7 +1046,7 @@ router.get("/:id/git/file", async (req, res) => {
res.json({ available: true, ...file });
} catch (err) {
// A secret file (where a proxy's API key once lived) is refused at every rev.
if (err.code === "ESECRET") {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
@@ -1023,7 +1068,7 @@ router.get("/:id/git/blame", async (req, res) => {
const blame = await blameFile(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
res.json({ available: true, ...blame });
} catch (err) {
if (err.code === "ESECRET") {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
@@ -1058,7 +1103,7 @@ router.get("/:id/git/raw", async (req, res) => {
});
res.send(buf);
} catch (err) {
if (err.code === "ESECRET") {
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) {
@@ -1083,7 +1128,7 @@ router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) });
} catch (err) {
const status =
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
{ ESECRET: 403, EFOREIGN: 403, ESTALE: 409 }[err.code] ??
(["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500);
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
}
@@ -1216,6 +1261,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EUNTRACKED",
"EBADHOST",
"EBADCRED",
"EMEMBERCRED",
"ESUBDIR",
"EBADSIDE",
"EMARKERS",
@@ -1245,6 +1291,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EPUSHED",
"EEMPTY",
"ESTALE",
"EMEMBERGONE",
].includes(err.code)
? 409
: 500;
@@ -1342,6 +1389,14 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
}
const sourceRepo = body.sourceRepo;
// Before any git work (which would already run as the member).
if (
typeof sourceRepo === "string" &&
path.isAbsolute(sourceRepo) &&
refuseS3Folder(res, lanesLib.canonicalCwd(sourceRepo))
) {
return;
}
if (
typeof sourceRepo !== "string" ||
!path.isAbsolute(sourceRepo) ||
@@ -1744,7 +1799,7 @@ router.get("/:id/logs/:svc", (req, res) => {
* streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports`
* the boot may have changed.
*/
router.post("/:id/up", sameOriginGuard, (req, res) => {
router.post("/:id/up", sameOriginGuard, s3Guard, (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
@@ -1774,7 +1829,7 @@ router.post("/:id/up", sameOriginGuard, (req, res) => {
});
/** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */
router.post("/:id/down", sameOriginGuard, async (req, res) => {
router.post("/:id/down", sameOriginGuard, s3Guard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
@@ -1795,7 +1850,7 @@ router.post("/:id/down", sameOriginGuard, async (req, res) => {
* `args` travels as an array of strings straight into argv. Neither is ever
* joined into a command string.
*/
router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
router.post("/:id/hook/:name", sameOriginGuard, s3Guard, (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
const { name } = req.params;
@@ -1853,7 +1908,7 @@ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
* routes already keep; the caller decides what a collision or conflict means
* for the lane's declared stage.
*/
router.post("/:id/sync-base", sameOriginGuard, async (req, res) => {
router.post("/:id/sync-base", sameOriginGuard, s3Guard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
let profile;
@@ -2126,7 +2181,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
proxy:
(body.proxy ?? body.router) !== undefined
? (body.proxy ?? body.router)
: resumeProxy(body.resumeSessionId || (body.resume ? current.session_id : null)),
: resumeProxy(
body.resumeSessionId || (body.resume ? current.session_id : null),
current.owner_id ?? null
),
model: body.model,
permissionMode: body.permissionMode,
effort: body.effort,
@@ -2201,7 +2259,12 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
}
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "") ? 400 : 500;
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
? 400
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
? 409
: 500;
return res.status(status).json({ error: { code: err.code, message: err.message } });
}