diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6cff9a7b..7079fcc7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -39,6 +39,16 @@ "async": true } ] + }, + { + "matcher": "Artifact|mcp__claude_ai_.*__batch", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/artifact-export-hint.js\"", + "timeout": 5 + } + ] } ], "Stop": [ diff --git a/CLAUDE.md b/CLAUDE.md index c0254461..50f24265 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.68 (2026-11-03) — S3 panes capped (commands die first on OOM, no tests from Claude, 4 AI sessions per member); the pet no longer chats._ 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.71 (2026-11-04) — artifacts shared with chosen people, per-account folders, and a viewable copy of every page._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -8,6 +8,7 @@ _Last synced: v2.8.68 (2026-11-03) — S3 panes capped (commands die first on OO ## Repo map - `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js` (omp's journals → `tool = 'omp'` sessions, tokens and subagent agents)). +- `server/hub/`: the hub half of remote clients (docs/plans/remote-clients/README.md) — device tokens, the `/api/remote/gw` gateway, session registration, hourly-upload ingest and retention. Required ONLY inside `server/index.js`'s `isServerMode()` branch; nothing outside the folder may require it (`server/__tests__/hub-boundary.test.js`). The client half is `server/lib/remote-client.js` + `/api/remote-client` + `agenthub remote` (local mode). - `client/`: React + Vite UI. - `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. While it runs, the step it is on is `plugin-update-progress.json` → `plugin_update_progress` (the update modal's step line and the chip it leaves when hidden). Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir. `scripts/clone-runner.js` runs one background clone (`server/lib/clone-jobs.js`) detached, so it outlives a server restart and is adopted by the next server.) - `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:` (bare `/` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap. @@ -45,7 +46,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. -- **The dashboard's git writes are these forty-seven, and the guards are IN `git-ops.js` (and the `git-refs.js`, `git-rewrite.js`, `git-conflicts.js` and `git-pick.js` it dispatches to), not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop`, the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` (`git-refs.js`), and the History tools' `conflict-save`, `rebase`, `rebase-continue`, `rebase-skip`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-add`, `remote-rename`, `remote-remove` (`git-rewrite.js`; `conflict-save` is `git-conflicts.js`'s), and the History tab's `pick-continue`, `pick-skip`, `pick-abort`, `undo-pick` (`git-pick.js`) — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. **A history rewrite or remote write is allowed only because its recovery is a button too, and the server refuses what the UI only discourages:** every one runs from a repo-root lane (`ESUBDIR`), never while a merge/cherry-pick/revert/rebase is open (`EMERGING`), names the full HEAD sha its plan showed (`ESTALE`), and refuses to rewrite a commit any remote-tracking branch holds unless the body says `force: true` (`EPUSHED` — the UI shows that a force-push will be needed); each answers `before`/`after`, and `undo-rewrite` (`reset --keep `, `confirm: true`, only while HEAD is still `after`) is its Undo. `rebase` (a ref looked up by `branchRef`) and `rebase-interactive` need a clean tree; a stopped rebase is finished by `rebase-continue`/`-skip`/`-abort` (`confirm: true`) — `merge-commit`/`merge-abort` map onto it — and lets through only `ALLOWED_WHILE_REBASING` (fetch, stage, unstage, discard, ignore, resolve, conflict-save and its own steps); `operationInProgress` reports `rebase` first. `rebase-interactive` takes a todo that must list exactly `base..HEAD`'s commits by full sha (`EBADTODO`, no merge commit); git is run with `GIT_SEQUENCE_EDITOR` = `cp -- ` and `GIT_EDITOR=true`, the todo and the reword/squash messages are 0600 files AgentHub writes in the repo's own git dir (as the folder's account), and the only `exec` line is `git commit --amend -F ` — never put request text into a todo line or any shell string. `reset` is `--soft|--mixed|--keep|--hard` to an ancestor or descendant of HEAD (`ENOTRELATED`), `confirm: true`; `--hard` only on a CLEAN tree and with `confirmBranch` = the current branch (so nothing uncommitted can be lost) — the lane-destroying `reset` of `worktree.js`'s three-check guard is a different thing and stays there. `force-push` pushes HEAD only, to its upstream branch, with `--force-with-lease=:` (the sha the UI showed; a moved remote is `ESTALE`), `confirm: true`, and refuses `main`/`master`/`development`/`develop` unless the server sets `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`. `remote-branch-delete` takes a fetched `/`, `confirm: true`, never a protected name nor the remote's default (`refs/remotes//HEAD`, `EDEFAULTBRANCH`). `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` and `remote-add` are the only writes that take a destination from the request** (`{url}`), and both pass it through `validateRemoteUrl`'s allowlist; `remote-add` names a remote of letters, digits, `.`, `_`, `-` (`EBADREMOTE`), never re-points an existing one (`EREMOTEEXISTS`), and `remote-rename`/`remote-remove` (`confirm: true`) touch only a remote that exists. `publish` can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let `publish` touch a repo that already has a remote; a stored credential still goes only to its own host (the git-credentials rule below) whatever remote a user adds. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort`, `shelf-drop`, `tag-delete`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-remove`, `pick-skip`, `pick-abort` and `undo-pick` refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open; with `shas: [...]` instead of `sha` — 1–50 non-merge commits, `git-pick.js` — they run as ONE git sequence in the order `GET /git/pick-plan` previews (a pick oldest→newest, a revert newest→oldest), a stop on a conflict is finished by `pick-continue` (`ECONFLICTS` while anything is unmerged), `pick-skip` or `pick-abort` (abort also ends a sequence whose *_HEAD is gone), none of them — nor `undo-pick` — is in `ALLOWED_WHILE_REBASING`, and the single-`sha` body stays git-ops.js's own path): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add any stash use beyond shelves, nor a write whose recovery is not a button: every rewrite above keeps its `before` for `undo-rewrite`, and a stopped rebase its `rebase-abort`. `undo-pick` = `reset --soft ` takes back what a pick/revert just made (`confirm: true`, repo root, only while HEAD is still the `to` that pick produced — `ESTALE` —, `from` its ancestor 1–50 commits back with no merge between, none of them on a remote-tracking branch — `EPUSHED`, no override). `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit) and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`, no override); a rebase open in a terminal blocks every history action, shelves and the ref tools (`EMERGING`) but may be continued/skipped/aborted from the banner. Rewriting a pushed commit needs the request's explicit `force: true` and then a `force-push` with lease — do not relax either check. The 3-way merge editor reads `GET /git/conflict` (stages by blob id, only a path `ls-files -u` lists) and saves through `conflict-save` = `lane-file-write.js`'s guards then `git add`, `EMARKERS` before anything is written. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log` — paged with `skip`, `all=1`, search by `q`/`author`/`path`, each commit with parents and ref badges —, `/git/commit`, `/git/patch` — a secret file's diff left out —, `/git/file-history` (`--follow`), `/git/blame-at`, `/git/tree-at` — a secret left out of the list —, `/git/compare`, `/git/pick-plan`, `/git/sequence` (`server/lib/git-history.js`, mounted by `server/routes/lane-git-history.js`, every revision resolved to a commit sha before git sees it, heavy walks timed out at 20 s; a rebase is never reported as a paused pick), `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`, `/git/refs`, `/git/conflict`, `/git/reflog`, `/git/remotes` — URLs with any password stripped —, `/git/rewrite-plan`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. +- **The dashboard's git writes are these forty-seven, and the guards are IN `git-ops.js` (and the `git-refs.js`, `git-rewrite.js`, `git-conflicts.js` and `git-pick.js` it dispatches to), not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop`, the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` (`git-refs.js`), and the History tools' `conflict-save`, `rebase`, `rebase-continue`, `rebase-skip`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-add`, `remote-rename`, `remote-remove` (`git-rewrite.js`; `conflict-save` is `git-conflicts.js`'s), and the History tab's `pick-continue`, `pick-skip`, `pick-abort`, `undo-pick` (`git-pick.js`) — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. **A history rewrite or remote write is allowed only because its recovery is a button too, and the server refuses what the UI only discourages:** every one runs from a repo-root lane (`ESUBDIR`), never while a merge/cherry-pick/revert/rebase is open (`EMERGING`), names the full HEAD sha its plan showed (`ESTALE`), and refuses to rewrite a commit any remote-tracking branch holds unless the body says `force: true` (`EPUSHED` — the UI shows that a force-push will be needed); each answers `before`/`after`, and `undo-rewrite` (`reset --keep `, `confirm: true`, only while HEAD is still `after`) is its Undo. `rebase` (a ref looked up by `branchRef`) and `rebase-interactive` need a clean tree; a stopped rebase is finished by `rebase-continue`/`-skip`/`-abort` (`confirm: true`) — `merge-commit`/`merge-abort` map onto it — and lets through only `ALLOWED_WHILE_REBASING` (fetch, stage, unstage, discard, ignore, resolve, conflict-save and its own steps); `operationInProgress` reports `rebase` first. `rebase-interactive` takes a todo that must list exactly `base..HEAD`'s commits by full sha (`EBADTODO`, no merge commit); git is run with `GIT_SEQUENCE_EDITOR` = `cp -- ` and `GIT_EDITOR=true`, the todo and the reword/squash messages are 0600 files AgentHub writes in the repo's own git dir (as the folder's account), and the only `exec` line is `git commit --amend -F ` — never put request text into a todo line or any shell string. `reset` is `--soft|--mixed|--keep|--hard` to an ancestor or descendant of HEAD (`ENOTRELATED`), `confirm: true`; `--hard` only on a CLEAN tree and with `confirmBranch` = the current branch (so nothing uncommitted can be lost) — the lane-destroying `reset` of `worktree.js`'s three-check guard is a different thing and stays there. `force-push` pushes HEAD only, to its upstream branch, with `--force-with-lease=:` (the sha the UI showed; a moved remote is `ESTALE`), `confirm: true`, and refuses `main`/`master`/`development`/`develop` unless the server sets `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`. `remote-branch-delete` takes a fetched `/`, `confirm: true`, never a protected name nor the remote's default (`refs/remotes//HEAD`, `EDEFAULTBRANCH`). `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` and `remote-add` are the only writes that take a destination from the request** (`{url}`), and both pass it through `validateRemoteUrl`'s allowlist; `remote-add` names a remote of letters, digits, `.`, `_`, `-` (`EBADREMOTE`), never re-points an existing one (`EREMOTEEXISTS`), and `remote-rename`/`remote-remove` (`confirm: true`) touch only a remote that exists. `publish` can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let `publish` touch a repo that already has a remote; a stored credential still goes only to its own host (the git-credentials rule below) whatever remote a user adds. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort`, `shelf-drop`, `tag-delete`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-remove`, `pick-skip`, `pick-abort` and `undo-pick` refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open; with `shas: [...]` instead of `sha` — 1–50 non-merge commits, `git-pick.js` — they run as ONE git sequence in the order `GET /git/pick-plan` previews (a pick oldest→newest, a revert newest→oldest), a stop on a conflict is finished by `pick-continue` (`ECONFLICTS` while anything is unmerged), `pick-skip` or `pick-abort` (abort also ends a sequence whose *_HEAD is gone), none of them — nor `undo-pick` — is in `ALLOWED_WHILE_REBASING`, and the single-`sha` body stays git-ops.js's own path): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a restore while anything is staged or one of the shelf's own files changed (`EDIRTY`; other files' changes stay), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add any stash use beyond shelves, nor a write whose recovery is not a button: every rewrite above keeps its `before` for `undo-rewrite`, and a stopped rebase its `rebase-abort`. `undo-pick` = `reset --soft ` takes back what a pick/revert just made (`confirm: true`, repo root, only while HEAD is still the `to` that pick produced — `ESTALE` —, `from` its ancestor 1–50 commits back with no merge between, none of them on a remote-tracking branch — `EPUSHED`, no override). `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit) and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`, no override); a rebase open in a terminal blocks every history action, shelves and the ref tools (`EMERGING`) but may be continued/skipped/aborted from the banner. Rewriting a pushed commit needs the request's explicit `force: true` and then a `force-push` with lease — do not relax either check. The 3-way merge editor reads `GET /git/conflict` (stages by blob id, only a path `ls-files -u` lists) and saves through `conflict-save` = `lane-file-write.js`'s guards then `git add`, `EMARKERS` before anything is written. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log` — paged with `skip`, `all=1`, search by `q`/`author`/`path`, each commit with parents and ref badges —, `/git/commit`, `/git/patch` — a secret file's diff left out —, `/git/file-history` (`--follow`), `/git/blame-at`, `/git/tree-at` — a secret left out of the list —, `/git/compare`, `/git/pick-plan`, `/git/sequence` (`server/lib/git-history.js`, mounted by `server/routes/lane-git-history.js`, every revision resolved to a commit sha before git sees it, heavy walks timed out at 20 s; a rebase is never reported as a paused pick), `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`, `/git/refs`, `/git/conflict`, `/git/reflog`, `/git/remotes` — URLs with any password stripped —, `/git/rewrite-plan`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`), and so is commit signing (`PUT /api/me/git/signing`: `gpg.format ssh` + `user.signingkey` = one of the account's AgentHub key files + `commit.gpgsign`; a signing key the user set themselves only with `replace`). An imported key (`POST /api/me/git/ssh-key/import`) is written exactly like a generated one — the private text goes IN, only its public half comes back, a passphrase key is refused. Commit signing is PER WORKSPACE (`PUT /api/me/git/signing/lane`): one small file of ours per workspace (`lane-.gitconfig` beside the account's keys) pulled in by `includeIf "gitdir:…"` lines in the account's `~/.gitconfig` for the workspace's git dir and its sessions' `ah--*` worktrees — never written into the repository, and deleting a key stops every workspace signing with it. Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config, the account's own `~/.gitconfig` or those signing files. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. - **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. @@ -60,7 +61,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`; a member's single file goes to a hidden temp name and is `write-as move`d onto its name only once every byte arrived, never written in place) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `/trash//`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `/trash//`, items tagged `store`). **An upload survives a dashboard restart because its bytes are staged first** (`server/lib/upload-staging.js`): parts go to `/upload-staging///` on the HOST, keyed by the caller's user and a browser-made id (never a path), and `?staged=` on `upload`/`upload-batch` writes them through the SAME code and guards — a commit's answer is stored and replayed, never written twice. Do not add a staged write that skips `upload`/`uploadBatch`, nor stage inside a member's home. Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`. - **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo. - **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-` sessions. -- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `/artifacts//` and every viewer reads only that copy (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; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `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 `/artifacts//` 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; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `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 those shared with everyone (`shared`, the Hub) or with them by name (`artifact_shares`, **Shared with you** — the name list only its owner or an admin sees); only the owner or an admin changes one, and Share again never widens a by-name share to everyone. Artifact folders are the viewer's own (`agenthub.artifacts.folders` preference), never an artifact column. A `page` keeps a COPY anyone it reaches can view without the publishing claude.ai account — its HTML file (and the files the tool named) snapshotted at publish, or the `artifacts-export/.*` Claude saves for a Doc / Slides / Design (`scripts/artifact-export-hint.js`, the one hook that answers Claude: synchronous, matched to those tools only, silent outside AgentHub panes) attached at Stop; it is viewed in `HtmlFrame`'s sandbox or drawn by pdf.js, never fetched from claude.ai — do not add a route that proxies or logs into claude.ai for it. 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 `/workspace-context//` 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 `/workspace-prompts//` (a member's: `prompt-/` 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=` — 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 ` (`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=` — 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=` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `/.claude/worktrees/`; 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. @@ -85,6 +86,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. `/cc-config` is in neither: its router gates itself (`server/lib/cc-target.js` — a non-admin reaches only an ALLOWLIST of reads and own-config writes; `/profiles*`, `/plugins/update-all` and every unlisted endpoint stay an admin's) and every handler acts on ONE target from `resolveCcTarget` — the host's home for an admin/local mode, a member's own `~/.claude` under S3 (writes through `write-as`, `claude plugin|mcp` as their sudo argv: user scope, no `-y`, no env/headers), `403 ENOACCOUNT` otherwise, never the host's; a new endpoint there joins `MEMBER_ROUTES` only if it acts on the target. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`; tmux through `/opt/agenthub/libexec/tmux-member` once installed, which runs the member's RUNNING server's own binary so a tmux upgrade never mixes versions); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. - **Under S3 the box is for writing code, and a member's runaway job dies alone.** The unit caps its cgroup (`MemoryMax=85%`, `MemorySwapMax=1G`, `OOMPolicy=continue`); inside it `oom_score_adj` orders the kill — Claude's commands 1000 through `CLAUDE_CODE_SHELL_PREFIX=/opt/agenthub/libexec/tool-run`, a Terminal tab 800, an AI session 300, dashboard/tmux -100 — so a test run dies, never the conversation. `scripts/member-guard.js` (synchronous managed hook, `ah-*` only) denies test runs and containers from Claude's Bash (unless an admin's Settings switch — `member-policy.json` in the app root, `/api/ai-tools/member-policy` — allows them) and refuses the prompts of a session past `MAX_AI_SESSIONS` (default 4, counted from /proc by `server/lib/ai-procs.js`, which `spawnRun` also checks: `429 ESESSIONLIMIT`). It decides locally and fails open like every hook. See `docs/SERVER-MODE.md` § Operator runbook. +- **A company account on a user's own machine is the hub's credential at a distance, and it never leaves the hub.** A local install enrolled with `agenthub remote login` holds only an `ahdev_` device token (0600, `/runtime/remote-client.json`; the hub keeps its SHA-256, 90 days, revocable); its **Company account** panes (reserved AI Proxy name `agenthub.company` — a dot no older profile name can have —, Claude only, refused in server mode) get `--settings=/proxy-settings/agenthub.company.json` with the hub's `/api/remote/gw` and that token, and the gateway swaps in the pool account (`claude-pool.forRemote`, sticky per session) only for a session id THAT device registered and never for one the hub already knew from elsewhere (else `AGENTHUB_REMOTE_GATE`: `warn` flags it unmonitored, `block` 403; a message with no session id, a user over quota — `EQUOTA` — and a purged session are refused whatever the gate). Only company sessions are registered and uploaded (hourly + after SessionEnd, whole transcript, `privacy.redactValue` per line, at the hub's own offset); a session on the user's own login sends nothing. The hub never connects to a client, never reads its source and never runs anything there — do not add a route that does, one that returns a pool credential to a device, or a path that uploads a non-company session. Uploaded sessions are owned by `sessions.owner_id`, which `scope.js`, `session-account.js` and `users.js` honour. - **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex|cursor]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it; a Cursor API key (`server/lib/cursor-pool.js`) is `-e CURSOR_API_KEY` and nothing else. **Pool groups** (`server/lib/pool-groups.js`) narrow every balancer through `allowed(ownerId, tool)`: a group's member takes only its accounts, a user in no group only the accounts no group lists, a pin wins, then the user's OWN choice (`account_choices`, `/api/me/pool-accounts` — only an account `allowed` names, re-checked at every spawn, refused while pinned); a login already live in a home is never swapped for a group change. The tool list is `account-pins.js` `TOOLS` (no DB CHECK). - **A shared Oh My Pi login is reached only through omp's gateway, by a per-pane key, never handed out.** `server/lib/omp-share.js` (S3 only, an admin's switch, off by default) runs `omp auth-broker serve` + `omp auth-gateway serve --clients=` as the host on loopback; both tokens stay in the host's home and the broker's reaches only the gateway's env — never a pane, a route, a log or argv. A member's omp gets the `agenthub` provider in its `models.yml` (written as them) whose `apiKey` is the env var NAME `AGENTHUB_OMP_KEY`; that key is one already QUEUED in the clients file (the gateway re-reads it only every 10 s, and an omp started on a refused key hangs for good), and the clients file holds only SHA-256s — of the queue and of LIVE omp panes' keys. Do not give a pane the broker or gateway token, `OMP_AUTH_BROKER_*`, or a copy of the host's `agent.db`. - Preserve existing behavior unless explicitly asked to change it. diff --git a/bin/agenthub.js b/bin/agenthub.js index 6941e416..71f2cbbd 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -1133,6 +1133,107 @@ function renderPricingTable(rules) { ); } +/** + * `agenthub remote login|status|logout|upload` — this LOCAL AgentHub's link + * to the company hub (server/lib/remote-client.js, through the running + * dashboard's /api/remote-client). The password goes to the hub once and is + * never stored; only a device token is, which no command prints. + */ +async function cmdRemote(flags, positional) { + const sub = positional[0] || "status"; + const show = (st) => { + if (!st.enrolled) { + console.log( + st.expired + ? c.yellow( + `This machine's hub login expired (${st.hub}) — run: agenthub remote login ${st.hub}` + ) + : "Not enrolled with a company hub — run: agenthub remote login " + ); + return; + } + console.log( + `${c.green("●")} Enrolled with ${c.bold(st.hub)} as ${c.bold(st.user?.username || "?")}` + ); + console.log( + c.dim( + ` device ${st.device?.name} (#${st.device?.id}), valid until ${new Date(st.device?.expiresAt).toLocaleString()}` + ) + ); + console.log( + c.dim( + " Only sessions started on the Company account send their conversation and tokens to the hub (hourly)." + ) + ); + }; + if (sub === "login") { + const hub = positional[1]; + if (!hub) { + console.error( + c.red("✖ usage: agenthub remote login [--user NAME] [--name DEVICE]") + ); + process.exit(1); + } + let username = flags.user ? String(flags.user) : ""; + if (!username) { + if (!process.stdin.isTTY) { + console.error(c.red("✖ --user is required when the password comes from stdin")); + process.exit(1); + } + const rl = require("node:readline").createInterface({ + input: process.stdin, + output: process.stdout, + }); + username = await new Promise((r) => + rl.question("Hub username: ", (a) => (rl.close(), r(a.trim()))) + ); + } + const password = await askPassword(`Hub password for ${username}: `); + const r = await api( + "POST", + "/api/remote-client/login", + { hub, username, password, deviceName: flags.name ? String(flags.name) : undefined }, + { allowError: true, timeoutMs: 30_000 } + ); + if (r && r.status) { + console.error(c.red(`✖ ${r.data?.error?.message || `HTTP ${r.status}`}`)); + process.exit(1); + } + show(r); + return; + } + if (sub === "logout") { + const r = await post("/api/remote-client/logout", {}); + console.log( + r.wasEnrolled + ? `${c.green("✔")} Logged out${r.revokedOnHub ? " — the hub revoked this device" : c.yellow(" — the hub could not be reached; the token stops working when it expires or an admin revokes it")}` + : "This machine was not enrolled." + ); + return; + } + if (sub === "upload") { + const r = await post("/api/remote-client/upload", {}, { timeoutMs: 600_000 }); + console.log(`${c.green("✔")} Sent ${r.bytes} bytes of ${r.sessions} session(s)`); + for (const e of r.errors || []) console.log(c.yellow(` ${e.sessionId}: ${e.message}`)); + return; + } + if (sub === "status") { + const st = await get("/api/remote-client"); + if (flags.json) return console.log(JSON.stringify(st, null, 2)); + show(st); + for (const u of (st.uploads || []).slice(0, 10)) { + console.log( + c.dim( + ` ${u.sessionId.slice(0, 8)} ${String(u.bytes || 0).padStart(9)} B ${u.lastSentAt || "not sent yet"} ${u.cwd || ""}` + ) + ); + } + return; + } + console.error(c.red(`✖ unknown: agenthub remote ${sub} (login | status | logout | upload)`)); + process.exit(1); +} + async function cmdPricing(flags, positional) { const sub = positional[0]; if (sub === "set" && positional[1]) { @@ -2934,6 +3035,19 @@ const COMMAND_GROUPS = [ ["remote-sources rm ", "[--purge]", "Remove a source (--purge also deletes its data)"], ], ], + [ + "Company hub", + [ + [ + "remote login ", + "[--user NAME --name DEVICE]", + "Enroll this machine (Company account)", + ], + ["remote status", "[--json]", "Enrollment and what was sent to the hub"], + ["remote upload", "", "Send the Company-account sessions' new lines now"], + ["remote logout", "", "Revoke this machine on the hub and forget it"], + ], + ], [ "Workspaces", [ @@ -4549,6 +4663,8 @@ async function runCommand(argv) { case "remote-sources": case "remotes": return cmdRemoteSources(flags, positional); + case "remote": + return cmdRemote(flags, positional); case "pricing": return cmdPricing(flags, positional); case "import": diff --git a/client/package-lock.json b/client/package-lock.json index 7f41d147..381443d5 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -25,6 +25,7 @@ "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.61.0", + "pdfjs-dist": "6.4.299", "react": "^19.3.1", "react-dom": "^19.3.1", "react-i18next": "^17.0.4", @@ -1194,6 +1195,241 @@ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", "license": "MIT" }, + "node_modules/@napi-rs/canvas": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz", + "integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.1.0", + "@napi-rs/canvas-darwin-arm64": "1.1.0", + "@napi-rs/canvas-darwin-x64": "1.1.0", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/canvas-linux-arm64-gnu": "1.1.0", + "@napi-rs/canvas-linux-arm64-musl": "1.1.0", + "@napi-rs/canvas-linux-riscv64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-musl": "1.1.0", + "@napi-rs/canvas-win32-arm64-msvc": "1.1.0", + "@napi-rs/canvas-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz", + "integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz", + "integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz", + "integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -5406,6 +5642,17 @@ "node": ">= 14.16" } }, + "node_modules/pdfjs-dist": { + "version": "6.4.299", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz", + "integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.10" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/client/package.json b/client/package.json index 7f5726fa..946af813 100644 --- a/client/package.json +++ b/client/package.json @@ -28,6 +28,7 @@ "i18next": "^26.0.8", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.61.0", + "pdfjs-dist": "6.4.299", "react": "^19.3.1", "react-dom": "^19.3.1", "react-i18next": "^17.0.4", diff --git a/client/src/components/AlertsNotifications.tsx b/client/src/components/AlertsNotifications.tsx index 318b2fcf..82def454 100644 --- a/client/src/components/AlertsNotifications.tsx +++ b/client/src/components/AlertsNotifications.tsx @@ -132,6 +132,8 @@ const RULE_TYPES: AlertRuleType[] = [ "cost_threshold", "budget", ]; +/** Only a hub (server mode) has remote clients to alert about. */ +const SERVER_RULE_TYPES: AlertRuleType[] = ["remote_unmonitored"]; type TabKey = "rules" | "channels" | "activity"; @@ -243,6 +245,8 @@ function buildConfig(form: RuleFormState): AlertRule["config"] { if (form.budget_tool) config.tool = form.budget_tool; return config; } + case "remote_unmonitored": + return {}; } } @@ -278,6 +282,8 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record(isAdmin ? "rules" : "activity"); // Rules @@ -613,7 +620,7 @@ export function AlertsNotifications() { title={editingId ? t("rules.typeFixed") : undefined} className="select w-full" > - {RULE_TYPES.map((type) => ( + {ruleTypes.map((type) => ( diff --git a/client/src/components/PdfView.tsx b/client/src/components/PdfView.tsx new file mode 100644 index 00000000..bf463a65 --- /dev/null +++ b/client/src/components/PdfView.tsx @@ -0,0 +1,102 @@ +/** + * @file PdfView.tsx + * @description A PDF drawn by pdf.js onto canvases, page after page — the + * Artifacts preview's PDF viewer. The browser's own viewer cannot run inside + * the sandbox the preview keeps (it blocks plugins), and opening a shared PDF + * on the dashboard's origin would be the risk the sandbox exists for; pdf.js + * only DRAWS the file (no scripting sandbox loaded, no XFA, no links + * followed), so nothing in it runs. pdf.js and its worker are loaded the + * first time a PDF is opened (a separate chunk), never with the dashboard. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; + +/** Pages drawn at most: a 500-page export would otherwise hold every canvas. */ +const MAX_PAGES = 200; +const MAX_BYTES = 64 * 1024 * 1024; + +async function loadPdfJs() { + // The legacy build: the modern one needs `Promise.try` and friends, which a + // browser a year old lacks — the PDF would then just fail to open. + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + if (!pdfjs.GlobalWorkerOptions.workerSrc) { + pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/legacy/build/pdf.worker.min.mjs", + import.meta.url + ).toString(); + } + return pdfjs; +} + +export function PdfView({ url, title }: { url: string; title: string }) { + const { t } = useTranslation("artifacts"); + const box = useRef(null); + const [state, setState] = useState<"loading" | "ready" | "failed">("loading"); + const [pages, setPages] = useState(0); + + useEffect(() => { + let live = true; + let destroy: (() => void) | null = null; + setState("loading"); + (async () => { + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) throw new Error(String(res.status)); + const data = new Uint8Array(await res.arrayBuffer()); + if (data.byteLength > MAX_BYTES) throw new Error("too big"); + const pdfjs = await loadPdfJs(); + const task = pdfjs.getDocument({ + data, + enableXfa: false, + disableAutoFetch: true, + }); + destroy = () => void task.destroy(); + const doc = await task.promise; + if (!live || !box.current) return; + setPages(doc.numPages); + const width = Math.max(320, box.current.clientWidth - 32); + const ratio = window.devicePixelRatio || 1; + box.current.replaceChildren(); + for (let n = 1; n <= Math.min(doc.numPages, MAX_PAGES); n++) { + const page = await doc.getPage(n); + if (!live || !box.current) return; + const base = page.getViewport({ scale: 1 }); + const viewport = page.getViewport({ scale: (width / base.width) * ratio }); + const canvas = document.createElement("canvas"); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + canvas.style.width = `${Math.floor(viewport.width / ratio)}px`; + canvas.className = "mx-auto mb-3 block bg-white shadow"; + canvas.setAttribute("aria-label", t("pdf.page", { n })); + box.current.appendChild(canvas); + await page.render({ canvas, viewport }).promise; + if (n === 1 && live) setState("ready"); + } + })().catch(() => live && setState("failed")); + return () => { + live = false; + destroy?.(); + }; + }, [url, t]); + + return ( +
+ {state === "loading" && ( +
+ +
+ )} + {state === "failed" && ( +

{t("pdf.failed")}

+ )} +
+ {pages > MAX_PAGES && ( +

+ {t("pdf.more", { shown: MAX_PAGES, total: pages })} +

+ )} +
+ ); +} diff --git a/client/src/components/__tests__/PdfView.test.tsx b/client/src/components/__tests__/PdfView.test.tsx new file mode 100644 index 00000000..61cece45 --- /dev/null +++ b/client/src/components/__tests__/PdfView.test.tsx @@ -0,0 +1,58 @@ +/** + * @file Tests for PdfView: pdf.js (mocked — jsdom has no canvas) draws one + * canvas per page, scripting off, and a PDF that cannot be read says so. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import "../../i18n"; + +const getDocument = vi.fn(); +vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({ + GlobalWorkerOptions: { workerSrc: "" }, + getDocument: (...a: unknown[]) => getDocument(...a), +})); + +import { PdfView } from "../PdfView"; + +const realFetch = globalThis.fetch; +beforeEach(() => { + getDocument.mockReset(); + globalThis.fetch = vi.fn( + async () => new Response(new Uint8Array([37, 80, 68, 70])) + ) as typeof fetch; +}); +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("PdfView", () => { + it("draws every page on its own canvas, with XFA off", async () => { + const render1 = vi.fn(() => ({ promise: Promise.resolve() })); + getDocument.mockReturnValue({ + destroy: vi.fn(), + promise: Promise.resolve({ + numPages: 3, + getPage: async () => ({ + getViewport: ({ scale }: { scale: number }) => ({ + width: 600 * scale, + height: 800 * scale, + }), + render: render1, + }), + }), + }); + const { container } = render(); + await waitFor(() => expect(container.querySelectorAll("canvas")).toHaveLength(3)); + expect(render1).toHaveBeenCalledTimes(3); + expect(getDocument.mock.calls[0]![0]).toMatchObject({ enableXfa: false }); + expect(screen.getByLabelText("Page 2")).toBeInTheDocument(); + }); + + it("says so when the file cannot be read", async () => { + getDocument.mockReturnValue({ destroy: vi.fn(), promise: Promise.reject(new Error("bad")) }); + render(); + expect(await screen.findByText(/could not be shown/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index 9bfa55ce..dd563839 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -148,7 +148,7 @@ function HomeBody({ const proxyCapable = entry?.proxyCapable ?? (claude || codex); // A profile picked for a tool that can use one; its models replace the tool's own. const profile = proxyCapable - ? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null) + ? (proxies?.find((r) => r.name === proxy && !r.builtin && !r.company) ?? null) : null; const proxyModels: ModelChoice[] | undefined = profile ? [ diff --git a/client/src/components/remote/RemoteDevices.tsx b/client/src/components/remote/RemoteDevices.tsx new file mode 100644 index 00000000..37093493 --- /dev/null +++ b/client/src/components/remote/RemoteDevices.tsx @@ -0,0 +1,240 @@ +/** + * @file RemoteDevices.tsx + * @description The hub's view of remote clients (server mode, server/hub/): + * a user's enrolled machines with Revoke (the viewer's own password again — + * the server checks it and writes the audit entry), and their Company-account + * sessions grouped device › folder with what the gateway counted. Shown in + * Settings for the signed-in user's own machines and on the Users page's user + * detail for an admin/analyst (`userId`). + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Laptop, ShieldAlert } from "lucide-react"; +import { Link } from "react-router-dom"; +import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api"; +import { formatDateTime } from "../../lib/format"; +import { toast } from "../../lib/toast"; +import { isSubmitEnter } from "../../lib/keys"; +import { ConfirmModal } from "../ConfirmModal"; + +const fmt = (n: number) => + n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n); + +export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevoke: boolean }) { + const { t } = useTranslation("settings"); + const [devices, setDevices] = useState(null); + const [sessions, setSessions] = useState([]); + const [revoking, setRevoking] = useState(null); + + const load = useCallback(() => { + api.remoteHub + .devices(userId) + .then((r) => setDevices(r.devices)) + .catch(() => setDevices([])); + api.remoteHub + .sessions(userId) + .then((r) => setSessions(r.sessions)) + .catch(() => setSessions([])); + }, [userId]); + useEffect(load, [load]); + + // device › folder › sessions, the newest first (the API's order). + const groups = useMemo(() => { + const byDevice = new Map>(); + for (const s of sessions) { + const d = s.deviceName; + if (!byDevice.has(d)) byDevice.set(d, new Map()); + const folders = byDevice.get(d)!; + const f = s.cwd || "—"; + if (!folders.has(f)) folders.set(f, []); + folders.get(f)!.push(s); + } + return [...byDevice.entries()]; + }, [sessions]); + + if (devices === null) return null; + return ( +
+ {devices.length === 0 ? ( +

{t("companyHub.noDevices")}

+ ) : ( +
    + {devices.map((d) => ( +
  • + + {d.name} + + {d.revokedAt + ? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) }) + : d.active + ? t("companyHub.validUntil", { time: formatDateTime(d.expiresAt) }) + : t("companyHub.expired")} + + {d.lastSeenAt && ( + + ·{" "} + {t("companyHub.lastSeen", { + time: formatDateTime(d.lastSeenAt), + ip: d.lastIp || "?", + })} + + )} + {canRevoke && d.active && ( + + )} +
  • + ))} +
+ )} + + {groups.length > 0 && ( +
+

{t("companyHub.sessions")}

+ {groups.map(([device, folders]) => ( +
+
{device}
+ {[...folders.entries()].map(([folder, rows]) => ( +
+
+ {folder} +
+
    + {rows.map((s) => ( +
  • + {s.purgedAt ? ( + + {s.title || s.sessionId.slice(0, 8)} + + ) : ( + + {s.title || s.sessionId.slice(0, 8)} + + )} + {s.unmonitored ? ( + + + {t("companyHub.unmonitored")} + + ) : null} + + {t("companyHub.sessionLine", { + requests: s.requests, + input: fmt(s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens), + output: fmt(s.outputTokens), + account: s.account || "—", + })} + + + · {formatDateTime(s.lastRequestAt || s.registeredAt)} + + {s.purgedAt && ( + · {t("companyHub.purged")} + )} +
  • + ))} +
+
+ ))} +
+ ))} +
+ )} + + {revoking && ( + setRevoking(null)} + onDone={() => { + setRevoking(null); + load(); + }} + /> + )} +
+ ); +} + +function RevokeDialog({ + device, + onClose, + onDone, +}: { + device: RemoteDevice; + onClose: () => void; + onDone: () => void; +}) { + const { t } = useTranslation("settings"); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const submit = async () => { + if (!password || busy) return; + setBusy(true); + setError(null); + try { + await api.remoteHub.revoke(device.id, password); + toast.success(t("companyHub.revoked", { name: device.name })); + onDone(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + return ( + void submit()} + onCancel={onClose} + > +
{ + if (isSubmitEnter(e)) { + e.preventDefault(); + void submit(); + } + }} + > + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index eb07c95a..d0264e7d 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -57,7 +57,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { ArrowUp, @@ -145,6 +145,20 @@ const storedSide = () => { * older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M * (LiteLLM's list, 2026-10). Null for another vendor's model. */ // ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships. +/** When the conversation's last turn ended — its newest reply's timestamp + * when that reply finished (`end_turn`, the "Done in …" line) and no prompt + * came after it — else null. */ +export function endedTurnAt(messages: readonly TranscriptMessage[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + if (m.type === "session_event") continue; + const sender = m.sender ?? (m.type === "assistant" ? "assistant" : "user"); + if (sender === "assistant") return m.stop_reason === "end_turn" ? m.timestamp : null; + if (sender === "user" && m.content.some((c) => c.type === "text")) return null; + } + return null; +} + /** `value`, except that a drop to false shows only once it has lasted * `holdMs`; a change of `release` (Stop pressed) lets it drop at once. */ export function useHeldTrue(value: boolean, holdMs: number, release: unknown): boolean { @@ -172,13 +186,7 @@ export function contextWindow(model: string | null): number | null { /** How full the context is: a ring and a percentage, amber then red as * Claude Code's auto-compact comes near. */ -function ContextMeter({ - tokens, - model, -}: { - tokens: number; - model: string | null; -}) { +function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) { const { t } = useTranslation("run"); const size = contextWindow(model); if (!size || !tokens) return null; @@ -638,6 +646,9 @@ export const PaneChat = memo(function PaneChat({ const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId }; const text = promptWithImages(prompt, images); + // The conversation on screen: an artifact's versions are rebuilt from it, + // and its finished reply ends the turn (below). + const [convMessages, setConvMessages] = useState([]); // The main agent is "working" from a prompt's submit to its Stop hook, and // waits on the user from a Notification (a permission prompt) to the next hook. const [working, setWorking] = useState(false); @@ -741,7 +752,20 @@ export const PaneChat = memo(function PaneChat({ }, 700); return () => window.clearInterval(timer); }, [runId, onScreen]); - const busy = useHeldTrue(working || screenBusy, BUSY_HOLD_MS, stops); + // The reply the chat already shows as "Done in …" ends the turn, hook or + // not: a turn may end with no hook at all (no Stop, no SubagentStop) until + // the idle Notification a minute later. Only a reply that is not the one + // already there when `working` began counts, so a new prompt whose line the + // transcript has not caught up with still shows Stop. + const endedAt = useMemo(() => endedTurnAt(convMessages), [convMessages]); + const endedRef = useRef(endedAt); + endedRef.current = endedAt; + const [staleEnd, setStaleEnd] = useState(null); + useEffect(() => { + if (working) setStaleEnd(endedRef.current); + }, [working]); + const turnOver = endedAt !== null && endedAt !== staleEnd; + const busy = useHeldTrue((working && !turnOver) || screenBusy, BUSY_HOLD_MS, stops); const asking = waiting || menu !== null; // Gone until the pane redraws: no second press on a stale menu. const choose = (index: number) => { @@ -789,8 +813,6 @@ export const PaneChat = memo(function PaneChat({ () => showTerminal(runId, sessionId, run.tool), [runId, sessionId, run.tool] ); - // The conversation on screen: an artifact's versions are rebuilt from it. - const [convMessages, setConvMessages] = useState([]); // An artifact card opens in this chat's own panel, like Claude Desktop's — // not the workspace's Changes panel a plain file link goes to. const openArtifact = useCallback( @@ -839,6 +861,8 @@ export const PaneChat = memo(function PaneChat({ if (!text || uploading) return; const out = promptForPane(prompt, images, claude); if (!(await command(out.text, out.files))) return; + // A new turn: the reply now on screen is the previous one's. + setStaleEnd(endedRef.current); setFollow((n) => n + 1); setPrompt(""); images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); diff --git a/client/src/components/run/ProxyPicker.tsx b/client/src/components/run/ProxyPicker.tsx index 3de018a2..4abc7779 100644 --- a/client/src/components/run/ProxyPicker.tsx +++ b/client/src/components/run/ProxyPicker.tsx @@ -6,7 +6,10 @@ * reads to list the proxy's models). A tool that cannot use a proxy (only * Claude and Codex can) shows Native, disabled. The last pick is remembered * for the next session (`agenthub.session.proxy`; the pre-rename - * `agenthub.session.router` is still read when that is unset). + * `agenthub.session.router` is still read when that is unset). On a local + * machine enrolled with the company hub (`/api/remote-client`) the list ends + * with the Company account (`agenthub.company`): a Claude session there runs through + * the hub's gateway, and only such sessions are reported to the hub. * @author Nguyễn Ngọc Trí Vĩ */ @@ -39,14 +42,30 @@ export function useSessionProxy(): [string, (v: string) => void] { return [value, set]; } -/** The saved profiles (Native included), null until they arrive. */ +/** The remote client's Company account, as the list shows it. */ +const COMPANY: AiProxy = { + name: "agenthub.company", + label: "Company account", + builtin: false, + company: true, + baseUrl: "", + hasKey: false, + model: "", + models: {}, +}; + +/** The saved profiles (Native included, the Company account last when enrolled), null until they arrive. */ export function useAiProxies(): AiProxy[] | null { const [proxies, setProxies] = useState(null); useEffect(() => { let alive = true; - api.aiProxies - .list() - .then((r) => alive && setProxies(r.items)) + // A hub (server mode) answers 404 here, and a failure only hides the entry. + const enrolled = Promise.resolve() + .then(() => api.remoteClient.status()) + .then((r) => r.enrolled) + .catch(() => false); + Promise.all([api.aiProxies.list().then((r) => r.items), enrolled]) + .then(([items, company]) => alive && setProxies(company ? [...items, COMPANY] : items)) .catch(() => alive && setProxies([])); return () => { alive = false; @@ -82,12 +101,19 @@ export function ProxyPicker({ // A remembered profile that has since been deleted falls back to native, // or the start would be refused for a proxy the user cannot see. useEffect(() => { - if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native"); - }, [proxies, value, onChange]); + if (!proxies || value === "native") return; + const pick = proxies.find((r) => r.name === value); + if (!pick || (pick.company && tool !== "claude")) onChange("native"); + }, [proxies, value, onChange, tool]); const options = capable && proxies?.length - ? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label })) + ? proxies + .filter((r) => !r.company || tool === "claude") + .map((r) => ({ + value: r.name, + label: r.builtin ? native : r.company ? t("proxy.company") : r.label, + })) : [{ value: "native", label: native }]; return (
diff --git a/client/src/components/run/__tests__/PaneChat.test.tsx b/client/src/components/run/__tests__/PaneChat.test.tsx index a325f40f..f60f03bf 100644 --- a/client/src/components/run/__tests__/PaneChat.test.tsx +++ b/client/src/components/run/__tests__/PaneChat.test.tsx @@ -18,7 +18,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { PaneChat, contextWindow } from "../PaneChat"; +import { PaneChat, contextWindow, endedTurnAt } from "../PaneChat"; import { api } from "../../../lib/api"; import { answerRunMenu, @@ -33,16 +33,21 @@ import { import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges"; import { eventBus } from "../../../lib/eventBus"; +// What the stand-in conversation reports as on screen when asked. +const conv = vi.hoisted(() => ({ messages: [] as unknown[] })); vi.mock("../../conversation/ConversationView", () => ({ // Stands in for a turn's "Edited N files" card: a click names a file. ConversationView: ({ onOpenFile, onNotStarted, + onMessages, }: { onOpenFile?: (path: string) => void; onNotStarted?: (notStarted: boolean) => void; + onMessages?: (messages: never[]) => void; }) => (
+ + +
+
+

+ {t("companyHub.sentTitle")} +

+ {st.uploads && st.uploads.length > 0 ? ( +
    + {st.uploads.map((u) => ( +
  • + + {u.title || u.sessionId.slice(0, 8)} + + {u.cwd} + + {u.lastSentAt + ? t("companyHub.sentLine", { + bytes: formatBytes(u.bytes || 0), + time: formatDateTime(u.lastSentAt), + }) + : t("companyHub.notSentYet")} + {u.ended ? ` · ${t("companyHub.ended")}` : ""} + +
  • + ))} +
+ ) : ( +

{t("companyHub.nothingSent")}

+ )} +
+
+ ) : ( +
{ + if (isSubmitEnter(e) && hub && username && password) { + e.preventDefault(); + void enroll(); + } + }} + > + {st.expired && ( +

{t("companyHub.expiredHint")}

+ )} + setHub(e.target.value)} + aria-label={t("companyHub.hubAddress")} + /> + setUsername(e.target.value)} + autoComplete="username" + aria-label={t("companyHub.username")} + /> + setPassword(e.target.value)} + autoComplete="current-password" + aria-label={t("companyHub.password")} + /> + +

