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:
+80
-17
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user