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:
+77
-34
@@ -21,6 +21,14 @@
|
||||
* so **no route ever returns key material** — `listProxies` reports `hasKey`
|
||||
* and nothing else about it.
|
||||
*
|
||||
* **Shared and personal.** The store's top level holds the SHARED profiles —
|
||||
* the host's in local mode, the admin's in server mode. Each user may also keep
|
||||
* PERSONAL ones under `users/<user id>/`, named `u<id>_<slug>`: `slugify`
|
||||
* never emits `_`, so a personal name can never equal a shared one, and the
|
||||
* name alone (stored in `dashboard_runs.proxy`) says whose it is. Who may see,
|
||||
* write or start a session on which is decided by the routes and by
|
||||
* `proxy-apply.resolveProxy`, from `ownerOfName`.
|
||||
*
|
||||
* `native` is a built-in profile rather than a branch in the caller: it has no
|
||||
* endpoint and no model of its own (the session's model picker decides), so
|
||||
* "use the vendor's own API" is a choice like any profile. It cannot be
|
||||
@@ -97,11 +105,25 @@ function proxiesDir() {
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A personal profile's name: `u<owner id>_<slug>`. */
|
||||
const PERSONAL_RE = /^u([1-9][0-9]{0,8})_[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** The user id a personal profile belongs to, or null for a shared one. */
|
||||
function ownerOfName(name) {
|
||||
const m = typeof name === "string" ? PERSONAL_RE.exec(name) : null;
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** Where one owner's profiles live: the top level is the shared store. */
|
||||
function storeDir(owner) {
|
||||
return owner == null ? proxiesDir() : path.join(proxiesDir(), "users", String(owner));
|
||||
}
|
||||
|
||||
function proxyFile(name) {
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(proxiesDir(), `${name}.json`);
|
||||
return path.join(storeDir(ownerOfName(name)), `${name}.json`);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
@@ -138,15 +160,17 @@ function slugify(text) {
|
||||
return NAME_RE.test(base) ? base : "proxy";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is neither taken nor built in. */
|
||||
function freeName(base) {
|
||||
let name = base;
|
||||
for (
|
||||
let n = 2;
|
||||
name === BUILTIN_NAME || fs.existsSync(path.join(proxiesDir(), `${name}.json`));
|
||||
) {
|
||||
name = `${base}-${n}`;
|
||||
n += 1;
|
||||
/**
|
||||
* `base`, or `base-2`, `base-3`… — the first that is neither taken nor built
|
||||
* in — prefixed `u<owner>_` for a personal profile (the slug shortened so the
|
||||
* whole name still fits NAME_RE's 40 characters).
|
||||
*/
|
||||
function freeName(base, owner = null) {
|
||||
const prefix = owner == null ? "" : `u${owner}_`;
|
||||
const stem = base.slice(0, 40 - prefix.length - 4).replace(/-+$/, "") || "proxy";
|
||||
let name = prefix + stem;
|
||||
for (let n = 2; name === BUILTIN_NAME || fs.existsSync(proxyFile(name)); n += 1) {
|
||||
name = `${prefix}${stem}-${n}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -243,35 +267,49 @@ function readProxy(name) {
|
||||
/** The browser-safe view: everything except the key, plus whether one is set. */
|
||||
function publicView(profile) {
|
||||
const { apiKey, ...rest } = profile;
|
||||
return { ...rest, hasKey: Boolean(apiKey) };
|
||||
return {
|
||||
...rest,
|
||||
scope: ownerOfName(profile.name) == null ? "shared" : "personal",
|
||||
hasKey: Boolean(apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
/** The profile names saved in one owner's store, sorted. */
|
||||
function namesIn(owner) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(storeDir(owner))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n) && n !== BUILTIN_NAME && ownerOfName(n) === owner)
|
||||
.sort();
|
||||
} catch {
|
||||
return []; // nothing saved yet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every saved profile, with the built-in native one first.
|
||||
* The profiles `viewer` may choose: the built-in native one first, then the
|
||||
* shared ones, then the viewer's own personal ones (none for `null` — local
|
||||
* mode, or a request with no user). Nobody lists another user's personal
|
||||
* profiles, an admin included.
|
||||
* @param {number|null} [viewer] user id
|
||||
* @returns `{ items }` — key material is never included.
|
||||
*/
|
||||
function listProxies() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(proxiesDir())
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
/* nothing saved yet */
|
||||
}
|
||||
|
||||
function listProxies(viewer = null) {
|
||||
const items = [publicView(NATIVE)];
|
||||
for (const name of names.sort()) {
|
||||
if (!NAME_RE.test(name) || name === BUILTIN_NAME) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
const owners = viewer == null ? [null] : [null, viewer];
|
||||
for (const owner of owners) {
|
||||
for (const name of namesIn(owner)) {
|
||||
let stored;
|
||||
try {
|
||||
stored = readProxy(name);
|
||||
} catch {
|
||||
continue; // a corrupt profile file is skipped, not fatal for the list
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
if (!stored) continue;
|
||||
items.push(publicView({ ...stored, name, builtin: false }));
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
@@ -290,9 +328,10 @@ function getProxy(name) {
|
||||
/**
|
||||
* @param {{label:string, baseUrl:string, apiKey?:string, model:string,
|
||||
* models?:Record<string,string>, behavesAs?:string}} input
|
||||
* @param {number|null} [owner] user id for a personal profile, null = shared
|
||||
* @returns the browser-safe view of what was saved
|
||||
*/
|
||||
function createProxy(input = {}) {
|
||||
function createProxy(input = {}, owner = null) {
|
||||
const label = normalizeLabel(input.label);
|
||||
const slug = slugify(label);
|
||||
assertNotBuiltin(slug);
|
||||
@@ -308,7 +347,7 @@ function createProxy(input = {}) {
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const name = freeName(slug);
|
||||
const name = freeName(slug, owner);
|
||||
writeJson(proxyFile(name), profile);
|
||||
return publicView({ ...profile, name, builtin: false });
|
||||
}
|
||||
@@ -376,6 +415,9 @@ async function probe(url, apiKey, headers, body, model) {
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
|
||||
// A redirect is an answer, never followed: fetch drops `authorization`
|
||||
// on a cross-origin hop but would carry `x-api-key` to the new host.
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (e) {
|
||||
// Unreachable, DNS failure, TLS refusal, timeout — a real answer about the
|
||||
@@ -461,4 +503,5 @@ module.exports = {
|
||||
MODEL_SLOTS,
|
||||
BUILTIN_NAME,
|
||||
NATIVE,
|
||||
ownerOfName,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user