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:
+27
-16
@@ -555,6 +555,8 @@ export type AuditEntry = {
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
||||
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
* components mounting, a WS event fanning out to many listeners) share one
|
||||
@@ -675,13 +677,15 @@ export const api = {
|
||||
/** POST /api/auth/logout — ends this login. */
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── The account's own UI prefs (routes/me.js) ─────────────────────────
|
||||
// ───────────────────────── The account's own preferences (routes/me.js) ─────────────────────────
|
||||
me: {
|
||||
/** GET /api/me/prefs — this account's prefs (`local` in local mode). */
|
||||
prefs: () => request<MePrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merges `prefs` (a string per key, null removes it). */
|
||||
/** GET /api/me/prefs — this account's preferences and first-run flag. */
|
||||
prefs: () => request<UserPrefs>("/me/prefs"),
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<MePrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -2448,13 +2452,19 @@ export const api = {
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
|
||||
* name so the agent sees what it is. */
|
||||
dropFile: (file: File) =>
|
||||
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
|
||||
* name so the agent sees what it is. `lane` (a workspace id, or a pane's
|
||||
* run id) saves it where THAT workspace's panes can read it. */
|
||||
dropFile: (file: File, lane?: number | string | null) => {
|
||||
const laneId =
|
||||
typeof lane === "string" ? /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(lane)?.[1] : lane;
|
||||
const qs = new URLSearchParams({ name: file.name });
|
||||
if (laneId != null) qs.set("laneId", String(laneId));
|
||||
return request<{ path: string }>(`/run/drop-file?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
});
|
||||
},
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -3600,6 +3610,10 @@ export interface AiProxy {
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** "shared": the host's (an admin's, in server mode), usable by everyone;
|
||||
* "personal": the signed-in user's own, named `u<id>_<slug>` — server mode
|
||||
* only, and nobody else lists it. */
|
||||
scope?: "shared" | "personal";
|
||||
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
@@ -3651,6 +3665,9 @@ export interface AiProxyInput {
|
||||
models?: Partial<AiProxyModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
/** Server mode, on create: an admin's profile is shared unless "personal";
|
||||
* a member's is always personal. Ignored in local mode. */
|
||||
scope?: "shared" | "personal";
|
||||
}
|
||||
|
||||
/** One markdown-based config artifact (skill/agent/command/output-style),
|
||||
@@ -4312,12 +4329,6 @@ export interface RunStartArgs {
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
* name (`agenthub-lane-<laneId>`). */
|
||||
/** One account's own UI preferences (server/routes/me.js): known keys → string values. */
|
||||
export interface MePrefs {
|
||||
prefs: Record<string, string>;
|
||||
onboardedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
@@ -4528,7 +4539,7 @@ export interface RemoteSourceInput {
|
||||
* write, which it never touches. */
|
||||
export interface AutostartStatus {
|
||||
available: boolean;
|
||||
kind: "systemd" | "launchd";
|
||||
kind: "systemd" | "systemd-system" | "launchd";
|
||||
enabled: boolean;
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file sessionDefaults.ts
|
||||
* @description The account's own starting choices for new work, picked in the
|
||||
* first-run setup (docs/plans/onboarding) and synced per account like every
|
||||
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
|
||||
* the permission mode a new Claude session starts in. Bypass is NEVER one of
|
||||
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
|
||||
* is refused on read and on write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from "./api";
|
||||
|
||||
const TOOL_KEY = "agenthub.defaultTool";
|
||||
const MODE_KEY = "agenthub.permissionMode";
|
||||
|
||||
/** The permission modes a default may be. */
|
||||
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
|
||||
export type DefaultMode = (typeof DEFAULT_MODES)[number];
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function write(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}
|
||||
|
||||
/** The account's own default AI tool id, or null (the install's default applies). */
|
||||
export const readDefaultTool = (): string | null => read(TOOL_KEY);
|
||||
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
|
||||
|
||||
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
|
||||
export function readDefaultMode(): PermissionMode {
|
||||
const v = read(MODE_KEY);
|
||||
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
|
||||
}
|
||||
export function writeDefaultMode(mode: DefaultMode): void {
|
||||
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user