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:
@@ -10,6 +10,13 @@
|
||||
* profile through a file written at its spawn (`server/lib/proxy-apply.js`),
|
||||
* which an edit rewrites and a delete removes.
|
||||
*
|
||||
* Who may write what (server mode; local mode has no user and writes the
|
||||
* shared store as before): an admin writes the SHARED profiles; anyone may
|
||||
* keep PERSONAL ones (`scope: "personal"`, a member's only kind), and only
|
||||
* their owner lists, edits, deletes or tests them — another user, an admin
|
||||
* included, gets the same 404 as a profile that does not exist. An analyst
|
||||
* keeps personal ones like a developer.
|
||||
*
|
||||
* No response on this router carries key material. `GET /` returns the store's
|
||||
* public view (`hasKey: true|false`), and there is deliberately no
|
||||
* `GET /:name` — a route that returned one profile in full would be the one
|
||||
@@ -35,26 +42,48 @@ const ERR_TO_STATUS = {
|
||||
EBADJSON: 409,
|
||||
EBUILTIN: 409,
|
||||
ENOTFOUND: 404,
|
||||
EADMIN: 403,
|
||||
};
|
||||
|
||||
const notFound = (name) =>
|
||||
Object.assign(new Error(`no proxy profile named '${name}'`), { code: "ENOTFOUND" });
|
||||
const adminOnly = () =>
|
||||
Object.assign(new Error("only an admin may change a shared AI Proxy"), { code: "EADMIN" });
|
||||
|
||||
/** Throw unless this request may edit or delete profile `name`. */
|
||||
function assertMayWrite(req, name) {
|
||||
const owner = proxies.ownerOfName(name);
|
||||
if (owner != null) {
|
||||
if (req.user?.id !== owner) throw notFound(name);
|
||||
} else if (req.user && req.user.role !== "admin") {
|
||||
throw adminOnly();
|
||||
}
|
||||
}
|
||||
|
||||
function fail(res, e) {
|
||||
const status = ERR_TO_STATUS[e.code] || 500;
|
||||
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
// GET /api/ai-proxies — every profile, built-in `native` first. Never a key.
|
||||
router.get("/", (_req, res) => {
|
||||
router.get("/", (req, res) => {
|
||||
try {
|
||||
res.json(proxies.listProxies());
|
||||
res.json(proxies.listProxies(req.user?.id ?? null));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?, behavesAs? }
|
||||
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
|
||||
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
|
||||
// (a member's only choice); local mode has no user and always writes shared.
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
res.status(201).json(proxies.createProxy(req.body || {}));
|
||||
const body = req.body || {};
|
||||
const user = req.user;
|
||||
const personal = Boolean(user) && (user.role !== "admin" || body.scope === "personal");
|
||||
if (user && body.scope === "shared" && user.role !== "admin") throw adminOnly();
|
||||
res.status(201).json(proxies.createProxy(body, personal ? user.id : null));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
@@ -72,7 +101,12 @@ router.post("/", sameOriginGuard, (req, res) => {
|
||||
// is narrow).
|
||||
router.post("/test", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
res.json(await proxies.testProxy(req.body || {}));
|
||||
const body = { ...(req.body || {}) };
|
||||
// The stored-key fallback is the owner's alone: a name that is someone
|
||||
// else's personal profile is dropped, so their key is never sent.
|
||||
const owner = proxies.ownerOfName(body.name);
|
||||
if (owner != null && req.user?.id !== owner) delete body.name;
|
||||
res.json(await proxies.testProxy(body));
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
}
|
||||
@@ -85,6 +119,7 @@ router.post("/test", sameOriginGuard, async (req, res) => {
|
||||
// edit rewrites those files too — a rotated key reaches live Claude sessions.
|
||||
router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
assertMayWrite(req, req.params.name);
|
||||
const saved = proxies.updateProxy(req.params.name, req.body || {});
|
||||
refreshProfileFiles(saved.name);
|
||||
res.json(saved);
|
||||
@@ -96,6 +131,7 @@ router.patch("/:name", sameOriginGuard, (req, res) => {
|
||||
// DELETE /api/ai-proxies/:name — and the session files that copy its key.
|
||||
router.delete("/:name", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
assertMayWrite(req, req.params.name);
|
||||
const removed = proxies.removeProxy(req.params.name);
|
||||
removeProfileFiles(req.params.name);
|
||||
res.json(removed);
|
||||
|
||||
Reference in New Issue
Block a user