feat(server-mode): share a workspace with other users (sharing phase 1)

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).
This commit is contained in:
2026-11-06 10:43:16 +07:00
parent 045e46e111
commit 87ba935c6e
25 changed files with 1026 additions and 40 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -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),
};
}
+10 -2
View File
@@ -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<Lane | null>(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() {
</ContextMenu>
)}
<Suspense fallback={null}>
{sharing && <WorkspaceShareModal lane={sharing} onClose={() => setSharing(null)} />}
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
+10 -1
View File
@@ -57,6 +57,7 @@ import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { WorkspaceShareModal } from "./WorkspaceShareModal";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { menuPoint, useContextMenu } from "../ContextMenu";
@@ -195,6 +196,7 @@ function HomeBody({
const [nameDraft, setNameDraft] = useState("");
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const [sharing, setSharing] = useState(false);
const recentCtx = useContextMenu<Session>();
// 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 && <WorkspaceShareModal lane={lane} onClose={() => setSharing(false)} />}
</div>
);
}
@@ -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ĩ <vinnt@smartgift.vn>
*/
@@ -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 (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
{owner && <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>}
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem
onSelect={() => {
@@ -55,6 +60,11 @@ export function WorkspaceRowMenu({
>
{t("menu.copyPath")}
</MenuItem>
{owner && !local && (
<MenuItem testId="workspace-menu-share" onSelect={() => onAction("share")}>
{t("menu.share")}
</MenuItem>
)}
{onGroup && (
<>
<MenuSeparator />
@@ -84,15 +94,19 @@ export function WorkspaceRowMenu({
)}
</>
)}
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
{t("menu.reset")}
</MenuItem>
{owner && (
<>
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
{t("menu.reset")}
</MenuItem>
)}
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
</>
)}
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
</ContextMenu>
);
}
@@ -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ĩ <vinnt@smartgift.vn>
*/
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<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const [people, setPeople] = useState<Person[] | null>(null);
const [initial, setInitial] = useState<Set<number>>(new Set());
const [picked, setPicked] = useState<Set<number>>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="workspace-share-title"
data-testid="workspace-share-modal"
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
>
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h2 id="workspace-share-title" className="text-base font-semibold text-fg-primary">
{t("share.title")}
</h2>
<p className="mt-0.5 truncate text-xs text-fg-muted" title={lane.cwd}>
{name}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("share.close")}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
<p className="text-xs text-fg-secondary">{t("share.what")}</p>
<div
data-testid="workspace-share-warning"
className="flex gap-2 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
>
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("share.warning")}</span>
</div>
{people === null ? (
<p className="text-xs text-fg-muted">{t("share.loading")}</p>
) : people.length === 0 ? (
<p className="text-xs text-fg-muted">{t("share.nobody")}</p>
) : (
<>
{people.length > 8 && (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("share.search")}
aria-label={t("share.search")}
className="input w-full text-sm"
/>
)}
<ul className="space-y-1" data-testid="workspace-share-people">
{shown.map((p) => (
<li key={p.id}>
<Checkbox
checked={picked.has(p.id)}
onChange={(v) => toggle(p.id, v)}
disabled={busy}
label={
<span>
{p.name}
{p.name !== p.username && (
<span className="ml-1.5 text-fg-muted">@{p.username}</span>
)}
</span>
}
/>
</li>
))}
</ul>
</>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("share.cancel")}
</button>
<button
type="button"
className="btn-primary text-sm"
data-testid="workspace-share-save"
onClick={() => void save()}
disabled={busy || !changed || people === null}
>
{busy ? t("share.saving") : t("share.save")}
</button>
</div>
</div>
</div>
);
}
@@ -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ĩ <vinnt@smartgift.vn>
*/
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(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
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(<WorkspaceShareModal lane={lane} onClose={onClose} />);
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(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
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(<WorkspaceRowMenu lane={lane} x={0} y={0} onClose={() => {}} 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();
});
});
+2
View File
@@ -53,6 +53,8 @@ function load(): Promise<void> {
}
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;
+18
View File
@@ -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",
+18
View File
@@ -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",
+11
View File
@@ -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<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
+2
View File
@@ -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. */
+17
View File
@@ -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
+31 -1
View File
@@ -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 <id>` /
`unshare workspace <id>`). 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:
+1 -1
View File
@@ -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()`).
+203
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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
);
});
});
+59
View File
@@ -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"]);
});
});
+16
View File
@@ -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.
+4
View File
@@ -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
+3 -2
View File
@@ -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;
+2
View File
@@ -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.
+92 -20
View File
@@ -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<userId>}` =
* 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,
+37
View File
@@ -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"],
+120
View File
@@ -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;
+4
View File
@@ -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.