feat(workspace): workspace isolation + multi-workspace (switch + manage)#202
Merged
Conversation
…elpers Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ion guard) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…dlers + workspaces CRUD + test migration
Move workspace-scoped collection routes (channels, agents, teams,
decisions list, ui server-info, launch-trio) under /api/w/{workspace_id}
behind require_workspace, reading WorkspaceContext instead of the deleted
global active_workspace_id accessor. /internal agent routes derive their
workspace from the sender record (sender_workspace_id) since they carry no
WorkspaceContext. Add account-scoped workspaces CRUD (list/create/rename/
delete with last-workspace guard, 404-on-non-member). Keep CLI-backing
current/switch/rename routes (local_workspace_state) and migrate the
chorus channel/agent CLI to resolve the active workspace and target the
path-carried routes. Migrate the test harness (alice owns/joins one
workspace; ws_uri helper) and ~40 call sites across the suite.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add require_resource_in_workspace membership gate (404, never 403) and apply it to every by-id route still on require_auth-only: channels, agents, agent workspace file reads, teams, decision resolve, and agent runs. Public task board routes use the sibling channel_member_exists check. Attachments are scoped through the linking message's channel membership via a new attachment_visible_to_member store helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Remove /api/workspaces/current and /api/workspaces/switch (and the rename-current handler) — a server-global active workspace is incompatible with multi-tenant isolation. The channel/agent CLI now resolves its target workspace from the account-scoped GET /api/workspaces list (active marker, else single, else first). `chorus workspace` becomes a direct-store command group (--data-dir) that reads/writes the per-machine local_workspace_state, matching `chorus setup`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
As account A (alice), assert 404 on B's channel PATCH, agent GET/activity/ runs/workspace/file, decision resolve, and attachment fetch — and assert the mutations did not land. Add a mirror test proving the gate does not over-block A's own resources. Join the e2e HTTP actor to #general so the public task-board routes (now channel-membership-gated) keep passing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ount write IDOR) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ce CRUD isolation tests Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation for multi-workspace frontend (T8):
- uiStore: currentWorkspaceId/currentWorkspaceSlug + setCurrentWorkspace,
cleared on identity reset.
- client.ts: wsPath() prefixes /api/w/{id} for scoped collection calls (T9
will adopt it); throws if no workspace selected.
- routes.ts: all path builders carry the /w/{slug} prefix from the store;
resolveWorkspaceBySlug() resolves the URL slug to a workspace (matched +
first-as-fallback).
- App.tsx: AuthedApp syncs whoami + loads workspaces, then routes
/w/:slug/* -> WorkspaceShell (resolves slug, sets current workspace,
redirects unknown slugs to first) with scoped data bootstrap moved inside
so nothing scoped fetches before the workspace id is set; any other authed
path redirects to the first workspace. Public/sign-in gating unchanged.
- useRouteSubject/TabBar useMatch patterns prefixed with /w/:slug.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…/current client code Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…rent client code Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…count QA) dev-login minted a session but never provisioned a workspace (only OAuth + startup-seed did), so a second dev account landed workspaceless and require_workspace 404'd it. Provision on dev-login like the OAuth path, so `CHORUS_DEV_AUTH_USERS=a,b` yields two usable, isolated accounts for QA automation. Test asserts two dev logins get distinct workspaces. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…n spec Per-worker server now runs with the dev-auth bypass (CHORUS_DEV_AUTH); a new `accounts` fixture mints two dev-logged-in contexts (alice, bob) via /api/auth/dev-login, each in its own provisioned workspace — no OAuth/login dance. WRK-002 uses it to assert distinct workspaces, non-member redirect, and actor-scoped GET /api/workspaces. helpers/accounts.ts holds openDevAccount + workspaceSlugFromUrl. Validated via `playwright test --list` (transpile + collection); a live run needs the UI bundle rebuilt + chromium. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The route move to path-carried workspace routes broke the e2e helper layer
(helpers/api.ts) and two specs that still hit /api/{channels,agents,teams}.
Add workspaceApiBase(request) (resolves the actor's workspace, caches per
worker) and route the collection calls through it; CHN-002 inline posts use it
too; NAV-002 counts the new /api/w/{id}/… paths. Smoke: 8/9 green
(ENV/AUTH/NAV-001/CHN-001-003/AGT-001/WRK-002); NAV-002 flags an intermittent
double agents-fetch on bootstrap (tracked separately).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…gs mgmt WRK-003 drives the sidebar workspace-title switcher (list/create/switch); WRK-004 drives Settings ▸ Workspaces (create/rename/delete + last-workspace guard). Both run green against a live build (chromium). Cataloged in agents.md. Automates the manual UI click-through for the T10/T11 workspace UI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The workspace-isolation bootstrap (`ensure_builtin_channels`, run by `build_router_with_lifecycle`) migrates a legacy channel literally named "general" into the system "#all" channel. Both live-runtime test harnesses seeded a channel named "general", so the bootstrap renamed it out from under them — every later "general" lookup hit `QueryReturnedNoRows`. These tests are `#[ignore]` so CI never caught it. Seed a neutral "lobby" channel the migration leaves alone. Verified real LLM round-trips through the shared bridge on the new code: - live_runtime_tests: kimi (7.2s), codex (22.3s) post to #lobby → store - live_multi_session_tests: kimi bootstrap (3.8s) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements per-account workspace isolation + multi-workspace (switch + manage). Spec/plan:
docs/superpowers/{specs,plans}/2026-05-20-workspace-isolation*(local). 13 commits.What this does
/w/{slug}), with a quick-switch dropdown and a Settings management page.Backend
/api/w/{workspace_id}/…behind a newrequire_workspacemiddleware (membership check; 404 for unknown workspace AND non-member — enumeration guard, never 403). The server's globalactive_workspace_idcache was deleted (compile-gate forced converting all read sites)./api/{resource}/{id}) stay put but each handler now does a per-handler membership check (require_resource_in_workspace→ 404; conversation routes userequire_channel_membership). Attachments scoped via their linking message's channel membership."{name}'s Space"at OAuth sign-in; the local operator gets one atchorus start./api/workspaces/switch+/currentendpoints were removed (switching is navigation).chorus workspaceis now a direct-store command (--data-dir);chorus channel/agentresolve the target workspace viaGET /api/workspaces.Security
A cross-account IDOR was found and closed during review: the initial route move left ~24 by-id routes (and the message-send route) reachable cross-account. Both were caught by independent review passes, fixed with per-handler membership checks, and covered by IDOR tests (assert 404 + no over-blocking, including a proven message-send case).
Frontend
uiStore(currentWorkspaceId/currentWorkspaceSlug), set from the/w/{slug}route; a singlewsPath()helper inclient.tsbuilds/api/w/{id}/…for the ~8 collection calls (by-id/account calls unchanged).Tests / gates
cargo fmt --check+cargo clippy --all-targets -- -D warningscleancargo testgreen (all suites 0 failed; 12 pre-existing ignored live tests) — incl. middleware authz, cross-account isolation, by-id IDOR (channels/agents/agent-file/decisions/attachments/message-send), CRUD (list-scoping, delete-last)cd ui && npx tsc --noEmitclean ·npm run test124 pass ·npm run buildOKNOT done / follow-ups
/gstack-qa+/ultrareviewbefore merge.chorus workspaceflag changed--server-url→--data-dir(docs/CLI.md follow-up); a few unusedWorkspaceInfoTS fields could be trimmed.🤖 Generated with Claude Code