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:
2026-10-09 14:57:10 +07:00
parent b7f3735b29
commit 84e2a9f0ac
123 changed files with 12051 additions and 605 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* @file userPrefs.ts
* @description An account's UI preferences, kept per ACCOUNT on the server
* (`/api/me/prefs`, docs/plans/onboarding) while every component goes on
* reading and writing localStorage as it always did. On sign-in `hydrate()`
* copies the account's stored values into localStorage (the account wins),
* sends up the ones this browser has and the account does not yet, and makes
* the theme, language and pet re-read them. From then on a write to one of
* `PREF_KEYS` through `localStorage.setItem`/`removeItem` is sent to the
* server, batched. `useOnboarding()` tells the setup wizard whether this
* account has been through it. Never throws: a server that cannot answer
* leaves the browser's own values in charge. The same row also holds
* `agenthub.chatUi`, which `lib/chatUi.ts` reads and writes on the server
* directly (no localStorage copy).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import { api } from "./api";
import i18n from "../i18n";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [
"theme",
"agenthub.theme.dark",
"agenthub.theme.light",
"i18nextLng",
"agenthub.terminal.font",
"agenthub.pet",
"agenthub.pet.size",
"agent-dashboard-sagi-enabled",
"agent-dashboard-sagi-muted",
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.tour",
] as const;
const SYNCED = new Set<string>(PREF_KEYS);
const PUSH_DELAY_MS = 400;
// `forced`: Settings asked for the setup again, whatever the account has.
type State = { loaded: boolean; onboardedAt: string | null; forced: boolean };
let state: State = { loaded: false, onboardedAt: null, forced: false };
const listeners = new Set<() => void>();
const setState = (next: Partial<State>) => {
state = { ...state, ...next };
listeners.forEach((l) => l());
};
let pending: Record<string, string | null> = {};
let timer: ReturnType<typeof setTimeout> | undefined;
let syncing = false;
function queue(key: string, value: string | null) {
if (!syncing) return;
pending[key] = value;
clearTimeout(timer);
timer = setTimeout(flush, PUSH_DELAY_MS);
}
/** Send what is queued now (also on page hide). */
export function flush(): Promise<void> {
clearTimeout(timer);
const batch = pending;
pending = {};
if (!Object.keys(batch).length) return Promise.resolve();
return api.me
.savePrefs(batch)
.then(() => undefined)
.catch(() => {
/* offline or signed out: the browser keeps its own copy */
});
}
let wrapped = false;
function wrapStorage() {
if (wrapped || typeof Storage === "undefined") return;
wrapped = true;
const proto = Storage.prototype;
const setItem = proto.setItem;
const removeItem = proto.removeItem;
proto.setItem = function (key: string, value: string) {
setItem.call(this, key, value);
if (this === window.localStorage && SYNCED.has(key)) queue(key, String(value));
};
proto.removeItem = function (key: string) {
removeItem.call(this, key);
if (this === window.localStorage && SYNCED.has(key)) queue(key, null);
};
window.addEventListener("pagehide", () => void flush());
}
function readLocal(key: string): string | null {
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
/** Make what reads these keys read them again. */
function reapply(prefs: Record<string, string>) {
window.dispatchEvent(new Event("agenthub-theme"));
window.dispatchEvent(new Event("sagi:prefs"));
const lng = prefs.i18nextLng;
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
}
/**
* Load the signed-in account's preferences into this browser and start
* sending changes. Call once a login (or local mode) is known.
*/
export async function hydrate(): Promise<void> {
syncing = false;
pending = {};
let answer;
try {
answer = await api.me.prefs();
} catch {
// No answer (an older server, a test with no API): no wizard, no sync —
// the browser's own values stay in charge.
return;
}
const up: Record<string, string> = {};
for (const key of PREF_KEYS) {
const theirs = answer.prefs[key];
if (typeof theirs === "string") {
try {
window.localStorage.setItem(key, theirs);
} catch {
/* storage blocked */
}
} else {
const mine = readLocal(key);
if (mine !== null) up[key] = mine;
}
}
wrapStorage();
syncing = true;
reapply(answer.prefs);
if (Object.keys(up).length) {
pending = { ...up };
void flush();
}
setState({ loaded: true, onboardedAt: answer.onboardedAt });
}
/** The first-run setup is done or skipped, for this account. */
export async function markOnboarded(): Promise<void> {
await flush();
const at = new Date().toISOString();
setState({ onboardedAt: at, forced: false });
try {
const r = await api.me.onboarded();
setState({ onboardedAt: r.onboardedAt || at });
} catch {
/* shown again next time — better than never saving the choices */
}
}
/** Show the setup again (Settings → System). */
export function resetOnboarding(): void {
setState({ onboardedAt: null, forced: true });
}
const subscribe = (l: () => void) => {
listeners.add(l);
return () => listeners.delete(l);
};
/** `{loaded, onboardedAt, forced}` for the account signed in now. */
export function useOnboarding(): State {
return useSyncExternalStore(subscribe, () => state);
}
/** Tests only. */
export function __resetUserPrefs(): void {
state = { loaded: false, onboardedAt: null, forced: false };
pending = {};
syncing = false;
clearTimeout(timer);
}