Compare commits

..

18 Commits

Author SHA1 Message Date
nntrivi2001 efe21c79e9 chore: v2.8.43 — terminal glyphs stay sharp in every pane; a huge diff line no longer hides the rest 2026-10-30 10:34:51 +07:00
nntrivi2001 ee119fe437 fix(terminal): restamp every glyph-atlas page so no pane keeps a stale texture
v2.8.42 reacted to a page merge by toggling the font family on the next
frame, so each renderer re-acquired an atlas. Panes still showed squeezed,
overlapping glyphs: the toggle lands a frame late, swaps every pane onto a
fresh atlas (or onto one another pane already toggled to), and a page added
without a merge shifts nothing yet still raced it.

addon-webgl 0.20 fixes the root cause with globally monotonic page versions.
Do the same on 0.19: whenever the shared atlas adds, removes or merges a page,
give every page a version no texture slot has uploaded, synchronously, so
every renderer on that atlas re-uploads all pages before it draws again.
When the page comes back from the background, restamp and repaint. An addon
of another shape keeps the old font-toggle fallback.
2026-10-30 10:33:57 +07:00
nntrivi2001 d97c403aab fix(changes): a huge line no longer cuts the diff short; preview pages up to 64 MB
A page that inlines a video as base64 has rows of megabytes. Its patch was read
at the 200 KB per-file cap, so the first such row used the whole budget and
every hunk after it was lost ("Diff cut short"). The patch is now read up to
16 M characters, rows over 4,000 characters are cut to 1,000 with an `elided`
count the diff shows, and only then is the cap applied. Hunk actions re-read
git's own diff by header and are unchanged.

The HTML preview refused a page over 10 MB and showed only the file icon; a
self-contained 18 MB slide deck now opens (limit 64 MB).
2026-10-30 10:28:53 +07:00
nntrivi2001 c41114faf8 chore: v2.8.42 — terminal glyphs stay sharp after the WebGL atlas fills up 2026-10-30 10:10:33 +07:00
nntrivi2001 c850f7603a fix(terminal): re-upload the shared WebGL atlas after a page merge
@xterm/addon-webgl 0.19 shares one glyph atlas between every terminal
with the same font. When it fills, four pages are merged into a larger
one and the rest shift down; a GPU texture is re-uploaded only when its
page version differs, and those counters can coincide, so a slot keeps a
stale page of another size and every glyph is drawn squeezed or foreign
(fixed upstream only in the 0.20 betas). On a merge — and when the page
comes back — toggle the font family's trailing space on the next frame,
which makes each renderer re-acquire its atlas and re-upload all pages.

This also drops yesterday's clearTextureAtlas on focus: it emptied the
SHARED atlas but redrew only the calling pane, corrupting its siblings.
2026-10-30 10:09:28 +07:00
nntrivi2001 5463e354fa chore: v2.8.41 — a session outside every workspace counts toward its user's spend 2026-10-30 08:50:58 +07:00
nntrivi2001 783b7f4101 fix(cost): bill a session outside every workspace to its Linux account's user
A script's claude -p in a temp folder (the SG Flow eval runner strips the
pane's env) had no workspace, so its spend reached nobody's Today, the Users
page or a spend quota — ~$57 of a $74 day was missing from the status bar.

