Files
AgentHub/server/lib/member-claude.js
T
nntrivi2001 84e2a9f0ac 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>
2026-10-09 14:57:10 +07:00

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 };