From 879a6bb02d36d993a793b6a2be2c8fa91ffb8a70 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 11:25:40 +0700 Subject: [PATCH] feat(server-mode): share one session; scope collaborators' pane tokens (sharing phase 3) A workspace's owner (or an admin) can share ONE session from its right-click menu (GET/PUT /api/lanes/:id/sessions/:sid/shares, table session_shares, audited). The user sees the workspace (access: "session") with only that session, joins the pane holding it, resumes it on their own account in a free slot, and uses the workspace's git and Files reads and git writes; everything else of the workspace answers 403 ESESSIONSHARE (scope.js sessionShareAllows in scopeGuard). Hook/runtime broadcasts stay with full collaborators. A collaborator's pane token (readable by the owner's Linux account) now acts as them inside that workspace only, and never starts or bills a session (auth.js paneLaneAllows, 403 EPANESCOPE); a Terminal tab the owner or an admin opens acts as the owner again. Docs say plainly that under S3 a session share hands over the owner's account inside the terminal. --- CLAUDE.md | 4 +- client/src/components/lanes/SessionMenu.tsx | 8 + client/src/components/lanes/WorkspaceHome.tsx | 9 +- .../components/lanes/WorkspaceSessionList.tsx | 20 +- .../components/lanes/WorkspaceShareModal.tsx | 40 +++- .../__tests__/WorkspaceShareModal.test.tsx | 42 +++- client/src/hooks/useLanes.ts | 4 +- client/src/i18n/locales/en/lanes.json | 4 + client/src/i18n/locales/vi/lanes.json | 4 + client/src/lib/api.ts | 11 + client/src/lib/types.ts | 2 + docs/API.md | 18 +- docs/SERVER-MODE.md | 35 +++ docs/plans/workspace-sharing/README.md | 2 +- openapi.yaml | 72 ++++++ server/__tests__/lane-shares.test.js | 150 +++++++++++++ server/db.js | 14 ++ server/lib/auth.js | 68 +++++- server/lib/lanes.js | 7 +- server/lib/scope.js | 177 +++++++++++++-- server/lib/user-quota.js | 6 +- server/openapi-extra/lanes-more.js | 43 ++++ server/routes/lanes.js | 205 ++++++++++++------ server/routes/run.js | 13 +- 24 files changed, 838 insertions(+), 120 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d5dd8c83..e1920d98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_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. +_Last synced: v2.8.77+ (2026-11-06) — sharing: collaborators pay for their own runs; one session can be shared._ 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`), 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. +- **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); their pane token acts only inside that workspace (`paneLane`, `EPANESCOPE`); ONE session may be shared too (`session_shares`): that session, its pane, a resume of it and the workspace's git — anything else `ESESSIONSHARE` via `sessionShareAllows` in `scopeGuard`), 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/SessionMenu.tsx b/client/src/components/lanes/SessionMenu.tsx index 2ecc9a0b..3199ba86 100644 --- a/client/src/components/lanes/SessionMenu.tsx +++ b/client/src/components/lanes/SessionMenu.tsx @@ -31,6 +31,7 @@ export function SessionMenu({ onArchive, onUnarchive, onPin, + onShare, }: MenuPoint & { laneId: number; session: Session | null; @@ -42,6 +43,8 @@ export function SessionMenu({ onUnarchive?: () => void; /** Pin or unpin (by `session.pinned_at`), where the list offers it. */ onPin?: () => void; + /** Share this session on its own (server mode, the workspace's owner). */ + onShare?: () => void; }) { const { t } = useTranslation("lanes"); const navigate = useNavigate(); @@ -81,6 +84,11 @@ export function SessionMenu({ {t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")} )} + {onShare && session && ( + + {t("sessionMenu.share")} + + )} {session && ( <> navigate(`/sessions/${session.id}`))}> diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index b58aeee9..13903660 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -278,10 +278,13 @@ function HomeBody({ // own account and is billed to them (the server answers ENOCOLLABACCOUNT // when they have none). const collab = !ownsLane(lane); + // Only a session of it is shared with them (phase 3): no new session here — + // they open that session and send it messages (the server answers ESESSIONSHARE). + const sessionOnly = lane.access === "session"; 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 (startingRef.current || uploading || !text) return; + if (sessionOnly || startingRef.current || uploading || !text) return; startingRef.current = true; setStarting(true); setError(null); @@ -537,7 +540,7 @@ function HomeBody({ type="button" data-testid="workspace-home-start" onClick={() => void start()} - disabled={starting || uploading || !text} + disabled={sessionOnly || 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" @@ -549,7 +552,7 @@ function HomeBody({ {collab && (

- {t("shared.ownAccount")} + {t(sessionOnly ? "shared.sessionOnly" : "shared.ownAccount")}

)} {/* The session's quieter choices, under the box like Claude Desktop's. */} diff --git a/client/src/components/lanes/WorkspaceSessionList.tsx b/client/src/components/lanes/WorkspaceSessionList.tsx index 02f24d49..57bbb069 100644 --- a/client/src/components/lanes/WorkspaceSessionList.tsx +++ b/client/src/components/lanes/WorkspaceSessionList.tsx @@ -31,7 +31,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect, useState, type ReactNode } from "react"; +import { lazy, Suspense, useEffect, useState, type ReactNode } from "react"; import { endRuns } from "../../lib/stopSession"; import { isSubmitEnter } from "../../lib/keys"; import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react"; @@ -46,6 +46,11 @@ import { useAiTools } from "../../hooks/useAiTools"; import { useThrottledValue } from "../../hooks/useThrottledValue"; import { useContextMenu } from "../ContextMenu"; import { SessionMenu } from "./SessionMenu"; +import { usePermissions } from "../AuthGate"; + +const WorkspaceShareModal = lazy(() => + import("./WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal })) +); export const SESSION_LIMIT = 6; @@ -256,6 +261,9 @@ export function WorkspaceSessionList({ const [busy, setBusy] = useState(false); const ctx = useContextMenu(); const sessionCtx = useContextMenu(); + // Share one session (server mode, the workspace's owner or an admin). + const { local, ownsLane } = usePermissions(); + const [sharing, setSharing] = useState(null); // Rows shown; See all raises it to the whole list. const [limit, setLimit] = useState(SESSION_LIMIT); @@ -637,8 +645,18 @@ export function WorkspaceSessionList({ onPin={() => void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at }) } + onShare={!local && ownsLane(lane) ? () => setSharing(sessionCtx.menu!.item) : undefined} /> )} + {sharing && ( + + setSharing(null)} + /> + + )} ); } diff --git a/client/src/components/lanes/WorkspaceShareModal.tsx b/client/src/components/lanes/WorkspaceShareModal.tsx index 022e78a6..85910d7e 100644 --- a/client/src/components/lanes/WorkspaceShareModal.tsx +++ b/client/src/components/lanes/WorkspaceShareModal.tsx @@ -1,8 +1,9 @@ /** - * @file WorkspaceShareModal — share a workspace with other users (server mode, - * docs/plans/workspace-sharing/ phase 1). Ticks people from - * `GET /api/artifacts/people` (names only) and saves the whole list through - * `PUT /api/lanes/:id/shares`. It says plainly what sharing means on this + * @file WorkspaceShareModal — share a workspace, or one of its sessions + * (`session`), with other users (server mode, docs/plans/workspace-sharing/ + * phases 1 and 3). Ticks people from `GET /api/artifacts/people` (names only) + * and saves the whole list through `PUT /api/lanes/:id/shares` or + * `PUT /api/lanes/:id/sessions/:sid/shares`. It says plainly what sharing means on this * server: the people it is shared with work as the owner's account and can * read everything in the owner's home. Only the owner or an admin opens it — * the server refuses anyone else (`ENOTOWNER`). @@ -21,7 +22,16 @@ import { Checkbox } from "../Checkbox"; type Person = { id: number; username: string; name: string; disabled?: boolean }; -export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: () => void }) { +export function WorkspaceShareModal({ + lane, + session, + onClose, +}: { + lane: Lane; + /** Share this one session instead of the whole workspace. */ + session?: { id: string; name?: string | null }; + onClose: () => void; +}) { const { t } = useTranslation("lanes"); const panelRef = useRef(null); useDialog(true, panelRef, onClose); @@ -33,7 +43,11 @@ export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: () useEffect(() => { let alive = true; - Promise.all([api.artifacts.people(), api.lanes.shares(lane.id)]) + const sid = session?.id; + Promise.all([ + api.artifacts.people(), + sid ? api.lanes.sessionShares(lane.id, sid) : api.lanes.shares(lane.id), + ]) .then(([p, s]) => { if (!alive) return; // Never the owner: a workspace is not shared with them. Someone it is @@ -56,7 +70,7 @@ export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: () return () => { alive = false; }; - }, [lane.id, lane.owner_id]); + }, [lane.id, lane.owner_id, session?.id]); const shown = useMemo(() => { const q = query.trim().toLowerCase(); @@ -66,7 +80,7 @@ export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: () }, [people, query]); const added = [...picked].filter((id) => !initial.has(id)); const changed = added.length > 0 || [...initial].some((id) => !picked.has(id)); - const name = lane.title || lane.cwd; + const name = session ? session.name || session.id : lane.title || lane.cwd; const save = async () => { if (added.length > 0) { @@ -80,7 +94,9 @@ export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: () } setBusy(true); try { - const r = await api.lanes.setShares(lane.id, [...picked]); + const r = session + ? await api.lanes.setSessionShares(lane.id, session.id, [...picked]) + : await api.lanes.setShares(lane.id, [...picked]); toast.success( r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone") ); @@ -116,7 +132,7 @@ export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: ()

- {t("share.title")} + {t(session ? "share.sessionTitle" : "share.title")}

{name} @@ -133,7 +149,9 @@ export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: ()

-

{t("share.what")}

+

+ {t(session ? "share.sessionWhat" : "share.what")} +

*/ @@ -30,11 +31,18 @@ const m = vi.hoisted(() => ({ people: vi.fn(), shares: vi.fn(), setShares: vi.fn(), + sessionShares: vi.fn(), + setSessionShares: vi.fn(), })); vi.mock("../../../lib/api", () => ({ api: { artifacts: { people: m.people }, - lanes: { shares: m.shares, setShares: m.setShares }, + lanes: { + shares: m.shares, + setShares: m.setShares, + sessionShares: m.sessionShares, + setSessionShares: m.setSessionShares, + }, }, })); @@ -159,3 +167,33 @@ describe("WorkspaceRowMenu on a shared workspace", () => { expect(screen.getByText("Rename")).toBeInTheDocument(); }); }); + +describe("WorkspaceShareModal for one session", () => { + it("reads and saves that session's own list, never the workspace's", async () => { + m.shares.mockClear(); + m.setShares.mockClear(); + m.sessionShares.mockResolvedValue({ shares: [], canManage: true }); + m.setSessionShares.mockResolvedValue({ + shares: [{ id: 3, username: "carol", name: "Carol", sharedAt: "" }], + }); + render( + {}} + /> + ); + await settle(); + expect(m.sessionShares).toHaveBeenCalledWith(7, "s-1"); + expect(m.shares).not.toHaveBeenCalled(); + expect(screen.getByText("Share session")).toBeTruthy(); + expect(screen.getByText("Fix cart")).toBeTruthy(); + fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await settle(); + await act(async () => settleConfirm(true)); + await settle(); + expect(m.setSessionShares).toHaveBeenCalledWith(7, "s-1", [3]); + expect(m.setShares).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/useLanes.ts b/client/src/hooks/useLanes.ts index bedd1b39..8955fe2f 100644 --- a/client/src/hooks/useLanes.ts +++ b/client/src/hooks/useLanes.ts @@ -60,7 +60,9 @@ function onMessage(msg: WSMessage) { const lane = payload.lane; if (payload.removed !== undefined || !lane || !lanes) return void load(); const i = lanes.findIndex((l) => l.id === lane.id); - if (i === -1) return void load(); + // A broadcast carries no viewer's `access`: a lane seen through a shared + // session re-reads it (its access may have changed too). + if (i === -1 || lanes[i]?.access) return void load(); const next = [...lanes]; next[i] = lane; publish(next); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 84eaa8a2..2afda2b1 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -405,6 +405,10 @@ "share.disabled": "(disabled)", "share.savedNone": "No longer shared with anyone", "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.", + "share.sessionTitle": "Share session", + "share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.", + "sessionMenu.share": "Share…", + "shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.", "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 6c6cc7a2..3743daab 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -405,6 +405,10 @@ "share.disabled": "(đã vô hiệu hoá)", "share.savedNone": "Không còn chia sẻ với ai", "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.", + "share.sessionTitle": "Chia sẻ session", + "share.sessionWhat": "Người được chia sẻ thấy session này và các thay đổi của workspace, vào tab đang mở, gõ, gửi tin nhắn cho nó (bằng tài khoản của họ, tính tiền cho họ) và commit — không gì khác của workspace. Chỉ bạn chia sẻ được. Trên server này họ làm việc bằng TÀI KHOẢN CỦA BẠN trong terminal của nó, nên từ đó họ vẫn với tới được phần còn lại trong home của bạn.", + "sessionMenu.share": "Chia sẻ…", + "shared.sessionOnly": "Chỉ một session của workspace này được chia sẻ với bạn: mở nó từ danh sách để làm việc.", "shared.badge": "Được chia sẻ với bạn", "panel.changes": "Thay đổi", "panel.analyst": "Analyst", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 2d30580c..cf288743 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -5113,6 +5113,17 @@ export const api = { method: "PUT", body: JSON.stringify({ userIds }), }), + /** GET /api/lanes/:id/sessions/:sid/shares — the users one session is shared with. */ + sessionShares: (id: number, sid: string) => + request<{ shares: LaneShare[]; canManage: boolean }>( + `/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares` + ), + /** PUT /api/lanes/:id/sessions/:sid/shares — the WHOLE list (owner or admin). */ + setSessionShares: (id: number, sid: string, userIds: number[]) => + request<{ shares: LaneShare[] }>( + `/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`, + { method: "PUT", body: JSON.stringify({ userIds }) } + ), removeSource: (id: number, sourceId: number) => request(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }), /** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */ diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index d3e5a4fb..e732b00e 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -2708,6 +2708,8 @@ export interface Lane { owner_display_name?: string | null; /** When the owner's avatar was last set (`users.avatar_at`); null = none. */ owner_avatar_at?: string | null; + /** Server mode: `"session"` when only sessions of it are shared with this viewer. */ + access?: "session"; branch: string | null; kind: "managed" | "adopted"; /** The CLI this lane's runs spawn. Immutable once the lane exists. */ diff --git a/docs/API.md b/docs/API.md index 34fc27ce..382c0af6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2121,7 +2121,23 @@ 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. +`docs/SERVER-MODE.md` → Sharing a workspace. A collaborator's pane token +answers only that workspace's reads, the workspace list (filtered to it), its +stage / up / down / hook / feature / mcp-sync writes and `/api/health` +(`403 EPANESCOPE` — never a start or resume). + +```http +GET /api/lanes/:id/sessions/:sid/shares → { shares, canManage } +PUT /api/lanes/:id/sessions/:sid/shares {"userIds": [3]} → { shares } +``` + +One session shared on its own (`session_shares`), same rules as the workspace +list (owner or admin, enabled users, audited `share session `, `404 +ENOSESSION` for a session of another workspace). The user gets the workspace +in `GET /api/lanes` with `access: "session"`, only that session in its session +lists, the pane holding it, a resume of it (`start {resumeSessionId}` — no +fork, no new session, no shell), and the workspace's git/Files reads and git +writes; any other call on the workspace answers `403 ESESSIONSHARE`. #### Workspace context: instructions, folders, context files, memory diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 90586257..0f9f6bea 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -301,6 +301,41 @@ enabled. Every share and unshare is audited (`share workspace ` / the next attach re-check. Deleting the workspace, or the user, drops the rows; giving the workspace to a user it was shared with drops theirs. +**A collaborator's pane token acts as them inside that workspace only.** The +owner's account can read it (it sits in the owner's home), so it answers only +reads of `/api/lanes//…` (and `/api/workspaces//…`), the +workspace list (which then holds that workspace only), `/api/health`, and its +`stage`, `up`, `down`, `hook/`, `features/activate` and `mcp/sync` +writes (`paneLane`, `403 EPANESCOPE` for everything else) — never a start, +resume or any other call that could bill them. The CLI's workspace commands +work from their pane; the MCP server and the statusline's spend line do not. +A pane the owner or an admin opens still acts as the owner. + +#### Sharing one session + +The owner (or an admin) can also share ONE session: right-click it in the +sidebar → **Share…** (`GET/PUT /api/lanes/:id/sessions/:sid/shares`, table +`session_shares`, audited as `share session `). The user sees the +workspace in their list (`access: "session"`) with only that session in it, +attaches to the pane holding it, sends it messages (a resume, on their own +account and billed to them, as above) and uses the workspace's Changes, git +and Files reads and git writes. Everything else of the workspace — another +session, a new session, a fork, a Terminal tab, `/api/run`, its instructions, +context and settings — answers `403 ESESSIONSHARE` (`sessionShareAllows` in +`scope.js`, checked in `scopeGuard`). Their run lists show only runs that held +that session, and a pane switching to another session closes their terminal +at the next attach re-check. Taking it back, deleting the workspace or the +user drops the rows. A resume names a free slot (`slot: "auto"`, never pane +0) and a session of THAT workspace; the session's name and archive state stay +the owner's (`PATCH /api/sessions/` is `ESESSIONSHARE`), and the +workspace's extra folders (`?source=`), its files list for Artifacts and its +stage history stay out of reach. **These limits hold for the dashboard's API +only.** Under S3 the pane runs as the owner's Linux account: inside it the +user can `/resume` any session of the folder, run shell commands and read the +owner's home — a session share hands over as much as a workspace share +there. The Share dialog says so; share only with people you would hand your +keyboard to. + ### AI Proxy profiles Each user may keep **personal** AI Proxy profiles beside the **shared** ones: diff --git a/docs/plans/workspace-sharing/README.md b/docs/plans/workspace-sharing/README.md index 4709f81e..1f248131 100644 --- a/docs/plans/workspace-sharing/README.md +++ b/docs/plans/workspace-sharing/README.md @@ -1,6 +1,6 @@ # Workspace & session sharing — plan -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. +Status: **phases 1–3 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; one session can be shared — `session_shares`; R1 decided as option **(a)** — accept and warn hard). Phase 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 aee068e0..dfe692c5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -20478,6 +20478,78 @@ paths: description: ENOTOWNER. '404': description: ENOLANE — workspace not found. + /api/lanes/{id}/sessions/{sid}/shares: + get: + tags: + - Lanes + summary: The users one session of a workspace is shared with + description: 'Server mode (local mode: `{shares: [], canManage: false}`). `{shares:[{id, username, name, sharedAt}], canManage}` for session `sid` of this workspace (`404 ENOSESSION` otherwise).' + operationId: getSessionShares + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: sid + in: path + required: true + schema: + type: string + responses: + '200': + description: '`{shares, canManage}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '404': + description: ENOLANE — workspace not found. + put: + tags: + - Lanes + summary: Set the users one session of a workspace is shared with + description: '`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the workspace''s owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; audited as `share session ` / `unshare session `. A user it is shared with sees that session and its workspace (`access: "session"` in the workspace list), joins the pane holding it, resumes it on their own account and uses the workspace''s git — every other call on the workspace answers them `403 ESESSIONSHARE`.' + operationId: setSessionShares + parameters: + - name: id + in: path + required: true + schema: + type: integer + - name: sid + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - userIds + properties: + userIds: + type: array + items: + type: integer + responses: + '200': + description: '`{shares}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '400': + description: EBADUSER or ELOCALMODE. + '403': + description: ENOTOWNER. + '404': + description: ENOLANE — workspace not found. /api/lanes/{id}/memory: get: tags: diff --git a/server/__tests__/lane-shares.test.js b/server/__tests__/lane-shares.test.js index 212d8bbf..d78ccd3b 100644 --- a/server/__tests__/lane-shares.test.js +++ b/server/__tests__/lane-shares.test.js @@ -299,6 +299,35 @@ describe("sharing a workspace", () => { assert.equal(asUser(tokenFor(`agenthub-lane-${laneA}-1`, null))?.username, "alice"); const bobs = tokenFor(`agenthub-lane-${laneA}-2`, users.bob.id); assert.equal(asUser(bobs)?.username, "bob"); + // …inside this workspace only: the owner's account can read that token. + assert.equal(asUser(bobs)?.paneLane, laneA); + const guard = (p, method = "GET") => { + let out = null; + auth.authGuard( + { + path: p, + method, + headers: { "x-agenthub-pane-token": bobs }, + socket: { remoteAddress: "127.0.0.1" }, + }, + { + status: (code) => ({ json: (body) => (out = { code, body }) }), + }, + () => (out = "next") + ); + return out; + }; + assert.equal(guard(`/lanes/${laneA}`), "next"); + assert.equal(guard(`/workspaces/${laneA}/git`), "next"); + assert.equal(guard("/sessions").body.error.code, "EPANESCOPE"); + assert.equal(guard(`/lanes/${laneA + 1000}`).body.error.code, "EPANESCOPE"); + assert.equal(guard("/me").body.error.code, "EPANESCOPE"); + // The CLI finds its workspace in the list (which answers that one only)… + assert.equal(guard("/workspaces"), "next"); + // …and never starts or bills a session with it. + assert.equal(guard(`/lanes/${laneA}/start`, "POST").body.error.code, "EPANESCOPE"); + assert.equal(guard(`/lanes/${laneA}/reset`, "POST").body.error.code, "EPANESCOPE"); + assert.equal(guard("/run", "POST").body.error.code, "EPANESCOPE"); // No longer shared with him: his pane's token is nobody. db.prepare("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run( laneA, @@ -371,3 +400,124 @@ describe("sharing a workspace", () => { ); }); }); + +describe("sharing one session", () => { + let laneS; + before(() => { + laneS = Number( + db + .prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice2', 'claude', ?)") + .run(users.alice.id).lastInsertRowid + ); + for (const id of ["s1-shared", "s2-private"]) { + db.prepare( + "INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'completed', '/w/alice2', ?)" + ).run(id, id, laneS); + } + }); + const put = (as, userIds, sid = "s1-shared") => + call("PUT", `/api/lanes/${laneS}/sessions/${sid}/shares`, { as, body: { userIds } }); + + it("is managed by the workspace's owner only, for a session of that workspace", async () => { + assert.equal((await put("carol", [users.carol.id])).status, 404); + assert.equal((await put("alice", [users.carol.id], "sA")).status, 404, "not this workspace's"); + const r = await put("alice", [users.carol.id]); + assert.equal(r.status, 200); + assert.deepEqual( + r.body.shares.map((u) => u.username), + ["carol"] + ); + assert.equal( + db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = 'share session s1-shared'").get() + .n, + 1 + ); + }); + + it("shows that session and its workspace — no other session of it", async () => { + const lanes = (await call("GET", "/api/lanes", { as: "carol" })).body.lanes; + const mine = lanes.find((l) => l.id === laneS); + assert.equal(mine?.access, "session"); + const list = await call("GET", `/api/sessions?lane=${laneS}`, { as: "carol" }); + assert.deepEqual( + list.body.sessions.map((s) => s.id), + ["s1-shared"] + ); + assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 200); + assert.equal((await call("GET", "/api/sessions/s2-private", { as: "carol" })).status, 404); + }); + + it("allows that session's pane, Changes and a resume of it — nothing else of the workspace", async () => { + const scope = require("../lib/scope"); + const req = { user: { id: users.carol.id, role: "developer" }, method: "POST" }; + const run = (id, sid) => + db + .prepare( + `INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool) + VALUES (?, ?, 'interactive', '/w/alice2', 'running', ?, 'claude')` + ) + .run(id, sid, laneS); + run(`agenthub-lane-${laneS}-1`, "s1-shared"); + run(`agenthub-lane-${laneS}-2`, "s2-private"); + assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-1`), true); + assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-2`), false); + assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}`), false, "no run there"); + + const refused = async (method, p, body) => { + const r = await call(method, p, { as: "carol", body }); + assert.equal(r.status, 403, `${method} ${p} → ${r.status}`); + assert.equal(r.body.error.code, "ESESSIONSHARE"); + }; + await refused("PATCH", `/api/lanes/${laneS}`, { title: "x" }); + await refused("GET", `/api/lanes/${laneS}/sources`); + await refused("PUT", `/api/lanes/${laneS}/instructions`, { instructions: "x" }); + await refused("POST", `/api/lanes/${laneS}/start`, { slot: "auto", prompt: "new" }); + await refused("POST", `/api/lanes/${laneS}/start`, { shell: true }); + await refused("POST", `/api/lanes/${laneS}/start`, { + resumeSessionId: "s1-shared", + forkSession: true, + }); + await refused("POST", "/api/run", { laneId: laneS, initialPrompt: "hi" }); + await refused("POST", `/api/lanes/${laneS}/start`, { resumeSessionId: "s1-shared", slot: 0 }); + await refused("GET", `/api/lanes/${laneS}/git/tree?source=1`); + await refused("GET", `/api/artifacts/candidates?laneId=${laneS}`); + await refused("PATCH", "/api/sessions/s1-shared", { name: "mine now" }); + // A shared session resumed only in its own workspace. + const laneT = Number( + db + .prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice3', 'claude', ?)") + .run(users.alice.id).lastInsertRowid + ); + db.prepare( + "INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sT-other', 't', 'completed', '/w/alice3', ?)" + ).run(laneT); + db.prepare("INSERT INTO session_shares (session_id, user_id) VALUES ('sT-other', ?)").run( + users.carol.id + ); + await refused("POST", `/api/lanes/${laneT}/start`, { resumeSessionId: "s1-shared" }); + db.prepare("DELETE FROM session_shares WHERE session_id = 'sT-other'").run(); + // Another session of it is not even there. + const other = await call("POST", `/api/lanes/${laneS}/start`, { + as: "carol", + body: { resumeSessionId: "s2-private" }, + }); + assert.equal(other.status, 404); + // A resume of the shared one gets past the scope (and then needs their own account). + const resume = await call("POST", `/api/lanes/${laneS}/start`, { + as: "carol", + body: { resumeSessionId: "s1-shared", prompt: "hi" }, + }); + assert.ok(![403, 404].includes(resume.status), JSON.stringify(resume.body)); + // Reads of the workspace's git and its own row pass. + assert.equal((await call("GET", `/api/lanes/${laneS}`, { as: "carol" })).status, 200); + const git = await call("GET", `/api/lanes/${laneS}/git`, { as: "carol" }); + assert.notEqual(git.status, 403); + db.prepare("DELETE FROM dashboard_runs WHERE lane_id = ?").run(laneS); + }); + + it("ends their access when it is taken back", async () => { + assert.equal((await put("alice", [])).status, 200); + assert.ok(!(await laneIds("carol")).includes(laneS)); + assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 404); + }); +}); diff --git a/server/db.js b/server/db.js index 8cf1eda9..e35d9ab6 100644 --- a/server/db.js +++ b/server/db.js @@ -1014,6 +1014,20 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_lane_shares_user ON lane_shares(user_id); `); +// A single session shared with another user (sharing phase 3): they see that +// session and its workspace's Changes, join its pane and resume it — nothing +// else of the workspace. No foreign keys, like lane_shares. +db.exec(` + CREATE TABLE IF NOT EXISTS session_shares ( + session_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + granted_by INTEGER, + shared_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (session_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_session_shares_user ON session_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` diff --git a/server/lib/auth.js b/server/lib/auth.js index 0f4d0711..64289eae 100644 --- a/server/lib/auth.js +++ b/server/lib/auth.js @@ -258,9 +258,36 @@ function validateProfile(body) { // Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides. const CONFUSABLE = { - а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t", - у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e", - η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i", + а: "a", + в: "b", + е: "e", + к: "k", + м: "m", + н: "h", + о: "o", + р: "p", + с: "c", + т: "t", + у: "y", + х: "x", + і: "i", + ј: "j", + ԁ: "d", + ѕ: "s", + ӏ: "l", + α: "a", + β: "b", + ε: "e", + η: "n", + ι: "i", + κ: "k", + ν: "v", + ο: "o", + ρ: "p", + τ: "t", + υ: "u", + χ: "x", + ı: "i", }; /** @@ -519,6 +546,7 @@ function deleteUser(username) { sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`); // Workspaces other users shared with them (lane_shares has no foreign key). sql("DELETE FROM lane_shares WHERE user_id = ?").run(u.id); + sql("DELETE FROM session_shares WHERE user_id = ?").run(u.id); require("./avatars").removeAvatar(u.id); try { sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id); @@ -549,6 +577,9 @@ function assignWorkspace(laneId, username) { if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`); // Its new owner no longer needs it shared with them. sql("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(Number(laneId), u.id); + sql( + "DELETE FROM session_shares WHERE user_id = ? AND session_id IN (SELECT id FROM sessions WHERE lane_id = ?)" + ).run(u.id, Number(laneId)); } /** Take a workspace back from the user who owns it: it is left with no owner @@ -741,7 +772,7 @@ function userFromPaneToken(req) { if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null; if (!isLocalRequest(req)) return null; const row = sql( - `SELECT r.id, r.lane_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id + `SELECT r.id, r.lane_id, r.session_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0` ).get(sha256(t)); if (!row || row.owner_id == null) return null; @@ -754,11 +785,33 @@ function userFromPaneToken(req) { if ( uid !== row.owner_id && user.role !== "admin" && - !require("./scope").sharedWith(row.lane_id, uid) + !require("./scope").sharedWith(row.lane_id, uid) && + !require("./scope").sessionSharedWith(row.session_id, uid) ) { return null; } - return publicUser(user) || null; + const pub = publicUser(user); + if (!pub) return null; + // A collaborator's pane runs as the OWNER's Linux account, which can read + // its token: it acts as the collaborator inside this workspace only + // (`paneLane`, authGuard), never across their account. + return uid !== row.owner_id ? { ...pub, paneLane: row.lane_id } : pub; +} + +/** + * What a collaborator's pane token may call — the CLI's workspace commands, + * nothing that starts or bills a session (the owner's account can read the + * token): reads of its own workspace (and the workspace list, which answers + * that one only), health, and its stage / stack / hook / feature writes. + */ +function paneLaneAllows(method, lp, laneId) { + const read = method === "GET" || method === "HEAD"; + if (lp === "/health") return true; + if (read && /^\/(?:lanes|workspaces)\/?$/i.test(lp)) return true; + const m = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(lp); + if (!m || Number(m[1]) !== laneId) return false; + if (read) return true; + return /^\/(stage|up|down|hook\/[^/]+|features\/activate|mcp\/sync)$/i.test(m[2] || ""); } // ── Gates ─────────────────────────────────────────────────────────────────── @@ -814,6 +867,9 @@ function authGuard(req, res, next) { const cookieUser = userFromRequest(req); const user = cookieUser || userFromPaneToken(req); if (!user) return deny(res, 401, "EAUTH", "log in first"); + if (!cookieUser && user.paneLane != null && !paneLaneAllows(req.method, lp, user.paneLane)) { + return deny(res, 403, "EPANESCOPE", "this pane's token acts only inside its own workspace"); + } req.user = user; // A password an admin gave: the login may only read its own account and // pick its own password until it has. diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 5ab02975..ac7f459e 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -332,7 +332,12 @@ function setProvisioningFacts(id, facts = {}) { function deleteLane(id) { const gone = db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0; // Who it was shared with goes with it (lane_shares has no foreign key). - if (gone) db.prepare("DELETE FROM lane_shares WHERE lane_id = ?").run(id); + if (gone) { + db.prepare("DELETE FROM lane_shares WHERE lane_id = ?").run(id); + db.prepare( + "DELETE FROM session_shares WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?)" + ).run(id); + } // Its `lane_sources` rows cascade; AgentHub's own copies of its uploaded // context files go with them. A folder source is the user's and stays. // Lazy: lane-sources.js reads the db directly and must not load at import. diff --git a/server/lib/scope.js b/server/lib/scope.js index b8696ecf..1fc26ad4 100644 --- a/server/lib/scope.js +++ b/server/lib/scope.js @@ -66,7 +66,14 @@ function ownSessionWhere(req, col = "id", laneCol = "lane_id") { } // The workspaces a user owns or that are shared with them (2 params: uid, uid). -const MY_LANES = "SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?"; +const MY_LANES = + "SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?"; +// The sessions shared with a user one by one (1 param: uid), and the +// workspaces holding them (sharing phase 3: seen, but only that session). +const MY_SESSIONS = "SELECT session_id FROM session_shares WHERE user_id = ?"; +const SESSION_LANES = `SELECT lane_id FROM sessions WHERE lane_id IS NOT NULL AND id IN (${MY_SESSIONS})`; +// Every workspace a user may open (3 params: uid ×3). +const SEEN_LANES = `${MY_LANES} UNION ${SESSION_LANES}`; function ownedSessionWhere(uid, col, laneCol, shared = false) { if (uid == null) return { where: "1", params: [] }; @@ -77,8 +84,13 @@ function ownedSessionWhere(uid, col, laneCol, shared = false) { OR ${col} IN (SELECT r.session_id FROM dashboard_runs r WHERE r.lane_id IN (${lanes}) AND r.session_id IS NOT NULL) OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?) - OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`, - params: [...lp, ...lp, uid, uid], + OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?)${ + shared + ? ` + OR ${col} IN (${MY_SESSIONS})` + : "" + })`, + params: [...lp, ...lp, uid, uid, ...(shared ? [uid] : [])], }; } @@ -94,7 +106,36 @@ function bySessionWhere(req, col = "session_id") { function laneWhere(req, col = "id") { const uid = memberId(req); if (uid == null) return { where: "1", params: [] }; - return { where: `${col} IN (${MY_LANES})`, params: [uid, uid] }; + return { where: `${col} IN (${SEEN_LANES})`, params: [uid, uid, uid] }; +} + +/** Whether session `sessionId` is shared with `userId` on its own (phase 3). */ +function sessionSharedWith(sessionId, userId) { + if (!sessionId || userId == null) return false; + return Boolean( + sql("SELECT 1 FROM session_shares WHERE session_id = ? AND user_id = ?").get( + String(sessionId), + userId + ) + ); +} + +/** + * How much of workspace `laneId` this request may use: `all` (local mode, an + * admin or analyst read, its owner, a user it is shared with), `session` + * (only sessions shared with them one by one), or null. + */ +function laneAccess(req, laneId) { + const uid = memberId(req); + if (uid == null) return "all"; + const v = typeof laneId === "number" ? laneId : String(laneId); + const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v); + if (!row) return null; + if (row.owner_id === uid || sharedWith(row.id, uid)) return "all"; + const one = sql( + `SELECT 1 FROM sessions WHERE lane_id = ? AND id IN (${MY_SESSIONS}) LIMIT 1` + ).get(row.id, uid); + return one ? "session" : null; } /** Whether `userId` is one of the users workspace `laneId` is shared with. */ @@ -123,9 +164,7 @@ function canSeeLane(req, laneId) { // Bound as given, like getLane binds a route param: SQLite's integer // affinity reads '9002.0' or ' 9002' as 9002 for both, so the row checked // here is the row the route would load. - const v = typeof laneId === "number" ? laneId : String(laneId); - const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v); - return Boolean(row) && (row.owner_id === uid || sharedWith(row.id, uid)); + return laneAccess(req, laneId) != null; } function canSeeSession(req, sessionId) { @@ -146,7 +185,13 @@ function canSeeAgent(req, agentId) { function canSeeRun(req, runId) { if (memberId(req) == null) return true; const m = /^agenthub-lane-(\d+)(?:-\d+)?(?:@.*)?$/.exec(String(runId || "")); - if (m) return canSeeLane(req, m[1]); + if (m) { + const access = laneAccess(req, m[1]); + if (access !== "session") return access != null; + // A session shared on its own: only the pane holding that session. + const row = sql("SELECT session_id FROM dashboard_runs WHERE id = ?").get(String(runId)); + return Boolean(row?.session_id) && sessionSharedWith(row.session_id, memberId(req)); + } const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(runId)); return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id); } @@ -155,9 +200,9 @@ function canSeeRun(req, runId) { function visibleLaneIds(req) { const uid = memberId(req); if (uid == null) return null; - return sql(MY_LANES) - .all(uid, uid) - .map((r) => r.id); + return sql(SEEN_LANES) + .all(uid, uid, uid) + .map((r) => r.lane_id ?? r.id); } /** A row of `table` (`id` = `rowId`) whose `session_id` this request may see. */ @@ -170,8 +215,11 @@ function canSeeRowSession(req, table, rowId, idCol = "id") { /** A `dashboard_runs` row (`/api/run/history/:id`) of one of this request's workspaces. */ function canSeeRunRow(req, id) { if (memberId(req) == null) return true; - const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(id)); - return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id); + const row = sql("SELECT lane_id, session_id FROM dashboard_runs WHERE id = ?").get(String(id)); + if (!row || row.lane_id == null) return false; + const access = laneAccess(req, row.lane_id); + if (access !== "session") return access != null; + return Boolean(row.session_id) && sessionSharedWith(row.session_id, memberId(req)); } /** @@ -272,6 +320,28 @@ function scopeGuard(req, res, next) { if (bad) return notFound(); if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound(); if (sessions.some((id) => !canSeeSession(req, id))) return notFound(); + // A workspace seen only through a session shared on its own (phase 3): + // that session, its pane and the workspace's Changes — nothing else of it. + const write = req.method !== "GET" && req.method !== "HEAD"; + const pm = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(p); + if ( + pm && + laneAccess(req, Number(pm[1])) === "session" && + !sessionShareAllows(req, pm[2] || "", Number(pm[1])) + ) { + return sessionOnly(res); + } + const viaSession = lanes.some((id) => laneAccess(req, Number(id)) === "session"); + // Workspace-wide reads by `?laneId=`: files and skills of every session, stage history. + if ( + viaSession && + (write || /^\/(artifacts|lanes\/pipeline-stats|workspaces\/pipeline-stats)(\/|$)/i.test(p)) + ) { + return sessionOnly(res); + } + // The shared session itself: open it, never rename or archive it. + const sm = /^\/sessions\/([^/]+)/i.exec(p); + if (write && sm && sessionOnlyVia(req, sm[1])) return sessionOnly(res); // S3: a WRITE to a workspace re-checks that its owner may still own that // folder, by the LIVE role — an admin demoted since keeps owning their // host-folder workspaces, and `/up`, `/hook`, a commit (git hooks) or a @@ -292,6 +362,54 @@ function scopeGuard(req, res, next) { return next(); } +/** Whether this request sees session `sid` only because it is shared with them on its own. */ +function sessionOnlyVia(req, sid) { + const uid = memberId(req); + if (uid == null || !sessionSharedWith(sid, uid)) return false; + const row = sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sid)); + return !row?.lane_id || laneAccess(req, row.lane_id) === "session"; +} + +const sessionOnly = (res) => + res.status(403).json({ + error: { + code: "ESESSIONSHARE", + message: "only a session of this workspace is shared with you: open that session", + }, + }); + +/** + * What a user with only a session of workspace (rest = the path after + * `/lanes/`) may call there: reads of the workspace, its git, files and + * share list (not its instructions or context); git writes; and a `start` that + * resumes a session shared with them — no new session, fork or terminal. + */ +function sessionShareAllows(req, rest, laneId) { + const r = rest.toLowerCase(); + // A workspace's extra folders are its own, not the session's. + if (req.query?.source !== undefined) return false; + if (req.method === "GET" || req.method === "HEAD") { + return r === "" || r === "/" || /^\/(git|fs|shares)(\/|$)/.test(r); + } + if (/^\/git\//.test(r)) return true; + if (r === "/start") { + const b = req.body && typeof req.body === "object" ? req.body : {}; + return ( + typeof b.resumeSessionId === "string" && + b.forkSession !== true && + b.shell !== true && + b.resume !== true && + // A free slot the server picks: pane 0 is the workspace's own run. + (b.slot === undefined || b.slot === "auto") && + sessionSharedWith(b.resumeSessionId, memberId(req)) && + Boolean( + sql("SELECT 1 FROM sessions WHERE id = ? AND lane_id = ?").get(b.resumeSessionId, laneId) + ) + ); + } + return false; +} + /** Whether a workspace's owner may still own its folder (S3, live role). */ function ownerStillFits(laneId) { const lane = sql("SELECT cwd, owner_id FROM lanes WHERE id = ?").get(laneId); @@ -329,12 +447,23 @@ function laneOwners(laneId) { return new Set(row && row.owner_id != null ? [row.owner_id] : []); } -/** The users a workspace is shared with — they get its news, but it is not "theirs". */ -function laneViewers(laneId) { +/** + * The users a workspace is shared with — they get its news, but it is not + * "theirs". `sessions`: also those with only a session of it (its row + * changes, never its stack's output). + */ +function laneViewers(laneId, { sessions = false } = {}) { if (laneId == null) return new Set(); return new Set( - sql("SELECT user_id FROM lane_shares WHERE lane_id = ?") - .all(Number(laneId)) + sql( + `SELECT user_id FROM lane_shares WHERE lane_id = ?${ + sessions + ? ` UNION SELECT user_id FROM session_shares + WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?)` + : "" + }` + ) + .all(...(sessions ? [Number(laneId), Number(laneId)] : [Number(laneId)])) .map((r) => r.user_id) ); } @@ -346,9 +475,10 @@ function sessionViewers(sessionId) { sql( `SELECT user_id FROM lane_shares WHERE lane_id IN ( SELECT lane_id FROM sessions WHERE id = ? AND lane_id IS NOT NULL - UNION SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL)` + UNION SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL) + UNION SELECT user_id FROM session_shares WHERE session_id = ?` ) - .all(String(sessionId), String(sessionId)) + .all(String(sessionId), String(sessionId), String(sessionId)) .map((r) => r.user_id) ); } @@ -380,7 +510,10 @@ function broadcastAudience(type, data) { case "lane_update": // A removal names an id that no longer resolves; it reveals nothing. if (d.removed != null && !d.lane) return null; - return { owners: laneOwners(d.lane?.id), viewers: laneViewers(d.lane?.id) }; + return { + owners: laneOwners(d.lane?.id), + viewers: laneViewers(d.lane?.id, { sessions: true }), + }; case "lane_hook_output": case "lane_hook_result": case "lane_runtime": @@ -433,7 +566,7 @@ function audienceIncludes(audience, user) { if (audience === null || audience.strip) return true; return Boolean( (audience.owners && audience.owners.has(user.id)) || - (audience.viewers && audience.viewers.has(user.id)) + (audience.viewers && audience.viewers.has(user.id)) ); } @@ -556,6 +689,8 @@ module.exports = { PATH_RULES, canSeeRowSession, canSeeRunRow, + laneAccess, + sessionSharedWith, folderAllowed, ownFolder, memberId, diff --git a/server/lib/user-quota.js b/server/lib/user-quota.js index 43348c03..f9931e8b 100644 --- a/server/lib/user-quota.js +++ b/server/lib/user-quota.js @@ -335,9 +335,11 @@ function sharedAccounts(userId) { 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 = ?" + `SELECT l.cwd FROM lane_shares ls JOIN lanes l ON l.id = ls.lane_id WHERE ls.user_id = ? + UNION SELECT l.cwd FROM session_shares ss JOIN sessions s ON s.id = ss.session_id + JOIN lanes l ON l.id = s.lane_id WHERE ss.user_id = ?` ) - .all(userId)) { + .all(userId, userId)) { try { const acct = runAs.accountForPath(cwd); if (acct) out.set(acct.name, acct); diff --git a/server/openapi-extra/lanes-more.js b/server/openapi-extra/lanes-more.js index 6e42bcd4..fe1dd3e1 100644 --- a/server/openapi-extra/lanes-more.js +++ b/server/openapi-extra/lanes-more.js @@ -738,6 +738,49 @@ const paths = { }, }, }, + "/api/lanes/{id}/sessions/{sid}/shares": { + get: { + tags: ["Lanes"], + summary: "The users one session of a workspace is shared with", + description: + "Server mode (local mode: `{shares: [], canManage: false}`). `{shares:[{id, username, name, sharedAt}], canManage}` for session `sid` of this workspace (`404 ENOSESSION` otherwise).", + operationId: "getSessionShares", + parameters: [ + idParam, + { name: "sid", in: "path", required: true, schema: { type: "string" } }, + ], + responses: { 200: { description: "`{shares, canManage}`.", ...json(obj) }, 404: notFound }, + }, + put: { + tags: ["Lanes"], + summary: "Set the users one session of a workspace is shared with", + description: + "`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the workspace's owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; audited as `share session ` / `unshare session `. A user it is shared with sees that session and its workspace (`access: \"session\"` in the workspace list), joins the pane holding it, resumes it on their own account and uses the workspace's git — every other call on the workspace answers them `403 ESESSIONSHARE`.", + operationId: "setSessionShares", + parameters: [ + idParam, + { name: "sid", in: "path", required: true, schema: { type: "string" } }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["userIds"], + properties: { userIds: { type: "array", items: { type: "integer" } } }, + }, + }, + }, + }, + responses: { + 200: { description: "`{shares}`.", ...json(obj) }, + 400: { description: "EBADUSER or ELOCALMODE." }, + 403: { description: "ENOTOWNER." }, + 404: notFound, + }, + }, + }, "/api/lanes/{id}/memory": { get: { tags: ["Lanes"], diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 69c31d34..bf4493ac 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -229,11 +229,23 @@ function broadcastLane(id) { router.get("/", (req, res) => { // Server mode: a member lists (and counts) only their own workspaces. const visible = scope.visibleLaneIds(req); - const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id)); + // A collaborator's pane token: its own workspace only (auth.js paneLaneAllows). + const paneLane = req.user?.paneLane ?? null; + const rows = lanesLib + .listLanes() + .filter( + (l) => (visible === null || visible.includes(l.id)) && (paneLane == null || l.id === paneLane) + ); // ONE list-sessions answers every lane's run, instead of a synchronous // has-session per lane (null when tmux could not be asked: per-lane then). const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null; - const lanes = rows.map((l) => payload(l, live)); + // `access: "session"`: only sessions of it are shared with this user (phase 3). + const lanes = rows.map((l) => { + const p = payload(l, live); + return visible !== null && scope.laneAccess(req, l.id) === "session" + ? { ...p, access: "session" } + : p; + }); res.json({ lanes, counts: { @@ -1402,17 +1414,89 @@ router.patch("/:id", sameOriginGuard, (req, res) => { const MAX_SHARES = 200; -function laneShares(laneId) { +/** The users a workspace (`lane_shares`) or one session (`session_shares`) is shared with. */ +function sharesOf(table, keyCol, key) { return require("../db") .db.prepare( `SELECT u.id, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) AS name, s.shared_at AS sharedAt, u.disabled - FROM lane_shares s JOIN users u ON u.id = s.user_id - WHERE s.lane_id = ? ORDER BY name COLLATE NOCASE` + FROM ${table} s JOIN users u ON u.id = s.user_id + WHERE s.${keyCol} = ? ORDER BY name COLLATE NOCASE` ) - .all(Number(laneId)) + .all(key) .map((u) => ({ ...u, disabled: Boolean(u.disabled) })); } +const laneShares = (laneId) => sharesOf("lane_shares", "lane_id", Number(laneId)); +const sessionShares = (sessionId) => sharesOf("session_shares", "session_id", String(sessionId)); + +/** + * Replace the WHOLE share list of a workspace or a session with `req.body.userIds`: + * server mode, the workspace's owner or an admin, enabled users only (one + * already listed may stay though disabled since), never the owner; every + * change audited as ` `. Answers the new list, or sends the + * error and answers null. + */ +function replaceShares(req, res, { lane, table, keyCol, key, what }) { + const auth = require("../lib/auth"); + if (!auth.isServerMode()) { + res.status(400).json({ error: { code: "ELOCALMODE", message: "sharing needs server mode" } }); + return null; + } + if (!scope.isLaneOwner(req, lane.id)) { + notOwner(res); + return null; + } + const raw = req.body?.userIds; + if ( + !Array.isArray(raw) || + raw.length > MAX_SHARES || + !raw.every((x) => Number.isInteger(x) && x > 0) + ) { + res + .status(400) + .json({ error: { code: "EBADUSER", message: "userIds must be a list of user ids" } }); + return null; + } + const { db } = require("../db"); + const wanted = [...new Set(raw)]; + const before = new Set(sharesOf(table, keyCol, key).map((u) => u.id)); + const user = db.prepare("SELECT id, disabled FROM users WHERE id = ?"); + for (const id of wanted) { + const u = user.get(id); + // A user already on the list may since have been disabled: keeping them + // must not block every other change (it grants nothing while disabled). + if (!u || (u.disabled && !before.has(id))) { + res.status(400).json({ error: { code: "EBADUSER", message: `no enabled user ${id}` } }); + return null; + } + if (id === lane.owner_id) { + res + .status(400) + .json({ error: { code: "EBADUSER", message: `a ${what} is not shared with its owner` } }); + return null; + } + } + const added = wanted.filter((id) => !before.has(id)); + const removed = [...before].filter((id) => !wanted.includes(id)); + const actor = req.user?.id ?? null; + db.transaction(() => { + const del = db.prepare(`DELETE FROM ${table} WHERE ${keyCol} = ? AND user_id = ?`); + const ins = db.prepare( + `INSERT OR IGNORE INTO ${table} (${keyCol}, user_id, granted_by) VALUES (?, ?, ?)` + ); + for (const id of removed) del.run(key, id); + for (const id of added) ins.run(key, id, actor); + })(); + const audit = require("../lib/audit"); + for (const id of added) audit.recordAccountChange(actor, id, "PUT", `share ${what} ${key}`); + for (const id of removed) audit.recordAccountChange(actor, id, "PUT", `unshare ${what} ${key}`); + // The owner and everyone it is shared with now re-read it; the users taken + // off re-read their list, and their open terminals on it close at the next + // attach re-check (websocket.js). + broadcastLane(lane.id); + if (removed.length) broadcast("lane_access", { laneId: lane.id, userIds: removed }); + return sharesOf(table, keyCol, key); +} router.get("/:id/shares", (req, res) => { const lane = lanesLib.getLane(req.params.id); @@ -1424,67 +1508,55 @@ router.get("/:id/shares", (req, res) => { /** `{userIds}` — the WHOLE list of users the workspace is shared with. */ router.put("/:id/shares", sameOriginGuard, (req, res) => { - const auth = require("../lib/auth"); - if (!auth.isServerMode()) { - return res - .status(400) - .json({ error: { code: "ELOCALMODE", message: "sharing needs server mode" } }); - } const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); - if (!scope.isLaneOwner(req, lane.id)) return notOwner(res); - const raw = req.body?.userIds; - if ( - !Array.isArray(raw) || - raw.length > MAX_SHARES || - !raw.every((x) => Number.isInteger(x) && x > 0) - ) { - return res - .status(400) - .json({ error: { code: "EBADUSER", message: "userIds must be a list of user ids" } }); - } - const { db } = require("../db"); - const wanted = [...new Set(raw)]; - const before = new Set(laneShares(lane.id).map((u) => u.id)); - const user = db.prepare("SELECT id, disabled FROM users WHERE id = ?"); - for (const id of wanted) { - const u = user.get(id); - // A user already on the list may since have been disabled: keeping them - // must not block every other change (it grants nothing while disabled). - if (!u || (u.disabled && !before.has(id))) { - return res - .status(400) - .json({ error: { code: "EBADUSER", message: `no enabled user ${id}` } }); - } - if (id === lane.owner_id) { - return res - .status(400) - .json({ error: { code: "EBADUSER", message: "a workspace is not shared with its owner" } }); - } - } - const added = wanted.filter((id) => !before.has(id)); - const removed = [...before].filter((id) => !wanted.includes(id)); - const actor = req.user?.id ?? null; - db.transaction(() => { - const del = db.prepare("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?"); - const ins = db.prepare( - "INSERT OR IGNORE INTO lane_shares (lane_id, user_id, granted_by) VALUES (?, ?, ?)" - ); - for (const id of removed) del.run(lane.id, id); - for (const id of added) ins.run(lane.id, id, actor); - })(); - const audit = require("../lib/audit"); - for (const id of added) audit.recordAccountChange(actor, id, "PUT", `share workspace ${lane.id}`); - for (const id of removed) { - audit.recordAccountChange(actor, id, "PUT", `unshare workspace ${lane.id}`); - } - // The owner and everyone it is shared with now re-read it; the users taken - // off re-read their list (it is gone from it), and their open terminals on - // it close at the next attach re-check (websocket.js). - broadcastLane(lane.id); - if (removed.length) broadcast("lane_access", { laneId: lane.id, userIds: removed }); - res.json({ shares: laneShares(lane.id) }); + const shares = replaceShares(req, res, { + lane, + table: "lane_shares", + keyCol: "lane_id", + key: lane.id, + what: "workspace", + }); + if (shares) res.json({ shares }); +}); + +/** A session of this workspace (`sessions.lane_id`), or null after sending 404. */ +function laneSession(req, res, lane) { + const row = require("../db") + .db.prepare("SELECT id FROM sessions WHERE id = ? AND lane_id = ?") + .get(String(req.params.sid), lane.id); + if (!row) res.status(404).json({ error: { code: "ENOSESSION", message: "session not found" } }); + return row; +} + +// One session shared on its own (sharing phase 3): the users see that session +// and its workspace's Changes, join its pane and resume it (on their own +// account) — nothing else of the workspace (scope.js sessionShareAllows). +router.get("/:id/sessions/:sid/shares", (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) + return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); + const row = laneSession(req, res, lane); + if (!row) return; + if (!require("../lib/auth").isServerMode()) return res.json({ shares: [], canManage: false }); + res.json({ shares: sessionShares(row.id), canManage: scope.isLaneOwner(req, lane.id) }); +}); + +router.put("/:id/sessions/:sid/shares", sameOriginGuard, (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) + return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); + const row = laneSession(req, res, lane); + if (!row) return; + const shares = replaceShares(req, res, { + lane, + table: "session_shares", + keyCol: "session_id", + key: row.id, + what: "session", + }); + if (shares) res.json({ shares }); }); router.post("/:id/stage", sameOriginGuard, (req, res) => { @@ -3492,8 +3564,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { laneId: current.id, slot, cwd: current.cwd, - // A user it is shared with: their pane token acts as them. - startedBy: req.user?.id ?? null, + // A user it is shared with: their pane token acts as them + // (the owner's or an admin's acts as the owner, as before). + startedBy, }).id; return {}; }); diff --git a/server/routes/run.js b/server/routes/run.js index d7f7b4b4..fccea6e0 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -89,7 +89,13 @@ router.get("/", (req, res) => { // Server mode: a member sees only the panes of their own workspaces. const visible = scope.visibleLaneIds(req); const items = runs.listRuns(); - res.json({ items: visible === null ? items : items.filter((r) => visible.includes(r.laneId)) }); + // A workspace one of whose sessions is shared with them: that session's pane only. + res.json({ + items: + visible === null + ? items + : items.filter((r) => visible.includes(r.laneId) && scope.canSeeRun(req, r.id)), + }); }); /** @@ -400,8 +406,11 @@ router.get("/history", (req, res) => { .sort((a, b) => String(b.started_at).localeCompare(String(a.started_at))) .slice(0, Math.max(1, Math.min(500, Math.floor(opts.limit) || 50))); const liveIds = new Set(runs.listRuns().map((h) => h.id)); + // A session shared on its own: only the runs that held it. + const shown = + scope.memberId(req) == null ? items : items.filter((it) => scope.canSeeRunRow(req, it.id)); res.json({ - items: items.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), + items: shown.map((it) => ({ ...it, isLive: liveIds.has(it.id) })), }); });