sessions.account records the Linux account a session ran as (the hook's
peer uid, else its folder for poller-made and old rows); a session no
workspace owns is owned by that account's user — a member's to them, the
host's to the admin ticked 'Owns this server's host account' (or the only
admin). Workspace ownership still wins.
2026-10-30 08:50:34 +07:00
nntrivi2001 c28395402b chore: v2.8.40 — a Codex account switch reaches running sessions 2026-10-29 15:59:19 +07:00
nntrivi2001 7798530528 fix(profiles): a Codex switch reaches running and new sessions
Codex >= 0.164 sends every request through a shared app-server daemon that
outlives its TUIs and keeps the login it started with, so writing a new
auth.json changed nothing: open sessions and even new ones stayed on the
old (out-of-quota) account. After a switch, restart that CODEX_HOME's
daemon when one runs; open TUIs reconnect to it on their own (verified on
0.164.0 with two keys). The response says so (following), and the toast
tells the user running sessions follow.
2026-10-29 15:54:20 +07:00
nntrivi2001 25f63d9ad3 fix(terminal): rebuild the WebGL glyph atlas when the page comes back
A browser can drop the atlas's GPU memory without a context-loss event
(sleep/resume, a long-backgrounded tab, display/zoom change on macOS),
leaving every cell painted with a clipped or foreign glyph.
2026-10-29 15:44:16 +07:00
nntrivi2001 d060d288fb fix(sessions): Top tools counts each tool call once
The session analysis page's Top tools list counted every event carrying a
tool_name, so each call was counted twice (its PreToolUse and its
PostToolUse) while the Tool calls total beside it counted once — a session
with 840 calls showed Bash at 1,533. The list now counts the same way as
the total: PreToolUse, or PostToolUse in a session with none (imported).
2026-10-29 14:53:45 +07:00
nntrivi2001 6099841cef fix(sessions): archiving a live session stops its pane
Archive on a session a live pane held only hid its row, and the list
deliberately does not bring such a tab back, so the pane kept running
with nothing on screen showing it — a member archived five sessions
believing them closed and kept ~2.7 GB of claude/omp/postgres-mcp alive
for up to two days. Archive on a live session is now a Stop (endRuns,
the tab X's confirm, DELETE /api/run/:id?archive=1) in the sidebar row,
the workspace home's Recents and their right-click menu.
2026-10-29 14:26:25 +07:00
nntrivi2001 d70a7c9403 chore: v2.8.39 — no one can take a colleague's display name 2026-10-29 13:47:31 +07:00
nntrivi2001 63eaa739d4 fix(accounts): a display name may not read as another account's
A member kept renaming themselves to a colleague's display name, plus
"(you)", so the CPU/RAM card and every owner list showed two of that person.
A display name is now refused (409 ENAMETAKEN) when, with marks, case,
spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters
ignored, it equals another account's display name or username — in
Settings → Account and on the Users page alike. Keeping the name one already
has stays allowed, so an older clash does not block an admin's other edits.
2026-10-29 13:47:20 +07:00
nntrivi2001 9f8dc9082e chore: v2.8.38 — every user row shows its avatar 2026-10-29 13:40:39 +07:00
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00
nntrivi2001 db5a7776cc chore: v2.8.37 — the Artifacts installs list is never cut off 2026-10-29 11:25:10 +07:00
nntrivi2001 6c2c094f64 fix(artifacts): the installs list floats over the page, never cut off
It was an absolute box inside the card list, whose overflow-hidden clipped it
at the last card, and it opened to the right off a button near the edge. It
is now a portal at the button: below it, above when there is no room, kept
inside the window, following scroll and resize.
2026-10-29 11:25:10 +07:00
60 changed files with 4278 additions and 176 deletions
+6 -5
View File
@@ -1,4 +1,8 @@
{ {
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
},
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run:*)", "Bash(npm run:*)",
@@ -36,11 +40,8 @@
"Bash(node -e ':*)", "Bash(node -e ':*)",
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")", "Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
"Bash(claude --version)", "Bash(claude --version)",
"Bash(gh pr *)" "Bash(gh pr *)",
"Bash(grep *)"
] ]
},
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.36 (2026-10-29) — an HTML page opens in a modal over the screen (Changes Preview, Chat)._ 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.41 (2026-10-30) — a session outside every workspace is billed to the user of the Linux account it ran as._ 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -62,7 +62,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **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-<id>` sessions. - **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-<id>` 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 `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `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 BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; 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`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; 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. - **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 `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `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 BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; 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`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; 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.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md). - **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. - **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes. - **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -15,8 +15,10 @@ import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView"; import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline"; import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
/** A page read by `fetchFile` is shown up to this size. */ /** A page read by `fetchFile` is shown up to this size. A self-contained
const HTML_MAX = 10 * 1024 * 1024; * page (a slide deck with its video and images inlined as base64) is easily
* past 10 MB; the iframe renders far larger documents than this. */
const HTML_MAX = 64 * 1024 * 1024;
export function HtmlFrame({ export function HtmlFrame({
path, path,
@@ -3,7 +3,8 @@
* @description Tests for the sidebar's workspace list: one flat row per * @description Tests for the sidebar's workspace list: one flat row per
* workspace in the stored order, a row expanding to its sessions (remembered), * workspace in the stored order, a row expanding to its sessions (remembered),
* a live session opening its pane and an ended one its transcript, the * a live session opening its pane and an ended one its transcript, the
* workspace's OWN sessions only, a session's state dot, rename and archive, * workspace's OWN sessions only, a session's state dot, rename and archive
* (a live one's pane stopped first),
* selection * selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the * by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, the status dot, and the * search, the row menu's items by kind and tool, the status dot, and the
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
await user.type(input, "Better name{Enter}"); await user.type(input, "Better name{Enter}");
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" }); expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
await user.click(screen.getByTestId("workspace-session-archive-s-live")); await user.click(screen.getByTestId("workspace-session-archive-s-live"));
// A live session's Archive stops its pane first — never a hidden pane left running.
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true }); expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
}); });
+6 -1
View File
@@ -683,7 +683,12 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
aria-expanded={isOpen} aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default" className="flex w-full items-center gap-2 text-left disabled:cursor-default"
> >
<UserAvatar name={name} className="h-6 w-6 text-[10px]" /> <UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary"> <span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name} {name}
</span> </span>
@@ -69,6 +69,7 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() }, host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null }, me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
})); }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
} }
/** Tokenized line text. `emph` marks the span that actually changed. */ /** Tokenized line text. `emph` marks the span that actually changed. */
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
function Elided({ n }: { n?: number }) {
const { t, i18n } = useTranslation("lanes");
if (!n) return null;
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
return (
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
{t("changes.lineElided", { size })}
</span>
);
}
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) { function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) { if (emph) {
const [from, to] = emph; const [from, to] = emph;
@@ -544,6 +556,7 @@ function UnifiedLine({
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "} {line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span> </span>
<Code text={line.text} lang={lang} emph={emph} /> <Code text={line.text} lang={lang} emph={emph} />
<Elided n={line.elided} />
</span> </span>
{bar && <HunkBar change={bar} />} {bar && <HunkBar change={bar} />}
</Row> </Row>
@@ -621,6 +634,7 @@ function SplitLine({
/> />
<span className="whitespace-pre px-1"> <span className="whitespace-pre px-1">
{line ? <Code text={line.text} lang={lang} emph={e} /> : null} {line ? <Code text={line.text} lang={lang} emph={e} /> : null}
{line ? <Elided n={line.elided} /> : null}
</span> </span>
{bar && <HunkBar change={bar} />} {bar && <HunkBar change={bar} />}
</Row> </Row>
+17 -16
View File
@@ -14,7 +14,7 @@
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it * a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot, * owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in * **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane, * its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and * any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel). * Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -22,6 +22,7 @@
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { endRuns } from "../../lib/stopSession";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -307,30 +308,30 @@ function HomeBody({
} }
}; };
const archiveSession = async (s: Session) => { const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id); const run = liveRunFor(lane, liveRuns, s.id);
// A live one is stopped first, as the tab's X does (its own confirm):
// archiving only its row left a pane running that no list showed.
if (run) {
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
await patchSession(s, { archived: true });
return;
}
const wt = sessionWorktreeName(lane, s); const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog( const ok = await confirmDialog(
wt && !live wt
? { ? {
title: t("tree.archiveWorktreeTitle"), title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }), message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"), confirmLabel: t("tree.archiveWorktreeConfirm"),
} }
: live : {
? { title: t("tree.archiveSession"),
title: t("tree.archiveLiveTitle"), confirmLabel: t("sessionMenu.archive"),
message: t("tree.archiveLiveBody"), destructive: false,
confirmLabel: t("sessionMenu.archive"), }
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
); );
if (!ok) return; if (!ok) return;
if (wt && !live) { if (wt) {
try { try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => { await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err; if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
@@ -24,7 +24,7 @@
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that * The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A * loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session, * live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each * and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. `readOnly` (another user's * dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row * workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume. * opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
@@ -32,7 +32,7 @@
*/ */
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm"; import { endRuns } from "../../lib/stopSession";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react"; import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
}; };
const archive = async (s: Session) => { const archive = async (s: Session) => {
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s); if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
// A live one keeps running in its tab; archiving only hides its row. // A live one is stopped first, as the tab's X does: archiving only its row
if ( // left a pane running that no list showed any more.
runFor(s) && const run = runFor(s);
!(await confirmDialog({ if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}))
)
return;
await patch(s, { archived: true }); await patch(s, { archived: true });
}; };
const archiveWithWorktree = async (s: Session) => { const archiveWithWorktree = async (s: Session) => {
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull(); expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
}); });
}); });
describe("LaneDiffView — a row the server cut down", () => {
const CUT: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: [
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
],
},
],
};
it("says how much of the row it hides, in both layouts", () => {
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
unmount();
render(<LaneDiffView file={CUT} layout="split" />);
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
});
});
+75 -1
View File
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */ /** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader"; export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */ /** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
* fields unminified in its build. Undefined on any other shape. */
type AtlasPages = { pages: { version: number }[] };
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
?._charAtlas;
return Array.isArray(atlas?.pages) ? atlas : undefined;
};
/** Last version handed out by `restampAtlas`, shared by every pane. */
let atlasStamp = 0;
/**
* Gives every page of the addon's glyph atlas a version no texture slot has
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
* its canvas on its next draw — what addon-webgl 0.20 does with globally
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
* pages down a slot can hold a stale page whose counter happens to match, and
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
* per-glyph increments a page makes before the next restamp. False when the
* addon is not the shape this knows.
*/
const restampAtlas = (addon: WebglAddon): boolean => {
const atlas = atlasOf(addon);
if (!atlas) return false;
for (const page of atlas.pages) {
atlasStamp += 2 ** 24;
page.version = atlasStamp;
}
return true;
};
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen"; const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
const readFlag = (key: string) => { const readFlag = (key: string) => {
try { try {
@@ -468,7 +498,8 @@ export const TerminalView = memo(function TerminalView({
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`): // A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would // fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown. // reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight; const hasBox = () =>
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit(); if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the // Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went // page here, and having to click it first meant the first thing typed went
@@ -761,6 +792,44 @@ export const TerminalView = memo(function TerminalView({
refit(); refit();
}; };
document.fonts?.addEventListener("loadingdone", onFontLoaded); document.fonts?.addEventListener("loadingdone", onFontLoaded);
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// with the same font. When it fills up it merges four pages into one
// larger page and shifts the rest down, and a GPU texture is re-uploaded
// only when its page's `version` differs from the uploaded one — after a
// shift those per-page counters can coincide, so a slot keeps a stale
// page of another size and every glyph is drawn squeezed or foreign
// while the buffer stays correct (fixed upstream only in 0.20 betas).
// Any change to the page list therefore restamps every page's version
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// but redraws only this pane, leaving its siblings slicing reused space.
// An addon of another shape falls back to toggling the family's
// trailing space on the next frame, which makes each renderer re-acquire
// its atlas (as onFontLoaded does).
let atlasFrame = 0;
const rebuildAtlas = () => {
if (gpu && restampAtlas(gpu)) return true;
if (atlasFrame) return false;
// Never inside the render that is rasterizing the merging glyph.
atlasFrame = requestAnimationFrame(() => {
atlasFrame = 0;
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
});
return false;
};
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
// The browser may also drop the textures without a context-loss event
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
// back, and repaint, since nothing else asks this pane to draw.
const onPageShown = () => {
if (document.visibilityState === "visible" && rebuildAtlas()) {
term.refresh(0, term.rows - 1);
}
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
// Settings → Appearance changed the terminal's font or size: an open // Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing. // pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => { const onFontPref = () => {
@@ -779,6 +848,11 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null; searchRef.current = null;
resizeObserver.disconnect(); resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded); document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
atlasGrew?.dispose();
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref); window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert); term.textarea?.removeEventListener("focus", reassert);
detachIme(); detachIme();
@@ -63,6 +63,9 @@ const termState = {
}; };
const onResizeHandlers: Array<() => void> = []; const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {}; const termOptions: { theme?: unknown } = {};
const refreshMock = vi.fn();
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
const ctorArgs: Array<{ theme?: unknown }> = []; const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({ vi.mock("@xterm/xterm", () => ({
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
return { dispose: vi.fn() }; return { dispose: vi.fn() };
}, },
dispose: disposeMock, dispose: disposeMock,
refresh: refreshMock,
loadAddon: loadAddonMock, loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock, attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock, attachCustomKeyEventHandler: keyHandlerMock,
@@ -112,21 +116,44 @@ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
})); }));
// The newest WebGL addon, with the context-loss listener the view gave it. // The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = { const gpu: {
last: {
dispose: ReturnType<typeof vi.fn>;
lose: () => void;
merge: () => void;
grow: () => void;
} | null;
} = {
last: null, last: null,
}; };
vi.mock("@xterm/addon-webgl", () => ({ vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => { WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {}; let onLoss = () => {};
let onMerge = () => {};
let onGrow = () => {};
const addon = { const addon = {
__webgl: true, __webgl: true,
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
dispose: vi.fn(), dispose: vi.fn(),
onContextLoss: (fn: () => void) => { onContextLoss: (fn: () => void) => {
onLoss = fn; onLoss = fn;
return { dispose: vi.fn() }; return { dispose: vi.fn() };
}, },
onRemoveTextureAtlasCanvas: (fn: () => void) => {
onMerge = fn;
return { dispose: vi.fn() };
},
onAddTextureAtlasCanvas: (fn: () => void) => {
onGrow = fn;
return { dispose: vi.fn() };
},
};
gpu.last = {
dispose: addon.dispose,
lose: () => onLoss(),
merge: () => onMerge(),
grow: () => onGrow(),
}; };
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon; return addon;
}), }),
})); }));
@@ -488,6 +515,50 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
}); });
it("restamps every atlas page at once when the page list changes", () => {
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
// pages a texture slot can keep a stale page whose counter matches. Every
// page gets a version no slot has uploaded — in every pane on the atlas.
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
nextAtlas.pages = pages;
const raf = vi.spyOn(window, "requestAnimationFrame");
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
gpu.last!.merge();
const first = pages.map((p) => p.version);
expect(new Set(first).size).toBe(3);
expect(first.every((v) => v > 7)).toBe(true);
gpu.last!.grow();
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
true
);
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
// The page coming back re-uploads and repaints this pane.
refreshMock.mockClear();
window.dispatchEvent(new Event("focus"));
expect(refreshMock).toHaveBeenCalledWith(0, 27);
} finally {
nextAtlas.pages = null;
raf.mockRestore();
}
});
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
// An addon whose atlas this cannot reach: any option change makes each
// renderer re-acquire its atlas and re-upload every page.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const opts = termOptions as { fontFamily?: string };
opts.fontFamily = "JetBrains Mono";
gpu.last!.merge();
gpu.last!.merge(); // coalesced into one frame
expect(raf).toHaveBeenCalledTimes(1);
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
expect(opts.fontFamily).toBe("JetBrains Mono ");
raf.mockRestore();
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it. // xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => { it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D"); expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
@@ -262,11 +262,11 @@ function LabelEdit({
function useRunner(load: () => Promise<unknown>) { function useRunner(load: () => Promise<unknown>) {
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const run = useCallback( const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => { async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
setBusy(key); setBusy(key);
try { try {
await fn(); const r = await fn();
toast.success(success); toast.success(typeof success === "function" ? success(r) : success);
await load(); await load();
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err); toast.error(err);
@@ -399,7 +399,14 @@ function OtherProfiles({
void run( void run(
`switch:${p.name}`, `switch:${p.name}`,
() => api.toolProfiles.activate(tool, p.name), () => api.toolProfiles.activate(tool, p.name),
t("toolProfiles.switchSuccess", { name: p.label, tool: label }) (r) =>
t(
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
{
name: p.label,
tool: label,
}
)
) )
} }
className={btnCls} className={btnCls}
@@ -800,9 +807,7 @@ function ProfileUsageLine({
/** Org / seat / plan line shared by the active card and each profile row. */ /** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) { function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter( const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
Boolean
);
if (parts.length === 0) return null; if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>; return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
} }
+9 -1
View File
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import type { AuthUser, ServerUserUsage } from "../../lib/api"; import type { AuthUser, ServerUserUsage } from "../../lib/api";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
import { UserAvatar } from "./UserAvatar";
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"]; export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
/** A role, every user, or the users in no pool group. */ /** A role, every user, or the users in no pool group. */
@@ -168,7 +169,14 @@ export function MemberPicker({
checked={chosen.has(u.id)} checked={chosen.has(u.id)}
onChange={(on) => setMany([u.id], on)} onChange={(on) => setMany([u.id], on)}
label={ label={
<span> <span className="inline-flex items-center gap-1.5">
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
letters={1}
className="h-5 w-5 text-[10px]"
/>
{u.username} {u.username}
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>} {u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
{note?.(u)} {note?.(u)}
+8 -6
View File
@@ -36,6 +36,7 @@ import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { MemberPicker } from "./MemberPicker"; import { MemberPicker } from "./MemberPicker";
import { UserAvatar } from "./UserAvatar";
/** Pool tools with their labels, in display order. */ /** Pool tools with their labels, in display order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [ export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
@@ -363,12 +364,13 @@ export function PoolGroups({
} ${isDragged(drag) ? "opacity-40" : ""}`} } ${isDragged(drag) ? "opacity-40" : ""}`}
data-testid="pool-member" data-testid="pool-member"
> >
<span <UserAvatar
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent" name={name}
aria-hidden userId={id}
> avatarAt={u?.avatarAt}
{name.charAt(0)} letters={1}
</span> className="h-4 w-4 text-[9px]"
/>
{name} {name}
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>} {u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
{isAdmin && from && ( {isAdmin && from && (
+3 -1
View File
@@ -4,7 +4,9 @@
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET * (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one — * /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the * or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account. * sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
* Health's per-user CPU/RAM card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
+22 -7
View File
@@ -2,7 +2,8 @@
* @file UserEditDialog.tsx * @file UserEditDialog.tsx
* @description The Users page's ONE account editor (admin only): profile * @description The Users page's ONE account editor (admin only): profile
* (display name, email), access (role, active/disabled — neither for your own * (display name, email), access (role, active/disabled — neither for your own
* account), a new password (typed or generated, copyable — someone else's * account — and, for an admin, whether they own the host's Linux account, so
* its sessions outside every workspace are billed to them), a new password (typed or generated, copyable — someone else's
* may be anything and makes them choose their own at next sign-in; your own * may be anything and makes them choose their own at next sign-in; your own
* must pass every rule of `lib/passwordRules.ts`), the workspaces the * must pass every rule of `lib/passwordRules.ts`), the workspaces the
* account owns (remove, add from every workspace), its spend quota (the * account owns (remove, add from every workspace), its spend quota (the
@@ -33,6 +34,7 @@ import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { passwordProblems } from "../../lib/passwordRules"; import { passwordProblems } from "../../lib/passwordRules";
import { PasswordRules } from "./PasswordRules"; import { PasswordRules } from "./PasswordRules";
import { UserAvatar } from "./UserAvatar";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch"; import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
@@ -86,6 +88,7 @@ export function UserEditDialog({
const [email, setEmail] = useState(user.email ?? ""); const [email, setEmail] = useState(user.email ?? "");
const [role, setRole] = useState<Role>(user.role); const [role, setRole] = useState<Role>(user.role);
const [active, setActive] = useState(!user.disabled); const [active, setActive] = useState(!user.disabled);
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null); const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
@@ -138,6 +141,7 @@ export function UserEditDialog({
if (norm(email) !== (user.email ?? null)) patch.email = norm(email); if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
if (!self && role !== user.role) patch.role = role; if (!self && role !== user.role) patch.role = role;
if (!self && active === user.disabled) patch.disabled = !active; if (!self && active === user.disabled) patch.disabled = !active;
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
if (password) patch.password = password; if (password) patch.password = password;
if (added.length) patch.addWorkspaces = added; if (added.length) patch.addWorkspaces = added;
if (removed.size) patch.removeWorkspaces = [...removed]; if (removed.size) patch.removeWorkspaces = [...removed];
@@ -243,12 +247,12 @@ export function UserEditDialog({
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl" className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
> >
<div className="flex items-start gap-3 border-b border-border px-5 py-4"> <div className="flex items-start gap-3 border-b border-border px-5 py-4">
<span <UserAvatar
aria-hidden name={shown}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent" userId={user.id}
> avatarAt={user.avatarAt}
{shown.slice(0, 2)} className="h-10 w-10 text-sm"
</span> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary"> <h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
{t("edit.title", { name: user.displayName || user.username })} {t("edit.title", { name: user.displayName || user.username })}
@@ -363,6 +367,17 @@ export function UserEditDialog({
)} )}
</div> </div>
</div> </div>
{role === "admin" && (
<div className="mt-3">
<Checkbox
checked={hostAccount}
onChange={setHostAccount}
label={t("edit.hostAccount")}
labelClassName="text-sm text-fg-secondary"
/>
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
</div>
)}
</Section> </Section>
<Section title={t("edit.password")}> <Section title={t("edit.password")}>
@@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
updateGroup: vi.fn(), updateGroup: vi.fn(),
deleteGroup: vi.fn(), deleteGroup: vi.fn(),
})); }));
const users = vi.hoisted(() => ({ list: vi.fn() })); const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
const aiTools = vi.hoisted(() => ({ const aiTools = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
ompSharing: vi.fn(), ompSharing: vi.fn(),
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api"; import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() })); const users = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
avatarUrl: () => null,
}));
const confirmDialog = vi.hoisted(() => vi.fn()); const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog })); vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() })); const lanes = vi.hoisted(() => ({ list: vi.fn() }));
@@ -16,6 +16,7 @@ const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(), setQuotaDefault: vi.fn(),
update: vi.fn(), update: vi.fn(),
get: vi.fn(), get: vi.fn(),
avatarUrl: () => null,
})); }));
const lanes = vi.hoisted(() => ({ list: vi.fn() })); const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
+1 -2
View File
@@ -92,6 +92,7 @@
"changes.expandFile": "Expand {{path}}", "changes.expandFile": "Expand {{path}}",
"changes.fileGone": "That file no longer has changes in this range.", "changes.fileGone": "That file no longer has changes in this range.",
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.", "changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
"changes.lineElided": "… {{size}} more characters on this line",
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane", "changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there", "changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
"changes.commentSend": "Paste into pane", "changes.commentSend": "Paste into pane",
@@ -505,8 +506,6 @@
"changes.loadMore": "Load more commits", "changes.loadMore": "Load more commits",
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it", "changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
"changes.splitter": "Resize the changes panel", "changes.splitter": "Resize the changes panel",
"tree.archiveLiveTitle": "Archive a live session?",
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
"tree.state.awaiting": "waiting for you", "tree.state.awaiting": "waiting for you",
"tree.state.error": "error", "tree.state.error": "error",
"tree.state.live": "live", "tree.state.live": "live",
+1
View File
@@ -1396,6 +1396,7 @@
"noActive": "{{tool}} holds no login on this machine.", "noActive": "{{tool}} holds no login on this machine.",
"noProfiles": "Could not load the profiles.", "noProfiles": "Could not load the profiles.",
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.", "switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.", "deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
"loginHint": "Add another {{tool}} login by signing in from a terminal first:", "loginHint": "Add another {{tool}} login by signing in from a terminal first:",
"subtitle": { "subtitle": {
+3
View File
@@ -79,6 +79,7 @@
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).", "EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
"EBADPASSWORD": "Password cannot be empty.", "EBADPASSWORD": "Password cannot be empty.",
"EBADEMAIL": "That email address does not look valid.", "EBADEMAIL": "That email address does not look valid.",
"ENAMETAKEN": "Another account already uses that name. Pick one that is yours.",
"EBADFIELD": "One of the values is not valid.", "EBADFIELD": "One of the values is not valid.",
"ENOLANE": "That workspace no longer exists.", "ENOLANE": "That workspace no longer exists.",
"ENOTOWNED": "That workspace is not this user's any more.", "ENOTOWNED": "That workspace is not this user's any more.",
@@ -111,6 +112,8 @@
"status": "Status", "status": "Status",
"active": "Active", "active": "Active",
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.", "statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
"hostAccount": "Owns this server's host account",
"hostAccountHint": "Sessions run as the server's own Linux account outside every workspace (a script's claude -p, a CLI in a plain terminal) count toward this admin's spend. At most one admin; with none marked, the only admin.",
"selfRole": "You cannot change your own role.", "selfRole": "You cannot change your own role.",
"selfDisable": "You cannot disable yourself.", "selfDisable": "You cannot disable yourself.",
"password": "Password", "password": "Password",
+1 -2
View File
@@ -92,6 +92,7 @@
"changes.expandFile": "Mở {{path}}", "changes.expandFile": "Mở {{path}}",
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.", "changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.", "changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
"changes.lineElided": "… còn {{size}} ký tự trên dòng này",
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này", "changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó", "changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
"changes.commentSend": "Dán vào pane", "changes.commentSend": "Dán vào pane",
@@ -505,8 +506,6 @@
"changes.loadMore": "Tải thêm commit", "changes.loadMore": "Tải thêm commit",
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi", "changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
"changes.splitter": "Đổi độ rộng khung thay đổi", "changes.splitter": "Đổi độ rộng khung thay đổi",
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
"tree.state.awaiting": "đang chờ bạn", "tree.state.awaiting": "đang chờ bạn",
"tree.state.error": "lỗi", "tree.state.error": "lỗi",
"tree.state.live": "đang chạy", "tree.state.live": "đang chạy",
+1
View File
@@ -1384,6 +1384,7 @@
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.", "noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
"noProfiles": "Không tải được danh sách hồ sơ.", "noProfiles": "Không tải được danh sách hồ sơ.",
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.", "switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.", "deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:", "loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
"subtitle": { "subtitle": {
+3
View File
@@ -79,6 +79,7 @@
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).", "EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
"EBADPASSWORD": "Mật khẩu không được để trống.", "EBADPASSWORD": "Mật khẩu không được để trống.",
"EBADEMAIL": "Địa chỉ email không hợp lệ.", "EBADEMAIL": "Địa chỉ email không hợp lệ.",
"ENAMETAKEN": "Tên này đã có tài khoản khác dùng. Hãy chọn tên của chính bạn.",
"EBADFIELD": "Có một giá trị không hợp lệ.", "EBADFIELD": "Có một giá trị không hợp lệ.",
"ENOLANE": "Workspace đó không còn tồn tại.", "ENOLANE": "Workspace đó không còn tồn tại.",
"ENOTOWNED": "Workspace đó không còn thuộc người này.", "ENOTOWNED": "Workspace đó không còn thuộc người này.",
@@ -111,6 +112,8 @@
"status": "Trạng thái", "status": "Trạng thái",
"active": "Đang hoạt động", "active": "Đang hoạt động",
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.", "statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
"hostAccount": "Sở hữu tài khoản host của máy chủ",
"hostAccountHint": "Session chạy bằng tài khoản Linux của máy chủ nhưng ngoài mọi workspace (claude -p của script, CLI trong terminal thường) được tính vào chi phí của admin này. Tối đa một admin; nếu chưa ai được chọn thì là admin duy nhất.",
"selfRole": "Bạn không thể đổi vai trò của chính mình.", "selfRole": "Bạn không thể đổi vai trò của chính mình.",
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.", "selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
"password": "Mật khẩu", "password": "Mật khẩu",
+7 -1
View File
@@ -684,6 +684,8 @@ export interface HostUsageGroup {
username?: string; username?: string;
/** The user's display name, null when none is set (absent from an older server). */ /** The user's display name, null when none is set (absent from an older server). */
displayName?: string | null; displayName?: string | null;
/** When the user's avatar was last set (null = none; absent from an older server). */
avatarAt?: string | null;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */ /** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number; cpu: number;
/** Resident memory, bytes (shared pages count in every process). */ /** Resident memory, bytes (shared pages count in every process). */
@@ -798,6 +800,8 @@ export type UserPatch = {
addWorkspaces?: number[]; addWorkspaces?: number[];
removeWorkspaces?: number[]; removeWorkspaces?: number[];
signOut?: true; signOut?: true;
/** Owns the host's Linux account: its sessions outside every workspace bill to them (admins only). */
hostAccount?: boolean;
/** Their own spend quota (`default` = the default applies). */ /** Their own spend quota (`default` = the default applies). */
quota?: UserQuotaSetting; quota?: UserQuotaSetting;
}; };
@@ -981,6 +985,8 @@ export type AuthUser = {
createdAt?: string; createdAt?: string;
/** An admin gave this password: the user must choose their own before anything else. */ /** An admin gave this password: the user must choose their own before anything else. */
mustChangePassword?: boolean; mustChangePassword?: boolean;
/** Owns the host's Linux account (server/lib/session-account.js) — absent from an older server. */
hostAccount?: boolean;
}; };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null }; export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */ /** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
@@ -1399,7 +1405,7 @@ export const api = {
), ),
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */ /** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
activate: (tool: string, name: string) => activate: (tool: string, name: string) =>
request<{ ok: true; name: string; savedOutgoing: string | null }>( request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
`/tool-profiles/${encodeURIComponent(tool)}/activate`, `/tool-profiles/${encodeURIComponent(tool)}/activate`,
{ method: "POST", body: JSON.stringify({ name }) } { method: "POST", body: JSON.stringify({ name }) }
), ),
+2 -1
View File
@@ -6,7 +6,8 @@
* message. Both ask first when any pane holds an AI agent (it may be * message. Both ask first when any pane holds an AI agent (it may be
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps. * mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a * Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
* session's right-click menu (`SessionMenu`). * session's right-click menu (`SessionMenu`), and a live session's Archive
* (`WorkspaceSessionList`, `WorkspaceHome`) — archiving never leaves a pane running.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
+3
View File
@@ -2974,6 +2974,9 @@ export interface LaneDiffLine {
/** Line number on the right (after) side; null on a removed line. */ /** Line number on the right (after) side; null on a removed line. */
newLine: number | null; newLine: number | null;
text: string; text: string;
/** Characters cut off the end of a very long row (a base64 blob, a minified
* bundle): `text` holds only its start. Absent on a whole row. */
elided?: number;
} }
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of /** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
+97 -62
View File
@@ -48,7 +48,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ChevronRight, ChevronRight,
@@ -1629,12 +1630,41 @@ function DetailDialog({
); );
} }
/** "N installs" on a card; a click lists who installed it, when, and where. */ /** "N installs" on a card; a click lists who installed it, when, and where.
* The list floats over the page (a portal at the button, below it or above
* when there is no room, kept inside the window): inside the card list's
* `overflow-hidden` it was cut off at the last card. */
function InstallsButton({ item }: { item: Artifact }) { function InstallsButton({ item }: { item: Artifact }) {
const { t } = useTranslation("artifacts"); const { t } = useTranslation("artifacts");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [data, setData] = useState<ArtifactInstalls | null>(null); const [data, setData] = useState<ArtifactInstalls | null>(null);
const box = useRef<HTMLSpanElement>(null); const box = useRef<HTMLSpanElement>(null);
const pop = useRef<HTMLDivElement>(null);
const [at, setAt] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
if (!open) return setAt(null);
const place = () => {
const b = box.current?.getBoundingClientRect();
const p = pop.current;
if (!b || !p) return;
const w = p.offsetWidth;
const h = p.offsetHeight;
const gap = 4;
const below = b.bottom + gap + h <= window.innerHeight - 8 || b.top - gap - h < 8;
setAt({
left: Math.max(8, Math.min(b.left, window.innerWidth - w - 8)),
top: below ? b.bottom + gap : b.top - gap - h,
});
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open, data]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -1647,7 +1677,8 @@ function InstallsButton({ item }: { item: Artifact }) {
toast.error(err); toast.error(err);
}); });
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false); const n = e.target as Node;
if (!box.current?.contains(n) && !pop.current?.contains(n)) setOpen(false);
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false); if (e.key === "Escape") setOpen(false);
@@ -1670,66 +1701,70 @@ function InstallsButton({ item }: { item: Artifact }) {
> >
{t("installs", { count: item.installs })} {t("installs", { count: item.installs })}
</button> </button>
{open && ( {open &&
<div createPortal(
role="dialog" <div
aria-label={t("installsTitle")} ref={pop}
data-testid="artifact-installs" role="dialog"
className="absolute left-0 z-30 mt-1 w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl" aria-label={t("installsTitle")}
> data-testid="artifact-installs"
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p> style={at ? { left: at.left, top: at.top } : { left: 0, top: 0, visibility: "hidden" }}
{data === null ? ( className="fixed z-[60] w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p> >
) : ( <p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
<ul className="max-h-64 space-y-1 overflow-auto"> {data === null ? (
{data.items.map((i, n) => ( <p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2"> ) : (
<div className="flex items-center gap-2"> <ul className="max-h-64 space-y-1 overflow-auto">
<span className="min-w-0 flex-1 truncate text-fg-primary"> {data.items.map((i, n) => (
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))} <li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
{i.replaced && ( <div className="flex items-center gap-2">
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span> <span className="min-w-0 flex-1 truncate text-fg-primary">
)} {i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
</span> {i.replaced && (
<span className="shrink-0 text-[11px] text-fg-muted"> <span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
{new Date(i.at).toLocaleString()} )}
</span> </span>
</div> <span className="shrink-0 text-[11px] text-fg-muted">
{i.paths && ( {new Date(i.at).toLocaleString()}
<div className="mt-0.5 space-y-0.5"> </span>
{i.paths.length ? (
i.paths.map((p) => (
<p
key={p}
className="truncate font-mono text-[10px] text-fg-muted"
title={p}
>
{p}
</p>
))
) : (
<p className="font-mono text-[10px] text-fg-muted">
{t("installsPluginWhere")}
</p>
)}
</div> </div>
)} {i.paths && (
</li> <div className="mt-0.5 space-y-0.5">
))} {i.paths.length ? (
{data.unrecorded > 0 && ( i.paths.map((p) => (
<li className="px-1.5 py-1 text-[11px] text-fg-muted"> <p
{t("installsUnrecorded", { count: data.unrecorded })} key={p}
</li> className="truncate font-mono text-[10px] text-fg-muted"
)} title={p}
{data.items.length === 0 && data.unrecorded === 0 && ( >
<li className="px-1.5 py-1 text-[11px] text-fg-muted"> {p}
{t("installsNoneOfYours")} </p>
</li> ))
)} ) : (
</ul> <p className="font-mono text-[10px] text-fg-muted">
)} {t("installsPluginWhere")}
</div> </p>
)} )}
</div>
)}
</li>
))}
{data.unrecorded > 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsUnrecorded", { count: data.unrecorded })}
</li>
)}
{data.items.length === 0 && data.unrecorded === 0 && (
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
{t("installsNoneOfYours")}
</li>
)}
</ul>
)}
</div>,
document.body
)}
</span> </span>
); );
} }
@@ -362,6 +362,11 @@ describe("Artifacts", () => {
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument(); expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
expect(within(box).getByText(/khoa/)).toBeInTheDocument(); expect(within(box).getByText(/khoa/)).toBeInTheDocument();
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument(); expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
// Over the page, outside the card list that clips its overflow — and a
// click inside it keeps it open.
expect(box.parentElement).toBe(document.body);
await user.click(within(box).getByText(/khoa/));
expect(screen.getByTestId("artifact-installs")).toBeInTheDocument();
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(screen.queryByTestId("artifact-installs")).toBeNull(); expect(screen.queryByTestId("artifact-installs")).toBeNull();
}); });
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.36 (rebuild client to match) UI build v2.8.43 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+9 -4
View File
@@ -89,9 +89,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/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared | | `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` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), 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). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. 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 `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), 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). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
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. 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, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `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, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `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). `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, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `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).
@@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited | | `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) | | `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"}` | | `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` | | `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`; `409 ENAMETAKEN` for a display name that reads as another account's display name or username (marks, case, punctuation, bracketed parts like "(you)" and look-alike letters ignored; keeping the name you have is always allowed) |
| `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/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 | | `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 | | `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 |
@@ -817,6 +817,11 @@ that file.
- Three caps guard the response: **300** files listed, **200 KB** of patch per - Three caps guard the response: **300** files listed, **200 KB** of patch per
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
the response and on the file it cut. the response and on the file it cut.
- A row longer than **4,000** characters (an inlined base64 image or video, a
minified bundle) comes back cut to its first 1,000 with `elided: <characters
hidden>`, BEFORE the per-file cap is applied, so one huge line no longer eats
the hunks after it. Each file's raw patch is read up to 16 M characters; past
that it is still `truncated`.
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd - Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
git cannot read; `range=branch` compares against the workspace's base branch, git cannot read; `range=branch` compares against the workspace's base branch,
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`) else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
@@ -3036,7 +3041,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
DELETE /api/tool-profiles/:tool Body: { name } DELETE /api/tool-profiles/:tool Body: { name }
``` ```
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins. The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
### AI Proxy profiles ### AI Proxy profiles
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -2
View File
@@ -372,7 +372,13 @@ Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
workspace list's owner groups, the Users page and its audit log, the Edit user workspace list's owner groups, the Users page and its audit log, the Edit user
form — it shows their display name when one is set, else the username (the form — it shows their display name when one is set, else the username (the
Users page keeps `@username` beside a display name). Logging in, the CLI and Users page keeps `@username` beside a display name). Logging in, the CLI and
the Linux account always use the username. the Linux account always use the username. No one can pose as a colleague: a
display name that reads as another account's display name or username — marks,
case, spaces, punctuation, a bracketed part like "(you)" and look-alike letters
from other alphabets ignored — is refused (`409 ENAMETAKEN`), whether the user
sets it in Settings → Account or an admin does on the Users page. A name an
account already has is kept on an unrelated save; rename an old clash from the
Users page.
Under S3 an admin's folder browser opens on their own home too; ticking Under S3 an admin's folder browser opens on their own home too; ticking
**Server folder** opens the host account's home (`/home/<host>`), where a **Server folder** opens the host account's home (`/home/<host>`), where a
@@ -726,7 +732,17 @@ An admin limits what each user may spend, in dollars, on the Users page
every account change; each one re-applies at once (`sweep`). every account change; each one re-applies at once (`sweep`).
Spend is the users page's own figure: the tokens of the sessions the user Spend is the users page's own figure: the tokens of the sessions the user
OWNS through a workspace, priced by `calculateCost`. The Users table shows OWNS, priced by `calculateCost`. A session is owned through its workspace;
one no workspace owns — a script's `claude -p` in a temp folder, a CLI in a
plain terminal — is owned by the user of the Linux account it ran as
(`sessions.account`, `server/lib/session-account.js`: the hook's peer uid,
else the session's folder): a member's account bills to that member, the
host's to the admin ticked **Owns this server's host account** in Edit user
(`PATCH /api/users/:id` `{hostAccount: true}`, at most one), or with none
ticked to the only admin. The same rule feeds the status bar's **Today**.
A process that drops the pane's environment also drops
`AGENTHUB_QUOTA_FILE`, so its prompts are not refused by the hook — but its
spend still counts, and stops the user's next session. The Users table shows
each user's spend against their quota; the user sees their own as **Quota N%** each user's spend against their quota; the user sees their own as **Quota N%**
in the status bar (`GET /api/me/quota`). in the status bar (`GET /api/me/quota`).
+7 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.8.30 version: 2.8.40
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
@@ -15410,6 +15410,9 @@ paths:
type: boolean type: boolean
enum: enum:
- true - true
hostAccount:
type: boolean
description: 'An admin owns the host''s Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)'
quota: quota:
$ref: '#/components/schemas/UserQuotaSetting' $ref: '#/components/schemas/UserQuotaSetting'
currentPassword: currentPassword:
@@ -19932,6 +19935,9 @@ paths:
items: items:
type: string type: string
description: Files the switch wrote. description: Files the switch wrote.
following:
type: boolean
description: 'Codex: a running app-server daemon was restarted, so open and new sessions use the new login.'
'400': '400':
description: '`EBADNAME` / `EBADLABEL`.' description: '`EBADNAME` / `EBADLABEL`.'
content: content:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.36", "version": "2.8.43",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.36", "version": "2.8.43",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.36", "version": "2.8.43",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
@@ -473,6 +473,55 @@ describe("reads larger than the git() buffer report truncated, not missing", ()
}); });
}); });
describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => {
const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line
it("cuts the long rows down and still shows the hunks after them", async () => {
const body = (mid, end) =>
[
"<h1>title</h1>",
...Array.from({ length: 40 }, (_, i) => `row ${i}`),
mid,
...Array.from({ length: 40 }, (_, i) => `tail ${i}`),
end,
"",
].join("\n");
write(LANE, "deck.html", body(`<video src="${blob("A")}">`, "<p>old end</p>"));
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "deck");
write(
LANE,
"deck.html",
body(`<video src="${blob("B")}">`, "<p>new end</p>").replace(
"<h1>title</h1>",
"<h1>new title</h1>"
)
);
const out = await collectChanges(LANE, { range: "work", file: "deck.html" });
const f = out.files[0];
assert.equal(f.truncated, undefined);
assert.equal(out.truncated, false);
const lines = f.hunks.flatMap((h) => h.lines);
const long = lines.filter((l) => l.elided);
assert.equal(long.length, 2);
for (const l of long) {
assert.ok(l.text.length < 2000);
assert.equal(l.text.length + l.elided, '<video src="'.length + 3 * 1024 * 1024 + 2);
}
// The change AFTER the 3 MB rows survives: the cap no longer eats it.
assert.ok(lines.some((l) => l.type === "add" && l.text === "<p>new end</p>"));
assert.ok(lines.some((l) => l.type === "add" && l.text === "<h1>new title</h1>"));
});
it("leaves ordinary rows whole", async () => {
write(LANE, "a.txt", "one\nTWO\n");
const out = await collectChanges(LANE, { range: "work", file: "a.txt" });
const lines = out.files[0].hunks.flatMap((h) => h.lines);
assert.ok(lines.every((l) => l.elided === undefined));
assert.ok(lines.some((l) => l.text === "TWO"));
});
});
describe("loop 2: secret file at any depth, symlinked .claude", () => { describe("loop 2: secret file at any depth, symlinked .claude", () => {
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}'; const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
+11 -8
View File
@@ -203,18 +203,21 @@ describe("collectChanges — work range", () => {
}); });
}); });
it("still returns content when a huge file has no newline to cut on", async () => { it("still returns content when a huge file is one line", async () => {
// A minified bundle is ONE line. Cutting the patch at the last newline // A minified bundle is ONE line. It used to be cut at the per-file cap
// inside the cap would land at index -1 here and hand back an empty // (and, cut at the last newline, could come back empty — a file changed
// patch — a file changed by a megabyte rendering as "no textual change". // by a megabyte rendering as "no textual change"); now the row itself is
// cut down and says how much it hides, and nothing is truncated.
const { MAX_FILE_BYTES } = require("../lib/git-diff"); const { MAX_FILE_BYTES } = require("../lib/git-diff");
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`); write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
const out = await collectChanges(LANE, { range: "work", file: "min.js" }); const out = await collectChanges(LANE, { range: "work", file: "min.js" });
const entry = out.files[0]; const entry = out.files[0];
assert.equal(entry.truncated, true); assert.equal(entry.truncated, undefined);
assert.equal(out.truncated, true); assert.equal(out.truncated, false);
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk"); assert.ok(entry.hunks.length > 0, "the patch still carries its hunk");
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx")); const row = entry.hunks[0].lines.find((l) => l.type === "add");
assert.ok(row.text.startsWith("xxx"));
assert.equal(row.text.length + row.elided, MAX_FILE_BYTES + 5000);
}); });
it("reports a binary file without trying to diff it", async () => { it("reports a binary file without trying to diff it", async () => {
@@ -222,6 +222,26 @@ describe("Settings → Account (/api/me)", () => {
assert.equal(bad.body.error.code, "EBADEMAIL"); assert.equal(bad.body.error.code, "EBADEMAIL");
}); });
it("refuses a display name that reads as another account's (ENAMETAKEN)", async () => {
assert.equal(auth.nameKey("Nguyễn Ngọc Trí Vĩ (you)"), auth.nameKey("nguyen.ngoc-tri VI"));
assert.equal(auth.nameKey("Đào"), "dao");
// Cyrillic о and а in place of Latin letters.
assert.equal(auth.nameKey("Mіа Trоn"), auth.nameKey("Mia Tron"));
const leo = await login("leo", "member-pass-2");
// mia's display name is "Mia Tran"; boss has none, only a username.
for (const name of ["MIA TRẦN (you)", "Mia\u200b Tran", "mia", "Bóss"]) {
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
assert.equal(r.status, 409, name);
assert.equal(r.body.error.code, "ENAMETAKEN");
}
assert.equal(auth.findUser("leo").display_name, "Leo N");
// Keeping one's own name, or a name of one's own, still works.
for (const name of ["Leo N", "Leo Nguyen"]) {
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
assert.equal(r.status, 200, name);
}
});
it("changes its own password only with the old one, keeping this login", async () => { it("changes its own password only with the old one, keeping this login", async () => {
const leo = await login("leo", "member-pass-2"); const leo = await login("leo", "member-pass-2");
const other = await login("leo", "member-pass-2"); const other = await login("leo", "member-pass-2");
+110
View File
@@ -0,0 +1,110 @@
/**
* @file session-account.test.js
* @description A session no workspace owns is billed to the user of the Linux
* account it ran as (server/lib/session-account.js): a member's account to
* that member, the host's to the admin marked as its owner (or the only
* admin). A workspace's ownership always wins, a pushed session never gets an
* account, and the hook peer is stamped once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "session-account-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const sa = require("../lib/session-account");
const scope = require("../lib/scope");
const { OWNED_SESSIONS } = require("../routes/users");
const own = (id) => {
const w = scope.ownSessionWhere({ user: { id } });
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
};
before(() => {
const user = db.prepare(
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
);
user.run(1, "boss", "admin");
user.run(2, "dev", "developer");
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
);
s.run("host-free", "eval", null, "", "local");
s.run("dev-free", "eval", null, "dev", "local");
s.run("host-in-dev-lane", "lane", 1, "", "local");
s.run("unknown", "old", null, null, "local");
s.run("gone-member", "x", null, "ghost", "local");
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe("session-account", () => {
it("bills a member's account to that member and the host's to the only admin", () => {
assert.equal(sa.hostOwner(), 1);
assert.deepEqual(own(1), ["host-free"]);
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
});
it("lets the workspace's owner win over the account", () => {
assert.ok(!own(1).includes("host-in-dev-lane"));
});
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
db.prepare(
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
).run();
assert.equal(sa.hostOwner(), null);
assert.deepEqual(own(1), []);
sa.setHostOwner(3);
assert.equal(sa.hostOwner(), 3);
assert.deepEqual(own(3), ["host-free"]);
sa.setHostOwner(1);
assert.deepEqual(own(3), []);
assert.deepEqual(own(1), ["host-free"]);
});
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
const rows = db.prepare(OWNED_SESSIONS).all();
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
assert.equal(by["host-free"], 1);
assert.equal(by["dev-free"], 2);
assert.equal(by["host-in-dev-lane"], 2);
assert.equal(by.unknown, undefined);
assert.equal(by["gone-member"], undefined);
});
it("stamps the hook peer once and never a pushed session", () => {
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
sa.stampFromPeer("fresh", { kind: "host" });
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
sa.stampFromPeer("pushed", { kind: "refused" });
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
assert.equal(acct("fresh"), "dev");
assert.equal(acct("pushed"), null);
});
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
).run();
sa.stampFromFolders();
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
// Outside S3 every folder is the host's.
assert.equal(acct("unknown"), "");
assert.equal(acct("pushed"), "");
assert.equal(acct("remote"), null);
});
});
+27
View File
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
); );
}); });
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-b", "bob@example.com");
profiles.saveProfile("codex");
assert.equal(profiles.activateProfile("codex", "alice").following, false);
const log = path.join(ROOT, "codex-argv.log");
const bin = path.join(ROOT, "fake-codex.sh");
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
fs.mkdirSync(path.dirname(sock), { recursive: true });
fs.writeFileSync(sock, "");
process.env.AGENTHUB_CODEX_BIN = bin;
try {
assert.equal(profiles.activateProfile("codex", "bob").following, true);
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
await new Promise((r) => setTimeout(r, 20));
assert.equal(
fs.readFileSync(log, "utf8").trim(),
`${process.env.CODEX_HOME} app-server daemon restart`
);
} finally {
delete process.env.AGENTHUB_CODEX_BIN;
}
});
it("refuses to switch away from an unsaved login", () => { it("refuses to switch away from an unsaved login", () => {
login("acct-a", "alice@example.com"); login("acct-a", "alice@example.com");
profiles.saveProfile("codex"); profiles.saveProfile("codex");
+6
View File
@@ -93,6 +93,12 @@ describe("GET /api/sessions/:id/stats tool totals", () => {
assert.equal(body.tool_call_count, 2); assert.equal(body.tool_call_count, 2);
assert.equal(body.distinct_tool_count, 1); assert.equal(body.distinct_tool_count, 1);
}); });
it("counts each call once in tools_used, not its Pre and Post events", async () => {
const one = (await get("/api/sessions/ua-1/stats")).body.tools_used;
assert.ok(one.every((t) => t.count === 1));
const two = (await get("/api/sessions/ua-2/stats")).body.tools_used;
assert.deepEqual(two, [{ tool_name: "Read", count: 2 }]);
});
}); });
describe("GET /api/events labels", () => { describe("GET /api/events labels", () => {
+19
View File
@@ -1445,6 +1445,16 @@ try {
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run(); db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
} }
// Migrate: add `account` to sessions — the Linux account a session ran as
// (server/lib/session-account.js): "" = the host's, else the S3 member's name;
// NULL = not known yet. A session no workspace owns is billed to the user of
// that account (server/lib/scope.js), so a script's `claude -p` still counts.
try {
db.prepare("SELECT account FROM sessions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE sessions ADD COLUMN account TEXT").run();
}
// How far each Codex rollout journal has been read (server/lib/codex-usage.js). // How far each Codex rollout journal has been read (server/lib/codex-usage.js).
// In the DB, not a side file, so a session the user deleted stays deleted // In the DB, not a side file, so a session the user deleted stays deleted
// until Codex writes to its journal again, while a wiped DB re-imports all. // until Codex writes to its journal again, while a wiped DB re-imports all.
@@ -1952,6 +1962,9 @@ for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
for (const [col, type] of [ for (const [col, type] of [
["last_login_at", "TEXT"], ["last_login_at", "TEXT"],
["must_change_password", "INTEGER NOT NULL DEFAULT 0"], ["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
// The admin who owns the HOST's Linux account: its sessions outside every
// workspace are billed to them (server/lib/session-account.js). At most one.
["host_account", "INTEGER NOT NULL DEFAULT 0"],
]) { ]) {
try { try {
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get(); db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
@@ -2547,10 +2560,16 @@ const stmts = {
FROM events FROM events
WHERE session_id = ? AND tool_name IS NOT NULL WHERE session_id = ? AND tool_name IS NOT NULL
`), `),
// One row per call, like sessionToolCallTotals: a call is its PreToolUse,
// or its PostToolUse in a session that has none (imported). Counting every
// event with a tool_name counted each call twice.
sessionToolUsageCounts: db.prepare(` sessionToolUsageCounts: db.prepare(`
SELECT tool_name, COUNT(*) as count SELECT tool_name, COUNT(*) as count
FROM events FROM events
WHERE session_id = ? AND tool_name IS NOT NULL WHERE session_id = ? AND tool_name IS NOT NULL
AND event_type = CASE
WHEN EXISTS (SELECT 1 FROM events WHERE session_id = ? AND event_type = 'PreToolUse')
THEN 'PreToolUse' ELSE 'PostToolUse' END
GROUP BY tool_name GROUP BY tool_name
ORDER BY count DESC ORDER BY count DESC
LIMIT 15 LIMIT 15
+4 -1
View File
@@ -873,7 +873,9 @@ function startBackgroundServices() {
if (poolTimer.unref) poolTimer.unref(); if (poolTimer.unref) poolTimer.unref();
} }
// Server mode's spend quotas (user-quota.js): who is over theirs now, the // Server mode's spend quotas (user-quota.js): who is over theirs now, the
// flag files their hooks read, the panes no hook can stop. Off with // flag files their hooks read, the panes no hook can stop — after giving
// the sessions no hook made their Linux account (session-account.js), so a
// poller's session is billed to someone too. Off with
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode. // DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000); const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
if ( if (
@@ -883,6 +885,7 @@ function startBackgroundServices() {
) { ) {
const quotaSweep = () => { const quotaSweep = () => {
try { try {
require("./lib/session-account").stampFromFolders();
require("./lib/user-quota").sweep(); require("./lib/user-quota").sweep();
} catch (err) { } catch (err) {
console.warn("user quota sweep failed:", err.message); console.warn("user quota sweep failed:", err.message);
+52 -2
View File
@@ -20,7 +20,8 @@
* admin on the Users page (`server/routes/users.js`), where every change * admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled). * needs the admin's own password again (`confirmPassword`, throttled).
* An account also carries an optional display name and email * An account also carries an optional display name and email
* (`validateProfile`), which its owner edits in Settings → Account without * (`validateProfile`; no two accounts may read as one name — `assertNameFree`,
* ENAMETAKEN), which its owner edits in Settings → Account without
* that step-up; changing their OWN password there needs the old one and * that step-up; changing their OWN password there needs the old one and
* keeps the login it was changed from (`setPassword`'s `keepToken`). * keeps the login it was changed from (`setPassword`'s `keepToken`).
* - Two password bars: an admin may GIVE any non-empty password (a create or a * - Two password bars: an admin may GIVE any non-empty password (a create or a
@@ -212,6 +213,9 @@ const publicUser = (u) =>
createdAt: u.created_at ?? null, createdAt: u.created_at ?? null,
// An admin gave this password: the account must pick its own first. // An admin gave this password: the account must pick its own first.
mustChangePassword: Boolean(u.must_change_password), mustChangePassword: Boolean(u.must_change_password),
// Owns the host's Linux account: its sessions outside every workspace
// are billed to this admin (server/lib/session-account.js).
hostAccount: Boolean(u.host_account),
}; };
const MAX_DISPLAY_NAME = 100; const MAX_DISPLAY_NAME = 100;
@@ -247,6 +251,47 @@ function validateProfile(body) {
return out; return out;
} }
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
const CONFUSABLE = {
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
};
/**
* The name two accounts may not share: no marks (Vĩ = Vi), đ = d, any case,
* look-alike letters folded, and only letters and digits outside brackets —
* so "Nguyễn Ngọc Trí Vĩ (you)" and "nguyen.ngoc-tri vi" are both one name.
*/
function nameKey(s) {
return String(s || "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/\([^)]*\)|\[[^\]]*\]/g, "")
.replace(/[đð]/g, "d")
.replace(/./gu, (c) => CONFUSABLE[c] ?? c)
.replace(/[^\p{L}\p{N}]/gu, "");
}
/**
* Refuse (ENAMETAKEN) a display name that reads as another account's display
* name or username, so no one can pose as a colleague. Keeping the name one
* already has is always allowed — a clash from before this rule is no reason
* to block an admin's unrelated edit.
*/
function assertNameFree(userId, fields) {
if (!("display_name" in fields) || !fields.display_name) return;
const key = nameKey(fields.display_name);
if (!key) return;
const own = sql("SELECT display_name FROM users WHERE id = ?").get(userId);
if (own && own.display_name === fields.display_name) return;
const rows = sql("SELECT id, username, display_name FROM users WHERE id != ?").all(userId);
if (rows.some((u) => nameKey(u.username) === key || nameKey(u.display_name) === key)) {
throw err("ENAMETAKEN", "displayName: another account already uses that name");
}
}
/** Write `validateProfile`'s result for one account. */ /** Write `validateProfile`'s result for one account. */
function setProfile(userId, fields) { function setProfile(userId, fields) {
for (const [col, value] of Object.entries(fields)) { for (const [col, value] of Object.entries(fields)) {
@@ -373,7 +418,10 @@ function setRole(username, role) {
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`); if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
const u = requireUser(username); const u = requireUser(username);
if (role !== "admin") assertKeepsAdmin(u, "demoted"); if (role !== "admin") assertKeepsAdmin(u, "demoted");
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id); // Only an admin owns the host account (session-account.js): a demotion drops the mark.
sql(
"UPDATE users SET role = ?, host_account = CASE WHEN ? = 'admin' THEN host_account ELSE 0 END WHERE id = ?"
).run(role, role, u.id);
if (role !== "admin") stopHostPanes(u.id); if (role !== "admin") stopHostPanes(u.id);
} }
@@ -812,6 +860,8 @@ module.exports = {
unassignWorkspace, unassignWorkspace,
ownedWorkspaces, ownedWorkspaces,
validateProfile, validateProfile,
nameKey,
assertNameFree,
setProfile, setProfile,
validatePassword, validatePassword,
validateOwnPassword, validateOwnPassword,
+55 -5
View File
@@ -61,6 +61,19 @@ async function readCapped(dir, args, capChars) {
const MAX_FILES = 300; const MAX_FILES = 300;
/** Most patch bytes read for ONE file before its hunks are cut short. */ /** Most patch bytes read for ONE file before its hunks are cut short. */
const MAX_FILE_BYTES = 200_000; const MAX_FILE_BYTES = 200_000;
/** Characters of one file's raw patch read before its long lines are cut down
* (`elideLongLines`) and `MAX_FILE_BYTES` applied. A page that embeds a video
* or a font as base64 is a few lines of megabytes each: read at
* `MAX_FILE_BYTES`, its first such line used the whole budget and every hunk
* after it was lost. Past this the patch is still truncated. */
const MAX_PATCH_READ_CHARS = 16 * 1024 * 1024;
/** A diff row longer than this is shown cut down to `LONG_LINE_KEEP`
* characters, with how many it hides (`elided` on the parsed line). */
const LONG_LINE_CHARS = 4000;
const LONG_LINE_KEEP = 1000;
/** Ends a cut-down row in the raw patch: a private-use character, then the
* count of characters hidden. `parseUnifiedDiff` turns it into `elided`. */
const ELIDED_MARK = "\uE000";
/** Most patch bytes across all files in a `full` (review-mode) response. */ /** Most patch bytes across all files in a `full` (review-mode) response. */
const MAX_TOTAL_BYTES = 1_000_000; const MAX_TOTAL_BYTES = 1_000_000;
/** An untracked file larger than this is reported without content. */ /** An untracked file larger than this is reported without content. */
@@ -259,15 +272,23 @@ function parseUnifiedDiff(patch) {
// row of its own and carries no line number. // row of its own and carries no line number.
if (raw.startsWith("\\")) continue; if (raw.startsWith("\\")) continue;
const marker = raw[0]; const marker = raw[0];
const text = raw.slice(1); let text = raw.slice(1);
// A row `elideLongLines` cut down: the text it kept, and how much it hid.
let elided = 0;
const cut = text.lastIndexOf(ELIDED_MARK);
if (cut >= 0 && /^\d+$/.test(text.slice(cut + 1))) {
elided = Number(text.slice(cut + 1));
text = text.slice(0, cut);
}
const extra = elided ? { elided } : {};
if (marker === "+") { if (marker === "+") {
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text }); current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text, ...extra });
} else if (marker === "-") { } else if (marker === "-") {
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text }); current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text, ...extra });
} else if (marker === " ") { } else if (marker === " ") {
// An unchanged EMPTY line is " " — one space, no text. A truly empty // An unchanged EMPTY line is " " — one space, no text. A truly empty
// string is the tail of the final "\n", not a row. // string is the tail of the final "\n", not a row.
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text }); current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text, ...extra });
} }
} }
return hunks; return hunks;
@@ -532,7 +553,27 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
: range === "branch" : range === "branch"
? ["diff", u, `${baseRef}...HEAD`, "--", file] ? ["diff", u, `${baseRef}...HEAD`, "--", file]
: ["diff", u, "HEAD", "--", file]; : ["diff", u, "HEAD", "--", file];
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => ""); return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_PATCH_READ_CHARS).catch(
() => ""
);
}
/**
* The patch with every row longer than `LONG_LINE_CHARS` cut down to its first
* `LONG_LINE_KEEP` characters plus `ELIDED_MARK` and the count it hides. Only
* what the panel SHOWS is cut: the hunk actions (`hunkPatch`, `selectionPatch`)
* re-read git's own diff and match hunks by header, which this never changes.
*/
function elideLongLines(patch) {
if (patch.length <= LONG_LINE_CHARS) return patch;
return patch
.split("\n")
.map((row) =>
row.length > LONG_LINE_CHARS && !row.startsWith("@@")
? `${row.slice(0, LONG_LINE_KEEP + 1)}${ELIDED_MARK}${row.length - LONG_LINE_KEEP - 1}`
: row
)
.join("\n");
} }
/** /**
@@ -648,7 +689,15 @@ async function collectChanges(
untracked: entry.untracked, untracked: entry.untracked,
context: ctx, context: ctx,
}); });
// A read that hit its own ceiling came back partial: still cut short,
// however small eliding its long rows makes it.
const partial = patch.length >= MAX_PATCH_READ_CHARS;
patch = elideLongLines(patch);
const cap = Math.min(MAX_FILE_BYTES, budget); const cap = Math.min(MAX_FILE_BYTES, budget);
if (partial) {
entry.truncated = true;
truncated = true;
}
if (patch.length > cap) { if (patch.length > cap) {
// Cut on a line boundary so the last row is a whole line — but only // Cut on a line boundary so the last row is a whole line — but only
// when that boundary is near the cap. A minified file is one enormous // when that boundary is near the cap. A minified file is one enormous
@@ -1334,6 +1383,7 @@ async function plainTreeAll(dir) {
} }
module.exports = { module.exports = {
elideLongLines,
collectChanges, collectChanges,
CONFLICT_CODES, CONFLICT_CODES,
contentSha, contentSha,
+2 -1
View File
@@ -555,12 +555,13 @@ async function usage(t0, t1, p0, p1, mem) {
const round = (n) => Math.round(n * 10) / 10; const round = (n) => Math.round(n * 10) / 10;
/** A user's group fields: id, username and display name (every viewer reads them). */ /** A user's group fields: id, username, display name and avatar version (every viewer reads them). */
const userExtra = (u) => ({ const userExtra = (u) => ({
kind: "user", kind: "user",
userId: u.id, userId: u.id,
username: u.username, username: u.username,
displayName: u.displayName ?? null, displayName: u.displayName ?? null,
avatarAt: u.avatarAt ?? null,
}); });
async function collect() { async function collect() {
+12 -6
View File
@@ -5,7 +5,9 @@
* *
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session * A workspace (`lanes.owner_id`) belongs to the user who created it. A session
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of * belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). Agents, events and * that workspace holding it (a session not stamped yet). A session no workspace owns
* belongs to the user of the Linux account it ran as (`sessions.account`,
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
* token rows belong to their session. A developer sees and changes only what * token rows belong to their session. A developer sees and changes only what
* their own workspaces own; an analyst SEES everything but changes only their * their own workspaces own; an analyst SEES everything but changes only their
* own (every non-GET request is scoped like a developer's); an admin sees and * own (every non-GET request is scoped like a developer's); an admin sees and
@@ -17,6 +19,7 @@
*/ */
const sql = (q) => require("../db").db.prepare(q); const sql = (q) => require("../db").db.prepare(q);
const { ACCOUNT_OWNED } = require("./session-account");
/** The user whose rows this request is limited to, or null for "all". */ /** The user whose rows this request is limited to, or null for "all". */
function memberId(req) { function memberId(req) {
@@ -53,8 +56,9 @@ function ownedSessionWhere(uid, col, laneCol) {
return { return {
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?) where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`, WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
params: [uid, uid], OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
params: [uid, uid, uid],
}; };
} }
@@ -264,14 +268,16 @@ function ownerStillFits(laneId) {
const STAFF = { staffOnly: true }; const STAFF = { staffOnly: true };
/** Owner ids of the workspaces that own a session (stamped or held by a run). */ /** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
function sessionOwners(sessionId) { function sessionOwners(sessionId) {
if (!sessionId) return new Set(); if (!sessionId) return new Set();
const rows = sql( const rows = sql(
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ? `SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
UNION UNION
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?` SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
).all(String(sessionId), String(sessionId)); UNION
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
).all(String(sessionId), String(sessionId), String(sessionId));
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null)); return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
} }
+113
View File
@@ -0,0 +1,113 @@
/**
* @file session-account.js
* @description Which Linux account a session ran as (`sessions.account`: ""
* = the host's, else an S3 member's name), and whose bill a session no
* workspace owns goes on. A workspace owns a session through `lane_id` or a
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
* neither, and without this its spend counted for nobody: not in anyone's
* Today, the Users page or a spend quota.
*
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
* loopback connection, never the body) when a hook creates the session; a
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
* gets it from its folder, the S3 rule `accountForPath` already applies to
* every pane. A pushed session (another machine's) never gets one. A member
* account bills to the user of that name; the host account to the admin an
* admin marked as its owner (`users.host_account`), or — with no mark — the
* only enabled admin, if there is exactly one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
const HOST_OWNER = `COALESCE(
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
// A session no workspace owns: neither stamped onto an owned lane nor held by
// a run of one — exactly the complement of scope.js's ownership.
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
const ACCOUNT_OWNED = `
SELECT s.id AS session_id, u.id AS uid
FROM sessions s JOIN users u
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
ELSE (SELECT id FROM users WHERE username = s.account) END
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
function accountOfPeer(peer) {
if (!peer || peer.kind === "host") return "";
if (peer.kind === "member" && peer.name) return peer.name;
return null;
}
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
function stampFromPeer(sessionId, peer, pushSource = null) {
if (pushSource || !sessionId) return;
const account = accountOfPeer(peer);
if (account == null) return;
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
}
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
function accountOfPath(cwd) {
const linux = require("./linux-accounts");
const conf = linux.s3Enabled() ? linux.serverConf() : null;
const root = conf?.usersRoot;
const path = require("path");
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
const rel = path.relative(root, path.resolve(cwd));
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
// The users root itself is nobody's.
return rel.split(path.sep)[0] || null;
}
/**
* Give every local session still without an account the one its folder runs
* as (sessions the pollers made, and history from before the column). Cheap
* once caught up: only `account IS NULL` rows are read.
*/
function stampFromFolders() {
const rows = sql(
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
).all();
if (!rows.length) return 0;
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
let n = 0;
require("../db").db.transaction(() => {
for (const r of rows) {
const account = accountOfPath(r.cwd);
if (account != null) n += set.run(account, r.id).changes;
}
})();
return n;
}
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
function setHostOwner(userId) {
require("../db").db.transaction(() => {
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
})();
}
/** The user id the host account bills to right now, or null. */
function hostOwner() {
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
}
module.exports = {
ACCOUNT_OWNED,
accountOfPeer,
stampFromPeer,
accountOfPath,
stampFromFolders,
setHostOwner,
hostOwner,
};
+35 -2
View File
@@ -6,7 +6,9 @@
* swap it back in later, touching nothing but that login. * swap it back in later, touching nothing but that login.
* *
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API * - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
* key). Config, sessions, skills and history stay where they are. * key). Config, sessions, skills and history stay where they are. A
* switch then restarts Codex's shared app-server daemon when one runs,
* so running and new sessions follow the new login (`follow`).
* - `omp`: every credential Oh My Pi stores, read with `omp auth export` * - `omp`: every credential Oh My Pi stores, read with `omp auth export`
* and put back with `omp auth import -` (secrets on stdin, never argv). * and put back with `omp auth import -` (secrets on stdin, never argv).
* Import MERGES, so a switch first empties omp's own credential tables in * Import MERGES, so a switch first empties omp's own credential tables in
@@ -85,6 +87,15 @@ const codex = {
writeJson(codexAuthFile(), secret); writeJson(codexAuthFile(), secret);
return [codexAuthFile()]; return [codexAuthFile()];
}, },
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
// outlives every TUI and holds the login it started with: a new auth.json
// reached neither a running session nor a NEW one, which kept the old
// (often out-of-quota) account. Restarting the daemon makes it read the new
// login, and every open TUI reconnects to it on its own (verified on
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
follow() {
return restartCodexDaemon();
},
identify(auth) { identify(auth) {
if (auth.tokens && auth.tokens.id_token) { if (auth.tokens && auth.tokens.id_token) {
const c = jwtClaims(auth.tokens.id_token) || {}; const c = jwtClaims(auth.tokens.id_token) || {};
@@ -107,6 +118,26 @@ const codex = {
}, },
}; };
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
const codexDaemonSocket = () =>
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
function restartCodexDaemon() {
if (!fs.existsSync(codexDaemonSocket())) return false;
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
if (!bin) return false;
cp.execFile(
bin,
["app-server", "daemon", "restart"],
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
(e) => {
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
}
);
return true;
}
// ── Oh My Pi ─────────────────────────────────────────────────────────── // ── Oh My Pi ───────────────────────────────────────────────────────────
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp"); const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
} }
const target = readProfile(tool, name); const target = readProfile(tool, name);
const written = a.write(target.secret, live); const written = a.write(target.secret, live);
return { ok: true, name, savedOutgoing, written }; // `following`: running sessions take the new login without a restart of their own.
const following = typeof a.follow === "function" ? a.follow() : false;
return { ok: true, name, savedOutgoing, written, following };
} }
function deleteProfile(tool, name) { function deleteProfile(tool, name) {
+5
View File
@@ -790,6 +790,11 @@ const paths = {
addWorkspaces: { type: "array", items: { type: "integer" } }, addWorkspaces: { type: "array", items: { type: "integer" } },
removeWorkspaces: { type: "array", items: { type: "integer" } }, removeWorkspaces: { type: "array", items: { type: "integer" } },
signOut: { type: "boolean", enum: [true] }, signOut: { type: "boolean", enum: [true] },
hostAccount: {
type: "boolean",
description:
"An admin owns the host's Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)",
},
quota: { $ref: "#/components/schemas/UserQuotaSetting" }, quota: { $ref: "#/components/schemas/UserQuotaSetting" },
}), }),
responses: { responses: {
+5
View File
@@ -489,6 +489,11 @@ const paths = {
items: { type: "string" }, items: { type: "string" },
description: "Files the switch wrote.", description: "Files the switch wrote.",
}, },
following: {
type: "boolean",
description:
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
},
}, },
}, },
"Switched." "Switched."
+2
View File
@@ -1643,6 +1643,8 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
} else { } else {
touchLaneFromHook(hook_type, data, tool, body.run_id, peer); touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool); bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
// The Linux account it ran as: whose bill it is when no workspace owns it.
require("../lib/session-account").stampFromPeer(data.session_id, peer);
} }
} }
} finally { } finally {
+2
View File
@@ -197,6 +197,7 @@ const { recordAccountChange } = require("../lib/audit");
const ACCOUNT_STATUS = { const ACCOUNT_STATUS = {
EBADFIELD: 400, EBADFIELD: 400,
EBADEMAIL: 400, EBADEMAIL: 400,
ENAMETAKEN: 409,
EBADPASSWORD: 400, EBADPASSWORD: 400,
EWEAKPASSWORD: 400, EWEAKPASSWORD: 400,
EBADIMAGE: 400, EBADIMAGE: 400,
@@ -262,6 +263,7 @@ router.patch(
sameOriginGuard, sameOriginGuard,
accountWrite(async (_req, user, body) => { accountWrite(async (_req, user, body) => {
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email }); const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
auth.assertNameFree(user.id, fields);
auth.setProfile(user.id, fields); auth.setProfile(user.id, fields);
return { out: accountView(user.id), action: "own profile" }; return { out: accountView(user.id), action: "own profile" };
}) })
+1 -1
View File
@@ -702,7 +702,7 @@ router.get("/:id/file", async (req, res) => {
function sessionStats(sessionId) { function sessionStats(sessionId) {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0; const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId); const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
const tools = stmts.sessionToolUsageCounts.all(sessionId); const tools = stmts.sessionToolUsageCounts.all(sessionId, sessionId);
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count. // Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse. // A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {}; const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
+24 -3
View File
@@ -54,8 +54,9 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
const router = Router(); const router = Router();
// Which account each session belongs to — through its workspace, or a // Which account each session belongs to — through its workspace, a dashboard
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once). // run on one, or (no workspace owns it) the Linux account it ran as
// (server/lib/scope.js's sessionWhere, for every user at once).
const OWNED_SESSIONS = ` const OWNED_SESSIONS = `
SELECT s.id AS session_id, l.owner_id AS uid SELECT s.id AS session_id, l.owner_id AS uid
FROM sessions s JOIN lanes l ON l.id = s.lane_id FROM sessions s JOIN lanes l ON l.id = s.lane_id
@@ -63,7 +64,9 @@ const OWNED_SESSIONS = `
UNION UNION
SELECT s.id, l.owner_id SELECT s.id, l.owner_id
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
WHERE l.owner_id IS NOT NULL`; WHERE l.owner_id IS NOT NULL
UNION
${require("../lib/session-account").ACCOUNT_OWNED}`;
/** /**
* Map(user id → {cost, tokens}) per window, each window a UTC hour * Map(user id → {cost, tokens}) per window, each window a UTC hour
@@ -171,6 +174,7 @@ router.get("/tmux", (_req, res) => {
const STATUS = { const STATUS = {
EBADUSER: 400, EBADUSER: 400,
EBADEMAIL: 400, EBADEMAIL: 400,
ENAMETAKEN: 409,
ENOTOWNED: 409, ENOTOWNED: 409,
EBADPASSWORD: 400, EBADPASSWORD: 400,
EWEAKPASSWORD: 400, EWEAKPASSWORD: 400,
@@ -562,6 +566,7 @@ router.patch(
// 1. Validate everything — nothing is written until all of it passes. // 1. Validate everything — nothing is written until all of it passes.
const profile = auth.validateProfile(body); const profile = auth.validateProfile(body);
auth.assertNameFree(u.id, profile);
const roleChange = body.role !== undefined && body.role !== u.role; const roleChange = body.role !== undefined && body.role !== u.role;
if (roleChange) { if (roleChange) {
if (self) throw ESELF("you cannot change your own role"); if (self) throw ESELF("you cannot change your own role");
@@ -578,6 +583,18 @@ router.patch(
if (self && body.disabled) throw ESELF("you cannot disable yourself"); if (self && body.disabled) throw ESELF("you cannot disable yourself");
} }
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled); const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
if (body.hostAccount !== undefined) {
if (typeof body.hostAccount !== "boolean") {
throw Object.assign(new Error("hostAccount: true or false"), { code: "EBADFIELD" });
}
if (body.hostAccount && (body.role ?? u.role) !== "admin") {
throw Object.assign(new Error("only an admin can own the host account"), {
code: "EBADROLE",
});
}
}
const hostChange =
body.hostAccount !== undefined && body.hostAccount !== Boolean(u.host_account);
if (body.password !== undefined) { if (body.password !== undefined) {
// Your own password: every rule. Someone else's: a starting one they replace. // Your own password: every rule. Someone else's: a starting one they replace.
if (self) { if (self) {
@@ -617,6 +634,10 @@ router.patch(
auth.setRole(u.username, body.role); auth.setRole(u.username, body.role);
done.push(`role ${body.role}`); done.push(`role ${body.role}`);
} }
if (hostChange) {
require("../lib/session-account").setHostOwner(body.hostAccount ? u.id : null);
done.push(body.hostAccount ? "owns the host account" : "no longer owns the host account");
}
for (const id of add) auth.assignWorkspace(id, u.username); for (const id of add) auth.assignWorkspace(id, u.username);
for (const id of remove) auth.unassignWorkspace(id, u.username); for (const id of remove) auth.unassignWorkspace(id, u.username);
if (add.length) done.push(`workspace ${add.join(", ")}`); if (add.length) done.push(`workspace ${add.join(", ")}`);