{t("companyHub.cliHint")}

+
+ )} + {error && ( +

+ {error} +

+ )} + + ); +} + +function HubSide() { + const { t } = useTranslation("settings"); + const [audit, setAudit] = useState< + | { + id: number; + at: string; + sessionId: string; + reader: string | null; + readerDisplay: string | null; + }[] + | null + >(null); + useEffect(() => { + api.remoteHub + .audit() + .then((r) => setAudit(r.entries)) + .catch(() => setAudit(null)); + }, []); + // A hub without the remote routes (an older server) answers 404: show nothing. + if (audit === null) return null; + return ( + + +
+

{t("companyHub.whoRead")}

+ {audit.length === 0 ? ( +

{t("companyHub.nobodyRead")}

+ ) : ( +
    + {audit.map((a) => ( +
  • + {formatDateTime(a.at)} ·{" "} + {a.readerDisplay || a.reader || "?"} ·{" "} + + {a.sessionId.slice(0, 8)} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/components/settings/ToolProfilesSettings.tsx b/client/src/components/settings/ToolProfilesSettings.tsx index fbc940d7..4fce077f 100644 --- a/client/src/components/settings/ToolProfilesSettings.tsx +++ b/client/src/components/settings/ToolProfilesSettings.tsx @@ -11,7 +11,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { @@ -38,6 +38,12 @@ import { type ToolProfilesTool, } from "../../lib/api"; import { toast } from "../../lib/toast"; +import { + profileKey, + readHiddenProfiles, + toggleHidden, + writeHiddenProfiles, +} from "../../lib/hiddenProfilesStorage"; import { confirmDialog } from "../../lib/confirm"; import { copyText } from "../../lib/clipboard"; import { useAiTools } from "../../hooks/useAiTools"; @@ -129,7 +135,7 @@ export function ToolProfilesSettings() { )} {current.id === "claude" ? ( - + ) : ( (null); const [active, setActive] = useState(null); @@ -492,6 +498,34 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) { /** Names already auto-fetched, so the effect below fires once per profile. */ const autoFetched = useRef>(new Set()); const ctx = useContextMenu(); + /** Local mode only: profiles this browser leaves out (Alt+Shift+click a row); + * Alt+Shift+H shows them, dimmed, until pressed again. */ + const [hidden, setHidden] = useState(() => (local ? readHiddenProfiles() : [])); + const [reveal, setReveal] = useState(false); + useEffect(() => { + if (!local) return; + const onKey = (e: KeyboardEvent) => { + if (e.altKey && e.shiftKey && e.code === "KeyH") { + e.preventDefault(); + setReveal((r) => !r); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [local]); + const isHidden = useCallback( + (p: { accountUuid: string | null; name?: string }) => local && hidden.includes(profileKey(p)), + [local, hidden] + ); + const toggleRow = (e: MouseEvent, p: CcProfile) => { + if (!local || !e.altKey || !e.shiftKey) return; + e.preventDefault(); + e.stopPropagation(); + const next = toggleHidden(hidden, profileKey(p)); + setHidden(next); + writeHiddenProfiles(next); + }; + const activeHidden = !!active && !reveal && isHidden(active); const load = useCallback(async () => { try { @@ -540,10 +574,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) { if (!items) return; for (const p of items) { if (autoFetched.current.has(p.name)) continue; + if (!reveal && isHidden(p)) continue; autoFetched.current.add(p.name); void checkUsage(p.name); } - }, [items, checkUsage]); + }, [items, checkUsage, isHidden, reveal]); const activeSaved = items?.some((p) => p.active) ?? false; /** How many saved profiles hold the same account as `p`. */ @@ -587,7 +622,7 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) { {t("profiles.mismatch", { config: mismatchEmail, - owner: active?.email || active?.accountUuid || "?", + owner: activeHidden ? "?" : active?.email || active?.accountUuid || "?", })}

@@ -603,7 +638,9 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
{t("profiles.activeLabel")}
- {active ? ( + {active && activeHidden ? ( +
—
+ ) : active ? ( <>
{active.email || active.displayName || active.accountUuid} @@ -631,17 +668,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
{items === null && !error && } - {items !== null && items.length === 0 && ( + {items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
{t("profiles.empty")}
)} - {items?.map((p) => ( + {items?.filter((p) => reveal || !isHidden(p)).map((p) => (
ctx.open(e, p)} - className={rowCls} + onClickCapture={(e) => toggleRow(e, p)} + className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`} >
({ - api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } }, + api: { + artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn(), setExportsEnabled: vi.fn() }, + }, })); const settings = vi.mocked(api.artifacts.settings); @@ -32,15 +34,23 @@ describe("ArtifactsHubSettings", () => { await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false")); }); - it("renders nothing for a non-manager or in local mode", async () => { + it("renders nothing for a non-manager, and only the copy switch in local mode", async () => { settings.mockResolvedValue({ ...base, canManage: false }); const { container, unmount } = render(); await waitFor(() => expect(settings).toHaveBeenCalled()); expect(container).toBeEmptyDOMElement(); unmount(); - settings.mockResolvedValue({ ...base, serverMode: false }); - const r = render(); - await waitFor(() => expect(settings).toHaveBeenCalledTimes(2)); - expect(r.container).toBeEmptyDOMElement(); + settings.mockResolvedValue({ ...base, serverMode: false, exportsEnabled: true }); + vi.mocked(api.artifacts.setExportsEnabled).mockResolvedValue({ + installsEnabled: true, + exportsEnabled: false, + }); + render(); + const sw = await screen.findByRole("switch", { + name: "Keep a viewable copy of Docs and Slides", + }); + expect(screen.queryByRole("switch", { name: "Allow installing from the Hub" })).toBeNull(); + fireEvent.click(sw); + await waitFor(() => expect(api.artifacts.setExportsEnabled).toHaveBeenCalledWith(false)); }); }); diff --git a/client/src/components/settings/__tests__/CompanyHubSettings.test.tsx b/client/src/components/settings/__tests__/CompanyHubSettings.test.tsx new file mode 100644 index 00000000..391b9923 --- /dev/null +++ b/client/src/components/settings/__tests__/CompanyHubSettings.test.tsx @@ -0,0 +1,144 @@ +/** + * @file CompanyHubSettings.test.tsx + * @description Settings → Company hub. Local: the enroll form sends the hub + * address, username and password once and clears the password; enrolled, it + * lists what was sent and never shows a token; Log out asks first. Hub + * (server mode): the user's own machines with Revoke (their password again) + * and who opened their sessions. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "../../../i18n"; +import { settleConfirm } from "../../../lib/confirm"; +import { CompanyHubSettings } from "../CompanyHubSettings"; +import { api } from "../../../lib/api"; + +const perms = vi.hoisted(() => ({ local: true, isAdmin: true, seesAll: true })); +vi.mock("../../AuthGate", () => ({ usePermissions: () => perms })); +vi.mock("../../../lib/api", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + api: { + remoteClient: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), upload: vi.fn() }, + remoteHub: { devices: vi.fn(), sessions: vi.fn(), revoke: vi.fn(), audit: vi.fn() }, + }, + }; +}); + +const enrolled = { + enrolled: true, + hub: "http://10.1.13.7:4820", + user: { id: 3, username: "mia", role: "developer" }, + device: { id: 7, name: "mia-pc", expiresAt: "2027-02-01T00:00:00.000Z" }, + uploads: [ + { + sessionId: "11111111-2222-3333-4444-555555555555", + bytes: 2048, + lastSentAt: "2026-11-04T02:00:00.000Z", + ended: 0, + title: "fix the cart", + cwd: "/home/mia/shop", + startedAt: null, + }, + ], +}; + +const show = async () => { + render( + + + + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + perms.local = true; +}); + +describe("CompanyHubSettings — local", () => { + it("enrolls with the hub address, username and password, then clears the password", async () => { + vi.mocked(api.remoteClient.status) + .mockResolvedValueOnce({ enrolled: false }) + .mockResolvedValue(enrolled); + vi.mocked(api.remoteClient.login).mockResolvedValue(enrolled); + const user = userEvent.setup(); + await show(); + await user.type(screen.getByLabelText(/hub address|địa chỉ hub/i), "http://10.1.13.7:4820"); + await user.type(screen.getByLabelText(/hub username|tên đăng nhập hub/i), "mia"); + const pw = screen.getByLabelText(/^(hub password|mật khẩu hub)$/i); + await user.type(pw, "secret-1"); + await user.click(screen.getByRole("button", { name: /enroll this machine|đăng ký máy này/i })); + expect(api.remoteClient.login).toHaveBeenCalledWith({ + hub: "http://10.1.13.7:4820", + username: "mia", + password: "secret-1", + }); + await waitFor(() => expect(screen.getByTestId("company-uploads")).toBeInTheDocument()); + expect(pw).not.toBeInTheDocument(); + }); + + it("lists what was sent and logs out only after confirming", async () => { + vi.mocked(api.remoteClient.status).mockResolvedValue(enrolled); + vi.mocked(api.remoteClient.logout).mockResolvedValue({ ok: true, wasEnrolled: true }); + const user = userEvent.setup(); + await show(); + expect(screen.getByText("fix the cart")).toBeInTheDocument(); + expect(document.body.textContent).not.toMatch(/ahdev_/); + await user.click(screen.getByRole("button", { name: /log out|đăng xuất/i })); + expect(api.remoteClient.logout).not.toHaveBeenCalled(); + await act(async () => settleConfirm(true)); + await waitFor(() => expect(api.remoteClient.logout).toHaveBeenCalled()); + }); +}); + +describe("CompanyHubSettings — hub", () => { + it("shows the user's machines, revokes with their password, and who read their sessions", async () => { + perms.local = false; + vi.mocked(api.remoteHub.audit).mockResolvedValue({ + entries: [ + { + id: 1, + at: "2026-11-04T03:00:00.000Z", + sessionId: "abcdef12-0000", + reader: "boss", + readerDisplay: "Boss", + }, + ], + }); + vi.mocked(api.remoteHub.devices).mockResolvedValue({ + devices: [ + { + id: 7, + userId: 3, + name: "mia-pc", + createdAt: "2026-11-01T00:00:00.000Z", + expiresAt: "2027-02-01T00:00:00.000Z", + lastSeenAt: null, + lastIp: null, + revokedAt: null, + active: true, + }, + ], + }); + vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] }); + vi.mocked(api.remoteHub.revoke).mockResolvedValue({ ok: true } as never); + const user = userEvent.setup(); + await show(); + await waitFor(() => expect(screen.getByText("mia-pc")).toBeInTheDocument()); + expect(screen.getByTestId("company-audit")).toHaveTextContent("Boss"); + await user.click(screen.getByRole("button", { name: /^(revoke|thu hồi)$/i })); + await user.type(screen.getByLabelText(/your password|mật khẩu của bạn/i), "my-pass-1"); + const buttons = screen.getAllByRole("button", { name: /^(revoke|thu hồi)$/i }); + await user.click(buttons[buttons.length - 1]!); + await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7, "my-pass-1")); + }); +}); diff --git a/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx b/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx index 212282d7..43152ef2 100644 --- a/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx +++ b/client/src/components/settings/__tests__/ToolProfilesSettings.test.tsx @@ -68,6 +68,7 @@ async function show() { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); perms.local = true; m.cc.profiles.mockResolvedValue({ items: [ @@ -137,6 +138,34 @@ describe("ToolProfilesSettings — Claude", () => { expect(screen.getByTestId("profile-menu")).toBeInTheDocument(); expect(screen.queryByTestId("profile-menu-delete")).toBeNull(); }); + it("leaves a profile this browser hid out of the list and asks nothing about it", async () => { + localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"])); + await show(); + await settle(); + expect(screen.queryByTestId("profile-row-spare")).toBeNull(); + expect(screen.queryByText("d@e.f")).toBeNull(); + expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare"); + expect(screen.getByTestId("profile-row-work")).toBeInTheDocument(); + }); + + it("hides a row on Alt+Shift+click and shows it again with Alt+Shift+H", async () => { + const user = userEvent.setup(); + await show(); + await user.keyboard("{Alt>}{Shift>}"); + await user.click(screen.getByTestId("profile-row-spare")); + await user.keyboard("{/Shift}{/Alt}"); + expect(screen.queryByTestId("profile-row-spare")).toBeNull(); + expect(JSON.parse(localStorage.getItem("agenthub.cc.hiddenProfiles")!)).toEqual(["u2"]); + await user.keyboard("{Alt>}{Shift>}H{/Shift}{/Alt}"); + expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument(); + }); + + it("does not hide anything in server mode", async () => { + perms.local = false; + localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"])); + await show(); + expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument(); + }); }); describe("ToolProfilesSettings — other tools", () => { diff --git a/client/src/components/users/UserDetail.tsx b/client/src/components/users/UserDetail.tsx index a90bf305..84bfac90 100644 --- a/client/src/components/users/UserDetail.tsx +++ b/client/src/components/users/UserDetail.tsx @@ -25,6 +25,8 @@ import { AiToolIcon } from "../AiToolIcon"; import { SessionStatusBadge } from "../StatusBadge"; import { MiniChart } from "../admin/MiniChart"; import { QuotaBar, windowsOf } from "./AccountPoolCard"; +import { RemoteDevices } from "../remote/RemoteDevices"; +import { usePermissions } from "../AuthGate"; const POOL_LABEL: Record = { claude: "Claude Code", @@ -35,6 +37,7 @@ const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`; export function UserDetail({ userId }: { userId: number }) { const { t } = useTranslation("users"); + const { isAdmin } = usePermissions(); const { label: toolLabel } = useAiTools(); const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -289,6 +292,12 @@ export function UserDetail({ userId }: { userId: number }) { )} + + {/* Their own machines (AgentHub local) on the company hub, and those + machines' Company-account sessions — revocable by an admin. */} + + +
); } diff --git a/client/src/i18n/locales/en/alerts.json b/client/src/i18n/locales/en/alerts.json index 2ca1b4dd..0e049eed 100644 --- a/client/src/i18n/locales/en/alerts.json +++ b/client/src/i18n/locales/en/alerts.json @@ -9,7 +9,8 @@ "status_duration": "Stuck agent", "token_threshold": "Token threshold", "cost_threshold": "Cost threshold", - "budget": "Spend budget" + "budget": "Spend budget", + "remote_unmonitored": "Company account outside AgentHub" }, "ruleTypeHints": { "event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).", @@ -17,7 +18,8 @@ "status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.", "token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.", "cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.", - "budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool." + "budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool.", + "remote_unmonitored": "Fires when a user's machine uses the company account for a session it did not start from AgentHub (nothing of it is sent to the hub). Server mode only." }, "ruleDesc": { "eventPattern": "Matches {{pattern}}", @@ -26,7 +28,8 @@ "statusDuration": "Agent stuck in {{status}} for {{minutes}} min", "tokenThreshold": "Session tokens exceed {{tokens}}", "costThreshold": "Session cost reaches {{usd}}", - "budget": "{{limit}} per {{period}} · alerts at {{thresholds}}" + "budget": "{{limit}} per {{period}} · alerts at {{thresholds}}", + "remoteUnmonitored": "session not started from AgentHub" }, "rules": { "title": "Alert rules", diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index 4e20010c..3e78145a 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -27,7 +27,7 @@ "share": "Share", "unshare": "Unshare", "shareDone": "Shared to the Hub", - "unshareDone": "Removed from the Hub", + "unshareDone": "No longer shared", "reshare": "Share again", "reshareDone": "New version shared — people who installed it are offered Update", "reshareSame": "Already the newest version — shared", @@ -234,5 +234,46 @@ "pack.updateN": "Update {{count}}", "pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?", "pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:", - "pack.updated": "Updated {{ok}} · failed {{failed}}" + "pack.updated": "Updated {{ok}} · failed {{failed}}", + "tab.withme": "Shared with you", + "sharing": "Sharing…", + "shareTitle": "Share “{{title}}”", + "shareMode.people": "Only the people I pick", + "shareMode.peopleHint": "Only them (and admins) see it; nobody else finds it in the Hub.", + "shareMode.everyone": "Everyone (the Hub)", + "shareMode.everyoneHint": "Every user sees it in the Hub.", + "shareMode.nobody": "Stop sharing", + "shareMode.nobodyHint": "Only you (and admins) see it again.", + "sharePeople.search": "Find a person", + "sharePeople.none": "Nobody matches.", + "sharePeople.picked_one": "{{count}} person picked", + "sharePeople.picked_other": "{{count}} people picked", + "shareSave": "Save", + "sharePeopleDone_one": "Shared with {{count}} person", + "sharePeopleDone_other": "Shared with {{count}} people", + "sharedWithCount_one": "Shared with {{count}} person", + "sharedWithCount_other": "Shared with {{count}} people", + "sharedWithMe": "Shared with you", + "withme.empty": "Nothing has been shared with you by name yet.", + "folders.all": "All folders", + "folders.new": "New folder", + "folders.newName": "Folder name", + "folders.rename": "Rename", + "folders.delete": "Delete folder", + "folders.deleteTitle": "Delete the folder “{{name}}”?", + "folders.deleteBody": "Only the folder goes; its artifacts stay where they are.", + "folders.empty": "This folder is empty. Put an artifact here with ⋯ → Move to folder.", + "folders.move": "Move to folder…", + "folders.moveTitle": "Move “{{title}}” to a folder", + "folders.private": "Your folders are yours alone: nobody else sees them.", + "folders.createHere": "New folder", + "folders.takeOut": "Take it out of its folder", + "page.view": "View", + "page.viewHint": "Its copy, viewable here without the claude.ai account that published it", + "page.noCopy": "No viewable copy: the file this page was published from is gone. Open it on claude.ai instead.", + "pdf.page": "Page {{n}}", + "pdf.failed": "This PDF could not be shown. Download it instead.", + "pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.", + "exportsSwitch": "Keep a viewable copy of Docs and Slides", + "exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards." } diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 56dd4db2..6d3e92ec 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -277,6 +277,7 @@ "renameLabel": "Rename this workspace", "proxy.native": "Native ({{tool}})", "proxy.label": "AI Proxy", + "proxy.company": "Company account", "runtime.down": "down", "runtime.healthy": "healthy", "runtime.partial": "partial", @@ -749,7 +750,7 @@ "shelves.deleted": "Shelf deleted", "shelves.restored": "Shelved changes restored", "shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.", - "shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf", + "shelves.restoreDirty": "Commit, discard or unstage the changes in this shelf's files (and anything staged) before restoring it", "shelves.shelveTicked": "Shelve ({{count}})", "shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree", "shelves.shelveApply": "Shelve {{count}} file(s)", diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 1e0db542..761ba369 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -1450,5 +1450,49 @@ "automaticSaved": "Back to automatic — AgentHub picks the account", "switchedNow": "Switched to {{account}} — running sessions follow it", "nextSession": "{{account}} is used from your next session" + }, + "companyHub": { + "title": "Company hub", + "description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.", + "ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.", + "ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.", + "ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).", + "ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.", + "enrolledAs": "Enrolled with {{hub}} as {{user}}", + "validUntil": "valid until {{time}}", + "expired": "expired", + "expiredHint": "This machine's hub login expired — enroll again.", + "sendNow": "Send now", + "logout": "Log out", + "logoutTitle": "Log this machine out of the hub?", + "logoutHint": "Its device token is revoked on the hub. Company-account sessions stop working here until you enroll again.", + "sentTitle": "Sent to the hub", + "sentLine": "{{bytes}} · {{time}}", + "notSentYet": "not sent yet", + "ended": "ended", + "nothingSent": "No Company-account session yet.", + "sentToast": "Sent {{bytes}} of {{sessions}} session(s)", + "enrolledToast": "This machine is enrolled with the hub", + "hubAddress": "Hub address", + "username": "Hub username", + "password": "Hub password", + "enroll": "Enroll this machine", + "cliHint": "Or in a terminal: agenthub remote login ", + "hubTitle": "Your machines", + "hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.", + "noDevices": "No machine enrolled.", + "revokedAt": "revoked {{time}}", + "lastSeen": "seen {{time}} from {{ip}}", + "revoke": "Revoke", + "revokeTitle": "Revoke {{name}}?", + "revokeHint": "Its device token stops working at once; the machine must enroll again.", + "revoked": "{{name}} revoked", + "yourPassword": "Your password", + "sessions": "Company-account sessions", + "unmonitored": "not started from AgentHub", + "sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}", + "purged": "conversation removed (retention)", + "whoRead": "Who opened your sessions", + "nobodyRead": "Nobody has opened your sessions." } } diff --git a/client/src/i18n/locales/en/users.json b/client/src/i18n/locales/en/users.json index 6b20da2b..c887e508 100644 --- a/client/src/i18n/locales/en/users.json +++ b/client/src/i18n/locales/en/users.json @@ -409,7 +409,8 @@ "activeLabel": "Running now", "activeValue": "{{sessions}} sessions · {{panes}} panes", "nowTitle": "Running now", - "poolTheirs7d": "theirs 7 d {{cost}}" + "poolTheirs7d": "theirs 7 d {{cost}}", + "devicesTitle": "Machines (AgentHub local)" }, "activity": "Activity", "spend": "Spend today", diff --git a/client/src/i18n/locales/vi/alerts.json b/client/src/i18n/locales/vi/alerts.json index 7cdd10e3..b170c9de 100644 --- a/client/src/i18n/locales/vi/alerts.json +++ b/client/src/i18n/locales/vi/alerts.json @@ -9,7 +9,8 @@ "status_duration": "Agent bị kẹt", "token_threshold": "Ngưỡng token", "cost_threshold": "Ngưỡng chi phí", - "budget": "Ngân sách chi tiêu" + "budget": "Ngân sách chi tiêu", + "remote_unmonitored": "Tài khoản công ty dùng ngoài AgentHub" }, "ruleTypeHints": { "event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).", @@ -17,7 +18,8 @@ "status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.", "token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.", "cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.", - "budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI." + "budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI.", + "remote_unmonitored": "Kích hoạt khi máy của một người dùng tài khoản công ty cho một phiên không mở từ AgentHub (phiên đó không được gửi về hub). Chỉ có ở chế độ server." }, "ruleDesc": { "eventPattern": "Khớp {{pattern}}", @@ -26,7 +28,8 @@ "statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút", "tokenThreshold": "Token của phiên vượt {{tokens}}", "costThreshold": "Chi phí phiên chạm {{usd}}", - "budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}" + "budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}", + "remoteUnmonitored": "phiên không mở từ AgentHub" }, "rules": { "title": "Quy tắc cảnh báo", diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 393450de..907acc81 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -27,7 +27,7 @@ "share": "Chia sẻ", "unshare": "Bỏ chia sẻ", "shareDone": "Đã chia sẻ lên Hub", - "unshareDone": "Đã gỡ khỏi Hub", + "unshareDone": "Đã ngừng chia sẻ", "reshare": "Share lại", "reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật", "reshareSame": "Đã là bản mới nhất — đã share", @@ -232,5 +232,46 @@ "pack.updateN": "Cập nhật {{count}}", "pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?", "pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:", - "pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}" + "pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}", + "tab.withme": "Chia sẻ với bạn", + "sharing": "Chia sẻ…", + "shareTitle": "Chia sẻ “{{title}}”", + "shareMode.people": "Chỉ những người tôi chọn", + "shareMode.peopleHint": "Chỉ họ (và admin) thấy; người khác không thấy trên Hub.", + "shareMode.everyone": "Mọi người (Hub)", + "shareMode.everyoneHint": "Mọi người dùng đều thấy trên Hub.", + "shareMode.nobody": "Ngừng chia sẻ", + "shareMode.nobodyHint": "Chỉ bạn (và admin) thấy như trước.", + "sharePeople.search": "Tìm người", + "sharePeople.none": "Không có ai khớp.", + "sharePeople.picked_one": "Đã chọn {{count}} người", + "sharePeople.picked_other": "Đã chọn {{count}} người", + "shareSave": "Lưu", + "sharePeopleDone_one": "Đã chia sẻ với {{count}} người", + "sharePeopleDone_other": "Đã chia sẻ với {{count}} người", + "sharedWithCount_one": "Chia sẻ với {{count}} người", + "sharedWithCount_other": "Chia sẻ với {{count}} người", + "sharedWithMe": "Chia sẻ với bạn", + "withme.empty": "Chưa có ai chia sẻ riêng với bạn.", + "folders.all": "Tất cả thư mục", + "folders.new": "Thư mục mới", + "folders.newName": "Tên thư mục", + "folders.rename": "Đổi tên", + "folders.delete": "Xóa thư mục", + "folders.deleteTitle": "Xóa thư mục “{{name}}”?", + "folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.", + "folders.empty": "Thư mục này trống. Đưa artifact vào bằng ⋯ → Chuyển vào thư mục.", + "folders.move": "Chuyển vào thư mục…", + "folders.moveTitle": "Chuyển “{{title}}” vào thư mục", + "folders.private": "Thư mục là của riêng bạn: người khác không thấy.", + "folders.createHere": "Thư mục mới", + "folders.takeOut": "Bỏ khỏi thư mục", + "page.view": "Xem", + "page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang", + "page.noCopy": "Chưa có bản xem: file gốc của trang này không còn. Hãy mở trên claude.ai.", + "pdf.page": "Trang {{n}}", + "pdf.failed": "Không hiển thị được PDF này. Hãy tải về.", + "pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.", + "exportsSwitch": "Giữ bản xem được của Docs và Slides", + "exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó." } diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index c74f7e3c..75c4e097 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -277,6 +277,7 @@ "renameLabel": "Đổi tên workspace này", "proxy.native": "Native ({{tool}})", "proxy.label": "AI Proxy", + "proxy.company": "Tài khoản công ty", "runtime.down": "tắt", "runtime.healthy": "khoẻ", "runtime.partial": "một phần", @@ -749,7 +750,7 @@ "shelves.deleted": "Đã xoá shelf", "shelves.restored": "Đã khôi phục thay đổi đã cất", "shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.", - "shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf", + "shelves.restoreDirty": "Hãy commit, bỏ hoặc bỏ stage thay đổi trên các file của shelf này (và mọi thứ đã stage) trước khi khôi phục", "shelves.shelveTicked": "Cất ({{count}})", "shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc", "shelves.shelveApply": "Cất {{count}} file", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 5f134c41..625aeed2 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -1438,5 +1438,49 @@ "automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản", "switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn", "nextSession": "{{account}} được dùng từ phiên tiếp theo" + }, + "companyHub": { + "title": "Hub công ty", + "description": "Dùng tài khoản Claude chung của công ty từ máy này. Chỉ phần tốn tiền công ty mới được ghi nhận.", + "ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi.", + "ruleNotSent": "Session dùng tài khoản riêng hoặc AI Proxy riêng của bạn không gửi gì cả — kể cả tên thư mục. Hub không đọc file của bạn và không điều khiển máy này.", + "ruleKey": "Key của tài khoản công ty nằm ở hub; máy này chỉ giữ một device token (90 ngày, thu hồi được).", + "ruleOffice": "Hub nằm trong mạng công ty: ở ngoài, session tài khoản công ty không mở được; tài khoản riêng vẫn dùng bình thường.", + "enrolledAs": "Đã đăng ký với {{hub}} bằng {{user}}", + "validUntil": "hiệu lực đến {{time}}", + "expired": "đã hết hạn", + "expiredHint": "Đăng nhập hub của máy này đã hết hạn — đăng ký lại.", + "sendNow": "Gửi ngay", + "logout": "Đăng xuất", + "logoutTitle": "Đăng xuất máy này khỏi hub?", + "logoutHint": "Device token bị thu hồi trên hub. Session tài khoản công ty không dùng được ở máy này cho đến khi đăng ký lại.", + "sentTitle": "Đã gửi lên hub", + "sentLine": "{{bytes}} · {{time}}", + "notSentYet": "chưa gửi", + "ended": "đã kết thúc", + "nothingSent": "Chưa có session tài khoản công ty nào.", + "sentToast": "Đã gửi {{bytes}} của {{sessions}} session", + "enrolledToast": "Máy này đã đăng ký với hub", + "hubAddress": "Địa chỉ hub", + "username": "Tên đăng nhập hub", + "password": "Mật khẩu hub", + "enroll": "Đăng ký máy này", + "cliHint": "Hoặc trong terminal: agenthub remote login <địa chỉ hub>", + "hubTitle": "Máy của bạn", + "hubDescription": "Các máy chạy AgentHub local bạn đã đăng ký với hub này, và các session tài khoản công ty của chúng.", + "noDevices": "Chưa có máy nào đăng ký.", + "revokedAt": "đã thu hồi {{time}}", + "lastSeen": "thấy lần cuối {{time}} từ {{ip}}", + "revoke": "Thu hồi", + "revokeTitle": "Thu hồi {{name}}?", + "revokeHint": "Device token ngừng hoạt động ngay; máy đó phải đăng ký lại.", + "revoked": "Đã thu hồi {{name}}", + "yourPassword": "Mật khẩu của bạn", + "sessions": "Session tài khoản công ty", + "unmonitored": "không mở từ AgentHub", + "sessionLine": "{{requests}} request · {{input}} vào · {{output}} ra · {{account}}", + "purged": "đã xoá hội thoại (quá hạn lưu)", + "whoRead": "Ai đã xem session của bạn", + "nobodyRead": "Chưa ai xem session của bạn." } } diff --git a/client/src/i18n/locales/vi/users.json b/client/src/i18n/locales/vi/users.json index 2f94a0a9..64bf59dc 100644 --- a/client/src/i18n/locales/vi/users.json +++ b/client/src/i18n/locales/vi/users.json @@ -409,7 +409,8 @@ "activeLabel": "Đang chạy", "activeValue": "{{sessions}} session · {{panes}} pane", "nowTitle": "Đang chạy", - "poolTheirs7d": "của họ 7 ngày {{cost}}" + "poolTheirs7d": "của họ 7 ngày {{cost}}", + "devicesTitle": "Máy cá nhân (AgentHub local)" }, "activity": "Hoạt động", "spend": "Chi phí hôm nay", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 67971860..9bfa1b8d 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3601,7 +3601,11 @@ export const api = { /** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */ artifacts: { list: ( - params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {} + params: { + scope?: "mine" | "hub" | "withme" | "all" | "unowned"; + kind?: ArtifactKind; + q?: string; + } = {} ) => { const qs = new URLSearchParams(); if (params.scope) qs.set("scope", params.scope); @@ -3611,6 +3615,17 @@ export const api = { return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`); }, settings: () => request("/artifacts/settings"), + setExportsEnabled: (exportsEnabled: boolean) => + request<{ installsEnabled: boolean; exportsEnabled: boolean }>("/artifacts/settings", { + method: "PUT", + body: JSON.stringify({ exportsEnabled }), + }), + /** A page's viewable copy, taken now from the file it was published from (if still there). */ + copy: (id: number) => + request<{ item: Artifact }>(`/artifacts/${id}/copy`, { method: "POST", body: "{}" }), + /** Server mode: who the caller may share with — names only. */ + people: () => + request<{ people: { id: number; username: string; name: string }[] }>("/artifacts/people"), setInstallsEnabled: (installsEnabled: boolean) => request<{ installsEnabled: boolean }>("/artifacts/settings", { method: "PUT", @@ -3697,6 +3712,8 @@ export const api = { title?: string; description?: string; shared?: boolean; + /** Server mode: the user ids it is shared with by name (replaces the list). */ + sharedWith?: number[]; /** plugin only: `name@marketplace`. */ source?: string; /** plugin only: the marketplace source. */ @@ -3846,6 +3863,55 @@ export const api = { * * No response here ever carries an API key — a saved profile reports only * `hasKey`. */ + /** A LOCAL AgentHub's link to the company hub (server/routes/remote-client.js). */ + remoteClient: { + /** GET /api/remote-client - enrollment and what was sent; never the token. */ + status: () => request("/remote-client"), + /** POST /api/remote-client/login - the password goes to the hub once, not kept. */ + login: (body: { hub: string; username: string; password: string; deviceName?: string }) => + request("/remote-client/login", { + method: "POST", + body: JSON.stringify(body), + }), + /** POST /api/remote-client/logout - revoke on the hub, forget the token here. */ + logout: () => + request<{ ok: true; wasEnrolled: boolean; revokedOnHub?: boolean }>("/remote-client/logout", { + method: "POST", + }), + /** POST /api/remote-client/upload - send the Company-account sessions' new lines now. */ + upload: () => + request<{ + sessions: number; + bytes: number; + errors: { sessionId: string; message: string }[]; + }>("/remote-client/upload", { method: "POST" }), + }, + /** The hub's remote-client views (server mode, server/hub/). */ + remoteHub: { + /** GET /api/remote/devices - your machines; staff may name `user`. */ + devices: (user?: number) => + request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`), + /** POST /api/remote/devices/:id/revoke - your own password again. */ + revoke: (id: number, password: string) => + request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, { + method: "POST", + body: JSON.stringify({ password }), + }), + /** GET /api/remote/sessions - Company-account sessions (device › folder). */ + sessions: (user?: number) => + request<{ sessions: RemoteSessionRow[] }>(`/remote/sessions${user ? `?user=${user}` : ""}`), + /** GET /api/remote/audit - who opened your sessions. */ + audit: () => + request<{ + entries: { + id: number; + at: string; + sessionId: string; + reader: string | null; + readerDisplay: string | null; + }[]; + }>("/remote/audit"), + }, aiProxies: { /** GET /api/ai-proxies - every profile, the built-in `native` one first. */ list: () => request<{ items: AiProxy[] }>("/ai-proxies"), @@ -5222,6 +5288,10 @@ export interface Artifact { size: number | null; shared: boolean; sharedAt: string | null; + /** Server mode: the people it is shared with by name (only its owner or an admin gets the list). */ + sharedWith?: { id: number; name: string }[]; + /** Server mode: shared with the caller by name. */ + sharedWithMe?: boolean; installs: number; /** False once its owner removed it from their library while shared: Hub only. */ inLibrary: boolean; @@ -5302,6 +5372,8 @@ export interface ArtifactSettings { serverMode: boolean; canShare: boolean; installsEnabled: boolean; + /** Claude panes are asked to save a viewable copy of a Doc / Slides / Design they publish. */ + exportsEnabled?: boolean; canManage: boolean; } @@ -5614,6 +5686,9 @@ export interface AiProxy { /** Identifier, derived from the label. Stable: a session's run row stores it. */ name: string; label: string; + /** The remote client's Company account (`agenthub.company`) — the hub's gateway, + * added by the client on an enrolled machine; never a saved profile. */ + company?: boolean; /** True only for the read-only `native` profile (the vendor's own API). */ builtin: boolean; /** "shared": the host's (an admin's, in server mode), usable by everyone; @@ -6805,3 +6880,59 @@ export interface PetList { featured: FeaturedPet[]; sheet: { width: number; height: number; cellWidth: number; cellHeight: number }; } + +/** An enrolled machine on the hub (server/hub/devices.js) — never its token. */ +export interface RemoteDevice { + id: number; + userId: number; + name: string; + createdAt: string; + expiresAt: string; + lastSeenAt: string | null; + lastIp: string | null; + revokedAt: string | null; + active: boolean; +} + +/** A Company-account session a device registered (server/hub/remote-sessions.js). */ +export interface RemoteSessionRow { + sessionId: string; + deviceId: number; + deviceName: string; + userId: number; + cwd: string; + tool: string; + account: string | null; + registeredAt: string; + endedAt: string | null; + lastRequestAt: string | null; + requests: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + lastUploadAt: string | null; + purgedAt: string | null; + unmonitored: number; + title: string | null; +} + +/** GET /api/remote-client — this local machine's link to the hub. */ +export interface RemoteClientStatus { + enrolled: boolean; + expired?: boolean; + hub?: string; + user?: { id: number; username: string; role: string } | null; + device?: { id: number; name: string; expiresAt: string } | null; + enrolledAt?: string | null; + retainDays?: number | null; + uploads?: { + sessionId: string; + bytes: number; + lastSentAt: string | null; + ended: number; + title: string | null; + cwd: string | null; + startedAt: string | null; + }[]; +} diff --git a/client/src/lib/artifactFolders.ts b/client/src/lib/artifactFolders.ts new file mode 100644 index 00000000..ad3fa3d4 --- /dev/null +++ b/client/src/lib/artifactFolders.ts @@ -0,0 +1,150 @@ +/** + * @file artifactFolders — the Artifacts page's user-made folders ("Slides", + * "Client A", …): their names and order, and which folder each artifact sits + * in. One JSON value under `agenthub.artifacts.folders`, an account preference + * key (`lib/userPrefs.ts`, `server/routes/me.js`), so the folders are kept per + * ACCOUNT on the server and follow it to any browser. A viewer's arrangement, + * not an artifact fact: nothing about an artifact changes, other users never + * see these folders, and deleting a folder only takes its artifacts out of it. + * Same store shape as `workspaceGroups.ts`. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useSyncExternalStore } from "react"; +import { PREFS_EVENT } from "./workspaceGroups"; + +export const ARTIFACT_FOLDERS_KEY = "agenthub.artifacts.folders"; +export const MAX_FOLDER_NAME = 60; + +export interface ArtifactFolder { + id: string; + name: string; +} + +export interface ArtifactFolders { + /** In display order. */ + folders: ArtifactFolder[]; + /** Artifact id → folder id. An artifact absent here is in no folder. */ + of: Record; +} + +const EMPTY: ArtifactFolders = { folders: [], of: {} }; +const listeners = new Set<() => void>(); +let cache: { raw: string | null; value: ArtifactFolders } = { raw: null, value: EMPTY }; + +function readRaw(): string | null { + try { + return localStorage.getItem(ARTIFACT_FOLDERS_KEY); + } catch { + return null; + } +} + +function parse(raw: string | null): ArtifactFolders { + if (!raw) return EMPTY; + try { + const v = JSON.parse(raw) as Partial; + const folders = (Array.isArray(v.folders) ? v.folders : []).filter( + (f): f is ArtifactFolder => + !!f && typeof f.id === "string" && !!f.id && typeof f.name === "string" + ); + const ids = new Set(folders.map((f) => f.id)); + const of: Record = {}; + if (v.of && typeof v.of === "object") { + for (const [item, fid] of Object.entries(v.of)) { + if (typeof fid === "string" && ids.has(fid)) of[item] = fid; + } + } + return { folders, of }; + } catch { + return EMPTY; + } +} + +export function readArtifactFolders(): ArtifactFolders { + const raw = readRaw(); + if (raw === cache.raw) return cache.value; + cache = { raw, value: parse(raw) }; + return cache.value; +} + +function write(next: ArtifactFolders): void { + try { + localStorage.setItem(ARTIFACT_FOLDERS_KEY, JSON.stringify(next)); + } catch { + /* disabled storage: the folders live until the page closes */ + cache = { raw: JSON.stringify(next), value: next }; + } + listeners.forEach((l) => l()); +} + +function update(fn: (cur: ArtifactFolders) => ArtifactFolders): void { + write(fn(readArtifactFolders())); +} + +const cleanName = (name: string) => name.trim().slice(0, MAX_FOLDER_NAME); + +/** A new folder at the end, holding `itemIds` (moved out of their old folder). Returns its id, or null for a blank name. */ +export function createFolder(name: string, itemIds: number[] = []): string | null { + const clean = cleanName(name); + if (!clean) return null; + const id = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + update((cur) => { + const of = { ...cur.of }; + for (const item of itemIds) of[item] = id; + return { folders: [...cur.folders, { id, name: clean }], of }; + }); + return id; +} + +/** Rename; a blank name keeps the old one. */ +export function renameFolder(id: string, name: string): void { + const clean = cleanName(name); + if (!clean) return; + update((cur) => ({ + ...cur, + folders: cur.folders.map((f) => (f.id === id ? { ...f, name: clean } : f)), + })); +} + +/** Delete the folder; its artifacts are only taken out of it. */ +export function deleteFolder(id: string): void { + update((cur) => ({ + folders: cur.folders.filter((f) => f.id !== id), + of: Object.fromEntries(Object.entries(cur.of).filter(([, fid]) => fid !== id)), + })); +} + +/** Put an artifact in a folder, or (null) take it out of every folder. */ +export function moveToFolder(itemId: number, folderId: string | null): void { + update((cur) => { + const of = { ...cur.of }; + if (folderId && cur.folders.some((f) => f.id === folderId)) of[itemId] = folderId; + else delete of[itemId]; + return { ...cur, of }; + }); +} + +/** The folder an artifact is in, or null. */ +export function folderOf(value: ArtifactFolders, itemId: number): ArtifactFolder | null { + const fid = value.of[itemId]; + return (fid && value.folders.find((f) => f.id === fid)) || null; +} + +function subscribe(l: () => void) { + listeners.add(l); + const onStorage = (e: StorageEvent) => { + if (e.key === ARTIFACT_FOLDERS_KEY || e.key === null) l(); + }; + window.addEventListener("storage", onStorage); + window.addEventListener(PREFS_EVENT, l); + return () => { + listeners.delete(l); + window.removeEventListener("storage", onStorage); + window.removeEventListener(PREFS_EVENT, l); + }; +} + +export function useArtifactFolders(): ArtifactFolders { + return useSyncExternalStore(subscribe, readArtifactFolders); +} diff --git a/client/src/lib/hiddenProfilesStorage.ts b/client/src/lib/hiddenProfilesStorage.ts new file mode 100644 index 00000000..57dabb71 --- /dev/null +++ b/client/src/lib/hiddenProfilesStorage.ts @@ -0,0 +1,42 @@ +/** + * @file hiddenProfilesStorage.ts + * @description Claude login profiles this browser leaves out of Settings → + * AI tools → Profiles, kept in localStorage with the same read/validate/ + * fallback convention as laneOrderStorage.ts. A viewer preference only: the + * profile files, the API and the database are untouched, and another browser + * still lists every profile. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +const STORAGE_KEY = "agenthub.cc.hiddenProfiles"; + +/** A profile's stable key: its account (survives a rename), else its file name. */ +export function profileKey(p: { accountUuid: string | null; name?: string }): string { + return p.accountUuid || p.name || ""; +} + +export function readHiddenProfiles(): string[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((k): k is string => typeof k === "string" && k.length > 0); + } catch { + return []; + } +} + +export function writeHiddenProfiles(keys: string[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); + } catch { + /* ignore quota / disabled storage */ + } +} + +/** `keys` with `key` added when absent, removed when present. */ +export function toggleHidden(keys: string[], key: string): string[] { + return keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key]; +} diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 175af0db..ee51aae7 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1573,7 +1573,10 @@ export type AlertRuleType = | "status_duration" | "token_threshold" | "cost_threshold" - | "budget"; + | "budget" + /** Server mode: a user's machine used the company account for a session it + * never registered with the hub (no config). */ + | "remote_unmonitored"; /** * Rule-type-specific settings for an {@link AlertRule}. Which fields apply diff --git a/client/src/lib/userPrefs.ts b/client/src/lib/userPrefs.ts index 095cfe11..dfc7bfdf 100644 --- a/client/src/lib/userPrefs.ts +++ b/client/src/lib/userPrefs.ts @@ -47,6 +47,7 @@ export const PREF_KEYS = [ "agenthub.markdown.view", "agenthub.workspace.groups", "agenthub.changes.innerRepos", + "agenthub.artifacts.folders", ] as const; const SYNCED = new Set(PREF_KEYS); const PUSH_DELAY_MS = 400; diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index 380b4740..3b1f28e8 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -53,12 +53,14 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { ChevronRight, FileText, + Folder, + FolderPlus, Globe, LayoutGrid, List, @@ -83,6 +85,8 @@ import { eventBus } from "../lib/eventBus"; import { toast } from "../lib/toast"; import { confirmDialog } from "../lib/confirm"; import { useDialog } from "../hooks/useDialog"; +import { Checkbox } from "../components/Checkbox"; +import { isSubmitEnter } from "../lib/keys"; import { copyText } from "../lib/clipboard"; import { timeAgo } from "../lib/format"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; @@ -95,6 +99,17 @@ import { splitSkillMd, } from "../components/artifacts/ArtifactAddPanels"; import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview"; +import { HtmlFrame } from "../components/HtmlFrame"; +import { PdfView } from "../components/PdfView"; +import { + MAX_FOLDER_NAME, + createFolder, + deleteFolder, + folderOf, + moveToFolder, + renameFolder, + useArtifactFolders, +} from "../lib/artifactFolders"; // The storefront's shelf order: what installs first, what is read after. const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"]; @@ -110,22 +125,30 @@ const fmtDate = (iso: string) => new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i; -// What the sandboxed preview can show: the server's typed list minus PDF. +// What the preview can show: the sandboxed types, plus PDF drawn by pdf.js. const PREVIEWABLE = - /\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i; + /\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav|pdf)$/i; +const PDF = /\.pdf$/i; /** What a row can do, worked out once for its card, menu and detail sheet. */ +/** Shared at all: with everyone (the Hub) or with chosen people. */ +const isShared = (a: Artifact) => a.shared || (a.sharedWith?.length ?? 0) > 0; + function capabilities(a: Artifact) { const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""); return { previewPath, canPreview: a.files.length > 0 && - (a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))), + (a.kind === "skill" || + ((a.kind === "file" || a.kind === "page") && PREVIEWABLE.test(previewPath))), + // A page with no copy yet that was published from a file: one may still + // be taken (`POST /:id/copy`) when someone asks to view it. + mayCopy: a.kind === "page" && a.files.length === 0 && !!a.source, canDownload: a.kind === "file" && a.files.length > 0, installable: a.kind === "skill" || a.kind === "plugin", // Its owner removed it from their library; it lives on in the Hub only. - hubOnly: a.mine && a.shared && !a.inLibrary, + hubOnly: a.mine && isShared(a) && !a.inLibrary, }; } @@ -150,14 +173,25 @@ const footAction = export function Artifacts() { const { t } = useTranslation("artifacts"); const [settings, setSettings] = useState(null); - const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all"); + const [tab, setTab] = useState<"all" | "mine" | "hub" | "withme" | "unowned">("all"); const { isAdmin } = usePermissions(); // Server mode: rows nobody owns — every one for an admin, those offered to you otherwise. const [unownedCount, setUnownedCount] = useState(0); const [users, setUsers] = useState([]); const [kind, setKind] = useState(""); const [q, setQ] = useState(""); - const [items, setItems] = useState(null); + const [loaded, setItems] = useState(null); + // Folders are this account's own arrangement (`lib/artifactFolders.ts`). + const folders = useArtifactFolders(); + const [folder, setFolder] = useState(""); + const [sharing, setSharing] = useState(null); + const [moving, setMoving] = useState(null); + const activeFolder = folders.folders.some((f) => f.id === folder) ? folder : ""; + const items = useMemo( + () => + loaded && activeFolder ? loaded.filter((a) => folders.of[a.id] === activeFolder) : loaded, + [loaded, activeFolder, folders] + ); const [adding, setAdding] = useState(null); const [addMenu, setAddMenu] = useState(false); const addMenuRef = useRef(null); @@ -261,12 +295,16 @@ export function Artifacts() { const server = !!settings?.serverMode; + // Share opens the dialog: everyone (the Hub), chosen people, or nobody. A + // Hub-only row (its owner already removed it from their library) is + // deleted by unsharing it, so its one action is that, behind a confirm. const share = async (a: Artifact) => { - // A Hub-only row (its owner already removed it from their library) is - // deleted by unsharing it, so that asks first. + if (!capabilities(a).hubOnly) { + setDetailId(null); + setSharing(a); + return; + } if ( - a.shared && - !a.inLibrary && !(await confirmDialog({ title: t("hubRemoveTitle", { title: a.title }), message: t("hubRemoveBody"), @@ -275,8 +313,8 @@ export function Artifacts() { ) return; try { - await api.artifacts.update(a.id, { shared: !a.shared }); - toast.success(t(a.shared ? "unshareDone" : "shareDone")); + await api.artifacts.update(a.id, { shared: false, sharedWith: [] }); + toast.success(t("unshareDone")); load(); } catch (err) { toast.error(err); @@ -495,6 +533,10 @@ export function Artifacts() { const actionsFor = (a: Artifact): CardActions => ({ onOpen: () => setDetailId(a.id), onShare: () => void share(a), + onMove: () => { + setDetailId(null); + setMoving(a); + }, onReshare: () => void reshare(a), onRename: () => { setDetailId(null); @@ -506,7 +548,20 @@ export function Artifacts() { onUninstall: () => void uninstall(a), onPreview: (path) => { setDetailId(null); - setPreview({ item: a, path }); + if (path || !capabilities(a).mayCopy) { + setPreview({ item: a, path }); + return; + } + // An older page: take its copy now, then show it. + api.artifacts + .copy(a.id) + .then(({ item }) => { + const c = capabilities(item); + if (c.canPreview) setPreview({ item, path: c.previewPath }); + else toast.error(t("page.noCopy")); + load(); + }) + .catch((err) => toast.error(err)); }, }); @@ -609,20 +664,21 @@ export function Artifacts() { // shared (only an admin is shown those) gets a section of its own, so // "Shared by others" never holds a private row. const halves = - server && items && tab !== "unowned" + server && items && tab !== "unowned" && tab !== "withme" ? [ { key: `${tab}.others`, - rows: items.filter((a) => !a.mine && (a.shared || tab === "mine")), + rows: items.filter((a) => !a.mine && (a.shared || a.sharedWithMe || tab === "mine")), }, { key: "unshared", - rows: tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared), + rows: + tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared && !a.sharedWithMe), }, { key: `${tab}.own`, rows: items.filter((a) => a.mine) }, ].filter((h) => h.rows.length > 0 || h.key !== "unshared") : null; - const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null); + const detail = detailId === null ? null : (loaded?.find((a) => a.id === detailId) ?? null); return (
@@ -682,7 +738,7 @@ export function Artifacts() { className="-mb-1 flex gap-1 overflow-x-auto border-b border-border" data-testid="artifacts-scope" > - {(["all", "mine", "hub"] as const).map((v) => ( + {(["all", "mine", "hub", "withme"] as const).map((v) => ( ); + // A page with a copy is viewed here, by anyone it is shared with; the + // claude.ai link (⋯ → Open) needs the publishing account. + if (a.kind === "page" && (c.canPreview || c.mayCopy)) + return ( + + ); if (a.kind === "page" && a.url) return ( @@ -1251,11 +1355,25 @@ function Badges({ item: a, server }: { item: Artifact; server: boolean }) { {t("hubOnly")} )} - {server && !a.shared && ( + {server && !isShared(a) && !a.sharedWithMe && ( {t("private")} )} + {server && !a.shared && (a.sharedWith?.length ?? 0) > 0 && ( + p.name).join(", ")} + data-testid={`shared-people-${a.id}`} + > + {t("sharedWithCount", { count: a.sharedWith!.length })} + + )} + {server && !a.shared && a.sharedWithMe && ( + + {t("sharedWithMe")} + + )} {a.auto && ( {t("auto")} @@ -1316,6 +1434,7 @@ function ArtifactMenu({ menu, onOpen, onShare, + onMove, onReshare, onRename, onDelete, @@ -1369,12 +1488,13 @@ function ArtifactMenu({ {t("uninstall")} )} + {t("folders.move")} {a.canChange && ( <> {server && ( - {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} + {t(hubOnly ? "hubRemove" : isShared(a) ? "sharing" : "share")} )} {server && a.kind === "skill" && ( @@ -1567,9 +1687,18 @@ function DetailDialog({ {server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)} {a.source ? ` · ${a.source}` : ""}

- {a.shared && ( - - {t(hubOnly ? "hubOnly" : "shared")} + {(isShared(a) || a.sharedWithMe) && ( + p.name).join(", ") || undefined} + > + {hubOnly + ? t("hubOnly") + : a.shared + ? t("shared") + : a.sharedWithMe + ? t("sharedWithMe") + : t("sharedWithCount", { count: a.sharedWith?.length ?? 0 })} )}
@@ -1695,7 +1824,7 @@ function DetailDialog({
{server && ( )} {server && a.kind === "skill" && ( @@ -2107,6 +2236,374 @@ function RenameDialog({ ); } +/** Who an artifact is shared with: everyone (the Hub), chosen people, or nobody. */ +function ShareDialog({ + item, + onClose, + onSaved, +}: { + item: Artifact; + onClose: () => void; + onSaved: () => void; +}) { + const { t } = useTranslation("artifacts"); + const initialPeople = (item.sharedWith ?? []).map((p) => p.id); + const [mode, setMode] = useState<"nobody" | "everyone" | "people">( + item.shared ? "everyone" : "people" + ); + const [picked, setPicked] = useState>(new Set(initialPeople)); + const [people, setPeople] = useState<{ id: number; username: string; name: string }[] | null>( + null + ); + const [filter, setFilter] = useState(""); + const [busy, setBusy] = useState(false); + useEffect(() => { + api.artifacts + .people() + .then((r) => setPeople(r.people)) + .catch((err) => { + setPeople([]); + toast.error(err); + }); + }, []); + const shown = (people ?? []).filter((p) => { + const f = filter.trim().toLowerCase(); + return !f || p.name.toLowerCase().includes(f) || p.username.toLowerCase().includes(f); + }); + const toggle = (id: number, on: boolean) => + setPicked((cur) => { + const next = new Set(cur); + if (on) next.add(id); + else next.delete(id); + return next; + }); + const target = + mode === "everyone" + ? { shared: true, sharedWith: [] as number[] } + : mode === "people" + ? { shared: false, sharedWith: [...picked] } + : { shared: false, sharedWith: [] as number[] }; + const blocked = busy || (mode === "people" && picked.size === 0); + const save = async () => { + if (blocked) return; + // Out of its owner's library, a row that nobody can see any more is + // deleted: that asks first. + const goneForGood = + item.mine && !item.inLibrary && !target.shared && target.sharedWith.length === 0; + if ( + goneForGood && + !(await confirmDialog({ + title: t("hubRemoveTitle", { title: item.title }), + message: t("hubRemoveBody"), + confirmLabel: t("hubRemove"), + })) + ) + return; + setBusy(true); + try { + await api.artifacts.update(item.id, target); + toast.success( + target.shared + ? t("shareDone") + : target.sharedWith.length + ? t("sharePeopleDone", { count: target.sharedWith.length }) + : t("unshareDone") + ); + onSaved(); + } catch (err) { + toast.error(err); + } finally { + setBusy(false); + } + }; + const radio = (v: typeof mode, label: string, hint: string) => ( + + ); + return ( + +
+
+ {radio("people", t("shareMode.people"), t("shareMode.peopleHint"))} + {radio("everyone", t("shareMode.everyone"), t("shareMode.everyoneHint"))} + {(item.shared || initialPeople.length > 0) && + radio("nobody", t("shareMode.nobody"), t("shareMode.nobodyHint"))} +
+ {mode === "people" && ( +
+ setFilter(e.target.value)} + /> + {people === null ? ( +

{t("loading")}

+ ) : shown.length === 0 ? ( +

{t("sharePeople.none")}

+ ) : ( +
    + {shown.map((p) => ( +
  • + toggle(p.id, v)} + label={ + + {p.name} + {p.name !== p.username && ( + @{p.username} + )} + + } + /> +
  • + ))} +
+ )} +

+ {t("sharePeople.picked", { count: picked.size })} +

+
+ )} +
+ + +
+
+
+ ); +} + +/** Put one artifact in one of the viewer's own folders (or a new one), or take it out. */ +function MoveDialog({ + item, + current, + folders, + onClose, +}: { + item: Artifact; + current: string | null; + folders: { id: string; name: string }[]; + onClose: () => void; +}) { + const { t } = useTranslation("artifacts"); + const [name, setName] = useState(""); + const pick = (id: string | null) => { + moveToFolder(item.id, id); + onClose(); + }; + const create = () => { + const id = createFolder(name, [item.id]); + if (id) onClose(); + }; + return ( + +
+

{t("folders.private")}

+ {folders.length > 0 && ( +
    + {folders.map((f) => ( +
  • + +
  • + ))} +
+ )} +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (isSubmitEnter(e)) { + e.preventDefault(); + create(); + } + }} + /> + +
+
+ {current ? ( + + ) : ( + + )} + +
+
+
+ ); +} + +/** The viewer's folders as filter chips, with new / rename / delete. */ +function FolderBar({ + folders, + active, + counts, + onPick, +}: { + folders: { id: string; name: string }[]; + active: string; + counts: (id: string) => number; + onPick: (id: string) => void; +}) { + const { t } = useTranslation("artifacts"); + // `new`: naming a new folder; a folder id: renaming that one. + const [editing, setEditing] = useState<"new" | string | null>(null); + const [name, setName] = useState(""); + const begin = (what: "new" | string, start = "") => { + setEditing(what); + setName(start); + }; + const commit = () => { + if (editing === "new") { + const id = createFolder(name); + if (id) onPick(id); + } else if (editing) renameFolder(editing, name); + setEditing(null); + }; + const remove = async (f: { id: string; name: string }) => { + const ok = await confirmDialog({ + title: t("folders.deleteTitle", { name: f.name }), + message: t("folders.deleteBody"), + confirmLabel: t("folders.delete"), + }); + if (!ok) return; + deleteFolder(f.id); + if (active === f.id) onPick(""); + }; + const chip = (on: boolean) => + `inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap transition-colors ${ + on ? "bg-surface-4 text-fg-primary" : "text-fg-muted hover:text-fg-secondary" + }`; + const activeFolder = folders.find((f) => f.id === active); + return ( +
+ + + {folders.map((f) => ( + + ))} + {editing ? ( + setName(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (isSubmitEnter(e)) { + e.preventDefault(); + commit(); + } else if (e.key === "Escape") { + e.preventDefault(); + setEditing(null); + } + }} + /> + ) : ( + + )} + {activeFolder && !editing && ( + + + + + )} +
+ ); +} + // Text the dashboard renders itself: Markdown through `MarkdownPreview`, the // rest as code. A sandboxed iframe would draw a text/plain body with the // browser's own colours — light text on our white frame in a dark theme. @@ -2287,6 +2784,23 @@ function PreviewDialog({
+ ) : PDF.test(current) ? ( + + ) : html && item.kind === "page" && !/\.svg$/i.test(current) ? ( + // A page's copy is an app: it runs, in the chat artifacts' + // sandbox (scripts, never the dashboard's origin), with its + // own files read from the copy. + + fetch(api.artifacts.rawUrl(item.id, rel), { credentials: "same-origin" }).then( + (r) => (r.ok ? r.blob() : Promise.reject(new Error(String(r.status)))) + ) + } + /> ) : IMAGE.test(current) && !html ? ( {current} ) : ( diff --git a/client/src/pages/SettingsHub.tsx b/client/src/pages/SettingsHub.tsx index a80ed792..7e0688d2 100644 --- a/client/src/pages/SettingsHub.tsx +++ b/client/src/pages/SettingsHub.tsx @@ -75,6 +75,7 @@ import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSetting import { MemberPolicySettings } from "../components/settings/MemberPolicySettings"; import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings"; import { PoolAccountSettings } from "../components/settings/PoolAccountSettings"; +import { CompanyHubSettings } from "../components/settings/CompanyHubSettings"; import { OmpSharingSettings } from "../components/settings/OmpSharingSettings"; import { PricingSettings } from "../components/settings/PricingSettings"; import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings"; @@ -320,6 +321,8 @@ export function SettingsHub({ section }: { section?: string } = {}) { {/* Members' test runs on this box: server mode + S3, an admin's. */} + {/* The company hub: enroll this machine (local) / your machines (hub). */} +
), }, diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index 91ca574e..8d147213 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -28,6 +28,10 @@ import { api, type Artifact } from "../../lib/api"; import { confirmDialog } from "../../lib/confirm"; vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() })); +// pdf.js draws on a canvas jsdom does not have; the viewer is its own unit. +vi.mock("../../components/PdfView", () => ({ + PdfView: ({ title }: { title: string }) =>
{title}
, +})); vi.mock("../../lib/api", () => ({ api: { lanes: { list: vi.fn() }, @@ -35,6 +39,8 @@ vi.mock("../../lib/api", () => ({ settings: vi.fn(), list: vi.fn(), update: vi.fn(), + people: vi.fn(), + copy: vi.fn(), remove: vi.fn(), install: vi.fn(), updateInstalled: vi.fn(), @@ -386,6 +392,120 @@ describe("Artifacts", () => { expect(screen.queryByTestId("artifact-installs")).toBeNull(); }); + it("shares with the people picked, never with everyone unless asked", async () => { + vi.mocked(api.artifacts.settings).mockResolvedValue({ + serverMode: true, + canShare: true, + installsEnabled: true, + canManage: false, + }); + vi.mocked(api.artifacts.list).mockResolvedValue({ items: [item({ id: 4, title: "deck" })] }); + vi.mocked(api.artifacts.people).mockResolvedValue({ + people: [ + { id: 7, username: "ann", name: "Ann" }, + { id: 8, username: "bob", name: "Bob" }, + ], + }); + vi.mocked(api.artifacts.update).mockClear(); + vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 4 }) } as never); + const user = userEvent.setup(); + open(); + const card = await screen.findByTestId("artifact-4"); + await menuAction(user, "Share", card); + const dialog = await screen.findByTestId("share-dialog"); + const save = within(dialog).getByRole("button", { name: "Save" }); + expect(save).toBeDisabled(); // nobody picked yet + await within(dialog).findByText("Ann"); + await user.type(within(dialog).getByLabelText("Find a person"), "an"); + expect(within(dialog).queryByText("Bob")).toBeNull(); + await user.click(within(dialog).getByRole("checkbox", { name: /Ann/ })); + await user.click(save); + await waitFor(() => + expect(api.artifacts.update).toHaveBeenCalledWith(4, { shared: false, sharedWith: [7] }) + ); + }); + + it("shows who a row is shared with and lists Shared with you as its own tab", async () => { + vi.mocked(api.artifacts.settings).mockResolvedValue({ + serverMode: true, + canShare: true, + installsEnabled: true, + canManage: false, + }); + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: [ + item({ id: 5, title: "mine", sharedWith: [{ id: 7, name: "Ann" }] }), + item({ id: 6, title: "for-me", mine: false, canChange: false, sharedWithMe: true }), + ], + }); + const user = userEvent.setup(); + open(); + const badge = await screen.findByTestId("shared-people-5"); + expect(badge).toHaveTextContent("Shared with 1 person"); + expect(badge).toHaveAttribute("title", "Ann"); + // A row shared with me by name is "Shared by others", not private. + expect(within(screen.getByTestId("artifact-6")).queryByText("Private")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Shared with you" })); + await waitFor(() => + expect(api.artifacts.list).toHaveBeenLastCalledWith( + expect.objectContaining({ scope: "withme" }) + ) + ); + }); + + it("views a page's copy here: an app runs in the sandbox, a PDF is drawn, an older page is copied first", async () => { + const page = (over: Partial) => + item({ kind: "page", url: "https://claude.ai/artifact/p", ...over }); + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: [ + page({ id: 11, title: "app", files: [{ path: "app.html", size: 9 }] }), + page({ id: 12, title: "deck", files: [{ path: "deck-1.pdf", size: 9 }] }), + page({ id: 13, title: "old", files: [], source: "/w/old.html" }), + ], + }); + // The copy's own files come through the raw route. + const realFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => new Response("

app

")) as typeof fetch; + vi.mocked(api.artifacts.copy).mockResolvedValue({ + item: page({ id: 13, title: "old", files: [{ path: "old.html", size: 3 }] }), + }); + const user = userEvent.setup(); + open(); + await user.click(within(await screen.findByTestId("artifact-11")).getByTestId("artifact-view")); + expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument(); + expect(screen.queryByTestId("artifact-preview")).toBeNull(); + await user.keyboard("{Escape}"); + await user.click(within(screen.getByTestId("artifact-12")).getByTestId("artifact-view")); + expect(await screen.findByTestId("pdf-view")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + await user.click(within(screen.getByTestId("artifact-13")).getByTestId("artifact-view")); + await waitFor(() => expect(api.artifacts.copy).toHaveBeenCalledWith(13)); + expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument(); + globalThis.fetch = realFetch; + }); + + it("keeps folders per account: make one, move an artifact in, filter by it", async () => { + localStorage.removeItem("agenthub.artifacts.folders"); + const user = userEvent.setup(); + open(); + await screen.findByText("deploy"); + await user.click(screen.getByTestId("artifact-folder-new")); + await user.type(screen.getByLabelText("Folder name"), "Slides{Enter}"); + // The new folder is picked at once, and empty. + expect(await screen.findByTestId("artifacts-empty-filtered")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "All folders" })); + await menuAction(user, "Move to folder…", screen.getByTestId("artifact-2")); + const dialog = await screen.findByTestId("move-dialog"); + await user.click(within(dialog).getByRole("button", { name: "Slides" })); + const stored = JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!); + expect(Object.values(stored.of)).toHaveLength(1); + expect(stored.of["2"]).toBe(stored.folders[0].id); + await user.click(screen.getByRole("button", { name: /^Slides/ })); + expect(await screen.findByTestId("artifact-2")).toBeInTheDocument(); + expect(screen.queryByTestId("artifact-1")).toBeNull(); + localStorage.removeItem("agenthub.artifacts.folders"); + }); + it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => { vi.mocked(api.artifacts.settings).mockResolvedValue({ serverMode: true, @@ -404,7 +524,9 @@ describe("Artifacts", () => { expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull(); vi.mocked(confirmDialog).mockResolvedValueOnce(true); await menuAction(user, "Remove from Hub", card); - await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false })); + await waitFor(() => + expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false, sharedWith: [] }) + ); const calls = vi.mocked(confirmDialog).mock.calls; expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/); }); diff --git a/client/src/pages/__tests__/SettingsHub.test.tsx b/client/src/pages/__tests__/SettingsHub.test.tsx index 5014d563..721323b5 100644 --- a/client/src/pages/__tests__/SettingsHub.test.tsx +++ b/client/src/pages/__tests__/SettingsHub.test.tsx @@ -91,6 +91,13 @@ vi.mock("../../lib/api", async (importOriginal) => { api: { aiTools: { list: vi.fn(async () => view), iconUrl: () => "" }, lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) }, + // AI tools → Company hub (local: not enrolled; hub: nothing yet). + remoteClient: { status: vi.fn(async () => ({ enrolled: false })) }, + remoteHub: { + audit: vi.fn(async () => ({ entries: [] })), + devices: vi.fn(async () => ({ devices: [] })), + sessions: vi.fn(async () => ({ sessions: [] })), + }, // AI tools → Profiles. toolProfiles: { list: vi.fn(async () => ({ tools: [] })) }, ccConfig: { diff --git a/client/src/pages/__tests__/Users.test.tsx b/client/src/pages/__tests__/Users.test.tsx index 3ee36b62..25bede8c 100644 --- a/client/src/pages/__tests__/Users.test.tsx +++ b/client/src/pages/__tests__/Users.test.tsx @@ -21,6 +21,7 @@ import type { AuthState, ServerUserUsage } from "../../lib/api"; let authState: AuthState = { mode: "local" }; vi.mock("../../components/AuthGate", () => ({ useAuth: () => ({ state: authState, logout: async () => {} }), + usePermissions: () => ({ isAdmin: true, local: false }), })); const usage = { cost: 0, tokens: 0 }; @@ -56,6 +57,10 @@ vi.mock("../../lib/api", async (importOriginal) => { api: { users, lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) }, + remoteHub: { + devices: vi.fn(async () => ({ devices: [] })), + sessions: vi.fn(async () => ({ sessions: [] })), + }, accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })), profiles: vi.fn(async () => ({ profiles: [] })), diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 5cf17ed4..c3cd2ca1 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.8.70 (rebuild client to match) + UI build v2.8.71 (rebuild client to match)

diff --git a/docs/API.md b/docs/API.md index 1658c200..573c35b3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1366,8 +1366,10 @@ cannot collide on `index.lock`. `shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and `shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go - through the same `git status` check as `discard`. `unshelve` refuses a - dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean + through the same `git status` check as `discard`. `unshelve` refuses + anything staged and any change in one of the shelf's own files + (`400 EDIRTY`, those files in `paths`) so a restore never mixes with new + work — changes in other files (the ones a partial `shelve` left) stay put; a clean apply drops the entry (`kept: false`), a conflicted one KEEPS it and answers `conflicts: [...]`, `kept: true` — the files are resolved with `resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to @@ -3514,8 +3516,8 @@ A snapshot freezes a session as it is at that moment into one immutable `session ```http GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`) -GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage} -PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub +GET /api/artifacts/settings {serverMode, canShare, installsEnabled, exportsEnabled, canManage} +PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub (installs: server mode only) GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]} GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]} POST /api/artifacts/page {url, title?, description?} @@ -3527,11 +3529,13 @@ POST /api/artifacts/skill/new {name, description, content, titl POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]} POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?} GET /api/artifacts/:id {item} -PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?, skill?: {name, description, content}} (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow) +GET /api/artifacts/people → {people: [{id, username, name}]} — server mode: every enabled account but the caller, names only (who an artifact can be shared with) +PATCH /api/artifacts/:id {title?, description?, shared?, sharedWith?: [userId], source?, market?, skill?: {name, description, content}} (sharedWith: server mode, replaces who it is shared with by name — up to 100 enabled users, the owner dropped; a row out of its owner's library shared with nobody is deleted, `removed`) (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow) DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff) POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?} POST /api/artifacts/:id/uninstall → {uninstalled, paths} +POST /api/artifacts/:id/copy → {item} — a page captured before pages kept a copy: snapshot the HTML file it was published from, if still in its workspace (once per row per server run; any viewer, who gets only the copy) POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin @@ -3554,7 +3558,10 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar - **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills//` AND `~/.agents/skills//` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/[-agents]-