diff --git a/CLAUDE.md b/CLAUDE.md index 4338b19b..387e332b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.4 (2026-10-15) — tmux-member (member tmux follows upgrades), Users → Members' tmux._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.5 (2026-10-15) — `server setup` installs S3 on Linux by default (`--no-system` opts out); `install-system` runs WITHOUT sudo; admin Trash = one folder per user._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -56,7 +56,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it. - **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route. (A session's own `claude --worktree` tree is a different thing with its own guarded route — see the session-worktree rule below; a lane's folder is never it.) - **A lane can hold up to ten tmux sessions (slots 0–9; the Workspace groups them into tabs of up to four split panes) — and only slot 0 is the lane's run.** `agenthub-lane-` is slot 0 and the only one `run_id`/`status` ever record; `agenthub-lane--<1-9>` are extra sessions on the same lane (`MAX_PANE_SLOT` in `server/lib/pty-run.js`; the `-[1-9]` run id pattern is repeated in `pty-attach.js`, `websocket.js`, `routes/hooks.js` and the client — widen them together). Anything that must stop a lane's agents (`stop`, `reset`, `remove`) goes through `laneRunIds` so no slot survives it; anything that names "the lane's pane" (`agenthub lanes shell`) means slot 0. A client that wants "a new tab" sends `slot: "auto"` and the SERVER picks the first free slot under the lane lock (`409 ERUNLIVE` when all ten are busy) — never a slot computed from the client's own list, which may already be stale. -- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `/trash//`), restorable 7 days. `write-as` is root-installed: a change to it needs `sudo agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`. +- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `/trash//`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `/trash//`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`. - **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo. - **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-` sessions. - **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `/artifacts//` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. diff --git a/bin/agenthub.js b/bin/agenthub.js index 2159b908..d51090f9 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -3934,21 +3934,73 @@ async function cmdServer(sub, flags) { } else { console.log(c.dim(" an admin already exists — add more with `agenthub users add`")); } + const wasServer = auth.isServerMode(); settingsEnv.writeSetting("AGENTHUB_MODE", "server"); envNote("AGENTHUB_MODE"); console.log(`${c.green("✔")} server mode on (${file})`); + // One Linux user per account (S3) is the default on Linux: install it now + // (one sudo), unless it is already in place or `--no-system` opts out. + const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js")); + const shared = flags["no-system"] === true || process.platform !== "linux"; + if (!shared) { + if (linux.serverConf()) { + console.log(c.dim(" one Linux user per account is already installed")); + // The system unit serves; it reads the mode only when it starts. + if (!wasServer) { + const r = spawnSync("sudo", [ + "-n", + "systemctl", + "restart", + "--no-block", + "agenthub.service", + ]); + console.log( + r.status === 0 + ? `${c.green("✔")} agenthub.service restarted` + : c.yellow("! restart it: sudo systemctl restart agenthub") + ); + } + } else { + console.log(" Installing one Linux user per account (asks for your sudo password)…"); + try { + await installSystem({}, settingsEnv); + } catch (e) { + throw new Error( + `${e.message}\n Server mode is on, but every pane still runs as this account.` + + "\n Fix it and run `agenthub server install-system`, or keep one shared" + + " account with `agenthub server setup --no-system`." + ); + } + } + // The admin made above (and anyone made before) gets their Linux account. + process.env.AGENTHUB_MODE = "server"; + await cmdUsers("provision", [], { all: true }); + } console.log(" Next:"); console.log( - " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)" + shared + ? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)" + : " 1. Log out and back in once, so your shell joins the agenthub groups" ); console.log( " 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" + " and run setup with --hostname — see docs/SERVER-MODE.md." ); - console.log( - c.dim(" Members see only their own workspaces, but every pane still runs as this") - ); - console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md).")); + if (shared) { + console.log( + c.yellow( + process.platform === "linux" + ? "! --no-system: every pane runs as this one Linux account" + : "! one Linux user per account needs Linux: every pane runs as this one account" + ) + ); + console.log( + c.dim(" Members see only their own workspaces, but an agent in a pane can read") + ); + console.log( + c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).") + ); + } console.log( c.dim(" Workspaces made before now have no owner: agenthub users assign ") ); @@ -3989,7 +4041,7 @@ async function cmdServer(sub, flags) { } console.error( c.red( - "✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status" + "✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status" ) ); process.exitCode = 1; diff --git a/client/src/components/lanes/FileExplorer.tsx b/client/src/components/lanes/FileExplorer.tsx index 0d62bc9a..6ed9bd0d 100644 --- a/client/src/components/lanes/FileExplorer.tsx +++ b/client/src/components/lanes/FileExplorer.tsx @@ -18,7 +18,12 @@ * or dropped from the desktop), streamed with progress, ≤ 500 MB a batch; * drag items onto a folder to move them (Ctrl: copy); download a file as * itself, anything else as one zip; a taken name asks Replace / Keep both / - * Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js). + * Skip. Deleting forever asks first when it empties the Trash or touches + * another user's. An admin's Trash opens on one folder per user (with its + * item count); each folder is THAT user's Trash, to restore from or purge + * without waiting the 7 days. + * Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*` + * (server/lib/file-explorer.js). * @author Nguyễn Ngọc Trí Vĩ */ @@ -82,6 +87,7 @@ import { import type { Lane } from "../../lib/types"; import { useDialog } from "../../hooks/useDialog"; import { toast } from "../../lib/toast"; +import { confirmDialog } from "../../lib/confirm"; import { formatBytes } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; @@ -250,6 +256,10 @@ export function FileBrowser({ const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1"); const [query, setQuery] = useState(null); const [trash, setTrash] = useState([]); + // An admin's Trash opens on its root: one folder per user's Trash, counted + // from `GET /api/homes/trash` (a Trash that could not be read: its error). + const [trashRoot, setTrashRoot] = useState(false); + const [trashErrors, setTrashErrors] = useState>({}); const [busy, setBusy] = useState(false); const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null); const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null); @@ -330,7 +340,12 @@ export function FileBrowser({ setError(null); setLoading(true); try { - if (view === "trash") setTrash((await api.files.trash(target)).items); + if (view === "trash" && trashRoot) { + const out = await api.files.allTrash(); + setTrash(out.items); + // A user whose trash could not be read is said so on their folder. + setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message]))); + } else if (view === "trash") setTrash((await api.files.trash(target)).items); else setListing(await api.files.list(target, path)); } catch (e) { // A dashboard that does not know /fs yet (not restarted) answers 404. @@ -338,10 +353,11 @@ export function FileBrowser({ (e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e) ); if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState(""); + if (view === "trash") setTrash([]); } finally { setLoading(false); } - }, [target, path, view, t]); + }, [target, path, view, trashRoot, t]); useEffect(() => { void load(); }, [load]); @@ -635,11 +651,70 @@ export function FileBrowser({ const homeName = (who: string) => who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who); + /** Delete forever — asked first when it is all of it or someone else's. */ + const purge = async (id: string) => { + if (id === "all" || ro) { + const item = trash.find((i) => i.id === id); + const ok = await confirmDialog({ + title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"), + message: + id === "all" + ? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel }) + : t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }), + confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"), + }); + if (!ok) return; + } + const item = trash.find((i) => i.id === id); + await run(() => api.files.purge(target, id, item?.store), t("fx.purged")); + }; + const restoreItem = (id: string) => { + const item = trash.find((i) => i.id === id); + void run(() => api.files.restore(target, id, item?.store), t("fx.restored")); + }; + /** + * Open a Trash. Every user has ONE recycle bin — their home's, over every + * place they deleted in — so `home` opens that user's ("me": the caller's); + * null (no home to open) falls back to the trash of the place shown, and + * "*" opens the root an admin starts on: one folder per user's Trash. + */ + const openTrash = (home: string | null) => { + if (home === "*") { + setTrashRoot(true); + setView("trash"); + setSelected(new Set()); + return; + } + setTrashRoot(false); + if (home && !(isHomePlace(target) && target.home === home)) { + setTarget({ home }); + setPathState(""); + setHistory({ back: [], fwd: [] }); + onNavigate?.({ home }, ""); + } + setView("trash"); + setSelected(new Set()); + }; + // Someone else's Trash can be opened: the Trash starts on its folders. + const manyTrashes = homes.some((h) => h.id !== "me"); + const openTrashes = () => + openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null); const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!)); const laneName = (id: number) => { const l = lanes.find((x) => x.id === id); return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`; }; + // A home's Trash is its USER's recycle bin, named after them: another + // user's shows THAT user's (an admin may restore from or purge it; the rest + // of the home stays read-only). A workspace's (no home to open) is the + // trash of whoever owns its folder. + const trashLabel = trashRoot + ? t("fx.trash") + : !isHomePlace(target) + ? t("fx.trashAt", { name: laneName(laneOf(target)!) }) + : target.home === "me" + ? t("fx.myTrash") + : t("fx.trashOf", { name: homeName(target.home) }); const crumbs = path ? path.split("/") : []; const menuItem = ctx.menu?.item ?? null; const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : []; @@ -748,17 +823,10 @@ export function FileBrowser({ ))} -
-
@@ -774,8 +842,8 @@ export function FileBrowser({ openTrash("*") : goUp} > @@ -805,9 +873,29 @@ export function FileBrowser({ aria-label={t("fx.location")} className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5" > - {view === "trash" ? ( + {view === "trash" && manyTrashes ? ( + <> + + {!trashRoot && ( + + + + {isHomePlace(target) ? homeName(target.home) : trashLabel} + + + )} + + ) : view === "trash" ? ( - {t("fx.trash")} + {trashLabel} ) : ( <> @@ -1169,12 +1257,23 @@ export function FileBrowser({ )} + ) : trashRoot ? ( + ) : ( void run(() => api.files.restore(target, id), t("fx.restored"))} - onPurge={(id) => void run(() => api.files.purge(target, id))} + onRestore={restoreItem} + onPurge={(id) => void purge(id)} /> )} @@ -1651,13 +1750,102 @@ function InlineName({ ); } +/** + * An admin's Trash root: one folder per user's Trash (theirs first), each + * with its item count — or why it could not be read. Opening one shows that + * user's Trash, where its items are restored or deleted forever. + */ +function TrashFolders({ + homes, + items, + errors, + error, + loading, + homeName, + onOpen, +}: { + homes: FsHome[]; + /** Every Trash's items, merged, each tagged with its `home`. */ + items: FsTrashItem[]; + /** Per home id: why its Trash could not be read. */ + errors: Record; + error: string | null; + loading: boolean; + homeName: (id: string) => string; + onOpen: (home: string) => void; +}) { + const { t } = useTranslation(["lanes"]); + const counts = new Map(); + for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1); + const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")]; + return ( +
+
+ {t("fx.trashFoldersHint")} +
+ {error && ( +
+ {error} +
+ )} +
+ {ordered.map((h) => { + const n = counts.get(h.id) ?? 0; + const failed = errors[h.id]; + const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id); + return ( + + ); + })} +
+
+ ); +} + function TrashView({ items, + error, laneName, onRestore, onPurge, }: { items: FsTrashItem[]; + error: string | null; laneName: (id: number) => string; onRestore: (id: string) => void; onPurge: (id: string) => void; @@ -1665,6 +1853,11 @@ function TrashView({ const { t } = useTranslation(["lanes"]); return (
+ {error && ( +
+ {error} +
+ )}
{t("fx.trashHint")}