chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password

- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as
  the host on loopback; a member's omp pane reaches it as a scoped client keyed
  by its pane token (clients file = live omp panes only), runs on
  --model agenthub/<model>, never holds a credential.
- omp catalog: --allow-home, so a workspace at the account's home keeps its
  sessions there (they were recorded under a temp dir and dropped).
- Settings → Appearance → Open Markdown as: Preview (default) or Raw, for
  Files/Changes, the config viewer and a chat's file panel.
- First-login password change and password rules, avatar URLs, Artifacts
  "Mine" includes installed shared skills/plugins, a member's own hook pages.
This commit is contained in:
2026-10-22 10:04:30 +07:00
parent 8a6dc6a29e
commit 750d74a2af
66 changed files with 3021 additions and 696 deletions
+9 -6
View File
@@ -85,9 +85,9 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
@@ -104,10 +104,11 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none |
| `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `<data dir>/avatars/u<id>.<ext>`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited |
| `POST /api/me/avatar/url` (`{url}`) | the same picture, downloaded by the SERVER from an `https://` link (no userinfo; every resolved address — an IP literal too — must be public, never loopback/private/link-local/CGNAT/reserved, and the connection is pinned to the vetted address; ≤ 3 redirects, each re-checked; 10 s timeout; ≤ 1 MB read; no cookie sent), then checked like an upload (`server/lib/avatar-url.js`). `400 EBADURL`/`EPRIVATEURL`/`EBADIMAGE`, `413 ETOOBIG`, `502 EFETCH`, `403 ELOCALMODE`. Login cookie only, audited |
| `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too |
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
@@ -2918,6 +2919,8 @@ Every history row carries `lane_id` (the workspace whose run it belongs to) and
GET /api/ai-tools Catalog + detection — { tools: [...], default }
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true, defaultModel?: string|null } — same body
GET /api/ai-tools/omp-sharing { available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?} } — S3 only, never a token
PUT /api/ai-tools/omp-sharing Body: { enabled: boolean } — an admin's; 409 ENOS3 without per-user accounts; audited
GET /api/ai-tools/:id/models { flag, models: [{ id, label, hint? }], defaultModel } — the tool's own list
GET /api/ai-tools/:id/usage { sessions, cost } — sessions with tool = id, their tokens priced
GET /api/ai-tools/:id/config { files: [{ index, path, format: "json"|"jsonc"|"text", exists, size }] }
@@ -3056,16 +3059,16 @@ GET /api/artifacts/:id/installs {items: [{user, mine, at, replace
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner.
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned.
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page shows **Uninstall** (card icon, ⋯ menu, detail sheet) beside **Installed**.
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
### Query Explorer
+1 -1
View File
@@ -625,7 +625,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns.
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns, and each admin account change; rows older than `AGENTHUB_AUDIT_DAYS` (default 180) are pruned as new ones are written. `users.last_login_at` is the account's last login (kept past its `auth_sessions` row); `users.must_change_password` (0/1) is set when an admin gives the password (create, reset) and cleared when the account picks its own.
`dashboard_runs.pane_token_hash` is the SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a server-mode pane was started with; it is never returned by any route. `push_subscriptions.user_id` is the user whose browser a subscription is (`NULL` in local mode).
+4
View File
@@ -636,6 +636,10 @@ reads the workspace's git surface in five modes:
A workspace folder that is **not a git repo** still gets the tree and
quick-open, read off the disk (no colours; `.git` hidden, `node_modules`
listed but never entered, symlinks never followed, at most 20 000 entries).
A whole `.md` file opens rendered, with a **Raw**/**Preview** button for
that file; Settings → Appearance → **Open Markdown as** (`agenthub.markdown.view`,
`client/src/lib/markdownView.ts`, synced to the account) makes Raw the
default instead — here, in the config file viewer and in a chat's file panel.
**Download** on a file's right-click menu, or in an open file's header,
saves that file (`/git/download`, any type, up to 512 MB); the **Upload**
button beside the finder uploads files to the workspace root and **Upload
+69 -5
View File
@@ -155,6 +155,10 @@ Workspaces made before server mode, or by the host account, have none.
- An **admin** or **analyst** sees everything. Each time one of them opens a
session a developer owns (its page, transcript, events or cost), the read is
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
Entries are kept `AGENTHUB_AUDIT_DAYS` days (default 180; `0` keeps them
all) — older ones are deleted as new ones are written, at most once an hour.
An account's last login is kept in `users.last_login_at`, so it still shows
after the login itself has ended.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days — the
**Administration** page in the sidebar (`/admin`; the old `/users` link
@@ -264,7 +268,22 @@ settings file that lacks the variable falls back to `USERS_ROOT` in
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
change and disabling the user each end it.
- Passwords are stored as scrypt hashes; any non-empty password is accepted (no minimum length).
- Passwords are stored as scrypt hashes. Two bars:
- A password an admin **gives** someone — **Add user**, a reset on the Edit
form, `agenthub users add` — may be anything non-empty (`123` included).
The account is then flagged `must_change_password`: after signing in it
sees only a "Choose your own password" page, the server answers every
other request with `403 EPWCHANGE` (only `/auth/*`, the `/me` reads and
`POST /api/me/password` pass) and no WebSocket opens. A pane token is not
held back — agents started before a reset keep running.
- A password an account **picks for itself** (that page, Settings → Account,
an admin's own on the Edit form) must pass every rule: at least 8
characters, a lower-case and an upper-case letter, a digit, a symbol, not
containing the username, and not the current password (`400
EWEAKPASSWORD`, with the failed `rules`). The form ticks each rule as it is
typed (`client/src/lib/passwordRules.ts` mirrors `passwordProblems` in
`server/lib/auth.js`). `agenthub users passwd` and the first admin of
`agenthub server setup` set no flag.
Every login attempt counts before its password is checked, so attempts sent
in parallel count too. After 10 failed logins from one address, or 30 for one
username, in 15 minutes, further logins answer `429 ETOOMANY` until the
@@ -307,7 +326,10 @@ everywhere". Every field is checked before any is written, so a bad email or
a workspace the user does not own changes nothing. Every change asks for the admin's OWN
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
themselves (`ESELF`), and each change is written to the audit log. In local
themselves (`ESELF`), no path (the CLI included) may demote, disable or
delete the last enabled admin (`ELASTADMIN`), and each change is written to
the audit log. A row says **New password due** while the account still has a
password an admin gave. In local
mode these routes refuse (`ELOCALMODE`): there is no login to check.
Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
@@ -335,10 +357,10 @@ The same from the host's shell:
```bash
agenthub users list
agenthub users add <name> [--role admin|analyst|developer]
agenthub users add <name> [--role admin|analyst|developer] # they choose their own password at first login
agenthub users passwd <name> # ends that user's logins
agenthub users role <name> <role>
agenthub users disable <name> # ends that user's logins; users are never deleted
agenthub users role <name> <role> # never the last enabled admin (ELASTADMIN)
agenthub users disable <name> # ends that user's logins; never the last enabled admin
agenthub users enable <name>
agenthub users assign <workspace id> <name> # give a pre-server-mode workspace an owner
agenthub users audit [--limit N] # who read which developer's session
@@ -507,6 +529,48 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
> `auth.json`, like the Claude pool's token.
## Oh My Pi login sharing
With one Linux user per account, a member's omp has its own `~/.omp` and no
login. An admin can let every member's omp run on the HOST account's omp
logins instead: **Settings → AI tools → Oh My Pi login sharing** (off by
default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
broker and gateway (`server/lib/omp-share.js`):
```bash
# as the host account: the logins to share (omp's built-in providers only)
omp login # e.g. anthropic
```
- While it is on, the dashboard runs, as the host and on loopback only,
`omp auth-broker serve --bind=127.0.0.1:4826` over the host's `~/.omp` store
and `omp auth-gateway serve --bind=127.0.0.1:4827 --clients=<data dir>/omp-share/clients.json`
(ports: `AGENTHUB_OMP_BROKER_PORT`, `AGENTHUB_OMP_GATEWAY_PORT`; logs in
`<data dir>/omp-share/`). Both tokens stay in the host's 0700 home; the
broker's reaches only the gateway's env. A crashed daemon restarts with a
back-off; the server stops both on shutdown, and kills ones a previous
server left (by pid file and argv) before starting its own.
- A member's omp pane gets one provider `agenthub` in its
`~/.omp/agent/models.yml` (written as the member; the rest of the file is
kept, a file that is not a YAML mapping is left alone), pointing at the
gateway with `apiKey: AGENTHUB_PANE_TOKEN` — the NAME of the env var holding
its pane token — and `OMP_SKIP_SETUP=1`. It runs on `--model agenthub/<model>`:
the model picked in AgentHub, else the host's own `modelRoles.default`
(`~/.omp/agent/config.yml`) — left to itself omp would take the provider's
first listed model, a retired one the API answers 404 for. The clients file lists the SHA-256
of each LIVE omp pane's token (rewritten every 15 s; the gateway re-reads it
every 10 s), so a token stops working shortly after its pane ends, and
turning sharing off empties it at once.
- A scoped client token calls models only: it cannot read a credential, the
gateway's usage or credential health, and refresh tokens never leave the
broker. Host panes use the host's login directly and get nothing new.
- A custom provider in the host's own `models.yml` (a 9Router, say) is not
shared — the gateway serves omp's built-in providers.
> **Ceiling:** an agent can SPEND through the shared logins while its pane
> runs; it cannot carry a login away. A member who edits the `agenthub`
> provider out of their `models.yml` gets it back at their next omp pane.
## One Linux user per account
Every pane of a user, an agent's shell included, runs as that user's own Linux