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
@@ -0,0 +1,256 @@
/**
* @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only
* for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const prefsMock = vi.fn();
const saveMock = vi.fn();
const onboardedMock = vi.fn();
const lanesMock = vi.fn();
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
me: {
prefs: () => prefsMock(),
savePrefs: (p: unknown) => saveMock(p),
onboarded: () => onboardedMock(),
},
lanes: { list: () => lanesMock() },
aiTools: {
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
list: async () => ({
default: "claude",
catalog: [],
tools: [
{
id: "claude",
label: "Claude Code",
command: "claude",
installed: true,
enabled: true,
},
{ id: "codex", label: "Codex", command: "codex", installed: true, enabled: true },
{ id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true },
],
}),
},
},
};
});
vi.mock("../../settings/PetSettings", () => ({
PetSettings: () => <div data-testid="pet-settings" />,
}));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools";
beforeEach(() => {
localStorage.clear();
__resetUserPrefs();
__resetAiTools();
prefsMock.mockReset();
saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null });
onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" });
lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} });
});
describe("account preferences", () => {
it("loads the account's values, sends up this browser's, then every change", async () => {
vi.useFakeTimers();
try {
localStorage.setItem("agenthub.terminal.font", "maple");
localStorage.setItem("theme", "dark");
prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null });
await hydrate();
expect(localStorage.getItem("theme")).toBe("light");
await vi.advanceTimersByTimeAsync(10);
expect(saveMock).toHaveBeenCalledWith(
expect.objectContaining({ "agenthub.terminal.font": "maple" })
);
saveMock.mockClear();
localStorage.setItem("agenthub.theme.dark", "darcula");
localStorage.setItem("unrelated.key", "x");
localStorage.removeItem("agenthub.pet");
await vi.advanceTimersByTimeAsync(500);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(saveMock).toHaveBeenCalledWith({
"agenthub.theme.dark": "darcula",
"agenthub.pet": null,
});
} finally {
vi.useRealTimers();
}
});
it("never keeps bypass as the starting permission mode", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto");
writeDefaultMode("bypassPermissions" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan");
});
});
function Probe() {
const s = useOnboarding();
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
}
const renderWizard = () =>
render(
<MemoryRouter>
<OnboardingWizard />
<Probe />
</MemoryRouter>
);
describe("the setup wizard", () => {
it("does not show before the account's preferences load, nor once done", async () => {
renderWizard();
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" });
await act(() => hydrate());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
});
it("skips itself for an account that already has a workspace", async () => {
lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} });
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
});
it("walks four steps, saving each pick, and ends in the tour", async () => {
const user = userEvent.setup();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await screen.findByTestId("onboarding-wizard");
expect(screen.getByText("Pick your default agent")).toBeInTheDocument();
await user.click(await screen.findByTestId("onboarding-agent-codex"));
expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex");
// A tool that is not installed is not offered until asked for, and never pickable.
expect(screen.queryByText("Kimi Code")).toBeNull();
await user.click(screen.getByText(/Show 1 more agents/));
expect(screen.getByText("Kimi Code").closest("button")).toBeNull();
await user.click(screen.getByRole("radio", { name: "Plan" }));
expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan");
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByText("Make it feel like home")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-mode-light"));
expect(localStorage.getItem("theme")).toBe("light");
await user.click(screen.getByRole("button", { name: "Light theme: Canvas" }));
expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas");
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByTestId("pet-settings")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-next"));
expect(screen.getByText("Set up notifications")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Add your first workspace/ }));
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
expect(localStorage.getItem("agenthub.tour")).toBe("add");
});
it("is skipped for good with Skip setup", async () => {
const user = userEvent.setup();
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
renderWizard();
await act(() => hydrate());
await user.click(await screen.findByRole("button", { name: "Skip setup" }));
await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no"));
expect(onboardedMock).toHaveBeenCalled();
expect(localStorage.getItem("agenthub.tour")).toBeNull();
});
});
describe("the tour", () => {
const mount = (html: string) => {
const host = document.createElement("div");
host.innerHTML = html;
document.body.appendChild(host);
return host;
};
// jsdom has no layout: give the targets a box.
const boxed = () =>
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
x: 10,
y: 10,
left: 10,
top: 10,
width: 100,
height: 30,
right: 110,
bottom: 40,
toJSON: () => ({}),
} as DOMRect);
it("follows the real UI: add → folder → create → prompt", async () => {
const spy = boxed();
const user = userEvent.setup();
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
try {
startTour();
render(
<MemoryRouter initialEntries={["/workspace"]}>
<OnboardingTour />
</MemoryRouter>
);
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
// The dialog opened: the tour moves on by itself.
const dialog = mount(
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
);
expect(await screen.findByText("Choose the project folder")).toBeInTheDocument();
await user.click(screen.getByTestId("onboarding-tour-next"));
expect(screen.getByText("Create it")).toBeInTheDocument();
// Created: the dialog is gone and the workspace's prompt box is there.
dialog.remove();
page.innerHTML = '<div data-testid="workspace-home-composer"></div>';
expect(await screen.findByText("Start your first session")).toBeInTheDocument();
expect(localStorage.getItem("agenthub.tour")).toBe("prompt");
await user.click(screen.getByRole("button", { name: "Skip tour" }));
expect(screen.queryByTestId("onboarding-tour")).toBeNull();
expect(localStorage.getItem("agenthub.tour")).toBe("done");
} finally {
spy.mockRestore();
page.remove();
}
});
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
const spy = boxed();
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
try {
localStorage.setItem("agenthub.tour", "create");
render(
<MemoryRouter initialEntries={["/workspace"]}>
<OnboardingTour />
</MemoryRouter>
);
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
} finally {
spy.mockRestore();
page.remove();
}
});
});