From 1a365a45bd8004e68f598526263d8ac323f3ca7a Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 11:10:05 +0700 Subject: [PATCH] feat(server-mode): collaborators run sessions on their own account (sharing phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user a workspace is shared with may now start and resume AI sessions in it. The run uses THEIR OWN account — a Claude pool setup-token, a Codex/Cursor pool API key, or an AI Proxy of theirs — never the owner's login or a pool login (409 ENOCOLLABACCOUNT otherwise). Their quota decides (EQUOTA, their own flag file, the sweep goes by payer). Money is per payer by UTC hour (session_payers, server/lib/session-payers.js): a session nobody else ran is billed as before; one a collaborator started is theirs; a resume changes hands from the next hour. Only Claude books by the hour, so a cross-payer resume of any other tool and a collaborator's resumeLast are refused (409 ECOLLABRESUME); a Terminal tab stays the owner's. Users page, details, quotas, Today (mine=1) and developer budgets count by payer. Docs: SERVER-MODE.md, API.md, CLAUDE.md, plan status. --- CLAUDE.md | 4 +- client/src/components/lanes/WorkspaceHome.tsx | 11 +- client/src/i18n/locales/en/lanes.json | 4 +- client/src/i18n/locales/vi/lanes.json | 4 +- docs/API.md | 13 +- docs/SERVER-MODE.md | 46 ++++- docs/plans/workspace-sharing/README.md | 2 +- openapi.yaml | 2 +- server/__tests__/collab-runs.test.js | 180 ++++++++++++++++++ server/__tests__/lane-shares.test.js | 140 ++++++++++++-- server/db.js | 15 ++ server/lib/budgets.js | 29 ++- server/lib/claude-pool.js | 17 ++ server/lib/codex-pool.js | 24 ++- server/lib/dashboard-runs.js | 14 +- server/lib/pty-run.js | 67 ++++++- server/lib/session-payers.js | 149 +++++++++++++++ server/lib/user-quota.js | 131 ++++++++++--- server/openapi-extra/lanes-more.js | 2 +- server/routes/lanes.js | 37 ++-- server/routes/pricing.js | 33 +++- server/routes/run.js | 24 ++- server/routes/users.js | 22 ++- 23 files changed, 834 insertions(+), 136 deletions(-) create mode 100644 server/__tests__/collab-runs.test.js create mode 100644 server/lib/session-payers.js diff --git a/CLAUDE.md b/CLAUDE.md index 1bd49334..d5dd8c83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.77 (2026-11-06) — pool accounts are rows of Settings → Profiles; Changes panel on the workspace home._ 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.77+ (2026-11-06) — shared-workspace collaborators run sessions on their own account, billed per payer._ 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. @@ -83,7 +83,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A session reload is typed into a live pane, never spawned.** `/reload-plugins` and `/reload-skills` are scoped BY CLAUDE CODE to the session that runs them, so nothing can reload a session from the outside. `server/lib/cc-reload.js` therefore types them into the dashboard's existing tmux lane panes and spawns NO `claude` process — a one-shot `claude -p "/reload-plugins"` reloads only its own short-lived session, exits, and leaves a junk session in the dashboard's own history. Do not add a path that starts a session in order to reload one. - **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id. - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`, `clone-runner.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). -- **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 (plus every row of a workspace SHARED with them — `lane_shares`, `docs/SERVER-MODE.md` § Sharing a workspace: they work in it like its owner, under S3 as the owner's Linux account, but rename/reset/remove/delete/share stay the owner's or an admin's (`isLaneOwner`, `ENOTOWNER`), they start no AI session there yet (`ECOLLABSTART`), and budgets/Today stay on OWNED rows — `ownedOnlySessionWhere`/`ownSessionWhere`), 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. +- **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 (plus every row of a workspace SHARED with them — `lane_shares`, `docs/SERVER-MODE.md` § Sharing a workspace: they work in it like its owner, under S3 as the owner's Linux account, but rename/reset/remove/delete/share stay the owner's or an admin's (`isLaneOwner`, `ENOTOWNER`), a session they start or resume runs only on THEIR OWN pool token/API key or AI Proxy — never the owner's login (`ENOCOLLABACCOUNT`) — under their quota, and money is per PAYER by hour (`session_payers`, `server/lib/session-payers.js`: users page, quotas, Today, budgets — never a second cost formula)), 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. diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index 8f1f6e7b..b58aeee9 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -274,13 +274,14 @@ function HomeBody({ }; const text = promptWithImages(prompt, images, claude); - // A workspace shared with this user: they join its open sessions, but start - // none of their own yet (the server answers ECOLLABSTART). + // A workspace shared with this user: a session they start runs on their + // own account and is billed to them (the server answers ENOCOLLABACCOUNT + // when they have none). const collab = !ownsLane(lane); const start = async () => { // Enter reaches here past the disabled button: the same guards again. // A ref, not only the state: two Enters in one tick both read `starting` false. - if (collab || startingRef.current || uploading || !text) return; + if (startingRef.current || uploading || !text) return; startingRef.current = true; setStarting(true); setError(null); @@ -536,7 +537,7 @@ function HomeBody({ type="button" data-testid="workspace-home-start" onClick={() => void start()} - disabled={collab || starting || uploading || !text} + disabled={starting || uploading || !text} aria-label={t("home.start")} title={t("home.start")} className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40" @@ -548,7 +549,7 @@ function HomeBody({ {collab && (

- {t("shared.noStart")} + {t("shared.ownAccount")}

)} {/* The session's quieter choices, under the box like Claude Desktop's. */} diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index b47da520..84eaa8a2 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -388,7 +388,7 @@ "menu.remove": "Delete workspace…", "menu.share": "Share…", "share.title": "Share workspace", - "share.what": "People you share it with see this workspace and its sessions, join its open terminals, type into them and commit — like you. Only you rename, reset, delete or share it. For now they cannot start a new AI session in it.", + "share.what": "People you share it with see this workspace and its sessions, join its open terminals, type into them and commit — like you. Only you rename, reset, delete or share it. A session they start runs on their own account and is billed to them.", "share.warning": "They work as YOUR account on this server: they can read everything in your home folder (your other workspaces, SSH keys and AI logins) and their commits carry your name. Share only with people you would hand your keyboard to.", "share.loading": "Loading…", "share.nobody": "There is no one else to share with.", @@ -404,7 +404,7 @@ "share.saved_other": "Shared with {{count}} people", "share.disabled": "(disabled)", "share.savedNone": "No longer shared with anyone", - "shared.noStart": "This workspace is shared with you: open one of its sessions to work in it. Only its owner starts new sessions for now.", + "shared.ownAccount": "This workspace is shared with you: a session you start here runs on your own account — a pool account or an AI Proxy of yours — and is billed to you.", "shared.badge": "Shared with you", "panel.changes": "Changes", "panel.analyst": "Analyst", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 25647a30..6c6cc7a2 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -388,7 +388,7 @@ "menu.remove": "Xóa workspace…", "menu.share": "Chia sẻ…", "share.title": "Chia sẻ workspace", - "share.what": "Người được chia sẻ thấy workspace này và các session của nó, vào được terminal đang mở, gõ và commit — như bạn. Chỉ bạn được đổi tên, đặt lại, xoá hay chia sẻ nó. Hiện tại họ chưa mở được session AI mới trong đó.", + "share.what": "Người được chia sẻ thấy workspace này và các session của nó, vào được terminal đang mở, gõ và commit — như bạn. Chỉ bạn được đổi tên, đặt lại, xoá hay chia sẻ nó. Session họ mở chạy bằng tài khoản của chính họ và tính tiền cho họ.", "share.warning": "Họ làm việc bằng TÀI KHOẢN CỦA BẠN trên server này: đọc được mọi thứ trong thư mục home của bạn (các workspace khác, SSH key, đăng nhập AI) và commit của họ mang tên bạn. Chỉ chia sẻ với người bạn tin như đưa bàn phím cho họ.", "share.loading": "Đang tải…", "share.nobody": "Không còn ai khác để chia sẻ.", @@ -404,7 +404,7 @@ "share.saved_other": "Đã chia sẻ với {{count}} người", "share.disabled": "(đã vô hiệu hoá)", "share.savedNone": "Không còn chia sẻ với ai", - "shared.noStart": "Workspace này được chia sẻ với bạn: mở một session có sẵn của nó để làm việc. Hiện chỉ chủ workspace mới mở được session mới.", + "shared.ownAccount": "Workspace này được chia sẻ với bạn: session bạn mở ở đây chạy bằng tài khoản của chính bạn — tài khoản pool hoặc AI Proxy của bạn — và tính tiền cho bạn.", "shared.badge": "Được chia sẻ với bạn", "panel.changes": "Thay đổi", "panel.analyst": "Analyst", diff --git a/docs/API.md b/docs/API.md index ff669221..34fc27ce 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2110,10 +2110,15 @@ WHOLE list — at most 200 enabled users, never the owner (`400 EBADUSER`) — f owner or an admin only (`403 ENOTOWNER`), same-origin, each change audited. Local mode: `GET` answers an empty list, `PUT` `400 ELOCALMODE`. A user it is shared with sees and works in it like its owner, but `PATCH {title}`, `reset`/`remove`/`purge`, -`DELETE` and `PUT …/shares` answer them `403 ENOTOWNER`, and a non-shell -`POST /api/lanes/:id/start` or any `POST /api/run` `403 ECOLLABSTART`. A Terminal -tab they open (`start {shell: true}`) records them in `dashboard_runs.started_by`: -its pane token acts as them, not the owner. A user disabled since stays on the +`DELETE` and `PUT …/shares` answer them `403 ENOTOWNER`. A session they start or +resume (`POST /api/lanes/:id/start`, `POST /api/run`) runs on THEIR OWN account — +a pool setup-token (Claude) or API key (Codex, Cursor) they may use, or an AI +Proxy of theirs — else `409 ENOCOLLABACCOUNT`; resuming a non-Claude session the other side pays +for, or a collaborator's `resumeLast`, is `409 ECOLLABRESUME`; their spend quota (`429 EQUOTA`) +decides, not the owner's. Every pane they open (a Terminal tab too) records them +in `dashboard_runs.started_by`: its pane token acts as them, and an AI pane's +session hours are billed to them (`session_payers`; a Terminal tab's stay the +owner's). A user disabled since stays on the list (`disabled: true`) and may be kept or removed; only a new one must be enabled. Users taken off get the WebSocket message `lane_access` `{laneId, userIds}` (sent to them only). See `docs/SERVER-MODE.md` → Sharing a workspace. diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 2a0e325c..90586257 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -246,11 +246,35 @@ owner, joins its open terminals, types into them, commits and pushes, edits its instructions and context — every row and live update, through the same `scope.js` predicates (`canSeeLane`, `sessionWhere`, `laneWhere`, `broadcastAudience`'s `viewers`). Only the owner or an admin renames, resets, -removes, deletes or shares it (`403 ENOTOWNER`), and for now a collaborator -starts no AI session in it (`403 ECOLLABSTART`; a plain terminal tab is fine) — -phase 2 of `docs/plans/workspace-sharing/` bills a collaborator's own sessions -to them. Their own figures stay their own: budgets and Today count only -workspaces they own (`ownedOnlySessionWhere`, `ownSessionWhere`). +removes, deletes or shares it (`403 ENOTOWNER`). + +**Whoever starts or resumes a session pays for it.** A session a collaborator +starts or resumes runs on THEIR OWN account and never on the owner's login +(`spawnRun` `startedBy`): a pool **setup-token** (Claude) or **API key** +(Codex, Cursor) their pool group, pin or choice allows (`forCollaborator` — +never a pool *login*, which would be written into the owner's home), or an AI +Proxy profile of theirs (resolved as them; the gateway relays it). With none, +the start is refused (`409 ENOCOLLABACCOUNT`); omp and every other tool, which +can only run on the folder account's own login, are refused too. Their own +spend quota decides (`EQUOTA`), their pane's hooks read their own flag +(`quota-u.json` in the owner's handoff dir under S3, `u.json` on the +host), and a quota sweep ends or blocks only the panes THEY pay for +(`runPayer`). Money is per hour (`session_payers`, `server/lib/session-payers.js`): +a session nobody else ran has no row and is billed exactly as before; a session +a collaborator started is theirs from its first hour; a resume changes hands +from the NEXT hour (the hour it happens in stays with the previous payer), in +either direction. Only a Claude session books its spend by the hour, so only +it can change hands: a Codex/Cursor/other session is billed whole at its start +hour, and resuming one the other side pays for is refused (`409 +ECOLLABRESUME`), as is a collaborator's `resumeLast` (it names no session). +A Claude session's sub-agent usage is likewise booked at the session's start +hour, so after a hand-over it stays with the first payer — an accepted +rounding. A Terminal tab stays the owner's money: a `claude` typed there runs +on the folder account's own login. The Users page, spend quotas, Today (`/api/pricing/cost?mine=1`) +and a developer's budgets all count by payer; the workspace owner's agent +instructions still go into every pane of the workspace. `MAX_AI_SESSIONS` counts +per Linux account, so a collaborator's sessions count against the owner's +account limit. **Under S3 a collaborator works as the OWNER's Linux account** — the folder decides the account. They can read everything in the owner's home (the owner's @@ -259,11 +283,15 @@ and their commits carry the owner's git identity; only AgentHub's `audit_log` records who did what. The Share dialog says so. Share only with people you would hand your keyboard to. Two consequences follow and are accepted, not bugs: a collaborator can read the pane token of a pane the OWNER started (and -so act as the owner through the CLI from it), and `ECOLLABSTART` is a +so act as the owner through the CLI from it), and `ENOCOLLABACCOUNT` is a dashboard rule, not a barrier — in a terminal they can run `claude` as the -owner's account. A Terminal tab the collaborator opens records them as its -starter (`dashboard_runs.started_by`), so its own pane token acts as THEM, and -only while the workspace is still shared with them (`userFromPaneToken`). +owner's account on the owner's login, and either side can edit the other's +quota flag file in that home (the flag is fail-open anyway; the start check is +server-side). A collaborator's personal Codex proxy writes no key into the +owner's home, only its endpoint. Every pane the collaborator opens records them +as its starter (`dashboard_runs.started_by`), so its own pane token acts as +THEM, and only while the workspace is still shared with them +(`userFromPaneToken`). A user already on the list who was disabled since stays listed (marked disabled, granting nothing) and can be taken off; only a NEW addition must be diff --git a/docs/plans/workspace-sharing/README.md b/docs/plans/workspace-sharing/README.md index 5df33406..4709f81e 100644 --- a/docs/plans/workspace-sharing/README.md +++ b/docs/plans/workspace-sharing/README.md @@ -1,6 +1,6 @@ # Workspace & session sharing — plan -Status: **phase 1 built 2026-11-06** (workspace sharing; R1 decided as option **(a)** — accept and warn hard). Phases 2–4 not built. +Status: **phases 1–2 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; R1 decided as option **(a)** — accept and warn hard). Phases 3–4 not built. An independent review of phase 1 found and got fixed: a collaborator's Terminal tab acted as the owner through its pane token (now `dashboard_runs.started_by`), `POST /api/run {shell:true}` bypassed `ECOLLABSTART`, a disabled collaborator diff --git a/openapi.yaml b/openapi.yaml index 26bbd764..aee068e0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -20443,7 +20443,7 @@ paths: tags: - Lanes summary: Set the users a workspace is shared with - description: '`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; each user added or taken off is written to the audit log. A user it is shared with sees it and works in it like its owner — under S3 as the owner''s Linux account — except rename, reset, remove, delete and sharing (`ENOTOWNER`), and starts no AI session in it yet (`ECOLLABSTART`). Users taken off get `lane_access` and lose access at once.' + description: '`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; each user added or taken off is written to the audit log. A user it is shared with sees it and works in it like its owner — under S3 as the owner''s Linux account — except rename, reset, remove, delete and sharing (`ENOTOWNER`), and a session they start runs on their own account (else `409 ENOCOLLABACCOUNT`) and is billed to them. Users taken off get `lane_access` and lose access at once.' operationId: setLaneShares parameters: - name: id diff --git a/server/__tests__/collab-runs.test.js b/server/__tests__/collab-runs.test.js new file mode 100644 index 00000000..d77e3560 --- /dev/null +++ b/server/__tests__/collab-runs.test.js @@ -0,0 +1,180 @@ +/** + * @file collab-runs.test.js + * @description A session started by a user a workspace is shared with + * (sharing phase 2, pty-run.js spawnRun `startedBy`): it runs on a pool + * setup-token of THEIR OWN through tmux `-e` (never the owner's pick, never a + * login), is refused with `ENOCOLLABACCOUNT` when they have none, records + * `started_by`, makes them the payer of the new session from its first hour + * (session-payers.js), points its hooks at their own quota flag and is + * refused while they are over their quota. The owner's own start is unchanged. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "collab-runs-test-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); +process.env.CLAUDE_HOME = path.join(TMP, "claude"); + +const { db } = require("../db"); +const ccUsage = require("../lib/cc-usage"); +const pool = require("../lib/claude-pool"); +const tmux = require("../lib/tmux"); +const pty = require("../lib/pty-run"); + +const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`; +const realFetchUsage = ccUsage.fetchUsage; +const realFetchOwner = ccUsage.fetchOwner; +let argv = null; +let alice; +let bob; +let lane; + +const envOf = (name) => { + const hit = (argv || []).find((x) => String(x).startsWith(`${name}=`)); + return hit ? String(hit).slice(name.length + 1) : undefined; +}; + +before(() => { + ccUsage.fetchUsage = async () => { + throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" }); + }; + ccUsage.fetchOwner = async () => null; + tmux.__setExecImpl((args) => { + if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 }); + if (args[0] === "new-session") argv = args; + return ""; + }); + const user = (name) => + Number( + db + .prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'developer')") + .run(name).lastInsertRowid + ); + alice = user("alice"); + bob = user("bob"); + lane = Number( + db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(TMP, alice) + .lastInsertRowid + ); + db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(lane, bob); + process.env.AGENTHUB_MODE = "server"; +}); + +after(() => { + delete process.env.AGENTHUB_MODE; + ccUsage.fetchUsage = realFetchUsage; + ccUsage.fetchOwner = realFetchOwner; + tmux.__setExecImpl(null); + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +beforeEach(() => { + fs.rmSync(pool.poolDir(), { recursive: true, force: true }); + db.prepare("DELETE FROM dashboard_runs").run(); + db.prepare("DELETE FROM session_payers").run(); + db.prepare("DELETE FROM account_pins").run(); + db.prepare("DELETE FROM user_quotas").run(); + pool.__quotaCache.clear(); + argv = null; +}); + +describe("a collaborator's own session", () => { + it("is refused without an account of their own, never run on the owner's", () => { + assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), { + code: "ENOCOLLABACCOUNT", + }); + assert.equal(argv, null, "no pane was started"); + // The owner's own start is as before. + pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude" }); + const row = db.prepare("SELECT started_by FROM dashboard_runs").get(); + assert.equal(row.started_by, null); + }); + + it("runs on their own pool token, records them and makes them its payer", () => { + pool.addAccount("theirs", TOKEN("b")); + pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob, slot: 1 }); + assert.equal(envOf("CLAUDE_CODE_OAUTH_TOKEN"), TOKEN("b")); + assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("b")}`) < argv.indexOf("--")); + // Their own quota flag, never the owner's. + assert.match(envOf("AGENTHUB_QUOTA_FILE"), new RegExp(`u${bob}\\.json$`)); + const run = db + .prepare("SELECT session_id, started_by, account FROM dashboard_runs WHERE id = ?") + .get(`agenthub-lane-${lane}-1`); + assert.equal(run.started_by, bob); + assert.equal(run.account, "theirs"); + assert.deepEqual( + db.prepare("SELECT session_id, from_hour, user_id FROM session_payers").all(), + [{ session_id: run.session_id, from_hour: "0000", user_id: bob }] + ); + }); + + it("starts nothing while they are over their own quota — and the owner still starts", () => { + pool.addAccount("theirs", TOKEN("b")); + const MODEL = "claude-sonnet-4-5-20250929"; + const hour = new Date().toISOString().slice(0, 13); + db.prepare( + "INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sBob', 'b', 'active', ?, ?, ?)" + ).run(TMP, lane, new Date().toISOString()); + db.prepare( + "INSERT INTO token_usage (session_id, model, output_tokens) VALUES ('sBob', ?, 10000000)" + ).run(MODEL); + db.prepare( + "INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sBob', ?, ?, 10000000)" + ).run(hour, MODEL); + db.prepare("INSERT INTO session_payers VALUES ('sBob', '0000', ?)").run(bob); + const q = require("../lib/user-quota"); + for (const id of [alice, bob]) { + q.setUserQuota(id, { mode: "custom", limit_usd: 1, period: "5h", tz_offset: 0 }); + } + assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), { + code: "EQUOTA", + }); + // Those hours are bob's money, not alice's. + pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", slot: 2 }); + assert.ok(argv, "the owner's pane started"); + }); + + it("never resumes a non-Claude session across payers, nor the latest one for a collaborator", () => { + pool.addAccount("theirs", TOKEN("b")); + db.prepare( + "INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sCodexAAA', 'a', 'completed', ?, ?, 'codex')" + ).run(TMP, lane); + assert.throws( + () => + pty.spawnRun({ + laneId: lane, + cwd: TMP, + tool: "codex", + resumeSessionId: "sCodexAAA", + startedBy: bob, + }), + { code: "ECOLLABRESUME" } + ); + // Nor the owner a session the collaborator pays for. + db.prepare("INSERT INTO session_payers VALUES ('sCodexBBB', '0000', ?)").run(bob); + assert.throws( + () => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "codex", resumeSessionId: "sCodexBBB" }), + { code: "ECOLLABRESUME" } + ); + assert.throws( + () => + pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", resumeLast: true, startedBy: bob }), + { code: "ECOLLABRESUME" } + ); + }); + + it("keeps a Terminal tab's session the owner's money", () => { + const payers = require("../lib/session-payers"); + db.prepare( + `INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by) + VALUES ('agenthub-lane-shell', 'sShell', 'interactive', ?, 'running', ?, 'shell', ?)` + ).run(TMP, lane, bob); + payers.noteRun("agenthub-lane-shell"); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_payers").get().n, 0); + }); +}); diff --git a/server/__tests__/lane-shares.test.js b/server/__tests__/lane-shares.test.js index 0d4427a3..212d8bbf 100644 --- a/server/__tests__/lane-shares.test.js +++ b/server/__tests__/lane-shares.test.js @@ -4,8 +4,9 @@ * docs/plans/workspace-sharing/ phase 1): only the owner or an admin manages * the list (`GET/PUT /api/lanes/:id/shares`, audited, enabled users only, never * the owner); the user it is shared with sees the workspace and its sessions - * but may not rename, reset, remove or delete it (`ENOTOWNER`) and starts no AI - * session in it yet (`ECOLLABSTART`, `/api/run` whatever the body says); a + * but may not rename, reset, remove or delete it (`ENOTOWNER`); an AI session + * they start needs an account of their own (`ENOCOLLABACCOUNT`) and each hour + * of a session is billed to whoever ran it then (users page, quotas, Today); a * Terminal tab they open acts as THEM through its pane token, never as the * owner; a collaborator disabled since does not block the list; a user it is * not shared with still gets 404; taking it back ends their access at once. @@ -157,25 +158,124 @@ describe("sharing a workspace", () => { ); }); - it("starts no AI session for the user it is shared with yet", async () => { - const r = await call("POST", `/api/lanes/${laneA}/start`, { - as: "bob", - body: { slot: "auto", prompt: "hi" }, + it("starts a collaborator's AI session only on an account of their own", async () => { + // No pool account and no AI Proxy of theirs: refused, never the owner's login. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lane-shares-cwd-")); + db.prepare("UPDATE lanes SET cwd = ? WHERE id = ?").run(dir, laneA); + let r; + let run; + try { + r = await call("POST", `/api/lanes/${laneA}/start`, { + as: "bob", + body: { slot: "auto", prompt: "hi" }, + }); + run = await call("POST", "/api/run", { + as: "bob", + body: { laneId: laneA, initialPrompt: "hi" }, + }); + } finally { + db.prepare("UPDATE lanes SET cwd = '/w/alice' WHERE id = ?").run(laneA); + fs.rmSync(dir, { recursive: true, force: true }); + } + assert.equal(r.status, 409, JSON.stringify(r.body)); + assert.equal(r.body.error.code, "ENOCOLLABACCOUNT"); + assert.equal(run.status, 409, JSON.stringify(run.body)); + assert.equal(run.body.error.code, "ENOCOLLABACCOUNT"); + }); + + it("bills each hour of a session to whoever ran it then", async () => { + const payers = require("../lib/session-payers"); + const MODEL = "claude-sonnet-4-5-20250929"; + const hour = (h) => new Date(Date.now() - h * 3600e3).toISOString().slice(0, 13); + const spend = (sid, h, tokens) => { + db.prepare( + `INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?) + ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens` + ).run(sid, MODEL, tokens); + db.prepare( + "INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)" + ).run(sid, hour(h), MODEL, tokens); + }; + const started = new Date(Date.now() - 3 * 3600e3).toISOString(); + db.prepare( + `INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at) + VALUES ('sPaid', 'paid', 'active', '/w/alice', ?, ?, ?)` + ).run(laneA, started, new Date().toISOString()); + spend("sPaid", 2, 1_000_000); // alice's hours + // Bob resumes it now: his from the next hour on. + const runIns = (id, by) => + db + .prepare( + `INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by, started_at) + VALUES (?, 'sPaid', 'interactive', '/w/alice', 'completed', ?, 'claude', ?, ?)` + ) + .run(id, laneA, by, new Date().toISOString()); + runIns("agenthub-lane-x-bob", users.bob.id); + const now = Date.now(); + payers.noteRun("agenthub-lane-x-bob", now); + assert.deepEqual( + db + .prepare( + "SELECT from_hour, user_id FROM session_payers WHERE session_id = 'sPaid' ORDER BY from_hour" + ) + .all(), + [ + { from_hour: "0000", user_id: users.alice.id }, + { from_hour: new Date(now + 3600e3).toISOString().slice(0, 13), user_id: users.bob.id }, + ] + ); + // Noting the same payer again changes nothing. + payers.noteRun("agenthub-lane-x-bob", now); + assert.equal( + db.prepare("SELECT COUNT(*) AS n FROM session_payers WHERE session_id = 'sPaid'").get().n, + 2 + ); + // Book bob's hour as the next one (a test cannot wait an hour). + db.prepare( + "INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sPaid', ?, ?, 2000000)" + ).run(new Date(now + 3600e3).toISOString().slice(0, 13), MODEL); + db.prepare( + "UPDATE token_usage SET output_tokens = output_tokens + 2000000 WHERE session_id = 'sPaid'" + ).run(); + + const resolve = payers.payerResolver(); + assert.equal(resolve({ session_id: "sPaid", date: hour(2) }), users.alice.id); + assert.equal( + resolve({ session_id: "sPaid", date: new Date(now + 3600e3).toISOString().slice(0, 13) }), + users.bob.id + ); + + // The users page: each sees only the hours they paid (2:1 tokens). + const details = async (u) => + (await call("GET", `/api/users/${u.id}/details`, { as: "boss" })).body.usage.last7d; + const a = await details(users.alice); + const b = await details(users.bob); + assert.ok(a.cost > 0 && b.cost > 0, JSON.stringify({ a, b })); + assert.ok(Math.abs(b.cost / a.cost - 2) < 0.01, JSON.stringify({ a, b })); + const list = (await call("GET", "/api/users", { as: "boss" })).body.users; + const last7 = (name) => list.find((u) => u.username === name).last7d.cost; + assert.ok(Math.abs(last7("bob") / last7("alice") - 2) < 0.01, JSON.stringify(list)); + + // Spend quotas count the same hours. + const q = require("../lib/user-quota"); + q.setUserQuota(users.bob.id, { mode: "custom", limit_usd: 1000, period: "day", tz_offset: 0 }); + q.setUserQuota(users.alice.id, { + mode: "custom", + limit_usd: 1000, + period: "day", + tz_offset: 0, }); - assert.equal(r.status, 403); - assert.equal(r.body.error.code, "ECOLLABSTART"); - const run = await call("POST", "/api/run", { - as: "bob", - body: { laneId: laneA, prompt: "hi" }, - }); - assert.equal(run.status, 403); - assert.equal(run.body.error.code, "ECOLLABSTART"); - // /api/run has no shell: `shell: true` does not get an AI session past it. - const shell = await call("POST", "/api/run", { - as: "bob", - body: { laneId: laneA, shell: true, initialPrompt: "hi" }, - }); - assert.equal(shell.body.error.code, "ECOLLABSTART"); + const st = q.statusAll({ now: now + 3600e3 + 60e3, userIds: [users.alice.id, users.bob.id] }); + assert.ok(Math.abs(st.get(users.bob.id).spend - b.cost) < 0.01, JSON.stringify([...st])); + q.setUserQuota(users.bob.id, { mode: "default" }); + q.setUserQuota(users.alice.id, { mode: "default" }); + + // Today (`mine=1`) is each one's own money, never the other's hours. + const mine = async (as) => + (await call("GET", "/api/pricing/cost?mine=1", { as })).body.total_cost; + assert.ok(Math.abs((await mine("bob")) - b.cost) < 0.01); + assert.ok(Math.abs((await mine("alice")) - a.cost) < 0.01); + db.prepare("DELETE FROM dashboard_runs WHERE id = 'agenthub-lane-x-bob'").run(); }); it("makes a pane token act as the user who opened that pane, never the owner", () => { diff --git a/server/db.js b/server/db.js index ebafa00f..8cf1eda9 100644 --- a/server/db.js +++ b/server/db.js @@ -1014,6 +1014,21 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_lane_shares_user ON lane_shares(user_id); `); +// Who pays for a session from which UTC hour on (sharing phase 2, +// server/lib/session-payers.js): written only once someone other than the +// workspace's owner runs it — a session with no row is its owner's. `from_hour` +// is `YYYY-MM-DDTHH`, or `0000` for the whole session. Kept when a user goes: +// it is money history. +db.exec(` + CREATE TABLE IF NOT EXISTS session_payers ( + session_id TEXT NOT NULL, + from_hour TEXT NOT NULL, + user_id INTEGER NOT NULL, + PRIMARY KEY (session_id, from_hour) + ); + CREATE INDEX IF NOT EXISTS idx_session_payers_user ON session_payers(user_id); +`); + // One row per install of an artifact (server/lib/artifacts.js `install`): // who, when, and the folders it was written to — `artifacts.installs` only // counts. Installs before this table existed stay counted, not listed. diff --git a/server/lib/budgets.js b/server/lib/budgets.js index 78d8783e..f1718b05 100644 --- a/server/lib/budgets.js +++ b/server/lib/budgets.js @@ -71,16 +71,29 @@ function budgetSpend(cfg, { now = Date.now(), back = 0, rules, req } = {}) { // A session that spent in the period was touched in it, so older ones are // skipped before their hours are read. const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }]; - if (memberId(req) != null) { - // Their OWN spend: a workspace shared with them is not their money. - const own = ownedOnlySessionWhere(req, "s.id", "s.lane_id"); - extra.push({ clause: own.where, params: own.params }); - } const scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null; + let paid = []; + if (memberId(req) != null) { + // Their OWN money: their workspaces' sessions, by the hours they paid + // where a collaborator ran one — and the hours they paid for in + // workspaces shared with them (server/lib/session-payers.js). + const own = ownedOnlySessionWhere(req, "s.id", "s.lane_id"); + const payers = require("./session-payers"); + extra.push({ clause: `(${own.where}) AND s.id NOT IN (${payers.SPLIT})`, params: own.params }); + const split = costWhere({ + scope, + lane: cfg.lane, + extra: [ + { clause: "s.updated_at >= ?", params: [b.startUtc] }, + { clause: `s.id IN (${payers.SPLIT})`, params: [] }, + ], + }); + paid = payers.paidDailyRows(req.user.id, split.whereClause, split.params, `${-tz} minutes`); + } const where = costWhere({ scope, lane: cfg.lane, extra }); - const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params).filter( - (r) => r.date >= b.start && r.date <= b.end - ); + const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params) + .concat(paid) + .filter((r) => r.date >= b.start && r.date <= b.end); const spend = calculateCost(rows, rules || stmts.listPricing.all()).total_cost; // The pace so far, carried to the period's end; at least an hour of pace so // the first minutes of a period do not project a fortune. diff --git a/server/lib/claude-pool.js b/server/lib/claude-pool.js index 25df1974..d820b051 100644 --- a/server/lib/claude-pool.js +++ b/server/lib/claude-pool.js @@ -627,6 +627,22 @@ function forSpawn(member = null, ownerId = null) { }; } +/** + * What a pane a user runs in a workspace SHARED with them needs (sharing + * phase 2): a setup-token of THEIR OWN — their group's, their pin or choice, + * else the balancer's pick — by env, or null. Never a login: one would be + * written into the owner's home (`place()`), and the owner's own login there + * is the owner's money. + */ +function forCollaborator(userId) { + const allowed = require("./pool-groups").allowed(userId, "claude"); + const usable = allAccounts().filter((a) => a.kind === "token" && !expired(a) && allowed(a.name)); + if (usable.length === 0) return null; + const pin = require("./account-pins").preferredFor(userId, "claude"); + const a = (pin && usable.find((x) => x.name === pin)) || rankAccounts(usable); + return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} }; +} + /** Is `name` an account in the pool (any state)? */ const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name))); @@ -640,6 +656,7 @@ module.exports = { poolStatus, pickAccount, forSpawn, + forCollaborator, forRemote, switchHome, refreshQuotas, diff --git a/server/lib/codex-pool.js b/server/lib/codex-pool.js index 06e00b08..9a6160d4 100644 --- a/server/lib/codex-pool.js +++ b/server/lib/codex-pool.js @@ -478,7 +478,28 @@ function pickAccount(member = null, ownerId = null) { * (a member's launch file); `place()` runs just before the pane starts. */ function forSpawn(member = null, ownerId = null) { - const a = pick(member, ownerId); + return spawnOf(pick(member, ownerId), member); +} + +/** + * What a pane a user runs in a workspace SHARED with them needs (sharing + * phase 2): an API key of THEIR OWN by env, or null — never a ChatGPT login, + * which would be written into the owner's CODEX_HOME. + */ +function forCollaborator(userId) { + const allowed = require("./pool-groups").allowed(userId, "codex"); + const usable = allAccounts().filter((a) => a.kind === "apikey" && allowed(a.name)); + if (usable.length === 0) return null; + const pin = require("./account-pins").preferredFor(userId, "codex"); + const a = + (pin && usable.find((x) => x.name === pin)) || + usable + .map((x) => ({ x, n: livePanes(x.name) })) + .sort((p, q) => p.n - q.n || p.x.name.localeCompare(q.x.name))[0].x; + return spawnOf(a, null); +} + +function spawnOf(a, member) { if (!a) return null; if (a.kind === "apikey") { const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`; @@ -522,6 +543,7 @@ module.exports = { poolStatus, pickAccount, forSpawn, + forCollaborator, syncBack, poolDir, __read: readAccount, diff --git a/server/lib/dashboard-runs.js b/server/lib/dashboard-runs.js index 77068d88..fdc22c3d 100644 --- a/server/lib/dashboard-runs.js +++ b/server/lib/dashboard-runs.js @@ -130,6 +130,9 @@ function recordRun(handle) { pane_token_hash: handle.paneTokenHash || null, started_by: Number.isInteger(handle.startedBy) ? handle.startedBy : null, }); + // Who pays for the session it holds (session-payers.js) — a no-op for + // the owner's own run of a session nobody else ran. + if (handle.sessionId) require("./session-payers").noteRun(handle.id); } catch (err) { // Best-effort by design (a live run must never fail because sqlite did), // but not silent: a swallowed insert leaves the runs modal showing rows with @@ -152,6 +155,7 @@ function patchRun({ id, sessionId, status, exitCode, endedAt }) { exit_code: typeof exitCode === "number" ? exitCode : null, ended_at: endedAt ? new Date(endedAt).toISOString() : null, }); + if (sessionId) require("./session-payers").noteRun(id); } catch { /* ignore */ } @@ -251,7 +255,15 @@ const claimRunStmt = db.prepare(` */ function claimToolSessions(sessionId = null) { try { - if (sessionId) claimRunStmt.run({ id: sessionId }); + if (sessionId && claimRunStmt.run({ id: sessionId }).changes) { + // The run just claimed it: the newest holding it. + const r = db + .prepare( + "SELECT id FROM dashboard_runs WHERE session_id = ? ORDER BY started_at DESC LIMIT 1" + ) + .get(sessionId); + if (r) require("./session-payers").noteRun(r.id); + } return claimToolStmt.run({ id: sessionId }).changes; } catch { return 0; diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 79f4b342..f8ee3709 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -419,6 +419,7 @@ function spawnRun(args) { bypass, initialPrompt, proxy, + startedBy, } = args || {}; if (typeof laneId !== "number" || !Number.isInteger(laneId)) { throw makeErr("EBADLANE", "laneId must be an integer"); @@ -497,10 +498,16 @@ function spawnRun(args) { // their .mcp.json) or a member in someone else's. if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER assertCwdAccount(laneId, cwd); // ECWDACCOUNT - // Server mode: an owner over their spend quota starts nothing new (user-quota.js). - require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA + // Who pays (sharing phase 2): a user the workspace is shared with runs on + // their OWN account — a pool token or API key, or an AI Proxy of theirs — + // under their own quota, never on the owner's login (session-payers.js). + const payer = + lane && Number.isInteger(startedBy) && startedBy !== laneOwner ? startedBy : laneOwner; + const collab = payer !== laneOwner; + // Server mode: a payer over their spend quota starts nothing new (user-quota.js). + require("./user-quota").assertSpawnAllowed(payer); // EQUOTA assertSessionRoom(laneId); // ESESSIONLIMIT - const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED + const proxyProfile = resolveProxy(tool, proxy, payer); // EBADPROXY, EUNSUPPORTED // A Claude session on the vendor's own API runs on the least-loaded pool // account, if the admin added any (claude-pool.js) — or the one the // workspace owner is pinned to (account-pins.js); an AI Proxy brings its @@ -509,21 +516,56 @@ function spawnRun(args) { // a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN. const poolAccount = entry.launcher === "claude" && !proxyProfile - ? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner) + ? collab + ? require("./claude-pool").forCollaborator(payer) + : require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner) : null; // Codex likewise, from its own pool (codex-pool.js): an API key through // `-e`, or a ChatGPT login written into the member's CODEX_HOME by place(). const codexPool = entry.launcher === "codex" && !proxyProfile - ? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner) + ? collab + ? require("./codex-pool").forCollaborator(payer) + : require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner) : null; // Cursor's pool is API keys only (cursor-pool.js): CURSOR_API_KEY through `-e`. - const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(laneOwner) : null; + const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(payer) : null; + // Only a Claude session books its spend by the hour, so only it can change + // hands mid-way (session-payers.js): any other tool's whole session is + // billed at its start hour, and a resume across payers would bill the wrong + // one. `resumeLast` names no session, so its payer could not be known. + if (collab && resumeLast === true) { + throw makeErr( + "ECOLLABRESUME", + "in a workspace shared with you, resume a session by picking it — not the latest one" + ); + } + if ( + resumeSessionId && + laneOwner != null && + entry.launcher !== "claude" && + require("./session-payers").currentPayer(resumeSessionId, laneOwner) !== payer + ) { + throw makeErr( + "ECOLLABRESUME", + `${tool} books a session's spend at its start, so only the user who pays for it may resume it — start a new session instead` + ); + } + // Any other tool, or no account of their own, would run on the owner's + // login in the owner's home: refused. + if (collab && !proxyProfile && !poolAccount && !codexPool && !cursorPool) { + throw makeErr( + "ENOCOLLABACCOUNT", + "this workspace is shared with you, so a session you start must run on your own account — " + + "a pool account an admin gave you, or an AI Proxy of yours — and you have none for " + + `${tool}. The owner's own login is never used for you.` + ); + } const paneCred = require("./auth").newPaneToken(); // A member's omp on the host's shared omp login (omp-share.js): its // models.yml points at the loopback gateway, with a key already listed there. const ompShare = - tool === "omp" + tool === "omp" && !collab ? require("./omp-share").forSpawn( require("./run-as").accountForLane(laneId), runIdForLane(laneId, slot) @@ -651,6 +693,7 @@ function spawnRun(args) { proxy: proxyProfile ? proxyProfile.name : null, account: (poolAccount || codexPool || cursorPool)?.name ?? null, paneTokenHash: paneCred ? paneCred.hash : null, + startedBy: collab ? payer : null, prompt: initialPrompt || "", status: "running", startedAt, @@ -734,9 +777,9 @@ function spawnRun(args) { for (const k of Object.keys(env)) delete env[k]; Object.assign(env, own); } - // Server mode: where this pane's hooks look for its owner's quota flag + // Server mode: where this pane's hooks look for its payer's quota flag // (user-quota.js) — a refused prompt is decided by the hook, locally. - Object.assign(env, require("./user-quota").flagEnv(laneOwner, member)); + Object.assign(env, require("./user-quota").flagEnv(payer, member, laneOwner)); // `after-start` tools take no prompt on their command line: paste it once // the CLI has had `delayMs` to draw its input box. ponytail: fixed delay, @@ -759,6 +802,9 @@ function spawnRun(args) { const prepareHome = () => { codexPool?.place(); poolAccount?.place(); + // A collaborator's flag sits in the owner's handoff dir: write its state + // now, so one left from an earlier share never says the wrong thing. + if (collab) require("./user-quota").writeCollabFlag(payer, member); ompShare?.place(); if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) { require("./run-as").markClaudeOnboarded(member); @@ -846,7 +892,8 @@ function spawnShell({ laneId, slot = 0, cwd, startedBy = null }) { worktree: false, proxy: null, paneTokenHash: paneCred ? paneCred.hash : null, - startedBy: lane && Number.isInteger(startedBy) && startedBy !== lane.owner_id ? startedBy : null, + startedBy: + lane && Number.isInteger(startedBy) && startedBy !== lane.owner_id ? startedBy : null, prompt: "", status: "running", startedAt: Date.now(), diff --git a/server/lib/session-payers.js b/server/lib/session-payers.js new file mode 100644 index 00000000..34bd2a7e --- /dev/null +++ b/server/lib/session-payers.js @@ -0,0 +1,149 @@ +/** + * @file session-payers.js + * @description Who pays for a session, hour by hour (server mode, workspace + * sharing phase 2 — docs/plans/workspace-sharing/). A session is its + * workspace OWNER's money until someone else runs it: a user the workspace is + * shared with starts or resumes it (`dashboard_runs.started_by`), and from + * then on it is theirs — until the owner (or another collaborator) runs it + * again. Recorded in `session_payers` (session, from UTC hour, user); a + * session with no row is billed exactly as before. + * + * Hours are the booking unit (`hourlyTokenRows`), so a switch takes effect + * from the NEXT hour: the hour a resume happens in stays with the previous + * payer, while a session the run itself started is the starter's from its + * first hour (`0000`). The rest of a session's total that no hour holds is + * booked at its start hour, like every other report. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const db = () => require("../db").db; + +const WHOLE = "0000"; +const HOUR_MS = 3_600_000; +const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13); +/** How far before its run a session may have started and still be the run's own (clock skew). */ +const NEW_SESSION_SLACK_MS = 60_000; + +/** + * Note the payer of run `runId`'s session — its starter, else the workspace + * owner — once the run knows its session. Best effort: a run never fails + * because this did. + */ +function noteRun(runId, now = Date.now()) { + try { + const run = db() + .prepare( + `SELECT r.session_id, r.started_by, r.started_at, r.tool, l.owner_id + FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.id = ?` + ) + .get(runId); + // A Terminal tab is no account of its starter's: a `claude` typed there + // runs on the folder account's own login, so it stays the owner's money. + if (!run?.session_id || run.owner_id == null || run.tool === "shell") return; + const payer = run.started_by ?? run.owner_id; + const put = db().prepare( + "INSERT OR REPLACE INTO session_payers (session_id, from_hour, user_id) VALUES (?, ?, ?)" + ); + db().transaction(() => { + const rows = timeline(run.session_id); + const current = rows.length ? rows[rows.length - 1].user_id : run.owner_id; + if (current === payer) return; + // The run's own new session (started with it, or not seen yet) is the + // starter's whole; a resumed one changes hands from the hour after the + // run started (a hook may tell us late). + const s = db().prepare("SELECT started_at FROM sessions WHERE id = ?").get(run.session_id); + const startedMs = Date.parse(run.started_at); + const fresh = !s || Date.parse(s.started_at) >= startedMs - NEW_SESSION_SLACK_MS; + const from = + fresh && !rows.length + ? WHOLE + : hourOf((Number.isFinite(startedMs) ? Math.min(startedMs, now) : now) + HOUR_MS); + // The first change of hands: the owner's part becomes a row too, so the + // timeline alone answers for every hour. + if (!rows.length && from !== WHOLE) put.run(run.session_id, WHOLE, run.owner_id); + put.run(run.session_id, from, payer); + })(); + } catch (err) { + console.warn(`[session-payers] run ${runId}: ${err.message}`); + } +} + +/** Who pays for `sessionId` now: its timeline's last payer, else `ownerId`. */ +function currentPayer(sessionId, ownerId) { + const rows = timeline(sessionId); + return rows.length ? rows[rows.length - 1].user_id : ownerId; +} + +function timeline(sessionId) { + return db() + .prepare( + "SELECT from_hour, user_id FROM session_payers WHERE session_id = ? ORDER BY from_hour" + ) + .all(sessionId); +} + +/** + * Every split session's timeline: `{has(sessionId), payer(sessionId, hour)}`. + * `hour` is an hourly row's `date` (`YYYY-MM-DDTHH`). + */ +function index() { + const map = new Map(); + for (const r of db() + .prepare("SELECT session_id, from_hour, user_id FROM session_payers ORDER BY from_hour") + .all()) { + if (!map.has(r.session_id)) map.set(r.session_id, []); + map.get(r.session_id).push(r); + } + return { + has: (sid) => map.has(sid), + payer(sid, hour) { + const rows = map.get(sid); + if (!rows) return null; + let uid = rows[0].user_id; + for (const r of rows) { + if (r.from_hour <= hour) uid = r.user_id; + else break; + } + return uid; + }, + }; +} + +/** SQL: the split sessions (`s.id IN (…)` / `NOT IN`). */ +const SPLIT = "SELECT session_id FROM session_payers"; + +/** + * Daily-shaped token rows (`date` = local date for `tzModifier`, as + * `dailyTokenRows`) of the hours `uid` pays for in split sessions matching + * `whereClause` — which must already narrow to `s.id IN (SPLIT)`. + */ +function paidDailyRows(uid, whereClause, params, tzModifier = "+0 minutes") { + const { hourlyTokenRows } = require("../routes/pricing"); + const shift = Number.parseInt(String(tzModifier), 10) || 0; + const idx = index(); + const out = []; + for (const r of hourlyTokenRows(whereClause, params)) { + if (idx.payer(r.session_id, r.date) !== uid) continue; + const { session_id: _sid, ...row } = r; + row.date = new Date(Date.parse(`${r.date}:00:00Z`) + shift * 60_000).toISOString().slice(0, 10); + out.push(row); + } + return out; +} + +/** Map(session id → first-wins owner) of `OWNED_SESSIONS`, with each hour's payer laid over it. */ +function payerResolver() { + const { OWNED_SESSIONS } = require("../routes/users"); + const owner = new Map(); + for (const r of db().prepare(OWNED_SESSIONS).all()) { + if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid); + } + const idx = index(); + /** The user an hourly row's money is (null: nobody's). */ + return (row) => + idx.has(row.session_id) + ? idx.payer(row.session_id, row.date) + : (owner.get(row.session_id) ?? null); +} + +module.exports = { noteRun, currentPayer, index, paidDailyRows, payerResolver, SPLIT, WHOLE }; diff --git a/server/lib/user-quota.js b/server/lib/user-quota.js index e813fc1a..43348c03 100644 --- a/server/lib/user-quota.js +++ b/server/lib/user-quota.js @@ -8,8 +8,10 @@ * browser's getTimezoneOffset() when they saved it) or `5h` (a sliding * window: the current UTC hour and the four before it — spend booked in hour * H counts until H + 5 h). Spend is the users page's own booking - * (`hourlyTokenRows` over the sessions a user OWNS through a workspace, - * priced by `calculateCost`), never a second formula. + * (`hourlyTokenRows` over the sessions a user OWNS through a workspace, each + * hour counted for its payer — session-payers.js — priced by `calculateCost`), + * never a second formula. A pane a collaborator opened in a workspace shared + * with them is THEIR quota's: its start, its flag and the sweep go by payer. * * A user over their quota cannot chat on until it resets: * - `assertSpawnAllowed` refuses every new pane (`EQUOTA`, 429) — start, @@ -203,10 +205,9 @@ function statusAll({ now = Date.now(), userIds = null } = {}) { const { OWNED_SESSIONS } = require("../routes/users"); const rules = require("../db").stmts.listPricing.all(); const oldest = quotas.reduce((a, q) => (q.win.since < a ? q.win.since : a), quotas[0].win.since); - const owner = new Map(); - for (const r of db().prepare(OWNED_SESSIONS).all()) { - if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid); - } + // Each hour's money is its payer's: the owner's, or a collaborator's who + // ran the session then (session-payers.js). + const payerOf = require("./session-payers").payerResolver(); // A session that spent in the window was touched in it. const rows = hourlyTokenRows( `WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`, @@ -214,7 +215,7 @@ function statusAll({ now = Date.now(), userIds = null } = {}) { ); const byUser = new Map(); for (const r of rows) { - const uid = owner.get(r.session_id); + const uid = payerOf(r); if (uid == null) continue; if (!byUser.has(uid)) byUser.set(uid, []); byUser.get(uid).push(r); @@ -275,7 +276,7 @@ function message(s, now = Date.now()) { ); } -/** Throws EQUOTA when the workspace owner is over their quota. No-op in local mode. */ +/** Throws EQUOTA when the payer (the workspace owner, or a collaborator) is over their quota. No-op in local mode. */ function assertSpawnAllowed(ownerId) { if (!isServerMode() || ownerId == null) return; const s = statusFor(ownerId); @@ -288,14 +289,63 @@ const FLAG = "quota.json"; const hostDir = () => path.join(require("./claude-home").getDataDir(), "user-quota"); const hostFlag = (userId) => path.join(hostDir(), `u${userId}.json`); +/** A collaborator's flag in the workspace owner's handoff dir (sharing phase 2). */ +const collabFlag = (userId) => `quota-u${userId}.json`; + /** - * The env a pane of `ownerId` gets: where its hooks look for the flag — - * the member's handoff dir (S3: the pane runs as them) or the host's data dir. + * The env a pane paid for by `payerId` gets: where its hooks look for the + * flag — the pane account's handoff dir (S3: the pane runs as the workspace + * owner's account; a collaborator's own file there) or the host's data dir. */ -function flagEnv(ownerId, member) { - if (!isServerMode() || ownerId == null) return {}; - const file = member ? path.join(require("./run-as").handoffDir(member), FLAG) : hostFlag(ownerId); - return { AGENTHUB_QUOTA_FILE: file }; +function flagEnv(payerId, member, ownerId = payerId) { + if (!isServerMode() || payerId == null) return {}; + if (!member) return { AGENTHUB_QUOTA_FILE: hostFlag(payerId) }; + const name = payerId === ownerId ? FLAG : collabFlag(payerId); + return { AGENTHUB_QUOTA_FILE: path.join(require("./run-as").handoffDir(member), name) }; +} + +function flagText(s, now) { + const body = s?.exceeded + ? { blocked: true, until: s.resetsAt, message: message(s, now) } + : { blocked: false }; + return `${JSON.stringify(body)}\n`; +} + +/** + * Write collaborator `userId`'s flag in pane account `member`'s handoff dir — + * at spawn, so a file left from an earlier share never says the wrong thing. + * (The host's per-user flag is the sweep's.) Best effort. + */ +function writeCollabFlag(userId, member, now = Date.now()) { + if (!isServerMode() || !member) return; + try { + require("./run-as").handoffWrite( + member, + collabFlag(userId), + flagText(statusFor(userId, { now }), now) + ); + } catch (err) { + console.warn(`user quota: collaborator flag u${userId} not written:`, err.message); + } +} + +/** The pane accounts of the workspaces shared with `userId` (not their own). */ +function sharedAccounts(userId) { + const runAs = require("./run-as"); + const out = new Map(); + for (const { cwd } of db() + .prepare( + "SELECT l.cwd FROM lane_shares ls JOIN lanes l ON l.id = ls.lane_id WHERE ls.user_id = ?" + ) + .all(userId)) { + try { + const acct = runAs.accountForPath(cwd); + if (acct) out.set(acct.name, acct); + } catch { + /* EMEMBERGONE: no pane runs there */ + } + } + return [...out.values()]; } function writeHostFlag(userId, text) { @@ -308,10 +358,7 @@ function writeHostFlag(userId, text) { /** Write a user's flag everywhere their panes may read it. Best effort per place. */ function writeFlags(user, s, now) { - const body = s?.exceeded - ? { blocked: true, until: s.resetsAt, message: message(s, now) } - : { blocked: false }; - const text = `${JSON.stringify(body)}\n`; + const text = flagText(s, now); try { writeHostFlag(user.id, text); } catch (err) { @@ -323,11 +370,30 @@ function writeFlags(user, s, now) { } catch { acct = null; } - if (!acct) return; + if (acct) { + try { + require("./run-as").handoffWrite(acct, FLAG, text); + } catch (err) { + console.warn(`user quota: ${user.username}'s flag not written:`, err.message); + } + } + // Their panes in workspaces shared with them run as the owner's account. + let shared = []; try { - require("./run-as").handoffWrite(acct, FLAG, text); - } catch (err) { - console.warn(`user quota: ${user.username}'s flag not written:`, err.message); + shared = sharedAccounts(user.id); + } catch { + shared = []; + } + for (const other of shared) { + if (acct && other.name === acct.name) continue; + try { + require("./run-as").handoffWrite(other, collabFlag(user.id), text); + } catch (err) { + console.warn( + `user quota: ${user.username}'s flag at ${other.name} not written:`, + err.message + ); + } } } @@ -338,15 +404,25 @@ let blocked = new Set(); /** user id → the flag text last written, so an unchanged state writes nothing. */ const written = new Map(); -function laneOwner(laneId) { +/** Who pays for run `runId`: its starter when someone shared the workspace with them, else its owner. */ +function runPayer(runId) { + const r = db() + .prepare( + `SELECT COALESCE(r.started_by, l.owner_id) AS uid + FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.id = ?` + ) + .get(runId); + if (r) return r.uid ?? null; + const laneId = require("./pty-run").laneIdFromRunId(runId); + if (laneId == null) return null; return db().prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id ?? null; } -/** Is run `runId`'s workspace owner over quota (by the last sweep)? omp-share asks. */ +/** Is run `runId`'s payer over quota (by the last sweep)? omp-share asks. */ function isRunBlocked(runId) { if (!blocked.size) return false; - const laneId = require("./pty-run").laneIdFromRunId(runId); - return laneId != null && blocked.has(laneOwner(laneId)); + const uid = runPayer(runId); + return uid != null && blocked.has(uid); } /** @@ -384,7 +460,7 @@ function endUnstoppable(userIds) { const runAs = require("./run-as"); const ended = []; for (const run of runs.listRuns()) { - if (run.status !== "running" || !userIds.has(laneOwner(run.laneId))) continue; + if (run.status !== "running" || !userIds.has(runPayer(run.id))) continue; if (hookCanStop(run, runAs.accountForLane(run.laneId))) continue; try { if (runs.killRun(run.id)) ended.push(run.id); @@ -460,6 +536,7 @@ module.exports = { message, assertSpawnAllowed, flagEnv, + writeCollabFlag, isRunBlocked, hookCanStop, sweep, diff --git a/server/openapi-extra/lanes-more.js b/server/openapi-extra/lanes-more.js index 79d1ed36..6e42bcd4 100644 --- a/server/openapi-extra/lanes-more.js +++ b/server/openapi-extra/lanes-more.js @@ -715,7 +715,7 @@ const paths = { tags: ["Lanes"], summary: "Set the users a workspace is shared with", description: - "`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; each user added or taken off is written to the audit log. A user it is shared with sees it and works in it like its owner — under S3 as the owner's Linux account — except rename, reset, remove, delete and sharing (`ENOTOWNER`), and starts no AI session in it yet (`ECOLLABSTART`). Users taken off get `lane_access` and lose access at once.", + "`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; each user added or taken off is written to the audit log. A user it is shared with sees it and works in it like its owner — under S3 as the owner's Linux account — except rename, reset, remove, delete and sharing (`ENOTOWNER`), and a session they start runs on their own account (else `409 ENOCOLLABACCOUNT`) and is billed to them. Users taken off get `lane_access` and lose access at once.", operationId: "setLaneShares", parameters: [idParam], requestBody: { diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 5995b0cc..69c31d34 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -426,16 +426,6 @@ router.get("/branches", async (req, res) => { /** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */ const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]); -/** Sharing phase 1: a user a workspace is shared with starts no AI session in it. */ -function collabStart(res) { - return res.status(403).json({ - error: { - code: "ECOLLABSTART", - message: "this workspace is shared with you: join its open sessions — only its owner starts new ones for now", - }, - }); -} - /** A shared workspace's owner-only action (rename, reset, remove, sharing). */ function notOwner(res) { return res.status(403).json({ @@ -3351,13 +3341,11 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); const body = req.body || {}; - // A workspace shared with this user: they may not destroy it, and (sharing - // phase 1) start no AI session in it — a terminal tab is fine; they join the - // owner's live panes. Phase 2 bills a collaborator's own sessions to them. - if (!scope.isLaneOwner(req, lane.id)) { - if (DESTRUCTIVE_ACTIONS.has(action)) return notOwner(res); - if (action === "start" && body.shell !== true) return collabStart(res); - } + // A workspace shared with this user: they may not destroy it. A session + // they start or resume runs on their own account and is billed to them + // (pty-run.js spawnRun `startedBy`, server/lib/session-payers.js). + const startedBy = scope.isLaneOwner(req, lane.id) ? null : (req.user?.id ?? null); + if (startedBy != null && DESTRUCTIVE_ACTIONS.has(action)) return notOwner(res); if (DESTRUCTIVE_ACTIONS.has(action)) { if (body.confirm !== true) { @@ -3571,7 +3559,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { const wantProxy = (body.proxy ?? body.router) !== undefined ? (body.proxy ?? body.router) - : resumeProxy(resumeId, lane.owner_id ?? null); + : resumeProxy(resumeId, startedBy ?? lane.owner_id ?? null); if (wantProxy === require("../lib/ai-proxies").COMPANY_NAME) { try { await require("../lib/remote-client").ping(); @@ -3630,7 +3618,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { ? (body.proxy ?? body.router) : resumeProxy( body.resumeSessionId || (body.resume ? current.session_id : null), - current.owner_id ?? null + startedBy ?? current.owner_id ?? null ), model: body.model, permissionMode: body.permissionMode, @@ -3641,6 +3629,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { bypass: body.bypass === true, resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined), forkSession: body.forkSession === true, + startedBy, }); startedRunId = handle.id; // An extra pane's session is not the lane's run: `run_id` stays the @@ -3711,7 +3700,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { // ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed. const status = /^(EBAD|EUNSUPPORTED$|ENOHUB$)/.test(err.code || "") ? 400 - : ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code) + : [ + "ENOTSHARED", + "EMEMBERGONE", + "EOWNERFOLDER", + "ECWDACCOUNT", + "ENOCOLLABACCOUNT", + "ECOLLABRESUME", + ].includes(err.code) ? 409 : err.code === "EQUOTA" || err.code === "ESESSIONLIMIT" ? 429 @@ -3740,6 +3736,5 @@ router.delete("/:id", sameOriginGuard, (req, res) => { module.exports = router; module.exports.broadcastLane = broadcastLane; module.exports.healAllRunIds = healAllRunIds; -module.exports.collabStart = collabStart; module.exports.notOwner = notOwner; module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges; diff --git a/server/routes/pricing.js b/server/routes/pricing.js index c324026a..59093a19 100644 --- a/server/routes/pricing.js +++ b/server/routes/pricing.js @@ -579,11 +579,25 @@ function costScope(req, res) { // `mine=1`: the caller's own sessions even for an admin/analyst, who would // otherwise get every user's sum (the status bar's Today is a personal figure). const extra = []; + let payer = null; if (req.query.mine === "1") { const own = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id"); - if (own.where !== "1") extra.push({ clause: own.where, params: own.params }); + if (own.where !== "1") { + // Money is per payer: a session a collaborator ran (in either + // direction) counts by the hours each paid (server/lib/session-payers.js). + const { SPLIT } = require("../lib/session-payers"); + extra.push({ clause: `(${own.where}) AND s.id NOT IN (${SPLIT})`, params: own.params }); + payer = { + uid: req.user.id, + ...costWhere({ + scope: parseScope(req), + lane, + extra: [{ clause: `s.id IN (${SPLIT})`, params: [] }], + }), + }; + } } - return costWhere({ scope: parseScope(req), lane, extra }); + return { ...costWhere({ scope: parseScope(req), lane, extra }), payer }; } /** @@ -610,11 +624,22 @@ function costByTool(dailyTokens, rules) { * the spend pace keep their own windows. Also used by scheduled reports. */ function computeCost( - { whereClause = "", params = [] } = {}, + { whereClause = "", params = [], payer = null } = {}, range = null, tzModifier = "+0 minutes" ) { - const dailyTokens = dailyTokenRows(tzModifier, whereClause, params); + // `payer` (`?mine=1` in server mode): plus the hours of split sessions this + // user paid for, the scope itself having left those sessions out. + const dailyTokens = dailyTokenRows(tzModifier, whereClause, params).concat( + payer + ? require("../lib/session-payers").paidDailyRows( + payer.uid, + payer.whereClause, + payer.params, + tzModifier + ) + : [] + ); const inside = range ? dailyTokens.filter((r) => inRange(range, r.date)) : dailyTokens; const rules = stmts.listPricing.all(); // Price the date-split rows so each day's usage bills at the rate effective on diff --git a/server/routes/run.js b/server/routes/run.js index e2fc2197..d7f7b4b4 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -618,11 +618,10 @@ router.post("/", async (req, res) => { if (!Number.isInteger(laneId)) { return res.status(400).json({ error: { code: "EBADLANE", message: "laneId is required" } }); } - // A workspace shared with this user: no AI session of theirs yet (sharing - // phase 1). This route always starts the lane's AI tool — no shell here. - if (lanesLib.getLane(laneId) && !scope.isLaneOwner(req, laneId)) { - return require("./lanes").collabStart(res); - } + // A workspace shared with this user: the session runs on their own account + // and is billed to them (pty-run.js spawnRun `startedBy`). + const startedBy = + lanesLib.getLane(laneId) && !scope.isLaneOwner(req, laneId) ? (req.user?.id ?? null) : null; let cwd; try { // A member's run with no cwd starts in its workspace, not the server's folder. @@ -640,7 +639,7 @@ router.post("/", async (req, res) => { ? (body.proxy ?? body.router) : resumeProxy( typeof body.resumeSessionId === "string" ? body.resumeSessionId : null, - lanesLib.getLane(laneId)?.owner_id ?? null + startedBy ?? lanesLib.getLane(laneId)?.owner_id ?? null ); if (wantProxy === require("../lib/ai-proxies").COMPANY_NAME) { try { @@ -665,7 +664,7 @@ router.post("/", async (req, res) => { ? (body.proxy ?? body.router) : resumeProxy( typeof body.resumeSessionId === "string" ? body.resumeSessionId : null, - lane?.owner_id ?? null + startedBy ?? lane?.owner_id ?? null ), model: typeof body.model === "string" && body.model ? body.model : null, permissionMode: @@ -685,12 +684,21 @@ router.post("/", async (req, res) => { ? body.resumeSessionId : null, initialPrompt: typeof body.initialPrompt === "string" ? body.initialPrompt : "", + startedBy, }); return res.status(201).json(handle); } catch (err) { // A busy pane is a conflict with live state, not a malformed request. if ( - ["ERUNLIVE", "ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code) + [ + "ERUNLIVE", + "ENOTSHARED", + "EMEMBERGONE", + "EOWNERFOLDER", + "ECWDACCOUNT", + "ENOCOLLABACCOUNT", + "ECOLLABRESUME", + ].includes(err.code) ) { return res.status(409).json({ error: { code: err.code, message: err.message } }); } diff --git a/server/routes/users.js b/server/routes/users.js index ce7f6e40..d190854f 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -81,10 +81,9 @@ const OWNED_SESSIONS = ` function usageSince(windows, rules) { const { calculateCost, hourlyTokenRows } = require("./pricing"); const oldest = windows.reduce((a, b) => (b < a ? b : a)); - const owner = new Map(); - for (const r of db.prepare(OWNED_SESSIONS).all()) { - if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid); - } + // Each hour is its payer's: the owner's, or a collaborator's who ran the + // session then (server/lib/session-payers.js). + const payerOf = require("../lib/session-payers").payerResolver(); // A session that spent in the window was touched in it (budgets.js does the same). const rows = hourlyTokenRows( `WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`, @@ -93,7 +92,7 @@ function usageSince(windows, rules) { return windows.map((since) => { const byUser = new Map(); for (const r of rows) { - const uid = owner.get(r.session_id); + const uid = payerOf(r); if (uid == null || r.date < since) continue; if (!byUser.has(uid)) byUser.set(uid, []); byUser.get(uid).push(r); @@ -362,6 +361,10 @@ router.get("/:id/details", async (req, res) => { const rules = stmts.listPricing.all(); const uid = u.id; const owned = `SELECT session_id FROM (${OWNED_SESSIONS}) WHERE uid = ?`; + // Spend is per payer: a session someone else ran (or this user ran in a + // workspace shared with them) counts only for the hours each paid. + const payers = require("../lib/session-payers").index(); + const paid = (r) => !payers.has(r.session_id) || payers.payer(r.session_id, r.date) === uid; const off = parseInt(req.query.tz_offset, 10); const offMin = Number.isFinite(off) && Math.abs(off) <= 840 ? off : 0; // A UTC hour (`YYYY-MM-DDTHH`) → the viewer's local date. @@ -370,9 +373,10 @@ router.get("/:id/details", async (req, res) => { const since30 = hourAgo(30); const rows = hourlyTokenRows( - `WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (${owned})`, - [since30, uid] - ).filter((r) => r.date >= since30); + `WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? + AND (s.id IN (${owned}) OR s.id IN (SELECT session_id FROM session_payers WHERE user_id = ?))`, + [since30, uid, uid] + ).filter((r) => r.date >= since30 && paid(r)); const today = todayHour(offMin); const since7 = hourAgo(7); @@ -408,7 +412,7 @@ router.get("/:id/details", async (req, res) => { r.model, r.account, r.proxy, r.session_id AS sessionId, r.started_at AS startedAt, r.status FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id - WHERE l.owner_id = ? AND r.status IN ('running', 'spawning') + WHERE COALESCE(r.started_by, l.owner_id) = ? AND r.status IN ('running', 'spawning') ORDER BY r.started_at DESC` ) .all(uid);