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
+12 -2
View File
@@ -29,6 +29,7 @@ import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { readDefaultTool } from "../../lib/sessionDefaults";
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
@@ -111,7 +112,14 @@ export function AddLaneModal({
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
const [pickedTool, setPickedTool] = useState<string | null>(null);
const aiTools = useAiTools();
const defaultTool = aiTools.defaultId ?? "claude";
// The account's own pick (first-run setup), while it is installed and on.
const ownTool = readDefaultTool();
const defaultTool =
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
? ownTool
: null) ??
aiTools.defaultId ??
"claude";
const tool = pickedTool ?? defaultTool;
// Installed + enabled, the default first.
const toolChoices = aiTools.tools
@@ -355,6 +363,7 @@ export function AddLaneModal({
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-tour="add-lane"
>
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
@@ -449,7 +458,7 @@ export function AddLaneModal({
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5">
<div className="flex gap-1.5" data-tour="add-lane-folder">
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
@@ -695,6 +704,7 @@ export function AddLaneModal({
type="button"
onClick={done ? handleCancel : submit}
disabled={busy || disabled}
data-tour="add-lane-create"
className="btn-primary text-xs disabled:opacity-50"
>
{done ? t("addLaneSetupClose") : t("add")}
@@ -173,6 +173,7 @@ export function SessionReplay({
rows={2}
slash={tool === "claude"}
fileCwd={session?.cwd || lane.cwd}
lane={lane.id}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
@@ -85,6 +85,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { PathPickButton } from "../PathPickButton";
import { readDefaultMode } from "../../lib/sessionDefaults";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -173,7 +174,8 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
const [mode, setMode] = useState<PermissionMode>("auto");
// The account's own default (first-run setup) — never bypass.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
@@ -529,6 +531,7 @@ function HomeBody({
rows={3}
slash={claude}
fileCwd={lane.cwd}
lane={lane.id}
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}