84e2a9f0ac
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>
114 lines
4.4 KiB
JavaScript
114 lines
4.4 KiB
JavaScript
/**
|
|
* @file member-claude.js
|
|
* @description Server mode, one Linux user per account (S3): what AgentHub
|
|
* keeps in step inside a MEMBER's own Claude Code config.
|
|
*
|
|
* - `setTheme`: the `theme` in `~/.claude/settings.json`, following the
|
|
* dark/light mode the member picked in AgentHub. Read through run-as's
|
|
* guarded reader (the file must be theirs) and written AS the member
|
|
* (`write-as`, merged, never replacing their other settings). A file that
|
|
* is not JSON is left alone.
|
|
* - `applyPluginBaseline` (P6 fallback, opt-in): when the admin keeps a
|
|
* `plugin-baseline.json` beside `server.conf` (`/etc/agenthub/`, root's),
|
|
* runs `claude plugin marketplace add <source>` for each of its
|
|
* `marketplaces`, then `claude plugin install <name@marketplace>` for each
|
|
* of its `plugins`, AS the member (`sudo -u`, the shared `claude`), in the
|
|
* background. No file, nothing runs. An entry that could be read as an
|
|
* option is skipped, and `-y` is never passed, so a plugin that installs by
|
|
* running a marketplace-declared command fails rather than runs unseen.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { promisify } = require("node:util");
|
|
const runAs = require("./run-as");
|
|
const linux = require("./linux-accounts");
|
|
|
|
function setTheme(acct, theme) {
|
|
const dir = path.join(acct.home, ".claude");
|
|
const file = path.join(dir, "settings.json");
|
|
let settings = {};
|
|
try {
|
|
settings = JSON.parse(runAs.readFileSafe(file, "utf8", { max: 1 << 20 }));
|
|
} catch (err) {
|
|
if (err.code !== "ENOENT") return false; // theirs to fix, not ours to overwrite
|
|
runAs.mkdirAs(acct, dir);
|
|
}
|
|
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return false;
|
|
if (settings.theme === theme) return true;
|
|
settings.theme = theme;
|
|
runAs.writeFileAs(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
return true;
|
|
}
|
|
|
|
// owner/repo, a git or https URL, an absolute path — never a leading `-`.
|
|
const MARKETPLACE_RE = /^[A-Za-z0-9/][^\s]{0,499}$/;
|
|
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
|
|
const baselinePath = () => path.join(path.dirname(linux.confPath()), "plugin-baseline.json");
|
|
|
|
// Test seam: async, a marketplace add is a git clone.
|
|
let execImpl = promisify(require("node:child_process").execFile);
|
|
function __setExecImpl(fn) {
|
|
execImpl = fn;
|
|
}
|
|
|
|
/**
|
|
* Install the admin's plugin baseline for `username`. Resolves to null when
|
|
* there is nothing to do (outside S3, no baseline file, empty lists), else to
|
|
* `[{item, ok, error?}]`, one per entry, in order. Throws on a baseline file
|
|
* that is not JSON, or ENOTSHARED when `claude` is not in the shared tools.
|
|
*/
|
|
async function applyPluginBaseline(username) {
|
|
const acct = linux.accountFor(username);
|
|
if (!acct) return null;
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(baselinePath(), "utf8");
|
|
} catch {
|
|
return null; // opt-in: no file, no baseline
|
|
}
|
|
const base = JSON.parse(raw);
|
|
const list = (v) => (Array.isArray(v) ? v.map(String) : []);
|
|
const steps = [
|
|
...list(base?.marketplaces).map((s) => [
|
|
s,
|
|
MARKETPLACE_RE,
|
|
["plugin", "marketplace", "add", s],
|
|
]),
|
|
...list(base?.plugins).map((p) => [p, PLUGIN_RE, ["plugin", "install", "--scope", "user", p]]),
|
|
];
|
|
if (!steps.length) return null;
|
|
const claude = require("./shared-tools").sharedPath("claude");
|
|
if (!claude) {
|
|
throw Object.assign(new Error("claude is not in the shared tools yet"), { code: "ENOTSHARED" });
|
|
}
|
|
const env = { HOME: acct.home, DISABLE_AUTOUPDATER: "1" };
|
|
const results = [];
|
|
for (const [item, re, args] of steps) {
|
|
if (!re.test(item)) {
|
|
results.push({ item, ok: false, error: "not a valid entry" });
|
|
continue;
|
|
}
|
|
const [cmd, argv] = runAs.sudoArgv(acct, claude, args, env);
|
|
try {
|
|
await execImpl(cmd, argv, { cwd: acct.home, timeout: 300_000, encoding: "utf8" });
|
|
results.push({ item, ok: true });
|
|
} catch (e) {
|
|
const why = String(e.stderr || e.message || "")
|
|
.trim()
|
|
.split("\n")
|
|
.pop();
|
|
results.push({ item, ok: false, error: why });
|
|
}
|
|
}
|
|
for (const r of results) {
|
|
const line = `[plugin-baseline] ${acct.login}: ${r.item} ${r.ok ? "ok" : `failed: ${r.error}`}`;
|
|
(r.ok ? console.log : console.warn)(line);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
module.exports = { setTheme, applyPluginBaseline, __setExecImpl };
|