Skip to content

feat(auth): the user role is Workspace-only; sharing stops creating logins (abilityai/trinity-enterprise#837) - #3488

Merged
vybe merged 7 commits into
devfrom
feature/837-workspace-only-role
Oct 11, 2026
Merged

vybe merged 7 commits into
devfrom
feature/837-workspace-only-role

Conversation

@webmixgamer

@webmixgamer webmixgamer commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The platform role user now means a member who works with agents in the Workspace. The operator UI and every operator API route start at operator. Sharing an agent, and approving an access request, no longer create a platform login.

This is PR 1 of abilityai/trinity-enterprise#837 (the OSS core). The Access-tab role control and the companion check (AC5/AC7) follow in PR 2, after ent#810.

Action required on upgrade: raise every account that operates agents to operator. No database migration runs and no role is rewritten. Runbook: docs/migrations/WORKSPACE_ONLY_USER_ROLE_2026-10.md.

Changes

Backend

  • The operator floor lives in get_current_user. It is now resolve_platform_user_unfloored (every scope fence, unchanged) followed by enforce_operator_floor. A principal below operator (user, or a role outside the ladder) gets 403 {"code": "workspace_only", "message": "This account works in the Workspace. Open /workspace."} before any agent lookup. There are two exceptions:
    • a browser session (JWT only, never a key) on one of the 11 routes the Workspace calls, each marked @workspace_route("<reason>");
    • an agent of a Workspace-only owner, on its own runtime routes (_AGENT_SELF_ROUTES). The name is bound to the key and must be a path parameter of the matched route, so a static route that merely spells it (GET /api/agents/slots) never counts.
  • Workspace doors. get_portal_principal, get_room_principal and the Workspace voice start resolve through resolve_platform_user_unfloored. The set of callers is pinned by a test. The rooms door still refuses an agent whose owner is Workspace-only.
  • WebSockets.
    • /ws/events and the agent terminal close with 4003 for the user rung.
    • /ws sends a Workspace-only socket only the event kinds the Workspace reads, each cut to the fields it reads (WORKSPACE_WS_EVENT_TYPES / _FIELDS), on live fan-out and on reconnect replay.
  • Sharing. Sharing an agent and approving an access request no longer write a login-whitelist row.

Frontend

  • /login lands a user in /workspace.
  • Operator URLs redirect a user to the Workspace (/agents/:name/* → /workspace?agent=:name). A workspace_only 403 moves the tab there too, but only once the re-read profile says user.
  • The operator stores don't follow /ws for a user.
  • Settings → Access:
    • a role picker on the whitelist form (default user, labelled "user — Workspace only") and a Role column;
    • a count of Workspace-only accounts in User Management, with a "Show only these" filter;
    • reworded Access-tab copy (placeholder name@company.com).
  • The Login page points to /workspace.

Docs

Review round 1

All of AndriiPasternak31's review is addressed in d9473d334. Details are in the PR comment.

  • A deliberate whitelist grant replaces a row that sharing wrote. The form names an existing account's role, and its refusals show inline under the form.
  • An authorized shared-canvas link recognises a Workspace session, limited to a roster agent and a roster-audience canvas. Its "Sign in" goes through the Workspace and back.
  • Connector keys keep their two fenced routes. The public chat page's history routes are marked; the Dashboard-only preference reset is not.
  • /m sends a member to the Workspace. A refused call reconnects /ws. The MCP server and trinity init name the workspace_only refusal.

Decisions (recorded on ent#837)

  • The floor lives at the auth entry point, not in each router.
  • Inside the Workspace, a user keeps the team view: every canvas audience, the agent's whole activity, voice and model choice. The narrower client view is for people without a platform account.

Known limits (follow-ups)

Verification

  • Backend unit tests: new suites for the floor, /ws, the route census, whitelist grants, shared-canvas viewers and the CLI. Mutation batteries (17 at first, then 29 in review round 1) turned every new guard red with its change reverted. On the final merge, the targeted backend set (140 files) passes: 4,128.
  • Frontend: the full suite passes (5,509). The three failing files are host-only and also fail without this branch.
  • MCP server: 837 of 839 pass. The two failures come from the host's pnpm install layout.
  • /verify-local --skip-agent on the merged tree: build, import smoke, boot, preflight and integration all pass. The unit stage fails 3 tests: the test_3262 system-seed tests, which fail because the run uses TRINITY_DEFAULT_SYSTEM_MANIFEST=disabled.
  • Live tests on the verify sibling: 27 passed.
  • Enterprise private suite: 782 passed. The one failure is pre-existing.
  • /cso --diff: 4 findings (1 medium, 3 low), all fixed in this PR. Report: docs/security-reports/cso-diff-2026-10-09-ent837-workspace-only-role.md.
  • Eyeball on localhost: all 8 steps passed (role landing, deep-link redirects, the workspace_only 403, raising and lowering a role, the Settings aids). The review round's 4 steps also passed (whitelist hints, the count and filter, the shared-canvas sign-in round trip, /m).

Test plan

  • CI green
  • Sign in as a user: you land in /workspace; /agents/<name> opens /workspace?agent=<name>; an operator API call answers 403 workspace_only.
  • Raise the account to operator: the operator UI returns.

Part of abilityai/trinity-enterprise#837

🤖 Generated with Claude Code

…ogins (Abilityai/trinity-enterprise#837)

Platform role `user` (and any role outside the ladder) now means a member
who works with agents in the Workspace. The operator UI starts at
`operator`, and the boundary is server-side.

- The floor lives in get_current_user: resolve_platform_user_unfloored
  (every scope fence, unchanged) + enforce_operator_floor. Every route that
  authenticates through it, OSS and enterprise, answers a Workspace-only
  principal with 403 {code: workspace_only, message: "This account works in
  the Workspace. Open /workspace."}, before any agent lookup.
- Exceptions: an interactive session on a route marked @workspace_route
  (the 11 operator-side routes the Workspace calls), and an agent of a
  Workspace-only owner on its own runtime surface (_AGENT_SELF_ROUTES =
  the ephemeral set + its own asks + the skill-gate check). The name is
  bound to the key AND must be a path parameter of the matched route, so a
  static route that spells it (GET /api/agents/slots) is not the agent's
  own; this closes the same case for the ghost-agent fence.
- The Workspace doors (get_portal_principal, get_room_principal,
  portal_voice_start) call the unfloored resolver; the caller set is pinned.
  The rooms door still refuses an agent whose owner is Workspace-only:
  rooming with the owner's agents is agent-to-agent chat.
- WebSockets: /ws/events and the agent terminal refuse the rung (4003). A
  Workspace-only /ws socket receives only the kinds the Workspace reads
  (agent_activity, loop_run_completed, loop_completed, agent_skills_changed,
  resync_required), each cut to type/event/agent_name/activity_state plus
  the _eid cursor, on fan-out and replay; connect defaults both allowlists
  to the empty set.
- Share and access-request approval no longer write the login whitelist.
- SPA: /login and operator URLs land a `user` in /workspace (an agent page
  opens that agent's conversation); a 403 workspace_only moves a stale tab
  there once the re-read profile says `user` (the body alone is not proof);
  /ws no longer drives operator-store refetches for a `user`; the
  empty-roster and Inbox links into the operator UI give way to the client
  copy.
- Settings: the `user` option reads "user — Workspace only"; the email
  whitelist takes a role (default user) and shows it; User Management
  counts the Workspace-only accounts and filters to them. Access tab copy:
  adding a person grants Workspace access (placeholder name@company.com).
- Inside the Workspace a `user` keeps the team view (every canvas audience,
  the agent's whole activity, voice, model choice); three comments that
  justified it with "can open Agent Detail" now give the team-member reason.
- Upgrade note: docs/migrations/WORKSPACE_ONLY_USER_ROLE_2026-10.md. No DB
  migration; ROLE_HIERARCHY and VALID_ROLES unchanged.
- /cso --diff: docs/security-reports/cso-diff-2026-10-09-ent837-workspace-only-role.{md,json}
  (5 findings, all fixed here).
- Tests whose fixtures used `user` as an arbitrary non-admin now use
  `operator` (the two rungs were identical before); tests that patched the
  portal door's resolver patch the renamed one; the two ghost-fence suites'
  hand-built requests now carry the path parameter a routed request has.

Mutations (each red, restored byte-identical): portal/rooms door back on the
floored resolver; marker removed from get_my_preferences; skill-gate check
dropped from the agent surface; name binding dropped; marked routes admitting
keys; /ws/events reading the wrong row; an aliased module-level resolver use;
a mark applied by call; the router guard redirect, the /ws queue gate, the
Login role and the api.js interceptor each disabled; the /ws kind filter
removed, not passed on fan-out, not passed on replay, the lifecycle `event`
key ignored, identity never filtering, register dropping it, /ws forgetting
it, connect's default opened; the field projection off in identity, on
fan-out, on replay, in register, at /ws and in connect's default; the rooms
refusal removed; the route-parameter binding removed; the SPA moving the tab
on the body alone.

Part of Abilityai/trinity-enterprise#837 (PR 1 of 2; the Access-tab role
control and the companion-assignment check follow ent#810).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this at 37829f0c6. I'm requesting changes for two bugs and one doc fix. The floor itself held up: a request to every protected route found no bypass.

What held up (each of these was run, not just read)

  • The floor. I built the app with the private module mounted and sent one request to each of the 631 protected routes.
    • A user JWT gets through only on the 11 marked routes.
    • A user-scope MCP key is refused everywhere.
    • An agent whose owner is a user reaches only its own runtime routes.
    • The role is read from the DB on every request. Roles outside the ladder, suspended users and deleted users are all refused, and the refusal reveals nothing about which agents exist.
  • The Workspace for a user. I traced all 204 files the Workspace loads and checked the result live with a user account.
    • Roster, chat, Inbox/asks, Work, files, projects and voice all work.
    • The only call refused in practice is images a canvas references by path, which is #3491.
    • A companion's ask reaches the Inbox, and the agent reads the answer.
  • /ws filtering. It is a real allowlist on both live delivery and reconnect replay, and a socket with no filter set receives nothing.
  • Tests.
    • 971 unit tests pass across 3 random seeds.
    • Reverting any one of 17 guards turns at least one test red.
    • The frontend specs, the ratchets and npm run build pass.
    • No existing test was weakened.

Please fix before merge

1. An old sharing-sourced whitelist row overrides a later operator grant.

  • get_or_create_email_user (db/email_auth.py:309) gives a new account the role from its whitelist row, and nothing replaces an existing row.
    • The whitelist form answers 409 "already whitelisted" (routers/settings/whitelist.py:161).
    • The invite and SSO provisioning paths also keep the existing row.
  • Scenario: Bob was shared an agent last month, which wrote a row with role user. An admin now invites him as operator. Bob signs in, gets role user, and every operator route refuses him.
  • On dev this was harmless because user and operator behaved the same. This PR is what makes it matter, and the runbook doesn't mention it.
  • Suggestion: let a new grant overwrite a row whose source is agent_sharing or access_request (or let the form update the role), and add a line to the runbook.

2. The authorized shared-canvas "Sign in" button leads nowhere for newly shared people.

  • SharedCanvas.vue:31 sends them to /login?redirect=…, and GET /api/public/canvas/{token} (public.py:1086) reads only a platform JWT.
  • Sharing no longer adds the email to the whitelist, so someone shared after this PR never gets a code at /login.
  • The Workspace pointer doesn't help either: it drops the redirect, and the canvas page never sends the Workspace token.
  • The route's own @workspace_route reason names exactly this audience.
  • Suggestion: give the canvas page a Workspace-token door and point "Sign in" at the Workspace with a return path. Otherwise, list it as a known limit with a follow-up issue.

3. Finding 1 in the committed CSO report (docs/security-reports/cso-diff-2026-10-09-ent837-*) says more than a public doc should.

  • Please drop it before merge, or cut it down to "wording fixed" with no file list.
  • docs/security-reports/ is outside the docs guard, so CI won't flag it.

Should fix (follow-ups are fine, please file them)

  1. Lowering a role doesn't affect sockets that are already open.
    • The /ws allowlists are fixed at connect (ws_identity_service.py:103).
    • /ws/events (main.py:1678) and the terminal (terminal.py:127) check the role only at connect.
    • update_user_role closes nothing.
    • Effect: an ex-admin's open /ws keeps the whole fleet's stream with every field, and an open terminal keeps its shell. Suspension has the same gap.
    • At minimum, note this in the runbook and reconnect the socket in reactToWorkspaceOnly.
  2. The marker guards don't cover the private module.
    • The route census and the caller pin on resolve_platform_user_unfloored (test_837_workspace_only_floor.py:399-449) both skip enterprise/.
    • In a sandbox, marking a private-module route @workspace_route kept both tests green, and a user got past the floor on that route.
    • This needs a matching guard in the private repo.
  3. The census test missed 5 of the 6 ways I tried of adding a new unmarked Workspace call.
    • It scans about 140 of the 204 files the Workspace loads, and misses the composables.
    • It trusts whole path prefixes, which covers 21 floored routes under /api/enterprise/client-portal/, /api/enterprise/projects/agent/ and /api/public/.
    • Suggestion: walk Portal.vue's import graph, and classify each call by the route's dependencies with the existing reaches() helper rather than by prefix.
  4. MCP turns workspace_only into a misleading message. getPermittedAgents and getAgentAccessInfo (src/mcp-server/src/client.ts:885-905, :789-803) swallow the error, so the caller sees "not permitted… configure permissions" or "not found". Rethrow when the body has code: workspace_only (same class as #678).
  5. Connector keys of a demoted owner now get 403 for external consumers. The connector principal matches neither floor exception, while shares and public links to the same agent keep working. Either exempt connectors or document it in the runbook.
  6. A user can't manage their own credentials.
    • These routes are all floored: /api/users/me/email*, /api/users/me/github-pat, MCP key list/revoke/delete (mcp_keys.py:97,198,235), and 2FA enrol/recovery-codes/disable.
    • A key minted before the upgrade still reaches the portal, but its owner can't revoke it. #3497 covers only the keys.
    • roles-and-permissions.md still says any user can change their own sign-in email.
  7. The runbook's Postgres command and its claim about a lowered owner's agents are both wrong.
    • psql "$DATABASE_URL" (line 56) doesn't reach the bundled Postgres. Use docker exec trinity-postgres psql -U trinity -d trinity ….
    • It says everything an agent of a lowered owner did is refused, but schedules, webhooks and channel bots keep running, and the owner can no longer pause them.
  8. trinity init for a user reports success while the CLI is unusable. src/cli/trinity_cli/commands/auth.py:58-69 swallows the 403 and prints "ready", but every later command fails.
  9. The /chat/:token history dropdown returns 403 for a signed-in user. public.py:1013,1049 aren't marked.
  10. /m escapes both the redirect and the 403 reaction (router/index.js:335, workspaceOnly.js:76). A user gets a mobile app where every call fails, with no pointer to the Workspace.
  11. The whitelist "Role" column shows default_role, which is the role at first sign-in, not the current role. Rename the column or show the current role.
  12. No test covers the main.js wiring of notifyWorkspaceOnly. Deleting that call leaves the suite green.

Nits

  • DELETE /api/users/me/preferences/{key} is marked, but only the operator UI calls it.
  • 9 file:line anchors in workspace-only-role.md are stale.
  • websocket.js:206 and :226 aren't gated, which contradicts the comment at :116.
  • The Workspace-only count line shifts the layout when it appears, and "Show only these" stays on after the list empties.
  • The 403 de-duplication is a 2-second timer, not an in-flight check.
  • /ws/events refuses before accept(), so clients see HTTP 403 rather than close code 4003.

Scope note

"A user keeps the team view inside the Workspace" comes from your 2026-10-09 comment, not the 2026-10-07 ruling. It's worth an explicit ack from the issue owner.

Not run

  • The four live-stack API suites.
  • The MCP TypeScript tests. The client.ts points above come from reading the code.

webmixgamer and others added 2 commits October 9, 2026 23:42
…ty-enterprise#837)

Must-fix:
- A deliberate whitelist grant replaces a row that agent sharing or an
  access-request approval wrote (add_to_whitelist), so an admin can add such
  an email as `operator` before the first sign-in. The form names an
  existing account's role, which only User Management changes.
- An `authorized` shared-canvas link recognises a Workspace session: with no
  platform credential the route reads one from the bearer token
  (portal_auth.workspace_email_from_request) and shows the canvas only for a
  roster agent and a `roster`-audience canvas (WorkspaceViewer). SharedCanvas
  sends the Workspace token when it has no platform one; "Sign in" goes
  through the Workspace and back (canvasReturnPath); /login stays for a
  Trinity account.
- The public /cso report no longer carries the finding about this PR's own
  wording.

Should-fix folded in:
- Connector keys of a Workspace-only owner pass the floor on their two
  ent#46-fenced routes.
- The public chat page's history routes are marked; the Dashboard-only
  DELETE preference route is unmarked.
- reactToWorkspaceOnly reconnects /ws (a socket keeps its connect-time
  scope); /m (signs in by itself) sends a signed-in member to the Workspace;
  the loops/skills operator stores no longer follow a member's stream.
- The MCP client rethrows `workspace_only` instead of reading it as "none";
  `trinity init` names the refusal and exits non-zero.
- Whitelist column "Role at first sign-in"; the Workspace-only count line
  keeps its space and the filter turns off when the list empties.
- Docs: runbook Postgres command, what keeps running for a demoted owner's
  agent, open sessions after a role change, connectors, re-adding a
  sharing-written email; roles doc email line; symbol anchors instead of
  stale line numbers; /ws/events refuses at the handshake (HTTP 403).
- main.js/api.js and page hand-offs pinned (platformSessionSync,
  workspaceOnlyWiring).

Tests: test_837_whitelist_grant (real DB), test_837_shared_canvas_workspace_viewer,
test_837_cli_workspace_only, and new cases in the floor, census, ghost-fence,
websocket, router-guard and MCP access suites. Mutation battery: 29 mutants
(whitelist overwrite, account hint, 409 hint, viewer branch, audience
narrowing, route fallback, blocked check, connector exemption, both marks,
rooms refusal, CLI exit and predicate, shareFetchClient, canvasReturnPath,
existingAccountNotice, rescope, both store gates, reconnect, /m guard,
operatorSurface, main.js wiring, the three page hand-offs, MCP rethrows and
predicate), every one red, every file restored byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-apply default_role through settingsStore.addWhitelistEmail after #3479)
@vybe

vybe commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-10): merged origin/dev into this branch. #3479 moved the whitelist POST into settingsStore.addWhitelistEmail, so the default_role field was re-applied there: addWhitelistEmail(raw, defaultRole = 'user') posts default_role, Settings.vue passes newEmailRole, and the two exact-body assertions in #3479's emailWhitelist.spec.js gained default_role: 'user'. One extra line: workspaceOnlyCount now guards Array.isArray(usersList.value) — #3479's mounted spec answers unknown GETs with {}, and the unguarded .filter threw during render. tests/registry.json union. Full frontend unit suite on the merged tree: 325 files / 5348 tests pass.

@vybe

vybe commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-10): second origin/dev merge after #3412 landed — docs/memory/feature-flows.md index keep-both (both rows retained, theirs first) and tests/registry.json union. No source change.

@vybe

vybe commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-10): third origin/dev merge — tests/registry.json union after #3428 landed. No source change.

@vybe

vybe commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-10): ejected at the merge step — validation (lane C: /validate-pr, /review, /cso --diff) found no critical, CI is green on the merged head (f900518), and the three dev merges above are mechanical and can stay. But the CHANGES_REQUESTED review from 2026-10-09 21:48 is still outstanding with nothing pushed against it, and its three blocking items need the author's call, not a train fix: (1) a sharing-sourced whitelist row overriding a later operator grant (db/email_auth.py:309, routers/settings/whitelist.py:161); (2) the shared-canvas "Sign in" door for people shared after this PR (SharedCanvas.vue:31, public.py:1086); (3) the committed CSO report under docs/security-reports/ naming more than a public doc should. Rides the next train once those land and the review is re-requested. Note for item 14 of the review: the re-applied default_role column is the same field, so that rename is still open.

@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

✅ Nightly unit-suite clean when this PR is merged into dev, all 3 seeds (head_sha: 6e7989188863f6e126ff9c304270ec72fd6d37e4).

…erges) into the review round

The remote PR branch carried two merge-train merges of dev on top of
37829f0 (#3412, #3428, #3479 and others). One conflict, Settings.vue:
- the whitelist add now goes through #3479's settingsStore.addWhitelistEmail,
  which returns { email, existingAccountRole } so the form can still name an
  existing account's role (emailWhitelist.spec updated, pass-through case
  added);
- the add's refusals use #3455's InlineError under the form, beside the
  account notice, and the page-wide error is no longer set (the eyeball of
  d9473d3: a 409 landed at the bottom of Settings); pinned in
  workspaceOnlyWiring.spec;
- #3479's guarded workspaceOnlyUsers computed, plus this round's filter reset.
test_3455's whitelist fake gains the two reads the route now makes.

Re-run on the merge: targeted backend 3983 passed (134 files incl. every unit
test the merges changed), frontend 5501 passed (3 known host-only files), MCP
829/831 (2 = the host's pnpm layout), CLI suites green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven commits since the merge train's last merge (#3088, #3244, #3293, #3340,
#3404, #3431, the #3509 enterprise pin). One conflict, tests/registry.json:
rebuilt as dev's file plus this branch's six entries (purely additive against
dev; dev's in-place change to the ent730 entry kept). #3404's new test patched
`portal_auth.get_current_user`; the Workspace door resolves through
`resolve_platform_user_unfloored` since ent#837, so the patch target follows
(the same rename the branch made in four tests).

Re-run on the merge (submodule at dev's pin): targeted backend 4128 passed
(140 files incl. every unit test dev changed), frontend 5509 passed (3 known
host-only files), MCP 837/839 (2 = the host's pnpm layout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@webmixgamer

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Everything is addressed in d9473d334; the floor commit itself is unchanged.

The branch is now at 6e7989188. After the round, I merged the merge train's branch and then dev. The only conflicts were Settings.vue and tests/registry.json. The registry is now additive against dev.

Must-fix

  1. An old sharing-sourced whitelist row overrode a later grant.
    • add_to_whitelist now lets a deliberate grant replace a row whose source is agent_sharing or access_request.
    • Any other existing row still answers 409, and the message now says how to change the role.
    • The form names an existing account's role, because a whitelist role applies only at a first sign-in.
    • Refusals, the 409 included, show inline under the form, using bug(ui): Email whitelist accepts any string: no client-side or server-side validation #3455's inline error. The page-wide error box is no longer set.
    • The runbook explains this.
    • The private module's SSO provisioning still reads an existing row as-is. That is tracked in the private tracker.
    • Tests: tests/unit/test_837_whitelist_grant.py, on a real database.
  2. The authorized canvas "Sign in" led nowhere.
    • With no platform credential, the canvas route now reads a Workspace session from the bearer token. It shows the canvas only when the agent is on that person's roster and the canvas's audience is roster, which is what the Workspace itself shows them.
    • The page sends the Workspace token when it has no platform one.
    • "Sign in" goes through the Workspace sign-in and back (?redirect=, shared-canvas paths only). A Trinity account still has /login.
    • Tests:
      • test_837_shared_canvas_workspace_viewer.py, covering the service on a real DB and the route;
      • canvasShare.spec.js and workspaceOnly.spec.js, covering the helpers;
      • workspaceOnlyWiring.spec.js, covering the page hand-offs.
  3. CSO report. Finding 1 is removed from both report files.

Should-fix

  1. Open sockets after a role change.
    • On a refused call, the Workspace now reconnects /ws (reconnectWebSocket), so the socket takes the new scope.
    • The runbook says how open sessions behave, and that a backend restart ends them all.
    • Closing a user's sockets and terminal on a role change or suspension is a follow-up, tracked privately.
  2. Marker guards and the private module. The matching guards are filed in the private repo.
  3. Census coverage: walking Portal.vue's import graph and classifying calls with reaches() is filed as Workspace route census: walk Portal.vue's import graph and classify calls by the route's dependencies #3511.
  4. MCP: getPermittedAgents and getAgentAccessInfo rethrow workspace_only instead of reading it as "none". Covered in access.test.ts.
  5. Connectors: a connector key of a Workspace-only owner passes the floor on its two ent#46-fenced routes, so external consumers keep working. Tested in the floor suite.
  6. Self-service credentials: A Workspace-only user cannot manage their own credentials (email, GitHub token, MCP keys, 2FA) #3497 now covers email, the GitHub token, MCP keys and 2FA, and the roles doc no longer says any user can change their email. Enrolling in 2FA during sign-in still works for a user: it runs on a challenge token, not get_current_user.
  7. Runbook:
    • the Postgres command is now docker exec trinity-postgres psql -U trinity -d trinity;
    • it says what keeps running for an agent of a demoted owner (schedules, webhooks, channel bots, connector routes), and that an admin pauses them.
  8. trinity init names the refusal and exits non-zero (trinity_cli/refusals.py; predicate executed, call site pinned).
  9. /chat/:token history: both routes are marked, and the census is updated.
  10. /m carries meta.operatorSurface, and the guard sends a signed-in member to the Workspace.
  11. The whitelist column is now "Role at first sign-in", with a tooltip.
  12. The main.js/api.js wiring is pinned in platformSessionSync.spec.js.

Nits

  • DELETE /users/me/preferences/{key} is unmarked; only the Dashboard grid calls it.
  • file:line anchors in workspace-only-role.md are replaced with symbol anchors.
  • websocket.js gates the loops and skills operator stores.
  • The Workspace-only count line keeps its space, and the filter turns off when the list empties.
  • The 2-second window stays, with the reason in a comment: a burst can outlive the reaction.
  • The docs now say /ws/events refuses at the handshake (HTTP 403).

Scope note

I've asked the issue owner on ent#837 to confirm the team-view decision.

Verification

  • 29 new mutants, every one red with its change reverted.
  • Targeted backend set: 3,354 passed.
  • Full frontend suite: 5,222 passed. The three failing files are host-only.
  • MCP: 826 of 828 passed. The two failures come from the host's install layout and don't occur in CI.
  • /verify-local on the merged tree: build, boot, preflight and integration all pass. The unit stage fails 3 tests: the test_3262 system-seed tests, which fail because the run uses TRINITY_DEFAULT_SYSTEM_MANIFEST=disabled.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

@AndriiPasternak31 AndriiPasternak31 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed this at 6e7989188, covering d9473d334 and the five dev merges. Nothing regressed, and the three must-fix items are fixed. Two notes below need a quick follow-up but don't block merge.

What I verified (by running it, not just reading)

  • The floor. I sent one request to each of the 634 floored routes with the private module at f7cf7dc mounted.

    Principal Passes on
    user JWT 12 routes: round 1's 11, plus the two /chat/:token history routes, minus DELETE /me/preferences/{key}
    user-scope key the public canvas only, as an anonymous visitor
    Agent of a user owner its 8 own routes
    Connector of a user owner chat, playbooks and the canvas
    • The routes merged in from dev refuse every principal.
    • A user can't mint a connector key, through any of the three connector routes or /api/mcp/keys.
  • #1, the whitelist grant. A deliberate grant now replaces an agent_sharing or access_request row and rewrites its source to manual. Any other row still answers 409, and no sharing-source write ever replaces a row. Checked on SQLite and on a real Postgres 16.

  • #2, the canvas Workspace door. I treated this as a new auth path.

    • No token can pass as another kind. Expired, revoked and bulk-revoked Workspace sessions are read as anonymous.
    • The roster check is the same function the Workspace roster uses, and the audience check is pinned to the canvas the share row names.
    • The status codes are the same set as at base, so there's no new oracle.
    • ?redirect= can't leave the site, and the Workspace token is attached only to the canvas call.
  • #3, the CSO report. Finding 1 is gone from both files, and nothing else in the diff points at it.

  • Fixed: #7 (MCP: full suite 839/839), #8, #10 (the Postgres command and the three queries run against the hosted compose), #12, #13, #14, #15, and the nits.

  • Deferred, and fine to defer: #5 (ent#864), #6 (#3511), #9 (#3497), and the server-side close for #4 (ent#863).

  • Tests.

    • Backend: 1,048 pass across 3 seeds, and 221 more under CI's FastAPI pin.
    • Frontend: the full suite (5,529) and npm run build pass.
    • The dev merges are clean. test_3404 and test_3455 kept their assertions, and registry.json only adds entries on top of dev.
    • The branch still merges cleanly into the current dev (96f88f982).
  • Mutants. I ran 20. Every mutant on the whitelist overwrite, the connector exemption, and the canvas roster and audience checks turns red. Two survived; see the test gaps below.

Needs a follow-up (doesn't block this PR)

  1. The invite path still keeps the old sharing row. Round-1 item 1 named both the invite path and SSO provisioning, but ent#864 only covers SSO. I checked on SQLite and Postgres: an operator invite over a legacy sharing row still gives a user account. Please add the invite path to ent#864.
  2. The Workspace page never reconnects /ws after a demotion.
    • reactToWorkspaceOnly returns early on Workspace pages (workspaceOnly.js:99), before the role re-read and rescope(). So only a tab on an operator page gets its socket reconnected.
    • An admin who is lowered to user while the Workspace tab is open keeps the fleet stream until a reload. The operator-store refetches keep getting 403, and each one exits early.
    • The runbook (:106), your response and ent#863 all say the Workspace reconnects. Either fix the code (re-read the role and reconnect without navigating) or correct those three.
    • The security half is still ent#863 plus the restart advice, so this isn't a blocker.

Test gaps (two mutants survived)

  • trinity init exiting with code 1 is never executed by a test. Change raise SystemExit(1) to return in _workspace_only_exit and all 7 tests in test_837_cli_workspace_only.py stay green, with the round-1 #11 bug back. Pin the exit code with a test that drives the call site, or with an AST check.
  • _workspace_viewer_may_see documents that it fails closed, but no test proves it. If the except branch returns True, all tests stay green. One test that makes the lookup raise would pin it.

Smaller items

  • A lapsed Workspace session loses the canvas return path. With an old Workspace token in storage, "Sign in" leads to /workspace?redirect=…. bootstrap() then replaces the route with /workspace/inbox, and resumePath wins over canvasReturnPath, so the person lands in the Inbox. Reopening the link works. workspaceOnlyWiring.spec.js:26 pins the current expression.
  • An admin's user-scope key can now turn a legacy sharing row into an admin grant. Before, it could only do this for new emails. This is the ent#717 class.
  • The whitelist replace isn't atomic. email_auth.py:89 reads the source and :98-107 updates in a separate transaction. A concurrent delete in between gives a 200 with no row. Put the source guard in the UPDATE and check the rowcount.
  • A blocked client's stored Workspace token now breaks public canvas links. It returns 403 "Access has been revoked" on a link any stranger can open. A blocked client should be treated as anonymous on that route.
  • The runbook says a restart makes everyone sign in again (:107). That isn't so: SECRET_KEY is stable, so JWTs survive a restart. What the restart does is close every socket and terminal, and reconnecting re-reads the role. The advice holds; the wording doesn't.
  • The Postgres half of test_837_whitelist_grant.py never runs in CI. The file has no requires_postgres marker, and schema-parity only selects that marker.
  • Docs drift.
    • One stale anchor remains: _AGENT_SELF_ROUTES is at dependencies.py:1033.
    • The flow doc's Testing section doesn't list the three new test files.
    • Its websocket.js row doesn't mention the loops and skills gates.
    • The CSO report still says 11 marked routes; there are now 12.

@vybe
vybe merged commit d127132 into dev Oct 11, 2026
30 checks passed
@vybe

vybe commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-11): merged. The delta since the 2026-10-10 validation (d9473d3 plus two dev merges) was re-validated at lane C. All three must-fix items are confirmed fixed, there are no criticals, and the squash keeps the pre-fix CSO text out of dev's history. Follow-ups worth filing, none of them blocking: (a) a role=user platform JWT still opens operator-audience shared canvases via can_user_access_agent, while the same person's Workspace session is narrowed to roster-audience canvases (canvas_share_service.py); (b) the whitelist replace is read-then-update rather than a guarded UPDATE (email_auth.py:89-102); (c) a blocked client's Workspace token gets 403 on public-audience canvas links instead of being read as anonymous; (d) no test covers _workspace_viewer_may_see's fail-closed except. No closing keyword here by design (PR 1 of abilityai/trinity-enterprise#837), so ent#837 stays open for PR 2.

vybe pushed a commit that referenced this pull request Oct 21, 2026
…cap room cost (#3495) (#3882)

ent#387 made a room's budget an operator control, but drew the line at
"platform principal" — which since #3488 includes the Workspace-only `user`
role. Such an account kept whatever it sent: an uncapped cost, 500 messages,
or a TTL of 0 (a room that never expires), while a portal client sending the
same values was cut back to the operator defaults.

- `_resolve_budget` now also requires a role at or above `operator`
  (`dependencies.is_workspace_only_role`, ent#837). Below it — the `user`
  rung, an MCP key such an account owns, a role outside the ladder, or no role
  at all — the caller's values are ignored and the operator defaults apply:
  the ent#387 rule, not a clamp. Operator and above may still bound a room,
  TTL 0 included.
- `max_cost_usd` gets an upper bound, `MAX_COST_USD_CEILING` = $1,000 per room,
  on `RoomCreate` and on the operator default (`RoomBudgetDefaultsUpdate`;
  the two ranges mirror each other). A stored default above it is CLAMPED to
  it by `_coerce_cost` — reading it as invalid would mean no cap at all. The
  defaults response reports the ceiling beside the message and TTL limits.
- MCP `create_room` declares the same 500 / $1,000 / 168 h bounds, and its
  stale "default 60" / "default 24" descriptions now point at the operator
  defaults.

Fixes #3495

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants