From 87ba935c6e3723355a1074f2865ca8910cf57835 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 10:43:16 +0700 Subject: [PATCH] feat(server-mode): share a workspace with other users (sharing phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace's owner (or an admin) shares it from its ⋯ menu → Share… (lane_shares, GET/PUT /api/lanes/:id/shares, audited). The users it is shared with see it, its sessions, Changes, history and live updates, join and type into its open terminals, commit and push — every check through scope.js (canSeeLane, sessionWhere, laneWhere, broadcastAudience viewers). Rename, reset, remove, delete and sharing stay the owner's or an admin's (403 ENOTOWNER), and a collaborator starts no AI session there yet (403 ECOLLABSTART; a plain terminal tab is fine). Budgets and Today stay on owned rows only (ownedOnlySessionWhere). Taking a share back sends lane_access to those users and their terminals close at the next attach re-check. Under S3 a collaborator works as the owner's Linux account; the Share dialog says so plainly (docs/plans/workspace-sharing/, R1 option a). --- CLAUDE.md | 2 +- client/src/components/AuthGate.tsx | 4 + client/src/components/WorkspaceTree.tsx | 12 +- client/src/components/lanes/WorkspaceHome.tsx | 11 +- .../src/components/lanes/WorkspaceRowMenu.tsx | 38 ++-- .../components/lanes/WorkspaceShareModal.tsx | 193 +++++++++++++++++ .../__tests__/WorkspaceShareModal.test.tsx | 142 ++++++++++++ client/src/hooks/useLanes.ts | 2 + client/src/i18n/locales/en/lanes.json | 18 ++ client/src/i18n/locales/vi/lanes.json | 18 ++ client/src/lib/api.ts | 11 + client/src/lib/types.ts | 2 + docs/API.md | 17 ++ docs/SERVER-MODE.md | 32 ++- docs/plans/workspace-sharing/README.md | 2 +- server/__tests__/lane-shares.test.js | 203 ++++++++++++++++++ server/__tests__/scope.test.js | 59 +++++ server/db.js | 16 ++ server/lib/auth.js | 4 + server/lib/budgets.js | 5 +- server/lib/lanes.js | 2 + server/lib/scope.js | 112 ++++++++-- server/openapi-extra/lanes-more.js | 37 ++++ server/routes/lanes.js | 120 +++++++++++ server/routes/run.js | 4 + 25 files changed, 1026 insertions(+), 40 deletions(-) create mode 100644 client/src/components/lanes/WorkspaceShareModal.tsx create mode 100644 client/src/components/lanes/__tests__/WorkspaceShareModal.test.tsx create mode 100644 server/__tests__/lane-shares.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 8e70762d..1bd49334 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, 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`), 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. - **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/AuthGate.tsx b/client/src/components/AuthGate.tsx index cfe0a302..f4583660 100644 --- a/client/src/components/AuthGate.tsx +++ b/client/src/components/AuthGate.tsx @@ -72,6 +72,10 @@ export function usePermissions() { // A developer only ever sees their own rows, so everything shown is theirs. canEditLane: (lane?: { owner_id?: number | null } | null) => isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id), + // Owner-only actions on a workspace (rename, reset, remove, share): its + // owner or an admin. A workspace shared with this user is not theirs. + ownsLane: (lane?: { owner_id?: number | null } | null) => + isAdmin || (!!user && lane?.owner_id === user.id), }; } diff --git a/client/src/components/WorkspaceTree.tsx b/client/src/components/WorkspaceTree.tsx index c5c6be98..6ddeb5f5 100644 --- a/client/src/components/WorkspaceTree.tsx +++ b/client/src/components/WorkspaceTree.tsx @@ -90,6 +90,9 @@ import { UserAvatar } from "./users/UserAvatar"; const AddLaneModal = lazy(() => import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal })) ); +const WorkspaceShareModal = lazy(() => + import("./lanes/WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal })) +); const DestructiveLaneModal = lazy(() => import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal })) ); @@ -116,7 +119,9 @@ export function WorkspaceTree() { const selected = selectedFrom(location.pathname, location.search); const { lanes, refresh: refreshLanes } = useLanes(); const { state: auth } = useAuth(); - const { seesAll, canEditLane } = usePermissions(); + const { seesAll, canEditLane, ownsLane } = usePermissions(); + // Sharing a workspace with other users (server mode): the open dialog's lane. + const [sharing, setSharing] = useState(null); const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null; // Server mode staff see every user's workspaces, so the list says whose. const grouped = auth.mode === "server" && seesAll; @@ -375,6 +380,7 @@ export function WorkspaceTree() { const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => { if (action === "rename") setRenamingId(lane.id); + else if (action === "share") setSharing(lane); else if (action === "new-here") openAdd(lane.source_repo || lane.cwd); else if (action === "reset" || action === "remove") setDestructive({ lane, action }); }; @@ -559,7 +565,8 @@ export function WorkspaceTree() { onNewSession={() => select(lane.id)} onReorder={nudge} onMenu={(x, y) => setMenu({ lane, x, y })} - onRenameStart={() => setRenamingId(lane.id)} + // A workspace shared with this user keeps its owner's name. + onRenameStart={() => ownsLane(lane) && setRenamingId(lane.id)} onRenameEnd={(title) => void rename(lane, title)} /> )} @@ -688,6 +695,7 @@ export function WorkspaceTree() { )} + {sharing && setSharing(null)} />} {destructive && ( (null); const [title, setTitle] = useState(null); + const [sharing, setSharing] = useState(false); const recentCtx = useContextMenu(); // A new pane, a pane gone, a session id learned — all move these lane fields. @@ -787,9 +789,16 @@ function HomeBody({ x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)} - onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))} + onAction={(a) => + a === "rename" + ? setTitle(lane.title || "") + : a === "share" + ? setSharing(true) + : onMenuAction(a) + } /> )} + {sharing && setSharing(false)} />} ); } diff --git a/client/src/components/lanes/WorkspaceRowMenu.tsx b/client/src/components/lanes/WorkspaceRowMenu.tsx index 6fe25f92..41614770 100644 --- a/client/src/components/lanes/WorkspaceRowMenu.tsx +++ b/client/src/components/lanes/WorkspaceRowMenu.tsx @@ -2,11 +2,13 @@ * @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at * the pointer (right-click) or under the row's `⋯` button. Every item is an * existing call: Rename (the row's inline field), New workspace here (the - * Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a + * Add modal prefilled), Copy path, Share (server mode: `WorkspaceShareModal`), + * and Reset/Remove through `DestructiveLaneModal` — Reset only on a * managed lane — plus Move to group (the sidebar's own groups, * `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The * server's guards are the real ones; the menu only hides - * what the lane's kind or tool makes meaningless. The shell (position, + * what the lane's kind or tool makes meaningless, and the owner-only items + * (Rename, Share, Reset, Remove) on a workspace shared with this user. The shell (position, * Escape / outside-click close) is `ContextMenu`. * @author Nguyễn Ngọc Trí Vĩ */ @@ -17,8 +19,9 @@ import { Check } from "lucide-react"; import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu"; import type { WorkspaceGroup } from "../../lib/workspaceGroups"; import { copyText } from "../../lib/clipboard"; +import { usePermissions } from "../AuthGate"; -export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove"; +export type WorkspaceMenuAction = "rename" | "new-here" | "share" | "reset" | "remove"; export function WorkspaceRowMenu({ lane, @@ -43,9 +46,11 @@ export function WorkspaceRowMenu({ onGroup?: (target: string | null | "new") => void; }) { const { t } = useTranslation("lanes"); + const { local, ownsLane } = usePermissions(); + const owner = ownsLane(lane); return ( - onAction("rename")}>{t("menu.rename")} + {owner && onAction("rename")}>{t("menu.rename")}} onAction("new-here")}>{t("menu.newHere")} { @@ -55,6 +60,11 @@ export function WorkspaceRowMenu({ > {t("menu.copyPath")} + {owner && !local && ( + onAction("share")}> + {t("menu.share")} + + )} {onGroup && ( <> @@ -84,15 +94,19 @@ export function WorkspaceRowMenu({ )} )} - - {lane.kind === "managed" && ( - onAction("reset")}> - {t("menu.reset")} - + {owner && ( + <> + + {lane.kind === "managed" && ( + onAction("reset")}> + {t("menu.reset")} + + )} + onAction("remove")}> + {t("menu.remove")} + + )} - onAction("remove")}> - {t("menu.remove")} - ); } diff --git a/client/src/components/lanes/WorkspaceShareModal.tsx b/client/src/components/lanes/WorkspaceShareModal.tsx new file mode 100644 index 00000000..d3792241 --- /dev/null +++ b/client/src/components/lanes/WorkspaceShareModal.tsx @@ -0,0 +1,193 @@ +/** + * @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 + * 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`). + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ShieldAlert, X } from "lucide-react"; +import { api } from "../../lib/api"; +import type { Lane } from "../../lib/types"; +import { toast } from "../../lib/toast"; +import { confirmDialog } from "../../lib/confirm"; +import { useDialog } from "../../hooks/useDialog"; +import { Checkbox } from "../Checkbox"; + +type Person = { id: number; username: string; name: string }; + +export function WorkspaceShareModal({ lane, onClose }: { lane: Lane; onClose: () => void }) { + const { t } = useTranslation("lanes"); + const panelRef = useRef(null); + useDialog(true, panelRef, onClose); + const [people, setPeople] = useState(null); + const [initial, setInitial] = useState>(new Set()); + const [picked, setPicked] = useState>(new Set()); + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + Promise.all([api.artifacts.people(), api.lanes.shares(lane.id)]) + .then(([p, s]) => { + if (!alive) return; + // Never the owner: a workspace is not shared with them. + setPeople(p.people.filter((x) => x.id !== lane.owner_id)); + const ids = new Set(s.shares.map((x) => x.id)); + setInitial(ids); + setPicked(new Set(ids)); + }) + .catch((err) => { + if (!alive) return; + toast.error(err); + setPeople([]); + }); + return () => { + alive = false; + }; + }, [lane.id, lane.owner_id]); + + const shown = useMemo(() => { + const q = query.trim().toLowerCase(); + return (people ?? []).filter( + (p) => !q || p.name.toLowerCase().includes(q) || p.username.toLowerCase().includes(q) + ); + }, [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 save = async () => { + if (added.length > 0) { + const names = (people ?? []).filter((p) => added.includes(p.id)).map((p) => p.name); + const ok = await confirmDialog({ + title: t("share.confirmTitle", { name }), + message: t("share.confirmBody", { people: names.join(", ") }), + confirmLabel: t("share.confirm"), + }); + if (!ok) return; + } + setBusy(true); + try { + const r = await api.lanes.setShares(lane.id, [...picked]); + toast.success( + r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone") + ); + onClose(); + } catch (err) { + toast.error(err); + } finally { + setBusy(false); + } + }; + + const toggle = (id: number, on: boolean) => + setPicked((prev) => { + const next = new Set(prev); + if (on) next.add(id); + else next.delete(id); + return next; + }); + + return ( +
+
+
+
+

+ {t("share.title")} +

+

+ {name} +

+
+ +
+ +
+

{t("share.what")}

+
+ + {t("share.warning")} +
+ {people === null ? ( +

{t("share.loading")}

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

{t("share.nobody")}

+ ) : ( + <> + {people.length > 8 && ( + setQuery(e.target.value)} + placeholder={t("share.search")} + aria-label={t("share.search")} + className="input w-full text-sm" + /> + )} +
    + {shown.map((p) => ( +
  • + toggle(p.id, v)} + disabled={busy} + label={ + + {p.name} + {p.name !== p.username && ( + @{p.username} + )} + + } + /> +
  • + ))} +
+ + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/client/src/components/lanes/__tests__/WorkspaceShareModal.test.tsx b/client/src/components/lanes/__tests__/WorkspaceShareModal.test.tsx new file mode 100644 index 00000000..85f41110 --- /dev/null +++ b/client/src/components/lanes/__tests__/WorkspaceShareModal.test.tsx @@ -0,0 +1,142 @@ +/** + * @file Tests for workspace sharing in the client: WorkspaceShareModal starts + * on the current list, never offers the owner, warns what sharing means, asks + * once before adding someone and saves the WHOLE list; WorkspaceRowMenu offers + * Share (server mode) and the other owner-only items only to the workspace's + * owner or an admin. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import "../../../i18n"; +import i18n from "../../../i18n"; +import { settleConfirm } from "../../../lib/confirm"; +import { WorkspaceShareModal } from "../WorkspaceShareModal"; +import { WorkspaceRowMenu } from "../WorkspaceRowMenu"; +import type { Lane } from "../../../lib/types"; + +const perms = vi.hoisted(() => ({ local: false, me: 1, admin: false })); +vi.mock("../../AuthGate", () => ({ + usePermissions: () => ({ + local: perms.local, + isAdmin: perms.admin, + ownsLane: (l?: { owner_id?: number | null } | null) => + perms.local || perms.admin || l?.owner_id === perms.me, + }), +})); + +const m = vi.hoisted(() => ({ + people: vi.fn(), + shares: vi.fn(), + setShares: vi.fn(), +})); +vi.mock("../../../lib/api", () => ({ + api: { + artifacts: { people: m.people }, + lanes: { shares: m.shares, setShares: m.setShares }, + }, +})); + +const lane = { + id: 7, + title: "Shop", + cwd: "/home/alice/shop", + owner_id: 1, + kind: "adopted", +} as Lane; + +async function settle() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +beforeEach(() => { + i18n.changeLanguage("en"); + perms.local = false; + perms.me = 1; + perms.admin = false; + m.people.mockResolvedValue({ + people: [ + { id: 1, username: "alice", name: "Alice" }, + { id: 2, username: "bob", name: "Bob" }, + { id: 3, username: "carol", name: "Carol" }, + ], + }); + m.shares.mockResolvedValue({ + shares: [{ id: 2, username: "bob", name: "Bob", sharedAt: "2026-11-06T00:00:00Z" }], + canManage: true, + }); + m.setShares.mockImplementation(async (_id: number, ids: number[]) => ({ + shares: ids.map((id) => ({ id, username: `u${id}`, name: `U${id}`, sharedAt: "" })), + })); +}); + +describe("WorkspaceShareModal", () => { + it("starts on the current list, never offers the owner and says what sharing means", async () => { + render( {}} />); + await settle(); + const list = screen.getByTestId("workspace-share-people"); + expect(list).not.toHaveTextContent("Alice"); + expect(screen.getByRole("checkbox", { name: /Bob/ })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("checkbox", { name: /Carol/ })).toHaveAttribute( + "aria-checked", + "false" + ); + expect(screen.getByTestId("workspace-share-warning")).toHaveTextContent(/your home folder/i); + // Nothing changed yet: nothing to save. + expect(screen.getByTestId("workspace-share-save")).toBeDisabled(); + }); + + it("asks once before adding someone, then saves the whole list", async () => { + const onClose = vi.fn(); + render(); + await settle(); + fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ })); + fireEvent.click(screen.getByTestId("workspace-share-save")); + await settle(); + expect(m.setShares).not.toHaveBeenCalled(); + await act(async () => settleConfirm(true)); + await settle(); + expect(m.setShares).toHaveBeenCalledWith(7, [2, 3]); + expect(onClose).toHaveBeenCalled(); + }); + + it("takes someone off without asking", async () => { + render( {}} />); + await settle(); + fireEvent.click(screen.getByRole("checkbox", { name: /Bob/ })); + fireEvent.click(screen.getByTestId("workspace-share-save")); + await settle(); + expect(m.setShares).toHaveBeenCalledWith(7, []); + }); +}); + +describe("WorkspaceRowMenu on a shared workspace", () => { + const open = () => + render( {}} onAction={() => {}} />); + + it("offers its owner Share, Rename and Delete", () => { + open(); + expect(screen.getByTestId("workspace-menu-share")).toBeInTheDocument(); + expect(screen.getByText("Rename")).toBeInTheDocument(); + expect(screen.getByText("Delete workspace…")).toBeInTheDocument(); + }); + + it("offers a user it is shared with none of the owner-only items", () => { + perms.me = 2; + open(); + expect(screen.queryByTestId("workspace-menu-share")).toBeNull(); + expect(screen.queryByText("Rename")).toBeNull(); + expect(screen.queryByText("Delete workspace…")).toBeNull(); + expect(screen.getByText("Copy path")).toBeInTheDocument(); + }); + + it("has no Share in local mode", () => { + perms.local = true; + open(); + expect(screen.queryByTestId("workspace-menu-share")).toBeNull(); + expect(screen.getByText("Rename")).toBeInTheDocument(); + }); +}); diff --git a/client/src/hooks/useLanes.ts b/client/src/hooks/useLanes.ts index 5b681898..bedd1b39 100644 --- a/client/src/hooks/useLanes.ts +++ b/client/src/hooks/useLanes.ts @@ -53,6 +53,8 @@ function load(): Promise { } function onMessage(msg: WSMessage) { + // A workspace shared with this user, or taken back: re-read the list. + if (msg.type === "lane_access") return void load(); if (msg.type !== "lane_update") return; const payload = msg.data as { lane?: Lane; removed?: number }; const lane = payload.lane; diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 6d3e92ec..cae86efd 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -386,6 +386,24 @@ "menu.copyPath": "Copy path", "menu.reset": "Reset worktree…", "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.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.", + "share.search": "Search people", + "share.cancel": "Cancel", + "share.close": "Close", + "share.save": "Save", + "share.saving": "Saving…", + "share.confirmTitle": "Share \"{{name}}\"?", + "share.confirmBody": "{{people}} will work in it as your account and can read everything in your home folder.", + "share.confirm": "Share", + "share.saved_one": "Shared with {{count}} person", + "share.saved_other": "Shared with {{count}} people", + "share.savedNone": "No longer shared with anyone", + "shared.badge": "Shared with you", "panel.changes": "Changes", "panel.analyst": "Analyst", "panel.preview": "Preview", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 75c4e097..72722f3f 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -386,6 +386,24 @@ "menu.copyPath": "Sao chép đường dẫn", "menu.reset": "Đặt lại worktree…", "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.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ẻ.", + "share.search": "Tìm người", + "share.cancel": "Huỷ", + "share.close": "Đóng", + "share.save": "Lưu", + "share.saving": "Đang lưu…", + "share.confirmTitle": "Chia sẻ \"{{name}}\"?", + "share.confirmBody": "{{people}} sẽ làm việc trong đó bằng tài khoản của bạn và đọc được mọi thứ trong thư mục home của bạn.", + "share.confirm": "Chia sẻ", + "share.saved_one": "Đã chia sẻ với {{count}} người", + "share.saved_other": "Đã chia sẻ với {{count}} người", + "share.savedNone": "Không còn chia sẻ với ai", + "shared.badge": "Được chia sẻ với bạn", "panel.changes": "Thay đổi", "panel.analyst": "Analyst", "panel.preview": "Xem trang", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 9bfa1b8d..157cc4bd 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -940,6 +940,8 @@ export type PoolProfile = { subscriptionType: string | null; active: boolean; }; +/** A user a workspace is shared with (`/api/lanes/:id/shares`) — names only. */ +export type LaneShare = { id: number; username: string; name: string; sharedAt: string }; /** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */ export type ToolProfileIdentity = { /** codex: `chatgpt` | `apikey`; omp: `credentials`. */ @@ -5095,6 +5097,15 @@ export const api = { }), /** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an * uploaded file's AgentHub copy is deleted. */ + /** GET /api/lanes/:id/shares — the users a workspace is shared with (server mode). */ + shares: (id: number) => + request<{ shares: LaneShare[]; canManage: boolean }>(`/workspaces/${id}/shares`), + /** PUT /api/lanes/:id/shares — the WHOLE list (owner or admin; `ENOTOWNER` else). */ + setShares: (id: number, userIds: number[]) => + request<{ shares: LaneShare[] }>(`/workspaces/${id}/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 02cfb7af..d3e5a4fb 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1942,6 +1942,8 @@ export interface WSMessage { | "remote_source.status" | "remote_data.updated" | "lane_update" + /** A workspace was shared with / taken back from the users named (`userIds`). */ + | "lane_access" /** One output line from a lane's profile hook, while it runs. */ | "lane_hook_output" /** A lane's stack finished coming up (or failed to), with fresh facts. */ diff --git a/docs/API.md b/docs/API.md index aea8f0f5..e8cbea8f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2098,6 +2098,23 @@ POST /api/locks/:name/release - **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows. - **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held. +#### Sharing a workspace (server mode) + +```http +GET /api/lanes/:id/shares → { shares: [{id, username, name, sharedAt}], canManage } +PUT /api/lanes/:id/shares {"userIds": [2, 5]} → { shares } +``` + +The users a workspace is shared with (`lane_shares`, names only). `PUT` takes the +WHOLE list — at most 200 enabled users, never the owner (`400 EBADUSER`) — from the +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 `POST /api/run` `403 ECOLLABSTART`. Users taken off +get the WebSocket message `lane_access` `{laneId, userIds}` (sent to them only). See +`docs/SERVER-MODE.md` → Sharing a workspace. + #### Workspace context: instructions, folders, context files, memory ```http diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index c96729cf..e809da87 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -152,7 +152,8 @@ Workspaces made before server mode, or by the host account, have none. - A session belongs to the workspace that owns it: `sessions.lane_id`, or the workspace whose pane holds it. Its agents, events, tokens, cost, alerts, snapshots and workflow runs belong to that session. -- A **developer** sees only rows of their own workspaces, everywhere: lists, +- A **developer** sees only rows of their own workspaces (and of workspaces + shared with them — see [Sharing a workspace](#sharing-a-workspace)), everywhere: lists, counts, charts, cost, the Sessions/Events/Agents tables, exports, live WebSocket updates and terminals. A request naming someone else's row (in the path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row @@ -235,6 +236,35 @@ hides them for developers. The command and skill lists show a developer only the own workspaces' project items: `?cwd=` outside one of them answers `403 EFOLDER`, and with no `cwd` they list no project items at all. +### Sharing a workspace + +A workspace's owner (or an admin) shares it with other users from its `⋯` +menu → **Share…** (`WorkspaceShareModal`, `GET/PUT /api/lanes/:id/shares`, +table `lane_shares`). There is one level: **collaborate**. A user it is shared +with sees the workspace, its sessions, Changes, history and Files like its +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`). + +**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 +other workspaces, SSH keys, AI logins, the pane tokens of the owner's panes) +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. + +Every share and unshare is audited (`share workspace ` / +`unshare workspace `). Taking it back sends the users taken off +`lane_access` (their list re-reads) and closes their open terminals on it at +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. + ### 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 9f58ba19..0e314f08 100644 --- a/docs/plans/workspace-sharing/README.md +++ b/docs/plans/workspace-sharing/README.md @@ -1,6 +1,6 @@ # Workspace & session sharing — plan -Status: **self-reviewed 2026-11-06 — BLOCKED on finding R1 (below)**. Nothing here is built. +Status: **phase 1 built 2026-11-06** (workspace sharing; R1 decided as option **(a)** — accept and warn hard). Phases 2–4 not built. Server mode only. In local mode there are no users, so there is nothing to share and every piece below is a no-op (`isServerMode()`). diff --git a/server/__tests__/lane-shares.test.js b/server/__tests__/lane-shares.test.js new file mode 100644 index 00000000..9235dadc --- /dev/null +++ b/server/__tests__/lane-shares.test.js @@ -0,0 +1,203 @@ +/** + * @file lane-shares.test.js + * @description Sharing a workspace with other users (server mode, + * 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`); a user it is not shared with still gets + * 404; taking it back ends their access at once. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +const TEST_DB = path.join(os.tmpdir(), `lane-shares-${Date.now()}-${process.pid}.db`); +process.env.DASHBOARD_DB_PATH = TEST_DB; +process.env.DASHBOARD_LIVENESS_PROBE = "0"; + +const { createApp, startServer } = require("../index"); +const { db } = require("../db"); +const auth = require("../lib/auth"); + +let server; +let BASE; +const cookies = {}; +const users = {}; +let laneA; + +async function call(method, p, { as, body } = {}) { + const res = await fetch(BASE + p, { + method, + headers: { + "Content-Type": "application/json", + Origin: BASE, + ...(as ? { cookie: cookies[as] } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, body: await res.json().catch(() => null) }; +} + +before(async () => { + server = await startServer(createApp(), 0); + BASE = `http://127.0.0.1:${server.address().port}`; + users.alice = await auth.createUser("alice", "alice-pass-1", "developer"); + users.bob = await auth.createUser("bob", "bob-pass-1", "developer"); + users.carol = await auth.createUser("carol", "carol-pass-1", "developer"); + users.boss = await auth.createUser("boss", "admin-pass-1", "admin"); + laneA = Number( + db + .prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice', 'claude', ?)") + .run(users.alice.id).lastInsertRowid + ); + db.prepare( + "INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sA', 'alice one', 'active', '/w/alice', ?)" + ).run(laneA); + process.env.AGENTHUB_MODE = "server"; + for (const [name, pass] of [ + ["alice", "alice-pass-1"], + ["bob", "bob-pass-1"], + ["carol", "carol-pass-1"], + ["boss", "admin-pass-1"], + ]) { + const r = await fetch(BASE + "/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: name, password: pass }), + }); + assert.equal(r.status, 200); + cookies[name] = r.headers.get("set-cookie").split(";")[0]; + } +}); + +after(() => { + delete process.env.AGENTHUB_MODE; + server?.close(); + for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) { + fs.rmSync(f, { force: true }); + } +}); + +const laneIds = async (as) => + (await call("GET", "/api/lanes", { as })).body.lanes.map((l) => l.id); + +describe("sharing a workspace", () => { + it("is managed only by its owner (or an admin), for enabled users other than the owner", async () => { + assert.equal((await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" })).status, 404); + const put = (as, userIds) => + call("PUT", `/api/lanes/${laneA}/shares`, { as, body: { userIds } }); + assert.equal((await put("bob", [users.bob.id])).status, 404); + assert.equal((await put("alice", [users.alice.id])).body.error.code, "EBADUSER"); + assert.equal((await put("alice", [9999])).body.error.code, "EBADUSER"); + assert.equal((await put("alice", "bob")).body.error.code, "EBADUSER"); + + const r = await put("alice", [users.bob.id]); + assert.equal(r.status, 200); + assert.deepEqual( + r.body.shares.map((u) => u.username), + ["bob"] + ); + const audit = db + .prepare("SELECT user_id, owner_id, path FROM audit_log WHERE path LIKE 'share workspace%'") + .all(); + assert.deepEqual(audit, [ + { user_id: users.alice.id, owner_id: users.bob.id, path: `share workspace ${laneA}` }, + ]); + }); + + it("shows the workspace and its sessions to the user it is shared with — and nobody else", async () => { + assert.ok((await laneIds("bob")).includes(laneA)); + assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 200); + const shares = await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" }); + assert.equal(shares.status, 200); + assert.equal(shares.body.canManage, false); + assert.ok(!(await laneIds("carol")).includes(laneA)); + assert.equal((await call("GET", "/api/sessions/sA", { as: "carol" })).status, 404); + }); + + it("keeps rename, remove, delete and sharing the owner's", async () => { + const code = (r) => r.body?.error?.code; + assert.equal( + code(await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { title: "mine" } })), + "ENOTOWNER" + ); + assert.equal( + code(await call("POST", `/api/lanes/${laneA}/remove`, { as: "bob", body: { confirm: true } })), + "ENOTOWNER" + ); + assert.equal(code(await call("DELETE", `/api/lanes/${laneA}`, { as: "bob" })), "ENOTOWNER"); + assert.equal( + code( + await call("PUT", `/api/lanes/${laneA}/shares`, { + as: "bob", + body: { userIds: [users.bob.id, users.carol.id] }, + }) + ), + "ENOTOWNER" + ); + // Notes are not owner-only: a collaborator writes them like the owner. + const notes = await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { notes: "hi" } }); + assert.equal(notes.status, 200); + // The owner and an admin still rename it. + assert.equal( + (await call("PATCH", `/api/lanes/${laneA}`, { as: "alice", body: { title: "A" } })).status, + 200 + ); + assert.equal( + (await call("PATCH", `/api/lanes/${laneA}`, { as: "boss", body: { title: "B" } })).status, + 200 + ); + }); + + 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" }, + }); + 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"); + }); + + it("ends their access as soon as it is taken back, and says so in the audit log", async () => { + const r = await call("PUT", `/api/lanes/${laneA}/shares`, { + as: "alice", + body: { userIds: [] }, + }); + assert.equal(r.status, 200); + assert.deepEqual(r.body.shares, []); + assert.ok(!(await laneIds("bob")).includes(laneA)); + assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 404); + assert.equal( + db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = ?").get(`unshare workspace ${laneA}`) + .n, + 1 + ); + }); + + it("goes with the workspace when it is deleted", async () => { + await call("PUT", `/api/lanes/${laneA}/shares`, { + as: "alice", + body: { userIds: [users.carol.id] }, + }); + assert.equal( + db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n, + 1 + ); + assert.equal((await call("DELETE", `/api/lanes/${laneA}`, { as: "alice" })).status, 200); + assert.equal( + db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n, + 0 + ); + }); +}); diff --git a/server/__tests__/scope.test.js b/server/__tests__/scope.test.js index 2374f2e1..7e90ab78 100644 --- a/server/__tests__/scope.test.js +++ b/server/__tests__/scope.test.js @@ -308,3 +308,62 @@ describe("scopeGuard against the router's own leniency", () => { assert.equal(run({ user: m1, path: "/lanes/pipeline-stats" }), 200); }); }); + +describe("a workspace shared with another user (lane_shares)", () => { + // Lane 2 (user 2's, session s2) shared with user 1; taken back afterwards so + // the suites above keep their own picture. + before(() => { + db.prepare("INSERT INTO lane_shares (lane_id, user_id, granted_by) VALUES (2, 1, 2)").run(); + }); + after(() => db.prepare("DELETE FROM lane_shares").run()); + + it("lets the user it is shared with see it, its sessions and its runs", () => { + assert.equal(scope.canSeeLane(member(1), 2), true); + assert.equal(scope.canSeeSession(member(1), "s2"), true); + assert.equal(scope.canSeeRun({ ...member(1), method: "POST" }, "agenthub-lane-2-1"), true); + assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1", "s2"]); + assert.deepEqual(scope.visibleLaneIds(member(1)).sort(), [1, 2]); + const w = scope.laneWhere(member(1)); + assert.deepEqual( + db.prepare(`SELECT id FROM lanes WHERE ${w.where} ORDER BY id`).all(...w.params).map((r) => r.id), + [1, 2] + ); + // Nobody else gained anything. + assert.equal(scope.canSeeLane(member(3), 2), false); + assert.deepEqual(visibleSessions(member(3)), []); + }); + + it("keeps it out of the user's OWN money (budgets, Today)", () => { + const own = (w) => + db.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`).all(...w.params).map((r) => r.id); + assert.deepEqual(own(scope.ownedOnlySessionWhere(member(1))), ["s-unstamped", "s1"]); + assert.deepEqual(own(scope.ownSessionWhere(member(1))), ["s-unstamped", "s1"]); + }); + + it("leaves owner-only actions to the owner and admins", () => { + assert.equal(scope.isLaneOwner(member(1), 2), false); + assert.equal(scope.isLaneOwner(member(2), 2), true); + assert.equal(scope.isLaneOwner(admin, 2), true); + assert.equal(scope.isLaneOwner(local, 2), true); + assert.equal(scope.isLaneOwner({ user: { id: 1, role: "analyst" } }, 2), false); + }); + + it("sends its news to the user it is shared with, without making it theirs", () => { + const a = scope.broadcastAudience("lane_update", { lane: { id: 2 } }); + assert.equal(scope.audienceIncludes(a, member(1).user), true); + assert.equal(a.owners.has(1), false); + const s = scope.broadcastAudience("session_updated", { id: "s2" }); + assert.equal(scope.audienceIncludes(s, member(1).user), true); + assert.equal(s.owners.has(1), false); + assert.equal(scope.audienceIncludes(s, member(3).user), false); + // Taken back: only the users named hear about it. + assert.deepEqual([...scope.exactAudience("lane_access", { laneId: 2, userIds: [1] })], [1]); + }); + + it("stops at once when it is taken back", () => { + db.prepare("DELETE FROM lane_shares WHERE lane_id = 2 AND user_id = 1").run(); + assert.equal(scope.canSeeLane(member(1), 2), false); + assert.equal(scope.canSeeRun({ ...member(1), method: "POST" }, "agenthub-lane-2-1"), false); + assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1"]); + }); +}); diff --git a/server/db.js b/server/db.js index 56f16d4a..29629ce9 100644 --- a/server/db.js +++ b/server/db.js @@ -998,6 +998,22 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_artifact_shares_user ON artifact_shares(user_id); `); +// A workspace shared with another user (docs/plans/workspace-sharing/, phase 1): +// they see it and work in it like its owner, except owner-only actions +// (rename, reset, remove, sharing). No foreign keys, like artifact_shares — a +// table rebuild of lanes/users must not cascade them away; deleteLane and +// deleteUser remove the rows themselves. +db.exec(` + CREATE TABLE IF NOT EXISTS lane_shares ( + lane_id INTEGER 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 (lane_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_lane_shares_user ON lane_shares(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/auth.js b/server/lib/auth.js index c9f4abfd..8ee9bcab 100644 --- a/server/lib/auth.js +++ b/server/lib/auth.js @@ -517,6 +517,8 @@ function deleteUser(username) { const lanesLib = require("./lanes"); for (const lane of lanes) lanesLib.deleteLane(lane.id); 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); require("./avatars").removeAvatar(u.id); try { sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id); @@ -545,6 +547,8 @@ function assignWorkspace(laneId, username) { if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id); const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId)); 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); } /** Take a workspace back from the user who owns it: it is left with no owner diff --git a/server/lib/budgets.js b/server/lib/budgets.js index 235bd085..78d8783e 100644 --- a/server/lib/budgets.js +++ b/server/lib/budgets.js @@ -12,7 +12,7 @@ */ const { stmts } = require("../db"); -const { memberId, sessionWhere } = require("./scope"); +const { memberId, ownedOnlySessionWhere } = require("./scope"); const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing"); const PERIODS = ["day", "week", "month"]; @@ -72,7 +72,8 @@ function budgetSpend(cfg, { now = Date.now(), back = 0, rules, req } = {}) { // skipped before their hours are read. const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }]; if (memberId(req) != null) { - const own = sessionWhere(req, "s.id", "s.lane_id"); + // 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; diff --git a/server/lib/lanes.js b/server/lib/lanes.js index 7956e081..5ab02975 100644 --- a/server/lib/lanes.js +++ b/server/lib/lanes.js @@ -331,6 +331,8 @@ 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); // 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 88db7fc7..b8696ecf 100644 --- a/server/lib/scope.js +++ b/server/lib/scope.js @@ -3,7 +3,10 @@ * @description Server mode's row ownership (phase S2, docs/SERVER-MODE.md) — * the ONE place that decides which rows a request may see. * - * A workspace (`lanes.owner_id`) belongs to the user who created it. A session + * A workspace (`lanes.owner_id`) belongs to the user who created it, and may be + * SHARED with other users (`lane_shares`): they see it and work in it like its + * owner — visibility here — while owner-only actions check `isLaneOwner`, and + * personal figures (Today, budgets) stay on owned rows only. A session * belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of * that workspace holding it (a session not stamped yet). A session no workspace owns * belongs to the user of the Linux account it ran as (`sessions.account`, @@ -41,7 +44,16 @@ const ownerFor = (req) => (req?.user ? req.user.id : null); * unrestricted. `col` is the session id expression, `laneCol` its lane id. */ function sessionWhere(req, col = "id", laneCol = "lane_id") { - return ownedSessionWhere(memberId(req), col, laneCol); + return ownedSessionWhere(memberId(req), col, laneCol, true); +} + +/** + * `sessionWhere` without shared workspaces: what this request's user OWNS — + * for their own money (budgets), never another user's spend in a workspace + * shared with them. "1" when unrestricted, like `sessionWhere`. + */ +function ownedOnlySessionWhere(req, col = "id", laneCol = "lane_id") { + return ownedSessionWhere(memberId(req), col, laneCol, false); } /** @@ -53,15 +65,20 @@ function ownSessionWhere(req, col = "id", laneCol = "lane_id") { return ownedSessionWhere(req?.user ? req.user.id : null, col, laneCol); } -function ownedSessionWhere(uid, col, laneCol) { +// 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 = ?"; + +function ownedSessionWhere(uid, col, laneCol, shared = false) { if (uid == null) return { where: "1", params: [] }; + const lanes = shared ? MY_LANES : "SELECT id FROM lanes WHERE owner_id = ?"; + const lp = shared ? [uid, uid] : [uid]; return { - where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?) - OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id - WHERE l.owner_id = ? AND r.session_id IS NOT NULL) + where: `(${laneCol} IN (${lanes}) + 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: [uid, uid, uid, uid], + params: [...lp, ...lp, uid, uid], }; } @@ -77,7 +94,27 @@ 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 (SELECT id FROM lanes WHERE owner_id = ?)`, params: [uid] }; + return { where: `${col} IN (${MY_LANES})`, params: [uid, uid] }; +} + +/** Whether `userId` is one of the users workspace `laneId` is shared with. */ +function sharedWith(laneId, userId) { + if (laneId == null || userId == null) return false; + return Boolean( + sql("SELECT 1 FROM lane_shares WHERE lane_id = ? AND user_id = ?").get(Number(laneId), userId) + ); +} + +/** + * Owner-only actions on a workspace (rename, reset, remove, sharing): its + * owner or an admin; always true in local mode. A user it is shared with may + * do everything else, which `canSeeLane` decides. + */ +function isLaneOwner(req, laneId) { + const u = req?.user; + if (!u || u.role === "admin") return true; + const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId)); + return Boolean(row) && row.owner_id === u.id; } function canSeeLane(req, laneId) { @@ -87,8 +124,8 @@ function canSeeLane(req, laneId) { // 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 owner_id FROM lanes WHERE id = ?").get(v); - return Boolean(row) && row.owner_id === uid; + const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v); + return Boolean(row) && (row.owner_id === uid || sharedWith(row.id, uid)); } function canSeeSession(req, sessionId) { @@ -118,8 +155,8 @@ function canSeeRun(req, runId) { function visibleLaneIds(req) { const uid = memberId(req); if (uid == null) return null; - return sql("SELECT id FROM lanes WHERE owner_id = ?") - .all(uid) + return sql(MY_LANES) + .all(uid, uid) .map((r) => r.id); } @@ -292,6 +329,30 @@ 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) { + if (laneId == null) return new Set(); + return new Set( + sql("SELECT user_id FROM lane_shares WHERE lane_id = ?") + .all(Number(laneId)) + .map((r) => r.user_id) + ); +} + +/** The users the workspace holding a session is shared with. */ +function sessionViewers(sessionId) { + if (!sessionId) return new Set(); + return new Set( + 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)` + ) + .all(String(sessionId), String(sessionId)) + .map((r) => r.user_id) + ); +} + /** * Who a broadcast may reach: `null` = every client, `{owners: Set}` = * admin/analyst plus those members, `{staffOnly: true}` = admin/analyst only. @@ -303,25 +364,27 @@ function broadcastAudience(type, data) { switch (type) { case "session_created": case "session_updated": - return { owners: sessionOwners(d.id) }; + return { owners: sessionOwners(d.id), viewers: sessionViewers(d.id) }; case "agent_created": case "agent_updated": case "new_event": case "workflow_upserted": case "transcript_changed": - return { owners: sessionOwners(d.session_id) }; + return { owners: sessionOwners(d.session_id), viewers: sessionViewers(d.session_id) }; case "alert_triggered": case "alert_updated": if (d.acked_all) return null; - return d.session_id ? { owners: sessionOwners(d.session_id) } : STAFF; + return d.session_id + ? { owners: sessionOwners(d.session_id), viewers: sessionViewers(d.session_id) } + : STAFF; 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) }; + return { owners: laneOwners(d.lane?.id), viewers: laneViewers(d.lane?.id) }; case "lane_hook_output": case "lane_hook_result": case "lane_runtime": - return { owners: laneOwners(d.laneId) }; + return { owners: laneOwners(d.laneId), viewers: laneViewers(d.laneId) }; // A shared artifact is everyone's news; a private one, its owner's and // the people it is shared with by name. case "artifact_changed": @@ -359,6 +422,8 @@ function broadcastAudience(type, data) { function exactAudience(type, data) { const d = data && typeof data === "object" ? data : {}; if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]); + // A workspace no longer shared with these users: only they re-read their list. + if (type === "lane_access" && Array.isArray(d.userIds)) return new Set(d.userIds); return null; } @@ -366,7 +431,10 @@ function exactAudience(type, data) { function audienceIncludes(audience, user) { if (!user || user.role !== "developer") return true; if (audience === null || audience.strip) return true; - return Boolean(audience.owners && audience.owners.has(user.id)); + return Boolean( + (audience.owners && audience.owners.has(user.id)) || + (audience.viewers && audience.viewers.has(user.id)) + ); } /** @@ -437,8 +505,8 @@ function insideOwnWorkspace(req, dir) { const u = req?.user; if (!u || u.role === "admin") return true; return insideLanes( - sql("SELECT id FROM lanes WHERE owner_id = ?") - .all(u.id) + sql(MY_LANES) + .all(u.id, u.id) .map((r) => r.id), dir ); @@ -494,6 +562,10 @@ module.exports = { ownerFor, sessionWhere, ownSessionWhere, + ownedOnlySessionWhere, + isLaneOwner, + sharedWith, + laneViewers, bySessionWhere, laneWhere, canSeeLane, diff --git a/server/openapi-extra/lanes-more.js b/server/openapi-extra/lanes-more.js index 4fbdf21f..79d1ed36 100644 --- a/server/openapi-extra/lanes-more.js +++ b/server/openapi-extra/lanes-more.js @@ -701,6 +701,43 @@ const paths = { }, }, }, + "/api/lanes/{id}/shares": { + get: { + tags: ["Lanes"], + summary: "The users a workspace is shared with", + description: + "Server mode (local mode: `{shares: [], canManage: false}`). `{shares:[{id, username, name, sharedAt}], canManage}` — names only; `canManage` is whether this caller (the owner or an admin) may change the list. A user it is shared with may read it too.", + operationId: "getLaneShares", + parameters: [idParam], + responses: { 200: { description: "`{shares, canManage}`.", ...json(obj) }, 404: notFound }, + }, + put: { + 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.", + operationId: "setLaneShares", + parameters: [idParam], + 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 4e83c50f..93ba7ba5 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -426,6 +426,23 @@ 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({ + error: { code: "ENOTOWNER", message: "only the workspace's owner (or an admin) may do this" }, + }); +} + /** Server mode: the folder is, holds or lies in another user's workspace. */ function refuseOwned(res) { return res.status(409).json({ @@ -1356,6 +1373,8 @@ router.patch("/:id", sameOriginGuard, (req, res) => { // that tie a workspace to a pane or a session (`run_id` pointed at another // user's pane would let stop/reset kill it). const body = req.body && typeof req.body === "object" ? req.body : {}; + // A workspace's name is its owner's: a user it is shared with renames nothing. + if ("title" in body && !scope.isLaneOwner(req, req.params.id)) return notOwner(res); if (scope.memberId(req) != null) { const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k)); if (off.length) { @@ -1386,6 +1405,95 @@ router.patch("/:id", sameOriginGuard, (req, res) => { res.json({ lane: payload(lane) }); }); +// ── Sharing a workspace with other users (server mode) ───────────────────── +// docs/plans/workspace-sharing/: a user it is shared with sees it and works in +// it like its owner — under S3 AS the owner's Linux account — except the +// owner-only actions (`scope.isLaneOwner`). Names only, never a password. + +const MAX_SHARES = 200; + +function laneShares(laneId) { + return require("../db") + .db.prepare( + `SELECT u.id, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) AS name, + s.shared_at AS sharedAt + FROM lane_shares s JOIN users u ON u.id = s.user_id + WHERE s.lane_id = ? ORDER BY name COLLATE NOCASE` + ) + .all(Number(laneId)); +} + +router.get("/:id/shares", (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) + return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); + if (!require("../lib/auth").isServerMode()) return res.json({ shares: [], canManage: false }); + res.json({ shares: laneShares(lane.id), canManage: scope.isLaneOwner(req, lane.id) }); +}); + +/** `{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 user = db.prepare("SELECT id, disabled FROM users WHERE id = ?"); + for (const id of wanted) { + const u = user.get(id); + if (!u || u.disabled) { + 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 before = new Set(laneShares(lane.id).map((u) => u.id)); + 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) }); +}); + router.post("/:id/stage", sameOriginGuard, (req, res) => { const current = lanesLib.getLane(req.params.id); if (!current) { @@ -3240,6 +3348,13 @@ 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); + } if (DESTRUCTIVE_ACTIONS.has(action)) { if (body.confirm !== true) { @@ -3603,6 +3718,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => { }); router.delete("/:id", sameOriginGuard, (req, res) => { + if (lanesLib.getLane(req.params.id) && !scope.isLaneOwner(req, req.params.id)) { + return notOwner(res); + } const ok = lanesLib.deleteLane(req.params.id); if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); @@ -3613,4 +3731,6 @@ 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/run.js b/server/routes/run.js index a49b8b4a..4ab06de3 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -618,6 +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). + if (lanesLib.getLane(laneId) && !scope.isLaneOwner(req, laneId) && body.shell !== true) { + return require("./lanes").collabStart(res); + } let cwd; try { // A member's run with no cwd starts in its workspace, not the server's folder.