diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e1b946940a..49ea045264 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -68,6 +68,11 @@ updates: - dependency-name: vitest versions: - ">= 5.0.0" + # Prettier tracks the version locked upstream (ColeMurray/background-agents), + # not the latest. A newer formatter rewrites upstream-owned files, which then + # conflict on every sync, and fails format:check on files a sync brings in + # unchanged. Bump it only when upstream's lockfile does. + - dependency-name: prettier - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/compose-smoke.yml b/.github/workflows/compose-smoke.yml index a13d307370..3bf5319b24 100644 --- a/.github/workflows/compose-smoke.yml +++ b/.github/workflows/compose-smoke.yml @@ -119,9 +119,9 @@ jobs: if: steps.changes.outputs.run == 'true' run: npm ci - - name: Test smoke change detection + - name: Test smoke tooling if: steps.changes.outputs.run == 'true' - run: node --test scripts/compose-smoke-paths.test.mjs + run: node --test scripts/compose-smoke-paths.test.mjs scripts/compose-smoke.test.mjs - name: Build shared package if: steps.changes.outputs.run == 'true' diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index 37c3f9a463..7e6b49fab9 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -294,6 +294,7 @@ jobs: TF_VAR_enable_service_bindings: "${{ vars.ENABLE_SERVICE_BINDINGS || secrets.ENABLE_SERVICE_BINDINGS || 'true' }}" TF_VAR_sandbox_provider: "${{ vars.SANDBOX_PROVIDER || secrets.SANDBOX_PROVIDER || 'modal' }}" TF_VAR_sandbox_inactivity_timeout_ms: "${{ vars.SANDBOX_INACTIVITY_TIMEOUT_MS || secrets.SANDBOX_INACTIVITY_TIMEOUT_MS || '600000' }}" + TF_VAR_teams_enforcement: "${{ vars.TEAMS_ENFORCEMENT || secrets.TEAMS_ENFORCEMENT || 'shadow' }}" TF_VAR_sandbox_boot_timeout_ms: "${{ vars.SANDBOX_BOOT_TIMEOUT_MS || secrets.SANDBOX_BOOT_TIMEOUT_MS || '1800000' }}" TF_VAR_daytona_api_url: ${{ vars.DAYTONA_API_URL || secrets.DAYTONA_API_URL }} TF_VAR_daytona_api_key: ${{ secrets.DAYTONA_API_KEY }} @@ -323,6 +324,10 @@ jobs: TF_VAR_e2b_auto_pause: "${{ vars.E2B_AUTO_PAUSE || secrets.E2B_AUTO_PAUSE || 'true' }}" TF_VAR_e2b_template_cpu: "${{ vars.E2B_TEMPLATE_CPU || secrets.E2B_TEMPLATE_CPU || '2' }}" TF_VAR_e2b_template_memory_mb: "${{ vars.E2B_TEMPLATE_MEMORY_MB || secrets.E2B_TEMPLATE_MEMORY_MB || '4096' }}" + # Documentation site (packages/docs). Leaving DOCS_SITE_ENABLED unset + # after provisioning the site destroys its Vercel project on apply. + TF_VAR_docs_site_enabled: "${{ vars.DOCS_SITE_ENABLED || secrets.DOCS_SITE_ENABLED || 'false' }}" + TF_VAR_docs_custom_domain: ${{ vars.DOCS_CUSTOM_DOMAIN || secrets.DOCS_CUSTOM_DOMAIN }} - name: Post Plan Results uses: actions/github-script@v9 @@ -489,6 +494,7 @@ jobs: TF_VAR_enable_service_bindings: "${{ vars.ENABLE_SERVICE_BINDINGS || secrets.ENABLE_SERVICE_BINDINGS || 'true' }}" TF_VAR_sandbox_provider: "${{ vars.SANDBOX_PROVIDER || secrets.SANDBOX_PROVIDER || 'modal' }}" TF_VAR_sandbox_inactivity_timeout_ms: "${{ vars.SANDBOX_INACTIVITY_TIMEOUT_MS || secrets.SANDBOX_INACTIVITY_TIMEOUT_MS || '600000' }}" + TF_VAR_teams_enforcement: "${{ vars.TEAMS_ENFORCEMENT || secrets.TEAMS_ENFORCEMENT || 'shadow' }}" TF_VAR_sandbox_boot_timeout_ms: "${{ vars.SANDBOX_BOOT_TIMEOUT_MS || secrets.SANDBOX_BOOT_TIMEOUT_MS || '1800000' }}" TF_VAR_daytona_api_url: ${{ vars.DAYTONA_API_URL || secrets.DAYTONA_API_URL }} TF_VAR_daytona_api_key: ${{ secrets.DAYTONA_API_KEY }} @@ -518,6 +524,10 @@ jobs: TF_VAR_e2b_auto_pause: "${{ vars.E2B_AUTO_PAUSE || secrets.E2B_AUTO_PAUSE || 'true' }}" TF_VAR_e2b_template_cpu: "${{ vars.E2B_TEMPLATE_CPU || secrets.E2B_TEMPLATE_CPU || '2' }}" TF_VAR_e2b_template_memory_mb: "${{ vars.E2B_TEMPLATE_MEMORY_MB || secrets.E2B_TEMPLATE_MEMORY_MB || '4096' }}" + # Documentation site (packages/docs). Leaving DOCS_SITE_ENABLED unset + # after provisioning the site destroys its Vercel project on apply. + TF_VAR_docs_site_enabled: "${{ vars.DOCS_SITE_ENABLED || secrets.DOCS_SITE_ENABLED || 'false' }}" + TF_VAR_docs_custom_domain: ${{ vars.DOCS_CUSTOM_DOMAIN || secrets.DOCS_CUSTOM_DOMAIN }} MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 875c8d5b21..a8de27a8d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,102 @@ New features, integrations, and notable improvements to Open-Inspect — newest first. +## Unreleased + +### Changed + +Team-owned session actions now require current owning-team membership in every `TEAMS_ENFORCEMENT` +mode, including for Owners and Administrators. Visibility still determines read access; collaborator +self-removal requires only read access. Sessions, automations, and environments cannot move between +teams or to/from the workspace; a team-owned session never becomes workspace-owned. Visibility and +collaborator controls remain available to authorized users. Historical `session.moved` audit events +remain readable. + +## October 1, 2026 + +### Added + +Team leads and workspace administrators can manage encrypted secrets from a team's Secrets tab. +Team-owned sessions receive global secrets, then team secrets, then environment or repository +secrets, with later scopes taking precedence. Environment image builds include the environment's +team secrets; repository-shared images do not. Secret mutation audits contain key names only. Team +secret changes atomically supersede affected environment images. After the database batch, detached, +best-effort rebuild scheduling is attempted for enabled team-owned environments; enumeration or +trigger failures may leave no rebuild request. Team-owned environment images require matching +session ownership. Team-only legacy OAuth refresh tokens do not enable managed authentication; API +keys remain usable. Team-secret read and decryption errors abort environment builds rather than +falling back to other secret scopes. + +### Removed + +Removed the team Activity tab and `GET /teams/:id/activity` endpoint. Team operations continue to be +recorded in the workspace audit log, available to viewers with `workspace.audit.read` and filterable +by team. No audit history is deleted. + +### Fixed + +The team directory and collaborator picker now show email addresses only to viewers with +`workspace.members.read` (Owners and Administrators in the built-in roles). Other viewers receive +names and avatars with no email address, and unnamed users have a neutral label with a short ID +suffix. This restriction applies in every team enforcement mode. + +Session navigation now defaults to **All my teams**, with the team selector available even for a +single membership. Composer team and visibility choices stay local, including automatic team +selection when new sessions require a team. Transient membership refresh failures retain loaded +data, and changing draft configuration retires the old warm session without starting a replacement +sandbox until the next prompt input or submission. Scope changes refresh lists without clearing +terminal access or per-session caches. Visibility changes require a changed selection and confirm +non-private child-session cascades. Workspace audit readers can filter by teams they do not belong +to. + +## September 30, 2026 + +### Added + +Teams now have a searchable directory with favorites, member lists, session overviews, and +visibility-filtered activity. Active users can browse team names and memberships; a team's work +remains restricted to members and administrators. Workspace audit readers can filter events by team. +Session details show the owning team and visibility, with server-authorized controls to move +sessions, change visibility, and manage private-session collaborators, including child-session +cascades. Archived team metadata and member lists remain visible only to team members and workspace +administrators. Team activity shows domain operations; HTTP authorization decisions remain in the +permission-gated workspace audit log. + +**Team-aware session discovery and creation.** Following the team and visibility APIs, the web app +now supports team selection and scoped session discovery. Inbox snapshot and paged reads accept +ownership, visibility, and workspace scope filters and return effective server capabilities for +roots and descendants. The current user's team response includes the require-team creation setting +without requiring settings-management permissions. Bot team selection and automation team ownership +remain later phases; repository-backed team sessions still require existing grants, with no grant +creation API or UI yet. + +### Fixed + +Allowed team directory, member, session, activity, and collaborator-candidate reads no longer add +authorization-decision rows to the audit log. Capability writes and membership departures remain +audited. Unauthorized cross-member removals are recorded as denied decisions. Live session +subscriptions now include team memberships when computing capabilities in every enforcement mode, +preserving team leads' move and visibility controls without adding reads to per-command +authorization in `off` or `shadow`. + ## September 29, 2026 ### Added -`TEAMS_ENFORCEMENT` controls active-user session item routes (`/sessions/:id` and its subpaths) -using the persisted session row (`off`, `shadow` by default, or `on`). On those routes, private -visibility applies in every mode; team visibility and the delete ownership rule apply when `on`. -Workspace-wide session lists, bulk export, and WebSocket authorization follow in subsequent changes. -No route can make a session private or team-owned before those changes land. +**Team-scoped session access.** Teams remain optional: existing sessions stay teamless workspace +rows, and **Settings > Teams > Require a team for new sessions** is off by default. Operators can +roll out `TEAMS_ENFORCEMENT=off|shadow|on` (`shadow` by default): `shadow` records would-be team and +ownership denials without blocking non-private sessions, while `on` enforces them. Private +visibility is restricted in every mode. Session item routes, lists and aggregates, live connections, +and sandbox access use the persisted session scope; Owners' private-session break-glass reads are +audited and do not make those sessions enumerable. Session creation and team moves check membership +and repository grants; team and visibility change APIs have landed, with discovery UI following in +the next entry. Visibility, scope, and collaborator mutations enforce the resolver in every mode and +cascades refuse inaccessible descendants. The require-team setting refuses teamless session creation +API requests; automation runs remain exempt until team ownership is supported. Repository grant +creation is not yet available, so missing grants refuse repository-backed team sessions with +`target_team_missing_grant`. Team grants do not yet narrow the shared source-control installation +token in sandboxes. See [Authentication and Authorization](docs/AUTH.md). ## September 28, 2026 diff --git a/docs/AUTH.md b/docs/AUTH.md index 78977bbc93..26d241f797 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -5,8 +5,9 @@ you can do. This guide explains the behavior users and workspace administrators > **Important:** Open-Inspect is designed for a single trusted organization. A deployment is one > workspace, and the source-control App installation defines the repositories available to that -> workspace. Roles control which Open-Inspect features a person can use; they are not per-repository -> access lists. +> workspace. Roles control which Open-Inspect features a person can use; teams and session +> visibility further limit access to sessions. Neither is a replacement for source-control +> repository permissions. --- @@ -44,13 +45,14 @@ Open-Inspect includes four built-in roles. | Use repositories and environments in sessions | Yes | Yes | Yes | No | | Manage shared settings, integrations, and secrets | Yes | Yes | No | No | | Create sessions | Yes | Yes | Yes | No | -| View every session | Yes | Yes | Yes | Yes | -| Collaborate in and manage sessions | Yes | Yes | Yes | No | +| View sessions allowed by visibility | Yes | Yes | Yes | Yes | +| Collaborate in and manage permitted sessions | Yes | Yes | Yes | No | | View automations | Yes | Yes | Yes | Yes | | Create automations | Yes | Yes | Yes | No | | Manage and trigger own automations | Yes | Yes | Yes | No | | Manage and trigger any automation | Yes | Yes | No | No | -| View and manage workspace members | Yes | Yes | No | No | +| View workspace members | Yes | Yes | No | No | +| Manage workspace members | Yes | Yes | No | No | | Transfer workspace ownership | Yes | No | No | No | | View analytics | Yes | Yes | Yes | Yes | | View provider accounts | Yes | Yes | Yes | No | @@ -59,51 +61,206 @@ Open-Inspect includes four built-in roles. ### Owner -Owners have full access to the workspace. Only Owners can grant or remove the Owner role or suspend -and restore another Owner. Open-Inspect also prevents the final active Owner from being suspended or -demoted, so the workspace cannot accidentally lose all ownership. +Owners administer the workspace but do not automatically collaborate in other people's private +sessions. Only Owners can grant or remove the Owner role or suspend and restore another Owner. +Open-Inspect also prevents the final active Owner from being suspended or demoted, so the workspace +cannot accidentally lose all ownership. ### Administrator -Administrators can operate the workspace day to day. They can manage members, sessions, automations, -repositories, environments, provider accounts, integrations, and secrets. They cannot transfer +Administrators can operate the workspace day to day. They can manage members, permitted sessions, +automations, repositories, environments, provider accounts, integrations, and secrets. They cannot +access another person's private session unless added as a collaborator. They cannot transfer ownership, change who holds the Owner role, or suspend and restore an Owner. ### Member -Members can create and use sessions, collaborate in existing sessions, use shared repositories and -environments, and create automations. They can manage and manually trigger automations they own but -cannot modify another person's automation or administer shared configuration. They can view -workspace analytics. +Members can create and use sessions, collaborate in sessions visible to them (with current +owning-team membership for team-owned sessions, and private-session participation), use shared +repositories and environments, and create automations. They can manage and manually trigger +automations they own but cannot modify another person's automation or administer shared +configuration. They can view workspace analytics. ### Viewer -Viewers have read-only access to shared workspace resources. They can inspect sessions, automations, -analytics, repositories, environments, skills, and MCP servers. They cannot create or prompt -sessions, access sandboxes, manage personal skill profiles, trigger automations, or change shared -configuration. - -## How Session Access Works - -Sessions are workspace resources rather than private resources owned by their creator. - -- Anyone with session read access can view every session in the workspace. -- Anyone with collaboration access can prompt and contribute to every session. -- Anyone with lifecycle access can stop, retry, archive, unarchive, and otherwise manage every - session. -- Anyone with sandbox access can use supported sandbox tools for every session. -- Anyone with delete access can delete every session. - -The creator shown on a session records attribution; it is not an access list. Likewise, participant -labels identify who contributed to a session but do not grant or remove workspace permissions. The -**Mine** filter is a convenience for finding sessions you created, not a security boundary. - -Creating a session also requires permission to use its selected repository or environment. A role -may therefore be able to view an existing session without being allowed to create a new one. - -New HTTP requests reflect role changes and suspension immediately. Live browser connections to a -session are rechecked at least every five minutes, so a connection may remain open for up to five -minutes after access changes. Recreating the session is not required. +Viewers have read-only access to shared workspace resources. They can inspect sessions visible to +them, automations, analytics, repositories, environments, skills, and MCP servers. They cannot +create or prompt sessions, access sandboxes, manage personal skill profiles, trigger automations, or +change shared configuration. + +## Teams and Session Visibility + +Teams are optional within a workspace. Existing and teamless sessions remain workspace rows with +`ownerTeamId: null`; creating a team does not move them into it. A team has members and leads, a +join policy (open or invite-only), and a default session visibility. Owners and Administrators can +create teams in **Settings > Teams**; the creator becomes the first lead. Team membership does not +replace the workspace role: a person still needs the relevant session permission in addition to any +team access. + +### Team Directory and Pages + +Every active workspace user can list active teams and read their member lists, even without +membership in those teams. The team directory supports search and favorites, and team pages show +team metadata and members. Archived teams and their member lists are available only to their members +and workspace Owners and Administrators. + +The team directory and the session collaborator picker identify people by display name and avatar. +Email addresses are included only for viewers with `workspace.members.read` (Owners and +Administrators in the built-in roles); all other viewers receive `email: null`, including team leads +and session owners. An unnamed user is labeled with a short user ID suffix instead of an email +address or full ID. This privacy rule applies in every team enforcement mode. + +A team's session overview is available to its members and workspace Owners and Administrators, with +session visibility checks applied on the server. Team pages do not expose an audit activity feed. +Team operations are still recorded in the workspace audit log behind `workspace.audit.read`; its +team filter includes teams the reader does not belong to. + +The sidebar context defaults to **All my teams**, which leaves session lists unfiltered by team +while preserving server visibility checks. Users with at least one active team can choose Workspace +(teamless rows), a team, or All my teams; Owners and Administrators can also choose All teams. Users +without active teams have no selector and keep unfiltered lists. A stored Workspace or active-team +choice is retained; unknown or archived selections fall back to All my teams. + +The new-session composer's team and visibility are draft-local choices initialized from the sidebar +context. Changing them does not change the sidebar or command-menu recents. If a team is required +and the context does not name one, the composer selects the user's first active team locally. + +### Session Visibility + +Each session stores a visibility independently of its team: + +| Visibility | Who can read the session when team enforcement is on | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workspace` | Workspace users with session read permission, even if the session has a team. | +| `team` | Members of the owning team, plus workspace Owners and Administrators, with session read permission. Requires an owning team. | +| `private` | The session owner and explicit collaborators (who must be current owning-team members on team-owned sessions) with session read permission. A workspace Owner can also open it by ID under audited break-glass access; Administrators do not get this exception. | + +Private visibility is enforced in every enforcement mode. An Owner's break-glass read is audited, +does not cause the session to appear in their lists, and does not grant prompt or sandbox access. An +Owner with the required lifecycle or delete permission can manage a private session they opened by +ID, but must also be a current member of the owning team if the session is team-owned; being an +Administrator alone does not grant access. Actorless bot services cannot read private sessions; +user-backed integration requests still depend on the acting user's access. + +For a team-owned session, **every non-read action requires current membership in the owning team** +in `off`, `shadow`, and `on` modes. Session owners, team leads, workspace Owners, and Administrators +are not exempt. This includes prompting, sandbox access, lifecycle operations, deletion, visibility +changes, and collaborator management, in addition to the relevant workspace permissions and +action-specific rules. Visibility still controls read access: a readable workspace-visible session +or an explicit private-session collaborator grant does not grant team membership or authorize +non-read actions. The sole exception is a collaborator removing themselves, which requires only +session read access. + +Owners and Administrators must join the owning team before acting on its sessions; team membership +changes are audited. + +The **Mine** filter helps find sessions you created but does not define who may access them. A +session's owner is its creating workspace user, not its team. Explicit collaborators are an access +grant for private sessions; they still need the relevant workspace permission to read, prompt, or +use the sandbox. Runtime participants record who connected or contributed and may carry runtime +credentials; being a participant alone is not a visibility grant. Conversely, making someone a +collaborator does not turn them into a runtime participant. Removing a collaborator revokes their +private-session access on subsequent authorization checks. Adding collaborators or removing someone +else requires `manageCollaborators`: after the session read check, only the session owner or a +workspace Owner may do so, with current owning-team membership for team-owned sessions. A +collaborator may remove themselves with session read access alone; team membership, collaboration, +or lifecycle permission is not required for self-removal. + +The collaborator picker is available to session owners and workspace Owners after the session read +and collaborator-management checks. For a workspace-owned session it lists every active workspace +user; for a team-owned session it lists only active members of the owning team, and adding anyone +else is rejected with `not_team_member`. Selecting a collaborator is an explicit private-session +access grant, not a team membership or workspace role change. On a team-owned session the grant is +honored only while the collaborator remains a current member of the owning team: leaving or being +removed from the team ends their collaborator access on the next authorization check, even though +the collaborator record itself is kept. + +Session actions have additional rules after visibility: prompting requires collaboration permission, +sandbox use requires sandbox permission, and lifecycle operations require lifecycle permission. With +team enforcement on, or for team-owned sessions in any mode, deletion requires delete permission +**and** session ownership, a lead role in the owning team, or a workspace Owner/Administrator role. +Team leads do not gain access to private sessions simply by leading the team. Changing private +visibility is reserved for the session owner or a workspace Owner. These role and ownership rules +never bypass the current-membership requirement for team-owned actions. Private-session action rules +apply even while team enforcement is off or in shadow mode. + +### Creating Sessions + +Session creation checks the selected repository or environment as well as the creator's workspace +permission. Supplying a team requires active membership in that team and a grant covering **every** +repository used by the session; archived teams cannot be selected. Without an explicit visibility, +team sessions use the team's default and teamless sessions default to `workspace`. `team` visibility +requires a team; `private` requires a workspace user owner. A teamless session may still be private. + +Owners and Administrators can configure **Settings > Teams > Require a team for new sessions** +(`requireTeamOnCreate`). It is off by default. When enabled, new sessions must select a team; it +refuses session creation API requests without a team with `team_required`. The web app supports team +selection; team selection in bots is a later phase, so their teamless creation API requests are also +refused when the setting is enabled. Automation runs are exempt until automation team ownership is +supported. The setting does not migrate or hide existing `ownerTeamId: null` workspace rows. + +There is no repository-grant creation API or UI yet. Repository-backed team sessions without +existing grants are refused with `target_team_missing_grant`; creating a team does not grant it +repository access. Repository-less team sessions do not need repository grants. + +Sessions, automations, and environments cannot move between teams or between a team and the +workspace. A session's owning team is fixed at creation: a team-owned session never becomes +workspace-owned. Changing its visibility to `workspace` changes who may read it, not its ownership +or the team membership required for non-read actions. + +Visibility changes can include descendants. A cascading visibility change refuses the entire request +if any included descendant is inaccessible or denies the requested action, including the +current-membership check for each team-owned descendant; it does not silently skip that descendant. +The web visibility control requires a changed selection and asks for confirmation when applying +non-private visibility to child sessions, since private descendants will receive that visibility +too. Team grants constrain repository selection, not the source-control App token already available +to a running sandbox. + +Session discovery and inbox filters compose on the server: `ownerFilter=started` matches the +creator, `participating` also includes explicit collaborators and users with persisted read state, +and `anyone` adds no ownership filter. `visibility=team|workspace|private` and repeated `teamIds[]` +narrow the readable rows. `scope=workspace` means teamless sessions, not workspace visibility; +`scope=all` is reserved for workspace Owners and Administrators and does not bypass visibility or +enumerate break-glass-only private sessions. Inbox `mine=true` remains creator-only and excludes +direct automation and GitHub-bot sessions, but retains eligible agent descendants. + +### Enforcement and Access Paths + +Operators set `TEAMS_ENFORCEMENT` to `off`, `shadow` (the default), or `on`: + +- `off`: legacy read visibility for non-private sessions and legacy actions for non-private + workspace-owned sessions. Private access and the full action resolver for team-owned sessions + remain enforced. +- `shadow`: continue those legacy reads and workspace-owned actions while auditing would-be denials. + Private access and the full action resolver for team-owned sessions remain enforced. +- `on`: enforce visibility, team membership, and action/ownership rules for sessions. + +No mode relaxes current owning-team membership for non-read actions, including for workspace Owners +and Administrators. The visibility and collaborator mutation routes always enforce the session +access resolver, including in `off` and `shadow` modes. Those modes do not relax these mutation +checks or the checks on descendants included in a cascading operation. Collaborator self-removal +remains read-only-authorized in every mode. + +The session boundary covers four paths, not just the session page: + +- **HTTP item routes** authorize by the persisted session row before serving snapshots, actions, + children, exports, or other session-specific data. A session hidden by visibility responds with a + non-enumerating `404` rather than confirming that its ID exists. +- **Lists and aggregates** filter by visibility before returning sessions in search, inbox, child + lists, bulk export, and analytics. Private sessions do not appear in an Owner's lists solely + because of break-glass access; administrative analytics can include a scope-filtered, unattributed + private cost total without exposing those sessions. +- **Durable Object connections** recheck subscription and commands against the current session row, + so a stale browser tab does not turn a previous grant into lasting access. A private break-glass + subscription requires an audit write. +- **Sandbox access** is a separate session action. Snapshot sandbox URLs and supported sandbox tools + are not granted just because a session can be read; a break-glass Owner cannot use another + person's private sandbox without becoming a collaborator. Session-bound sandbox credentials are + not general user visibility grants. + +New HTTP requests reflect role, membership, collaborator, and visibility changes on the next check. +Live browser connections are rechecked at least every five minutes, so an existing connection may +remain open for up to five minutes after access changes. Recreating the session is not required. ## How Automation Access Works @@ -167,7 +324,10 @@ Owner can manage that session separately. ## Repository and Credential Boundaries Open-Inspect uses a shared source-control App installation for clone, fetch, and push operations. -The App should be installed only on repositories intended for the workspace. +The App should be installed only on repositories intended for the workspace. Team repository grants +check which repositories may be chosen for a team session; they do **not** narrow the shared +installation token delivered to a sandbox. Token narrowing is a future phase, not a protection +provided by team visibility today. A user's role determines whether they may read or use workspace repositories, but Open-Inspect does not compare that role with the user's personal GitHub access for each repository. Linked GitHub diff --git a/docs/AVAILABLE_MODELS.md b/docs/AVAILABLE_MODELS.md index e7b3b135bd..6429199de7 100644 --- a/docs/AVAILABLE_MODELS.md +++ b/docs/AVAILABLE_MODELS.md @@ -51,16 +51,17 @@ Accounts) applies only on the Claude Agent harness; OpenCode sessions use `ANTHR OpenAI models support connected ChatGPT provider accounts or `OPENAI_API_KEY` mode. See [Using OpenAI Models](OPENAI_MODELS.md) for account setup and coexistence details. -| Model ID | Display name | Description | Reasoning efforts | Default effort | -| ---------------------- | ------------- | ---------------------------------------------- | ----------------------------------- | -------------- | -| `openai/gpt-5.4` | GPT 5.4 | Flagship model | none, low, medium, high, xhigh | Not set | -| `openai/gpt-5.5` | GPT 5.5 | Latest flagship model | none, low, medium, high, xhigh | Not set | -| `openai/gpt-5.6-sol` | GPT 5.6 Sol | Frontier model for complex professional work | none, low, medium, high, xhigh | medium | -| `openai/gpt-5.6-terra` | GPT 5.6 Terra | Balanced, cost-efficient everyday work | none, low, medium, high, xhigh | medium | -| `openai/gpt-5.6-luna` | GPT 5.6 Luna | Fast, cost-efficient high-volume workloads | none, low, medium, high, xhigh, max | medium | -| `openai/gpt-6-astra` | GPT-6 Astra | Most capable model for complex, demanding work | low, medium, high, xhigh, max | medium | -| `openai/gpt-6-sol` | GPT-6 Sol | Complex coding and agentic workflows | none, low, medium, high, xhigh, max | medium | -| `openai/gpt-6-luna` | GPT-6 Luna | Efficient model for focused, high-volume tasks | none, low, medium, high, xhigh, max | medium | +| Model ID | Display name | Description | Reasoning efforts | Default effort | +| ---------------------- | ------------- | --------------------------------------------------- | ----------------------------------- | -------------- | +| `openai/gpt-5.4` | GPT 5.4 | Flagship model | none, low, medium, high, xhigh | Not set | +| `openai/gpt-5.5` | GPT 5.5 | Latest flagship model | none, low, medium, high, xhigh | Not set | +| `openai/gpt-5.6-sol` | GPT 5.6 Sol | Frontier model for complex professional work | none, low, medium, high, xhigh | medium | +| `openai/gpt-5.6-terra` | GPT 5.6 Terra | Balanced, cost-efficient everyday work | none, low, medium, high, xhigh | medium | +| `openai/gpt-5.6-luna` | GPT 5.6 Luna | Fast, cost-efficient high-volume workloads | none, low, medium, high, xhigh, max | medium | +| `openai/gpt-6-astra` | GPT-6 Astra | Most capable model for complex, demanding work | low, medium, high, xhigh, max | medium | +| `openai/gpt-6-sol` | GPT-6 Sol | Complex coding and agentic workflows | none, low, medium, high, xhigh, max | medium | +| `openai/gpt-6.1-sol` | GPT-6.1 Sol | Complex coding, computer use, and professional work | low, medium, high, xhigh, max | medium | +| `openai/gpt-6-luna` | GPT-6 Luna | Efficient model for focused, high-volume tasks | none, low, medium, high, xhigh, max | medium | ## xAI / SuperGrok diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 0df19663dc..ef5dd7aedc 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -1158,6 +1158,10 @@ ENABLE_SERVICE_BINDINGS VERCEL_TEAM_ID VERCEL_PROJECT_ID +# Documentation site (packages/docs) +DOCS_SITE_ENABLED +DOCS_CUSTOM_DOMAIN + # Modal MODAL_WORKSPACE MODAL_ENVIRONMENT @@ -1243,6 +1247,8 @@ Secrets for credentials: | `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ | | `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ | | `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ | +| `DOCS_SITE_ENABLED` | `true` keeps the docs site Vercel project (default: `false`; see `packages/docs/README.md`) | +| `DOCS_CUSTOM_DOMAIN` | Optional docs site hostname, e.g. `docs.example.com` (default: the vercel.app URL only) | | `MODAL_TOKEN_ID` | Modal token ID | | `MODAL_TOKEN_SECRET` | Modal token secret | | `MODAL_WORKSPACE` | Modal workspace name | diff --git a/docs/MODAL_DOCKER.md b/docs/MODAL_DOCKER.md index 56af3182d7..7c21617f42 100644 --- a/docs/MODAL_DOCKER.md +++ b/docs/MODAL_DOCKER.md @@ -106,10 +106,13 @@ Create, restore, and resolve report typed HTTP 409 error `detail` values: - `other_generation`: the ownership tags do not match. - `window_closed`: create/restore missed the launch deadline with no owned allocation. - `race_pending`: create/restore cannot yet see the winner after `AlreadyExistsError`, or resolve - found a VM whose enabled tunnel URLs are not all visible yet. + found a VM with none of its tunnel URLs readable yet. Like create/restore, resolve returns a + partial tunnel map rather than waiting for ports Modal did not publish. -Unexpected provider errors remain 500. The pending-reference stop endpoint retains its separate -`pending_reference_not_visible` response. +Create reports HTTP 501 `docker_not_available` before retiring or allocating anything when the +deployment has no verified Docker image. The control plane fails that launch as permanent instead of +resolving it. Unexpected provider errors remain 500. The pending-reference stop endpoint retains its +separate `pending_reference_not_visible` response. ## Switching backends diff --git a/docs/plans/managed-skills.md b/docs/plans/managed-skills.md index f4da099468..2be04e646f 100644 --- a/docs/plans/managed-skills.md +++ b/docs/plans/managed-skills.md @@ -232,7 +232,9 @@ create-session request carries a discriminated choice, never an ambiguous nullab ```ts type SessionSkillSelection = - { mode: "all" } | { mode: "none" } | { mode: "profile"; profileId: string }; + | { mode: "all" } + | { mode: "none" } + | { mode: "profile"; profileId: string }; ``` Bot, automation, Slack, Linear, and GitHub-created sessions use `{ mode: "all" }` unless their diff --git a/docs/plans/sandbox-lifecycle-manager-refactor.md b/docs/plans/sandbox-lifecycle-manager-refactor.md new file mode 100644 index 0000000000..052f305573 --- /dev/null +++ b/docs/plans/sandbox-lifecycle-manager-refactor.md @@ -0,0 +1,268 @@ +# Sandbox Lifecycle Manager Refactor + +This is the ownership guide for the incremental +[COL-240](https://linear.app/colemurray/issue/COL-240/refactor-sandboxlifecyclemanager-into-focused-collaborators) +refactor. [ADR 0004](../adr/0004-sandbox-checkpoint-and-shutdown.md) governs lifecycle and durable +shutdown authority. The [characterization matrix](sandbox-lifecycle-refactor-baseline.md) records +compatibility evidence and separate behavior gaps, not fixes claimed by extraction. + +## Ownership + +Paths are relative to `packages/control-plane/src/`. + +- `sandbox/lifecycle/manager.ts` remains the public readiness, work-admission and recovery boundary. + It selects startup mode, reserves generation/token identity, sequences input awaits, registers + pending handles through VM reconciliation, records recovery invocation, dispatches providers, + claims results, rotates retry identity and owns failure/breaker accounting. +- `sandbox/lifecycle/launch-context.ts` owns environment reads, model/harness defaults, ordered + repository fields, MCP/Slack lookup, persisted-setting normalization, timeout conversion, image + scope selection, lookup/logging and explicitly requested best-effort invalidation. +- `sandbox/lifecycle/image-selection.ts` remains the pure fingerprint, harness/runtime compatibility + and provenance evaluator. Launch context reuses it rather than reimplementing policy. +- `sandbox/lifecycle/sandbox-access.ts` owns artifact-write mechanics, terminal JWT signing/reuse, + retirement and access notifications. It has no lifecycle state or eligibility authority. +- `sandbox/lifecycle/vm-startup-reconciliation.ts` owns pending-reference registration, + lost-response lookup recovery and bridge lookup, including all five interleaving-sensitive + bridge/auth fields. It reconciles an authorized attempt without owning startup admission or + shutdown/recovery policy. +- `sandbox/lifecycle/allocation-cleanup.ts` owns stateless bounded late-result destruction, + rejected-cleanup retry rearming and matching-handle completion. Rejection/claim authority, + admission flags and prior-generation replacement retirement remain in the manager. +- `sandbox/lifecycle/provider-stop.ts` owns the shared local stop bound, abort/timer mechanics and + explicit `confirmed`/`not_stopped` outcome contract, not either caller's retirement policy. +- `session/sandbox-repository.ts` owns conditional SQL and encrypted access storage. + `session/sandbox-shutdown.ts` and `sandbox-shutdown-repository.ts` own the durable + shutdown/checkpoint protocol, receipts, holds, source retirement and recovery. +- `session/components.ts` and `sandbox-lifecycle-adapters.ts` compose the existing graph. Consumer + ports remain in `sandbox/lifecycle/ports.ts`; consumers do not gain launch, access or shutdown + internals. Session access readers retain authentication/read eligibility and decryption rechecks. + +Watchdog effects remain manager responsibilities until their serial extraction increment lands. Each +increment must integrate before its successor; the manager is not intended to become a tiny facade +or lose lifecycle arbitration. + +## Launch Contract + +`SandboxLaunchContext` is explicitly constructed as a class. Its constructor receives only a +two-method environment/repository reader, default model and MCP/Slack ports, provider metadata, +optional image lookup and lazy logger. These dependencies are held in readonly fields; the class +owns no mutable lifecycle state, storage/shutdown authority, provider operations, full-manager +reference or service locator. Construction does not read the session or resolve log context. + +`AgentLaunchFields`, `RepositoryLaunchInputs` and `ResolvedSandboxSettings` name the return shapes. +Agent fields are explicitly mapped into provider configs. Repository payload fields are restricted +to the existing scalar identity/base branch and optional ordered member list; single-repo sessions +omit the list unless a base SHA requires it. Nested owners and immutable base SHAs remain intact. +`resolveSandboxSettings(session)` returns normalized/provider-filtered settings and the derived +timeout together, so callers cannot assemble that ordering incorrectly. + +`resolveImageBuildScope(session, repositories)` is a plain synchronous function returning +`ImageBuildScope | null`. The manager awaits the promise-only lookup only for a non-null scope. +Environment scope takes precedence and never falls back to a repo image. Ad-hoc multi-repo and +repo-less sessions have no scope. An eligible scope keeps its existing await even when lookup is +disabled or the repository list is empty. + +## Sequencing Invariants + +- Reserve identity and shutdown ownership synchronously, invalidate old credentials, then publish + the generation-conditional hash before asynchronous input work. No launch read moves ahead of it. +- Fresh: retire prior provider, env, agent/repositories, eligible image lookup, MCP, Slack, + settings/timeout, pending registration, provider create. +- Restore: seed snapshot runtime authority, retire prior provider, env, agent/repositories, Slack, + MCP, settings/timeout, pending registration, recovery-invoked recording, provider restore. +- Resume resolves only settings/timeout and its existing access contract. It gains no environment, + repository, MCP, Slack or image work. Bridge settings remain after pending-reference eligibility. +- Lookup misses/errors never invalidate images. Only the manager's confirmed-unavailable branch + requests best-effort invalidation and reserves a fresh identity/token for base-image retry. + Transient provider errors retain valid images; saved-state failure never silently becomes fresh. +- Preserve provider claims, generation/hold checks, access-write atomicity, conservative lifetime + provenance, undefined settings, milliseconds-to-seconds conversion and lazy session logging. + +There is no universal startup pipeline. Independently landed context injection, construction safety +or provider recovery behavior must be preserved, not implemented or removed as incidental cleanup. +No schema, wire/runtime/provider-backend contract, timeout or retry policy changes are authorized. + +## Access Contract + +`SandboxAccess` receives only an artifact storage port, broadcast, socket observation/detachment, +the provider's resumable-stop capability check, dashboard URL builder and lazy logger. It owns no +mutable lifecycle flags, generation checks, signing-key retention, encryption key or full-manager +reference. Its constructor performs no dependency work. + +Composition constructs repository/socket/messenger leaves, then access, then shutdown, then manager. +Shutdown receives a callback to `access.retireShutdownAccess()` directly; retirement clears access, +notifies clients, then detaches with the unchanged close code and reason. There is no manager +retirement forwarding method. URL-only retirement preserves credentials on resumable providers when +supported, falls back to full clearing otherwise, and always clears tunnels and notifies. Other +termination paths keep their own existing clear/detach order. + +The manager calls individual `storeCodeServer`, `storeVnc`, `storeAndBroadcastTunnelUrls`, +`storeTtyd` operations for fresh/restore at their original await points. It still orchestrates +secret reads and atomic `completeProviderResume` writes for resume; VM reconciliation owns bridge +completion with an expected pending reference. These operations are intentionally not unified. +`reusableTtydToken` validates an already-read JWT synchronously so disabled terminal access does not +acquire a new await. `mintTtydToken` uses the transient launch key, existing session/sandbox claims +and the single terminal TTL. A hash-only restart or expired/missing JWT cannot renew access. + +`broadcastSandboxDashboardUrl` and `broadcastProviderAccessIfConnected` preserve separate, +repeatable notifications, alongside tunnel notifications. The manager retains publication fallbacks +and caller-specific catches, including committed recovery's access failure treatment. Atomic +repository completion rechecks generation, status and fence after encryption; only bridge supplies +an expected pending reference. Fresh/restore per-artifact writes remain unguarded, as characterized +separately in the baseline gap notes, not silently hardened here. + +## VM Reconciliation Contract + +`VmStartupReconciliation` owns `bridgeResolution`, `bridgeRetryGeneration`, `bridgeStartupClaim`, +`bridgeResolvedStartup` and `vmStartupAuth`, with no duplicate manager state. The manager calls +`registerForegroundAuth`, `beginForegroundRetry` and `finalizeForeground` at the original points; +none exposes mutable state or a generic field setter. Registration retains the actual foreground +generation object. Foreground completion compares generation/claim object identity; bridge auth, +claim and queue checks retain their value comparisons. Retry drops old auth before reservation/hash +publication yields, then registers the replacement key after successful reservation. An older +finalizer cannot clear newer auth. Inconclusive foreground lookup hands off its claim and signing +key to a later equal-valued bridge; only the completing generation clears that key. + +Dependencies are the existing provider create/pending/classification/lookup hooks, three repository +operations, pending/resolved shutdown-handle registration, session reading, launch-context settings, +terminal signing/access publication, lazy logger and optional background submission. The only +manager callback is `acceptResolvedStartup(generation, providerObjectId, lifetime)`, which invokes +the existing generic `claimProviderStartup`. That operation still clears admission pending, refuses +held-current adoption without destruction, conditionally commits, cleans unacceptable late results, +records lifetime and publishes announcements. Resume still uses that same manager operation. + +The reconciler's bridge entry is synchronous and starts its background factory synchronously. An +in-flight lookup deduplicates equal generations and queues the latest newer generation; eligibility +is checked again before settings and provider work. Atomic bridge access completion still supplies +the expected pending reference, leaving post-encryption generation/status/fence/reference checks to +the repository. The cache projects only identity and lifetime, never credentials. Restart can +resolve permissible provider/access facts but cannot reconstruct a terminal signing key. + +`createWithVmRecovery` returns `CreateSandboxResult | null`; `resolveUnknownVmStartup` returns +`ResolveSandboxResult | null`. Null abandons the foreground path, not evidence of absence or +permission to replay create. Lookup remains lookup-only with the original retry interval and +materialization bounds. `modalVmAllocationDetail` lives beside the Modal provider and unwraps the +same typed outcomes: `not_visible`, `other_generation` and unknown transport results remain +distinct. The boolean unknown-startup hook does not replace detail classification. Hook existence +alone does not enable VM behavior; standard Modal returns no pending allocation and does not +classify launch errors as VM-unknown. Conservative lifetime provenance and resolved-handle +replacement are unchanged. + +Restore remains manager-orchestrated: await pending registration, synchronously record +`markRecoveryInvoked`, immediately invoke restore. No new await hides that durable boundary. +`startup-errors.ts` holds the two existing internal abandonment/expiry errors shared by reservation +and reconciliation, preserving the manager's distinct catches. No provider/backend/wire/persisted +contract or public consumer port changes. + +Review follow-up explicitly corrects one inherited publication bug: a refused deferred bridge claim +no longer emits an access-change notification. Auth finalization and queued lookup draining still +run on refusal. This is a narrow behavioral fix, not a redesign of the extraction's contracts. +Access may still have committed before the manager observes a hold; this fix does not make access +and lifecycle acceptance atomic. Pending sandbox/shutdown registration also retains separate writes +without a cross-record transaction (the shutdown write precedes its alarm await). Failure atomicity +and unified acceptance require separately scoped safety work; neither is claimed fixed here. + +## Allocation Cleanup Contract + +`destroyLateProviderResult`, `rearmRejectedStartupCleanupAlarm` and `attemptRejectedStartupCleanup` +are stateless functions. Their narrow dependencies are a two-method repository port (`getSandbox`, +`updateSandboxModalObjectId`), the shared scheduler's `schedule`, explicit-stop eligibility, a stop +operation requiring a supplied handle and signal with a `confirmed`/`not_stopped` result, and a lazy +warning logger. Rearming and late destruction each accept only their dependency subset. The manager +constructs the readonly dependency wiring without invoking it; no manager reference, local ownership +flag, shutdown policy, new persisted record or raw platform alarm belongs to cleanup. + +Rejection ordering remains manager-owned: recognize `SandboxLaunchRejectedError`, synchronously +`rejectProviderStartup` to fence credentials/socket authority and retain the handle, detach with the +existing code/reason, then clear/notify access. Only the repository result `failed` authorizes +failed-status/error publication and breaker accounting. `retained` preserves terminal status/error; +`superseded` does not touch the current row or access and only attempts bounded late destruction. +Generic `claimProviderStartup` still clears the pending admission flag and refuses a current held +generation without destroying its potentially unique recovery copy. + +Rejected cleanup awaits retry scheduling before provider I/O. It stops the explicit target with +`startup_superseded`, `destroy`, the public session name/internal ID fallback and a bounded signal, +retaining the existing **undefined** `generationCreatedAtMs`. The provider adapter still interprets +absence; row age does not change pending-reference retirement classification on this path. The +original stop bound and abort/timer implementation live in `provider-stop.ts`; late destruction and +the manager's separate replacement-retirement operation both call `boundedProviderStop`. Timeout +error messages, logging, retry and handle-clearing policies remain with the callers. + +Review follow-up corrects the inherited false-confirmation bug: the manager's stop adapter returns +`not_stopped` when no provider method, session context or target permits dispatch, and `confirmed` +only after the provider reports success. Rejected cleanup retains its handle/retry on `not_stopped`; +confirmation-required replacement on explicit-stop providers refuses an undispatched stop before +reserving a replacement. Best-effort replacement and providers without explicit stop retain their +existing handle-clearing/continuation policy. This is a narrow behavioral fix, not a new provider +absence rule or generalized teardown operation. + +Failed, unsupported or locally timed-out cleanup retains the handle and scheduled retry. Abort +bounds local waiting only; eventual completion of a timed-out stop does not clear the handle. A +confirmed result clears only when sandbox ID, reservation timestamp and handle still match, without +retargeting to a newly read row. It does not clear the fence or `startup_rejected` marker. Manager +shutdown-alarm dispatch still prioritizes rejected cleanup over ordinary shutdown processing, +including holds, and production runtime reconstruction retains its existing rearm hook. + +Generic superseded-result destruction remains bounded best effort rather than a new durable cleanup +record. The assembled alarm handler can retry a failed rejected cleanup twice per delivery because +it invokes shutdown processing before and after terminal-projection I/O, before generic watchdogs. +These inherited semantics, along with prior-generation unscoped clearing and access-retirement +failure boundaries recorded in the baseline, are unchanged; no stronger retirement/exactly-once +guarantee is claimed. + +## Verification + +Keep direct narrow-dependency tests for input resolution and lookup effects. Keep assembled tests +for exact fresh/restore/resume payloads, reservation before asynchronous work, distinct integration +ordering, the no-await boundary for ineligible images, retry identity rotation and lazy bridge +settings. Image compatibility policy belongs to `image-selection.test.ts`; real-storage and Workerd +tests retain generation, shutdown and early-connect coverage. + +Access coverage includes direct retirement/fallback/notification-order tests, assembled JWT and +resume/restart/bridge tests, committed recovery failure boundaries, real encryption interleavings +and real composition retirement/construction checks. Existing session access-reader and repository +tests remain independent; collaborator mocks do not replace assembled coverage. + +VM coverage retains assembled lost-create/lost-restore, bridge-first/provider-first, late-token, +restart, visibility/transport bounds, fencing, queued-generation and lifetime tests. Direct +narrow-port tests add object-identity finalization, retry reset/new-token isolation, identity-only +cache reuse, post-await atomic-commit refusal without publication, successful older +lookup/latest-generation queueing, pending expiry/supersession and non-VM hook gating. Assembled +launch tests pin both successful restore marker order and absence of invocation after rejected +pending registration. Real repository encryption-race tests retain reference/generation rechecks; +direct commit substitutes do not replace them. ESLint prevents public consumers from importing VM +reconciliation internals. + +Allocation cleanup coverage retains assembled rejection/repository tests and adds controlled +schedule/stop gates, local timeout followed by late completion, exact stop arguments, unsuccessful +provider results, skipped dispatch/missing session context, capability/method absence, +generation-ID/timestamp/handle replacement isolation, terminal failure-accounting ownership and +superseded results against a held successor. Existing real-storage held-current claim tests remain. +Workerd now reconstructs the production runtime over a persisted rejected row and durable shutdown +hold, exercises the actual rehydration hook/shared deadline storage, and confirms +failed/successful/repeated cleanup preserves the hold and recovery receipt. ESLint keeps cleanup +internals unavailable to public consumers. + +`manager-shutdown.test.ts` isolates the assembled shutdown/recovery cases, including the committed +access/publication failure matrix, from the manager's orchestration suite. It retains real +manager/access/shutdown composition with test storage/provider ports; real SQLite and Workerd checks +remain separate. The baseline gap notes distinguish inherited unsafe outcomes from desired safety +guarantees; green characterization tests do not make those outcomes safe or authorize hardening in +this extraction. + +Build shared first, then run sequentially from the repository root: + +```bash +npm run build -w @open-inspect/shared +npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/sandbox/providers src/session/sandbox-access src/session/sandbox-repository src/session/sandbox-shutdown +npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown sandbox-state-retention session-components +npm run typecheck -w @open-inspect/control-plane +npm run lint -w @open-inspect/control-plane +npm run test:lint-sandbox-boundaries +git diff --check +``` + +Check formatting of touched files. Record checkout details, exact command results and blockers in +the PR/issue handoff rather than this enduring ownership guide. Full-story/package/bundle checks +remain the final increment's scope. Provider substitutes are not live-provider verification, and +this refactor does not authorize deployment or claim exhaustive interleaving safety. diff --git a/docs/plans/sandbox-lifecycle-refactor-baseline.md b/docs/plans/sandbox-lifecycle-refactor-baseline.md new file mode 100644 index 0000000000..b5500c700b --- /dev/null +++ b/docs/plans/sandbox-lifecycle-refactor-baseline.md @@ -0,0 +1,137 @@ +# Sandbox lifecycle refactor: T1 verification baseline + +This is the verification companion to +[COL-240](https://linear.app/colemurray/issue/COL-240/refactor-sandboxlifecyclemanager-into-focused-collaborators) +and [ADR 0004](../adr/0004-sandbox-checkpoint-and-shutdown.md), not a replacement design. The +proposed `sandbox-lifecycle-manager-refactor.md` was not present in this checkout. No production +extraction or protocol change is part of T1. + +## Checkout and owners + +- Starting HEAD: `4e5c1f3bce26974890a822c894651688ae6e4045` (clean `main`, tracking `origin/main`); + research baseline: `eef911f36e704fc49104546a9cd52b584d8b9223`. Five subsequent commits change + session visibility/indexing, Modal VM Docker-preparation failure handling, VM lookup for partially + published tunnels, and client/provider response handling. `vm-resolve.test.ts` gains a + Docker-unavailable regression. None extracts lifecycle responsibilities. Do not replace these + changes with the research snapshot. +- COL-238 (heartbeat confirmation) and COL-161 (spawn-time context injection) were **In Progress** + in Linear at inspection, not landed in this HEAD. Current watchdog/bridge and launch-context + behavior is the tested baseline, not the proposals in those issues. Recheck at each subsequent + task; preserve any changes that land. +- `sandbox/lifecycle/manager.ts` still owns reservation, launch choice, provider claims, recovery, + failure/breaker accounting, public admission/readiness, access preparation, VM resolution state, + rejected cleanup, alarm dispatch/effects, and termination flags. `decisions.ts` and + `alarm-policy.ts` own pure policy; `ports.ts` exposes consumer boundaries; `test-helpers.ts` + assembles manager fixtures. +- `session/sandbox-repository.ts` owns conditional SQL and encrypted access; + `session/sandbox-shutdown.ts` plus `sandbox-shutdown-repository.ts` own durable holds, receipts, + source retirement and recovery. `session/components.ts` wires both. `connection-authenticator.ts` + authenticates/attaches sockets; `sandbox-events/runtime.handler.ts` reports runtime facts; + `alarm/handler.ts` orders shutdown before lifecycle watchdogs and `alarm/scheduler.ts` persists + shared deadlines. Queue/push use the public lifecycle policy, not direct shutdown decisions. + +The manager's admission/termination flags are `isSpawningSandbox`, `isTerminatingSandbox`, and +`providerStartupPending`; VM reconciliation owns `bridgeResolution`, `bridgeRetryGeneration`, +`bridgeStartupClaim`, `bridgeResolvedStartup`, and `vmStartupAuth` today, all in that same class. +`spawnSandbox` selects startup mode, `reserveSpawnIdentity` commits the launch identity, +`claimProviderStartup` admits the provider result, `resolveUnknownVmStartup` and +`resolvePendingBridge` reconcile VM responses, `attemptRejectedStartupCleanup` retries rejected +handles, and `handleAlarm` dispatches watchdog effects. `retireShutdownAccess` is still manager +wiring from the shutdown coordinator. These are **current** owners, not extracted contracts. + +## Compatibility evidence + +Paths in this table are relative to `packages/control-plane/`: `L/` means `src/sandbox/lifecycle/`, +`S/` means `src/session/`, `I/` means `test/integration/`. **P**: passed in the pre-edit focused +commands below; **N**: named existing test not selected by those commands (not a pre-edit pass +claim; related session suites were run after edits as recorded below). T1 additions were run +separately. Gaps are remaining evidence limits, not permission to change behavior during extraction. + +| # | Parent invariant | Named test evidence (pre-edit result) | Remaining gap / limit | +| --- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| 1 | One manager public policy boundary; coordinator durable owner | `L/manager.test.ts` "does not invoke collaborators during construction and honors the injected hold first" (P); `S/sandbox-shutdown.test.ts` "distinguishes unmanaged and held shutdown requests" (N) | Ownership also requires a code-boundary review; tests cannot prove no second authority was introduced. | +| 2 | ID + timestamp generation and post-await conditional writes | `S/sandbox-repository.test.ts` "refuses a ready that belongs to a generation the row no longer holds" (P); `L/manager.test.ts` "does not let a late completion touch a newer reservation that re-entered spawning" (P) | Fresh/restore per-artifact writes are not generation-conditional (see separate gap below). | +| 3 | Atomic reservation/shutdown, immediate credential revocation, conditional hash | `I/sandbox-shutdown.test.ts` "rolls back the sandbox reservation when the matching shutdown write fails" (P); `L/manager.test.ts` "fresh spawn: reserves the new identity before hashing opens the input gate", "snapshot restore: reserves the new identity before hashing opens the input gate", "abandons the attempt without failure writes when the reservation is superseded" (P); `S/sandbox-repository.test.ts` "publishes the hash scoped to the reserved identity" (P) | Old authentication racing a _second_ reservation during the hash wait lacks one assembled real-storage test. | +| 4 | Provider response, bridge, ready and acknowledgement distinct | `L/manager.test.ts` "leaves a sandbox that connected during the provider call ready when the call then fails" (P); `I/sandbox-shutdown.test.ts` "accepts early ready for a saved restore but gates the queue until provider lifetime settles", "holds queued work until a versioned runtime acknowledges its sandbox generation" (P) | Not every bridge/foreground finalization permutation is covered. | +| 5 | Queue startup versus live-push readiness | `I/sandbox-early-connect.test.ts` "holds a queued prompt while the bridge is attached but the sandbox is still booting, then dispatches on ready" (P); `S/message-queue.test.ts` "defers, without spawning, while the bridge is attached but the sandbox is still booting" (N); `S/sandbox-push-service.test.ts` "refuses, rather than fakes, a push while the attached sandbox is still booting" (N) | No single race tests both consumers across each gate transition. | +| 6 | Unknown create/capture never proves absence or authorizes replay | `L/vm-resolve.test.ts` "fails once when the allocation remains invisible past the bound, allowing respawn", "fails definitively for another generation", "bounds repeated transient lookup errors without failing the pending generation" (P), "retains the foreground token for an equal-valued bridge claim after inconclusive lookup" (**T1**); `S/sandbox-shutdown.test.ts` "holds a lost VM capture response without retiring the source" (N); `I/sandbox-state-retention.test.ts` "holds an ambiguous legacy snapshot restore across restart instead of invoking it again" (P) | Null foreground resolution is not an absence assertion; foreground/bridge handoff is not exhaustive. | +| 7 | Hold neither adopts nor destroys retained source; explicit continuation | `L/manager.test.ts` "does not run generic termination or replacement while shutdown owns the source" (P); `I/sandbox-state-retention.test.ts` "preserve-stops a persistent provider instead of destroying its only recovery copy" (P); `S/sandbox-shutdown.test.ts` "keeps the hold when the source cannot be stopped for a discard" (N) | Late startup acceptance under an acquired hold has narrower race coverage. | +| 8 | Fresh/restore/resume identity and prebuilt fallback differ | `L/manager.test.ts` "refreshes terminal URL after resume without replacing its token", "does not silently create a fresh sandbox after retained final resume fails", "marks the repo image restore-failed and retries from base when its artifact is unavailable" (P); `L/vm-resolve.test.ts` "resolves an ambiguous base-image retry after a prebuilt image is unavailable" (P) | Retry versus outstanding old bridge/finalizer and token rotation not combined in one test. | +| 9 | Resume/bridge encrypt then conditional commit; only bridge checks reference; fresh/restore writes differ | `S/sandbox-repository.test.ts` "resolves a VM only while its generation and pending handle still match", "atomically records access for the current ... generation", "rejects access encrypted across a ... row", "ordinary resume does not require an expected bridge reference after encryption" (first two P; latter two **T1**); `L/vm-resolve.test.ts` "does not attach bridge access to a newer generation" (P) | Fresh/restore's separate unscoped artifact writes can leak into a successor (separate bug; no safety claim). | +| 10 | Rejection fences/retains, rearm precedes I/O, matching cleanup | `L/rejected-allocation.test.ts` "fences an early connected generation before waiting for mismatch cleanup", "retains rejected cleanup responsibility across restart and failed retirement", "rearms ... cleanup through the assembled alarm handler even under a shutdown hold" (P); "rearms cleanup before stop and does not clear a ... after the old stop succeeds" (**T1**); `S/sandbox-repository.test.ts` "fences ... and persists cleanup responsibility" (P) | Prior-generation replacement retirement has a different unscoped handle clear (separate bug). | +| 11 | Failure writer, classification, breaker dispatch reset | `L/manager.test.ts` "counts an attempt once when the watchdog fails it before the provider rejects it", "handles provider errors and increments failure count for permanent errors", "does not increment circuit breaker for transient errors", "clears the boot-failure streak once a prompt is dispatched to the sandbox" (P); `S/message-queue.test.ts` "does not report a dispatch when the sandbox send fails" (N) | No assembled queue retry during the watchdog/provider double-failure race. | +| 12 | Alarm priority, pinned targets, effect ordering/guards/results | `L/alarm-policy.test.ts` "prioritizes terminal, connect watchdog, stale heartbeat, boot budget, then inactivity"; `L/alarm-effects.test.ts` "heartbeat/inactivity publishes retirement before awaiting snapshot, then uses its required stop/shutdown order", "connecting watchdog/boot budget stops the generation it observed, not a replacement installed mid-alarm" (P); `L/alarm-boot-budget-effects.test.ts` "holds the termination guard only for provider stop, after publishing and detaching" (**T1**) | Guard coverage describes current order only; do not generalize it to other watchdogs. | +| 13 | Lazy logging, background timing, event/deadline/units and failure boundaries | `L/manager.test.ts` "does not invoke collaborators during construction and honors the injected hold first", "derives session_id from getSessionId per use, upgrading once the id changes" (P); `L/pending-vm-respawn.test.ts` "tracks a create/restore's pending handle with ...-second timeout after ... ms setup" (P); `S/alarm/scheduler.test.ts` "retains persisted state when setting the runtime alarm fails" (N) | No exhaustive assertion of every log text or all three modes' best-effort/fatal boundaries. | +| 14 | Transient plaintext auth; expiry/restart cannot reconstruct token | `S/sandbox-repository.test.ts` "sets all spawn fields atomically and invalidates credentials", "stores encrypted credentials and clears them" (P); `L/vm-resolve.test.ts` "reconciles a restarted bridge without blocking readiness or writing a replaced generation", "mints terminal access when the bridge resolves while the original instance holds the token" (P), "retains the foreground token for an equal-valued bridge claim after inconclusive lookup" (**T1**); `L/manager.test.ts` "keeps a resumed sandbox without terminal access when its terminal token is missing/expired" (P) | Old finalizer/new auth overlap still lacks a controlled interleaving; no exhaustive plaintext log assertion. | +| 15 | Conservative lifetime provenance is scheduling, not retirement proof | `L/pending-vm-respawn.test.ts` "tracks a create/restore's pending handle with ...-second timeout after ... ms setup" (P); `S/sandbox-shutdown-safety.test.ts` "verifies provider stop for conservative expiry/legacy expiry without provenance before restoring saved state", "accepts authoritative provider expiry as retirement proof" (N); `L/manager.test.ts` "retires an ambiguously resumed retained object before explicitly retrying it" (P) | No assembled restart interleaving spanning conservative bound, lost recovery response and source retirement. | + +## Separate behavior gaps + +These are _not_ extraction acceptance or claims of fixes. Confirm them with separate +reproducers/issues before behavior changes: + +- Fresh and restore access use `updateSandboxAccess` / `updateSandboxTunnelUrls` without generation + guards after encryption/other awaits; a replacement can receive the old artifact. The T1 real-SQL + tests intentionally characterize only the stronger resume/bridge path. T3 adds the real-SQL + reproducer "characterizes the unguarded fresh/restore artifact write across replacement": + encryption yields, a new identity is reserved, then the old URL and encrypted secret land on the + new row. The access extraction deliberately retains this behavior; a generation-guard fix is + separate work. +- `connection-authenticator.ts` rechecks identity/credentials after `scheduleDisconnectCheck`, but + not status; a same-generation stop during attachment can pass the final check. COL-238 proposes + addressing this but is not landed here. +- `manager.ts` prior-generation `stopPriorProviderSandbox` clears the singleton provider handle + after an await without comparing the replacement's generation/handle. This is distinct from the + rejected-allocation cleanup characterized above. +- Foreground VM auth finalization uses generation **object identity** whereas bridge resolution uses + equal-valued fields. T1 covers an inconclusive foreground lookup handing its token to an + equal-valued bridge observation; an old-finalizer/new-auth overlap still lacks coverage. Treat a + new safety assertion failing on this checkout as a separate bug, not a refactor regression. +- T3 characterizes another existing error boundary: "holds saved resume when its terminal secret + read fails before startup is committed". A successful provider response followed by secret-read + failure still marks the attempt failed and holds saved recovery. This differs from + access-write/publication failures after committed recovery, which retain startup success. No + boundary was moved or fixed. +- Retirement also retains the baseline's capability-based secret clearing rather than the stop + operation's intent. Destructive watchdog/rejection/discard paths on a resumable provider can keep + encrypted credentials. Independent synchronous clearing writes can partially fail, and shutdown's + clear/notify-before-detach sequence can skip detachment on an exception. Explicit intent, atomic + clearing and failure-independent detachment require a separate behavioral fix, not an extraction + claim. `sandbox-access.test.ts` records the inherited failure boundary, not a desired safety rule. +- `providerResumesAfterStop` centralizes the original preserve-stop predicate; it does not check + `resumeSandbox` or authorize recovery. All shipped persistent-resume providers implement resume, + but mismatched provider objects remain possible. Adding that method check would change the + inherited stop/credential policy. Saved retained recovery without the method already holds rather + than spawning fresh; ordinary resume retains its separate fresh fallback. A stronger provider + contract must preserve that distinction in separately approved work. + +## Commands and results + +Node `v24.20.0` satisfies root `engines.node >=24.0.0`; dependencies were installed. Commands ran +sequentially from repository root before test edits: + +| Command | Pre-edit result | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| `npm run build -w @open-inspect/shared` | Passed (`tsc`). | +| `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/session/sandbox-access src/session/sandbox-repository` | Passed: 18 files, 600 tests. | +| `npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect session-lifecycle-alarm-recovery sandbox-shutdown sandbox-state-retention` | Passed: 4 files, 55 tests (Workerd provider substitutes, not live provider verification). | +| `npm run typecheck -w @open-inspect/control-plane` | Passed all four TypeScript configs. | +| `npm run test:lint-sandbox-boundaries` | Passed: 2 tests. | +| `git diff --check` | Passed; checkout was clean. | + +After T1 test edits, the targeted 3-file command +`npm test -w @open-inspect/control-plane -- src/session/sandbox-repository.test.ts src/sandbox/lifecycle/rejected-allocation.test.ts src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts` +passed: 98 tests. Its first run failed only because the new guard observation expected two +broadcasts when the existing path emits three; the assertion was corrected to record the actual +baseline sequence. +`npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle/vm-resolve.test.ts` then passed: +21 tests. No production code was changed. Post-edit focused unit tests passed (18 files, 608 tests), +Workerd integration passed (4 files, 55 tests), and control-plane typecheck, boundary lint (2 +tests), targeted Prettier and ESLint checks, and `git diff --check` passed. Shared was built before +dependent checks as recorded above. No full control-plane unit/integration sweep, bundle build, or +live-provider canary was run for T1; those remain later-story verification. + +Additional post-edit command: +`npm test -w @open-inspect/control-plane -- src/session/sandbox-shutdown.test.ts src/session/sandbox-shutdown-safety.test.ts src/session/message-queue.test.ts src/session/sandbox-push-service.test.ts src/session/connection-authenticator.test.ts src/session/alarm src/session/sandbox-events/runtime.handler.test.ts` +passed (8 files, 278 tests). This includes the session-side N entries above; N still records their +pre-edit baseline scope honestly. It is not a full unit-suite run. diff --git a/eslint.config.js b/eslint.config.js index 079667270a..83a7a31725 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,26 @@ const sandboxImplementationImports = [ message: "Only session composition constructs the lifecycle manager. Consumers depend on focused lifecycle ports.", }, + { + regex: "(?:^|/)lifecycle/sandbox-access(?:\\.[cm]?[jt]sx?)?$", + message: + "Access mechanics are internal to lifecycle composition. Consumers use lifecycle ports and session access readers.", + }, + { + regex: "(?:^|/)lifecycle/vm-startup-reconciliation(?:\\.[cm]?[jt]sx?)?$", + message: + "VM startup reconciliation is internal to the lifecycle manager. Consumers use lifecycle ports.", + }, + { + regex: "(?:^|/)lifecycle/allocation-cleanup(?:\\.[cm]?[jt]sx?)?$", + message: + "Allocation cleanup is internal to the lifecycle manager. Consumers use lifecycle ports.", + }, + { + regex: "(?:^|/)lifecycle/provider-stop(?:\\.[cm]?[jt]sx?)?$", + message: + "Bounded provider-stop mechanics are internal to lifecycle. Consumers use lifecycle ports.", + }, ]; export default tseslint.config( diff --git a/package-lock.json b/package-lock.json index 23ce0b8857..319c9a8adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "husky": "^9.1.7", "knip": "^6.38.0", "lint-staged": "^17.6.0", - "prettier": "^3.9.9", + "prettier": "3.8.4", "typescript": "^6.0.3", "typescript-eslint": "^8.70.1", "wrangler": "^4.141.0" @@ -87,7 +87,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^3.4.1", @@ -104,7 +104,7 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz", "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "bidi-js": "^1.1.0", @@ -1691,7 +1691,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -1954,7 +1954,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -1974,7 +1974,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -1998,7 +1998,7 @@ "version": "4.2.4", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -2026,7 +2026,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -2049,7 +2049,7 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -2074,7 +2074,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", - "devOptional": true, + "dev": true, "funding": [ { "type": "github", @@ -2207,6 +2207,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2223,6 +2224,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2239,6 +2241,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2255,6 +2258,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2271,6 +2275,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2287,6 +2292,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2303,6 +2309,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2319,6 +2326,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2335,6 +2343,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2351,6 +2360,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2367,6 +2377,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2383,6 +2394,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2399,6 +2411,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2415,6 +2428,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2431,6 +2445,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2447,6 +2462,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2463,6 +2479,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2479,6 +2496,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2495,6 +2513,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2511,6 +2530,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2527,6 +2547,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2543,6 +2564,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2559,6 +2581,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2575,6 +2598,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2591,6 +2615,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2607,6 +2632,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2777,7 +2803,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -2963,6 +2989,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2985,6 +3012,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3007,6 +3035,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3023,6 +3052,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3039,6 +3069,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3055,6 +3086,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3071,6 +3103,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3087,6 +3120,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3103,6 +3137,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3119,6 +3154,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3135,6 +3171,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3151,6 +3188,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3167,6 +3205,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3189,6 +3228,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3211,6 +3251,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3233,6 +3274,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3255,6 +3297,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3277,6 +3320,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3299,6 +3343,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3321,6 +3366,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3340,6 +3386,7 @@ "version": "0.35.5", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -3356,6 +3403,7 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3369,6 +3417,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3388,6 +3437,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3407,6 +3457,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3484,7 +3535,7 @@ "version": "0.3.11", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -3495,7 +3546,7 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3662,15 +3713,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.6.tgz", - "integrity": "sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz", + "integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.6.tgz", - "integrity": "sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz", + "integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==", "cpu": [ "arm64" ], @@ -3684,9 +3735,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.6.tgz", - "integrity": "sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz", + "integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==", "cpu": [ "x64" ], @@ -3700,9 +3751,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.6.tgz", - "integrity": "sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz", + "integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==", "cpu": [ "arm64" ], @@ -3716,9 +3767,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.6.tgz", - "integrity": "sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz", + "integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==", "cpu": [ "arm64" ], @@ -3732,9 +3783,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.6.tgz", - "integrity": "sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz", + "integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==", "cpu": [ "x64" ], @@ -3748,9 +3799,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.6.tgz", - "integrity": "sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz", + "integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==", "cpu": [ "x64" ], @@ -3764,9 +3815,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.6.tgz", - "integrity": "sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz", + "integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==", "cpu": [ "arm64" ], @@ -3780,9 +3831,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.6.tgz", - "integrity": "sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz", + "integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==", "cpu": [ "x64" ], @@ -3828,7 +3879,7 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -6429,6 +6480,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6445,6 +6497,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6461,6 +6514,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6477,6 +6531,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6493,6 +6548,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6509,6 +6565,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6525,6 +6582,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6541,6 +6599,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6557,6 +6616,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6573,6 +6633,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6589,6 +6650,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6605,6 +6667,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6621,6 +6684,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6637,6 +6701,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6653,6 +6718,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8197,7 +8263,7 @@ "version": "24.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -9530,7 +9596,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -9661,7 +9727,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/bytes": { @@ -10200,7 +10266,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -10759,7 +10825,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -10850,7 +10916,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/decimal.js-light": { @@ -11171,7 +11237,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -11438,7 +11504,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -13743,9 +13809,9 @@ } }, "node_modules/hono": { - "version": "4.13.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", - "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", + "version": "4.13.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", + "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -13755,7 +13821,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.15.1" @@ -14308,7 +14374,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/is-promise": { @@ -14574,7 +14640,7 @@ "version": "30.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^7.0.0", @@ -14614,7 +14680,7 @@ "version": "8.11.2", "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=22.19.0" @@ -14624,7 +14690,7 @@ "version": "17.1.2", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.15.1", @@ -15238,7 +15304,7 @@ "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "devOptional": true, + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -15641,7 +15707,7 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "devOptional": true, + "dev": true, "license": "CC0-1.0" }, "node_modules/media-typer": { @@ -16673,12 +16739,12 @@ } }, "node_modules/next": { - "version": "16.3.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.6.tgz", - "integrity": "sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==", + "version": "16.3.8", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz", + "integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==", "license": "MIT", "dependencies": { - "@next/env": "16.3.6", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -16692,14 +16758,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.6", - "@next/swc-darwin-x64": "16.3.6", - "@next/swc-linux-arm64-gnu": "16.3.6", - "@next/swc-linux-arm64-musl": "16.3.6", - "@next/swc-linux-x64-gnu": "16.3.6", - "@next/swc-linux-x64-musl": "16.3.6", - "@next/swc-win32-arm64-msvc": "16.3.6", - "@next/swc-win32-x64-msvc": "16.3.6", + "@next/swc-darwin-arm64": "16.3.8", + "@next/swc-darwin-x64": "16.3.8", + "@next/swc-linux-arm64-gnu": "16.3.8", + "@next/swc-linux-arm64-musl": "16.3.8", + "@next/swc-linux-x64-gnu": "16.3.8", + "@next/swc-linux-x64-musl": "16.3.8", + "@next/swc-win32-arm64-msvc": "16.3.8", + "@next/swc-win32-x64-msvc": "16.3.8", "sharp": "^0.35.4" }, "peerDependencies": { @@ -17241,7 +17307,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "entities": "^8.0.0" @@ -17593,9 +17659,9 @@ } }, "node_modules/prettier": { - "version": "3.9.9", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", - "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -17696,7 +17762,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -18549,7 +18615,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -18749,6 +18815,7 @@ "version": "0.35.5", "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -18771,6 +18838,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { @@ -19004,7 +19072,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -19023,7 +19091,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -19495,7 +19563,7 @@ "version": "5.16.9", "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.9.tgz", "integrity": "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.2", @@ -19514,7 +19582,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/thenify": { @@ -19590,7 +19658,7 @@ "version": "7.4.15", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz", "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.4.15" @@ -19603,7 +19671,7 @@ "version": "7.4.15", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz", "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/to-regex-range": { @@ -19631,7 +19699,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -19644,7 +19712,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -19923,7 +19991,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unenv": { @@ -20380,6 +20448,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20400,6 +20469,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20420,6 +20490,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20440,6 +20511,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20460,6 +20532,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20480,6 +20553,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20500,6 +20574,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20520,6 +20595,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20540,6 +20616,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20560,6 +20637,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20580,6 +20658,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -20716,7 +20795,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -20759,7 +20838,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -20769,7 +20848,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -20779,7 +20858,7 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -21183,7 +21262,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -21193,7 +21272,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/y18n": { @@ -21404,7 +21483,7 @@ "@hono/node-server": "^2.1.1", "@open-inspect/shared": "file:../shared", "better-auth": "1.6.33", - "hono": "^4.13.9", + "hono": "^4.13.12", "ws": "^8.22.0", "yaml": "^2.9.0", "zod": "^4.6.5" @@ -21998,7 +22077,7 @@ "fumadocs-mdx": "15.4.5", "fumadocs-ui": "16.15.14", "mermaid": "^12.0.0", - "next": "^16.3.6", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.3.0", "react-dom": "^19.3.0", @@ -22172,7 +22251,7 @@ "dependencies": { "@cloudflare/workers-types": "^5.20260927.1", "@open-inspect/shared": "file:../shared", - "hono": "^4.13.9", + "hono": "^4.13.12", "zod": "^4.6.5" }, "devDependencies": { @@ -22701,7 +22780,7 @@ "dependencies": { "@cloudflare/workers-types": "^5.20260927.1", "@open-inspect/shared": "file:../shared", - "hono": "^4.13.9", + "hono": "^4.13.12", "zod": "^4.6.5" }, "devDependencies": { @@ -24270,7 +24349,7 @@ "@anthropic-ai/sdk": "^0.128.0", "@cloudflare/workers-types": "^5.20260927.1", "@open-inspect/shared": "file:../shared", - "hono": "^4.13.9", + "hono": "^4.13.12", "zod": "^4.6.5" }, "devDependencies": { @@ -25301,6 +25380,7 @@ "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-toggle-group": "^1.1.19", "@radix-ui/react-tooltip": "^1.2.16", "@tailwindcss/typography": "^0.5.19", @@ -25308,7 +25388,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "next": "^16.3.6", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.3.0", "react-dom": "^19.3.0", diff --git a/package.json b/package.json index 7cbb78773f..c9ce68d182 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "husky": "^9.1.7", "knip": "^6.38.0", "lint-staged": "^17.6.0", - "prettier": "^3.9.9", + "prettier": "3.8.4", "typescript": "^6.0.3", "typescript-eslint": "^8.70.1", "wrangler": "^4.141.0" diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index be425698c0..7b1a72f35c 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -22,7 +22,7 @@ "@hono/node-server": "^2.1.1", "@open-inspect/shared": "file:../shared", "better-auth": "1.6.33", - "hono": "^4.13.9", + "hono": "^4.13.12", "ws": "^8.22.0", "yaml": "^2.9.0", "zod": "^4.6.5" diff --git a/packages/control-plane/src/auth/github.test.ts b/packages/control-plane/src/auth/github.test.ts deleted file mode 100644 index 13dd898074..0000000000 --- a/packages/control-plane/src/auth/github.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it, vi, afterEach } from "vitest"; -import { GITHUB_OAUTH_REQUEST_TIMEOUT_MS, refreshAccessToken } from "./github"; -import type { GitHubOAuthConfig, GitHubTokenResponse } from "./github"; - -describe("github auth", () => { - const originalFetch = globalThis.fetch; - const config: GitHubOAuthConfig = { - clientId: "client-id", - clientSecret: "client-secret", - }; - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - describe("refreshAccessToken", () => { - it("parses a valid token response", async () => { - const tokenResponse: GitHubTokenResponse = { - access_token: "gho_token", - token_type: "bearer", - scope: "repo,user", - refresh_token: "ghr_refresh", - expires_in: 28800, - }; - - globalThis.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(tokenResponse), - } as unknown as Response); - - await expect(refreshAccessToken("old-refresh", config)).resolves.toEqual(tokenResponse); - }); - - it("parses a valid token response with optional fields omitted", async () => { - const tokenResponse: GitHubTokenResponse = { - access_token: "gho_token", - token_type: "bearer", - scope: "repo", - }; - - globalThis.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(tokenResponse), - } as unknown as Response); - - await expect(refreshAccessToken("old-refresh", config)).resolves.toEqual(tokenResponse); - }); - - it("rejects a malformed token response", async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve({ access_token: "gho_token", token_type: "bearer" }), - } as unknown as Response); - - await expect(refreshAccessToken("old-refresh", config)).rejects.toThrow( - "Invalid GitHub token response" - ); - }); - - it("preserves GitHub OAuth error handling", async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ - json: () => - Promise.resolve({ - error: "bad_verification_code", - error_description: "The code passed is incorrect or expired.", - }), - } as unknown as Response); - - await expect(refreshAccessToken("old-refresh", config)).rejects.toThrow( - "The code passed is incorrect or expired." - ); - }); - - it("aborts a stalled token refresh at the request deadline", async () => { - const timeout = new AbortController(); - const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeout.signal); - globalThis.fetch = vi.fn().mockImplementation( - (_url, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { - once: true, - }); - }) - ); - - const refreshPromise = refreshAccessToken("old-refresh", config); - const timeoutError = new DOMException("deadline exceeded", "TimeoutError"); - timeout.abort(timeoutError); - - await expect(refreshPromise).rejects.toBe(timeoutError); - expect(timeoutSpy).toHaveBeenCalledWith(GITHUB_OAUTH_REQUEST_TIMEOUT_MS); - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://github.com/login/oauth/access_token", - expect.objectContaining({ signal: timeout.signal }) - ); - }); - }); -}); diff --git a/packages/control-plane/src/auth/github.ts b/packages/control-plane/src/auth/github.ts deleted file mode 100644 index 1939848881..0000000000 --- a/packages/control-plane/src/auth/github.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { z } from "zod"; - -export const GITHUB_OAUTH_REQUEST_TIMEOUT_MS = 10_000; - -const githubOAuthErrorSchema = z.object({ - error: z.string().optional(), - error_description: z.string().optional(), -}); - -const githubTokenResponseSchema = z.object({ - access_token: z.string(), - token_type: z.string(), - scope: z.string(), - refresh_token: z.string().optional(), - expires_in: z.number().optional(), -}); - -export type GitHubTokenResponse = z.infer; - -async function parseGitHubTokenResponse(response: Response): Promise { - const data: unknown = await response.json(); - const errorResult = githubOAuthErrorSchema.safeParse(data); - if (errorResult.success && errorResult.data.error) { - throw new Error(errorResult.data.error_description ?? errorResult.data.error); - } - - const tokenResult = githubTokenResponseSchema.safeParse(data); - if (!tokenResult.success) { - throw new Error("Invalid GitHub token response"); - } - - return tokenResult.data; -} - -export interface GitHubOAuthConfig { - clientId: string; - clientSecret: string; -} - -/** - * Refresh an expired access token. - */ -export async function refreshAccessToken( - refreshToken: string, - config: GitHubOAuthConfig -): Promise { - const response = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: config.clientId, - client_secret: config.clientSecret, - grant_type: "refresh_token", - refresh_token: refreshToken, - }), - signal: AbortSignal.timeout(GITHUB_OAUTH_REQUEST_TIMEOUT_MS), - }); - - return parseGitHubTokenResponse(response); -} diff --git a/packages/control-plane/src/auth/model-provider-account-adapters.ts b/packages/control-plane/src/auth/model-provider-account-adapters.ts index ee6282be69..52274dad2f 100644 --- a/packages/control-plane/src/auth/model-provider-account-adapters.ts +++ b/packages/control-plane/src/auth/model-provider-account-adapters.ts @@ -147,7 +147,9 @@ export class ProviderIdentityError extends Error {} * control plane learning the result; only a fresh authorization is safe. */ export type ProviderAuthorizationCodeExchangeClassification = - "rejected" | "retry_safe" | "ambiguous"; + | "rejected" + | "retry_safe" + | "ambiguous"; export class ProviderAuthorizationCodeExchangeError extends Error { constructor( diff --git a/packages/control-plane/src/auth/openai.ts b/packages/control-plane/src/auth/openai.ts index 0305932746..1a470c6a7b 100644 --- a/packages/control-plane/src/auth/openai.ts +++ b/packages/control-plane/src/auth/openai.ts @@ -61,7 +61,8 @@ export type OpenAIDeviceAuthorization = { intervalMs: number; }; export type OpenAIDeviceStatus = - { status: "pending" } | { status: "authorized"; authorizationCode: string; codeVerifier: string }; + | { status: "pending" } + | { status: "authorized"; authorizationCode: string; codeVerifier: string }; export class OpenAITokenRefreshError extends Error { constructor( diff --git a/packages/control-plane/src/authorization/session-admission.ts b/packages/control-plane/src/authorization/session-admission.ts index 022c20f4ff..b79e6a5fbc 100644 --- a/packages/control-plane/src/authorization/session-admission.ts +++ b/packages/control-plane/src/authorization/session-admission.ts @@ -1,4 +1,12 @@ -import { checkSessionAccess, type SessionAction, type SessionViewer } from "@open-inspect/shared"; +import { + checkSessionAccess, + sessionCapabilities, + type AccessDenialReason, + type SessionAccessRow, + type SessionAction, + type SessionCapabilities, + type SessionViewer, +} from "@open-inspect/shared"; import type { PermissionId } from "@open-inspect/shared/rbac"; import type { TeamRole } from "@open-inspect/shared/types/teams"; import { SessionCollaboratorStore } from "../db/session-collaborators"; @@ -10,6 +18,7 @@ import { auditPrivateSessionBreakGlass } from "./request-audit"; import { legacyPermissionForAction, parseTeamsEnforcementMode, + resolverDecides, type TeamsEnforcementMode, } from "./teams-enforcement"; @@ -37,9 +46,33 @@ export function viewerFromContext( }; } +/** Preserve legacy read visibility without relaxing team-owned actions. */ +export function effectiveSessionCapabilities( + viewer: SessionViewer, + row: SessionAccessRow, + mode: TeamsEnforcementMode +): SessionCapabilities { + const capabilities = sessionCapabilities(viewer, row); + if (viewer.kind !== "user" || resolverDecides(mode, row, "read")) { + return capabilities; + } + const has = (action: SessionAction, resolved: boolean) => + resolverDecides(mode, row, action) + ? resolved + : viewer.permissions.includes(legacyPermissionForAction(action)); + return { + ...capabilities, + canRead: has("read", capabilities.canRead), + canCollaborate: has("collaborate", capabilities.canCollaborate), + canManageLifecycle: has("lifecycle", capabilities.canManageLifecycle), + canDelete: has("delete", capabilities.canDelete), + canSandbox: has("sandbox", capabilities.canSandbox), + }; +} + export type SessionAdmissionOutcome = | { kind: "not_found" } - | { kind: "action_denied"; reason: string } + | { kind: "action_denied"; reason: AccessDenialReason } | { kind: "allowed"; legacyPermission: PermissionId | null }; /** Resolve one D1 session; a null slot is used by body-ID batches, not item routes. */ @@ -48,18 +81,19 @@ export async function evaluateSessionAdmission( env: Env, sessionId: string, action: SessionAction, - slot: "session" | "child" | null = "session" + slot: "session" | "child" | null = "session", + enforceAlways = false ): Promise { - const mode = teamsEnforcementMode(ctx, env); + const mode = enforceAlways ? "on" : teamsEnforcementMode(ctx, env); const row = await new SessionIndexStore(ctx.db).get(sessionId); if (!row) return { kind: "not_found" }; - if (mode === "off" && row.visibility !== "private") { + if (mode === "off" && !resolverDecides(mode, row, action)) { return { kind: "allowed", legacyPermission: legacyPermissionForAction(action) }; } const memberships = - mode === "off" || !ctx.authorization + (mode === "off" && row.ownerTeamId === null) || !ctx.authorization ? new Map() : (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( ctx.authorization.userId @@ -86,7 +120,7 @@ export async function evaluateSessionAdmission( // The signed route grant authorizes actorless actions; the service resolver only checks visibility. const decision = viewer.kind === "service" ? null : checkSessionAccess(viewer, accessRow, action); - if (mode === "on" && decision && !decision.allowed) { + if (resolverDecides(mode, row, action) && decision && !decision.allowed) { return { kind: "action_denied", reason: decision.reason }; } if (mode === "shadow") { @@ -102,6 +136,6 @@ export async function evaluateSessionAdmission( } return { kind: "allowed", - legacyPermission: mode === "on" ? null : legacyPermissionForAction(action), + legacyPermission: resolverDecides(mode, row, action) ? null : legacyPermissionForAction(action), }; } diff --git a/packages/control-plane/src/authorization/session-socket-audit.ts b/packages/control-plane/src/authorization/session-socket-audit.ts new file mode 100644 index 0000000000..7232be90c7 --- /dev/null +++ b/packages/control-plane/src/authorization/session-socket-audit.ts @@ -0,0 +1,29 @@ +import type { SessionAccessRow } from "@open-inspect/shared"; +import type { SqlDatabase } from "../db/sql-database"; + +/** Persist the Owner's private-session read before the WebSocket snapshot is sent. */ +export async function auditSocketPrivateBreakGlass( + db: SqlDatabase, + actorUserId: string, + row: SessionAccessRow +): Promise { + await db + .prepare( + `INSERT INTO authorization_audit_events + (id, occurred_at, request_id, principal_kind, actor_user_id_snapshot, + actor_service_snapshot, action, resource_type, resource_id, team_id, + reason_code, operation_result, metadata_json) + VALUES (?, ?, ?, 'user', ?, NULL, 'session.private_break_glass', 'session', + ?, ?, 'session.private_break_glass', 'applied', ?)` + ) + .bind( + crypto.randomUUID(), + Date.now(), + crypto.randomUUID(), + actorUserId, + row.id, + row.ownerTeamId, + JSON.stringify({ before: {}, requested: {}, after: {} }) + ) + .run(); +} diff --git a/packages/control-plane/src/authorization/teams-enforcement.test.ts b/packages/control-plane/src/authorization/teams-enforcement.test.ts index a9fd84ed59..f28ada9ea7 100644 --- a/packages/control-plane/src/authorization/teams-enforcement.test.ts +++ b/packages/control-plane/src/authorization/teams-enforcement.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; -import { SESSION_ACTIONS } from "@open-inspect/shared"; -import { legacyPermissionForAction, parseTeamsEnforcementMode } from "./teams-enforcement"; +import { SESSION_ACTIONS, type SessionAccessRow, type SessionViewer } from "@open-inspect/shared"; +import { permissionsForBuiltInRole } from "@open-inspect/shared/rbac"; +import { effectiveSessionCapabilities } from "./session-admission"; +import { + legacyPermissionForAction, + parseTeamsEnforcementMode, + resolverDecides, +} from "./teams-enforcement"; describe("teams enforcement", () => { it("defaults to shadow and accepts only the three modes", () => { @@ -20,7 +26,67 @@ describe("teams enforcement", () => { "sessions.sandbox_access", "sessions.lifecycle", "sessions.lifecycle", - "sessions.lifecycle", ]); }); + + it.each(["off", "shadow", "on"] as const)( + "uses the resolver for private rows and team-owned actions in %s mode", + (mode) => { + for (const ownerTeamId of [null, "team_one"]) { + for (const visibility of ["workspace", "private", "team"] as const) { + if (ownerTeamId === null && visibility === "team") continue; + for (const action of SESSION_ACTIONS) { + expect(resolverDecides(mode, { ownerTeamId, visibility }, action)).toBe( + mode === "on" || + visibility === "private" || + (ownerTeamId !== null && action !== "read") + ); + } + } + } + } + ); + + it.each(["off", "shadow", "on"] as const)( + "keeps non-member team capabilities read-only in %s mode", + (mode) => { + const row: SessionAccessRow = { + id: "session_one", + ownerTeamId: "team_one", + ownerUserId: "user_one", + visibility: "workspace", + collaboratorIds: [], + }; + for (const roleKey of ["member", "administrator", "owner"] as const) { + const viewer: SessionViewer = { + kind: "user", + userId: "user_one", + roleKey, + permissions: permissionsForBuiltInRole(roleKey), + suspended: false, + memberships: new Map(), + }; + expect(effectiveSessionCapabilities(viewer, row, mode)).toEqual({ + canRead: true, + canCollaborate: false, + canManageLifecycle: false, + canDelete: false, + canSandbox: false, + canManageCollaborators: false, + canChangeVisibility: false, + }); + expect( + effectiveSessionCapabilities(viewer, { ...row, ownerTeamId: null }, mode) + ).toMatchObject({ + canRead: true, + canCollaborate: true, + canManageLifecycle: true, + canDelete: true, + canSandbox: true, + canManageCollaborators: true, + canChangeVisibility: true, + }); + } + } + ); }); diff --git a/packages/control-plane/src/authorization/teams-enforcement.ts b/packages/control-plane/src/authorization/teams-enforcement.ts index 6edb6da94b..b1bb516352 100644 --- a/packages/control-plane/src/authorization/teams-enforcement.ts +++ b/packages/control-plane/src/authorization/teams-enforcement.ts @@ -1,5 +1,5 @@ import type { PermissionId } from "@open-inspect/shared/rbac"; -import type { SessionAction } from "@open-inspect/shared"; +import type { SessionAccessRow, SessionAction } from "@open-inspect/shared"; export type TeamsEnforcementMode = "off" | "shadow" | "on"; @@ -9,6 +9,17 @@ export function parseTeamsEnforcementMode(value: string | undefined): TeamsEnfor throw new Error(`Invalid TEAMS_ENFORCEMENT: ${value}`); } +/** Private access and team-owned actions are never gated by the rollout mode. */ +export function resolverDecides( + mode: TeamsEnforcementMode, + row: Pick, + action: SessionAction +): boolean { + return ( + mode === "on" || row.visibility === "private" || (row.ownerTeamId !== null && action !== "read") + ); +} + export function legacyPermissionForAction(action: SessionAction): PermissionId { switch (action) { case "read": @@ -20,7 +31,6 @@ export function legacyPermissionForAction(action: SessionAction): PermissionId { case "sandbox": return "sessions.sandbox_access"; case "lifecycle": - case "move": case "changeVisibility": case "manageCollaborators": return "sessions.lifecycle"; diff --git a/packages/control-plane/src/autofix/service.test.ts b/packages/control-plane/src/autofix/service.test.ts index b60fe54b06..6b519d4073 100644 --- a/packages/control-plane/src/autofix/service.test.ts +++ b/packages/control-plane/src/autofix/service.test.ts @@ -87,13 +87,15 @@ function buildService() { repoOwner: "acme", repoName: "widgets", })), - getPullRequestFeedback: vi.fn(async (): Promise => ({ - kind: "pr_comment", - id: "1234", - body: "Please handle the null case.", - url: "https://github.com/acme/widgets/pull/42#issuecomment-1234", - author: { id: "7", login: "alice", type: "User" }, - })), + getPullRequestFeedback: vi.fn( + async (): Promise => ({ + kind: "pr_comment", + id: "1234", + body: "Please handle the null case.", + url: "https://github.com/acme/widgets/pull/42#issuecomment-1234", + author: { id: "7", login: "alice", type: "User" }, + }) + ), hasPullRequestWritePermission: vi.fn(async () => true), }; const sessions = { diff --git a/packages/control-plane/src/created-at-cursor.ts b/packages/control-plane/src/created-at-cursor.ts index 0c321381ad..a0b98fd762 100644 --- a/packages/control-plane/src/created-at-cursor.ts +++ b/packages/control-plane/src/created-at-cursor.ts @@ -4,7 +4,8 @@ export interface CreatedAtCursor { } export type ParseCreatedAtCursorResult = - { ok: true; cursor: CreatedAtCursor | null } | { ok: false; error: "Invalid cursor" }; + | { ok: true; cursor: CreatedAtCursor | null } + | { ok: false; error: "Invalid cursor" }; export function encodeCreatedAtCursor(cursor: CreatedAtCursor): string { return `${cursor.createdAt}:${encodeURIComponent(cursor.id)}`; diff --git a/packages/control-plane/src/db/analytics-dashboard-store.test.ts b/packages/control-plane/src/db/analytics-dashboard-store.test.ts index 75655caf58..a5bf5c7f06 100644 --- a/packages/control-plane/src/db/analytics-dashboard-store.test.ts +++ b/packages/control-plane/src/db/analytics-dashboard-store.test.ts @@ -87,7 +87,11 @@ describe("AnalyticsDashboardStore", () => { }), batch: batch as SqlDatabase["batch"], }; - const store = new AnalyticsDashboardStore(db); + const store = new AnalyticsDashboardStore( + db, + { kind: "internal", reason: "verify batch" }, + "on" + ); const response = await store.get({ days: 7, diff --git a/packages/control-plane/src/db/analytics-dashboard-store.ts b/packages/control-plane/src/db/analytics-dashboard-store.ts index 8b72a4eb76..7c387a7402 100644 --- a/packages/control-plane/src/db/analytics-dashboard-store.ts +++ b/packages/control-plane/src/db/analytics-dashboard-store.ts @@ -7,6 +7,8 @@ import { AnalyticsStore } from "./analytics-store"; import { PullRequestAnalyticsStore } from "./pull-request-analytics-store"; import { SessionRunStore } from "./session-run-store"; import type { SqlDatabase } from "./sql-database"; +import type { SessionReadScope } from "./session-visibility"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; export interface AnalyticsDashboardFilters { days: AnalyticsDays; @@ -18,12 +20,16 @@ export interface AnalyticsDashboardFilters { export const DASHBOARD_RUNS_LIMIT = 20; export class AnalyticsDashboardStore { - constructor(private readonly db: SqlDatabase) {} + constructor( + private readonly db: SqlDatabase, + private readonly readScope: SessionReadScope, + private readonly mode: TeamsEnforcementMode + ) {} async get(filters: AnalyticsDashboardFilters): Promise { - const analytics = new AnalyticsStore(this.db); - const pullRequests = new PullRequestAnalyticsStore(this.db); - const runs = new SessionRunStore(this.db); + const analytics = new AnalyticsStore(this.db, this.readScope, this.mode); + const pullRequests = new PullRequestAnalyticsStore(this.db, this.readScope, this.mode); + const runs = new SessionRunStore(this.db, this.readScope, this.mode); const sessionFilters = { startAt: filters.startAt, endAt: filters.endAt, diff --git a/packages/control-plane/src/db/analytics-store.test.ts b/packages/control-plane/src/db/analytics-store.test.ts index d1969adfd2..aeb4bce5c3 100644 --- a/packages/control-plane/src/db/analytics-store.test.ts +++ b/packages/control-plane/src/db/analytics-store.test.ts @@ -7,14 +7,25 @@ function result(results: unknown[]): SqlResult { } describe("AnalyticsStore row decoding", () => { - const store = new AnalyticsStore({ - prepare: () => { - throw new Error("not used"); + const store = new AnalyticsStore( + { + prepare: () => { + throw new Error("not used"); + }, + batch: async () => { + throw new Error("not used"); + }, }, - batch: async () => { - throw new Error("not used"); + { + kind: "user", + userId: "owner", + roleKey: "owner", + permissions: [], + suspended: false, + memberships: new Map(), }, - }); + "on" + ); it("decodes a valid summary row", () => { expect( @@ -24,6 +35,7 @@ describe("AnalyticsStore row decoding", () => { total_sessions: 2, active_users: 1, total_cost: 4, + private_sessions_cost: 1.5, total_prs: 3, input_tokens: 2, output_tokens: 4, @@ -43,6 +55,7 @@ describe("AnalyticsStore row decoding", () => { totalSessions: 2, activeUsers: 1, totalCost: 4, + privateSessionsCostUsd: 1.5, avgCost: 2, totalPrs: 3, inputTokens: 2, @@ -67,6 +80,7 @@ describe("AnalyticsStore row decoding", () => { inputTokens: 0, cacheReadTokens: 0, cacheHitRatio: null, + privateSessionsCostUsd: 0, }); }); @@ -249,6 +263,53 @@ describe("AnalyticsStore row decoding", () => { }); describe("scope and breakdown merging", () => { + it("binds the privileged private-cost query to the same window and scope", () => { + const queries: string[] = []; + const bindings: unknown[][] = []; + const statement = { + bind: (...values: unknown[]) => { + bindings.push(values); + return statement; + }, + first: vi.fn(), + run: vi.fn(), + all: vi.fn(), + }; + const db = { + prepare: (query: string) => { + queries.push(query); + return statement; + }, + batch: async () => [], + }; + const filters = { startAt: 10, endAt: 20, scope: "agent" as const }; + new AnalyticsStore( + db, + { + kind: "user", + userId: "owner", + roleKey: "owner", + permissions: [], + suspended: false, + memberships: new Map(), + }, + "on" + ).prepareSummary(filters); + expect(queries[0]).toContain("private.spawn_source IN (?)"); + expect(bindings[0]).toEqual([10, 20, "agent", 10, 20, "agent", 1, "owner"]); + + new AnalyticsStore(db, { kind: "service", teamId: null }, "on").prepareSummary(filters); + expect(queries[1]).toContain("NULL AS private_sessions_cost"); + expect(bindings[1]).toEqual([10, 20, "agent"]); + const service = new AnalyticsStore(db, { kind: "service", teamId: null }, "on"); + expect(service.decodeSummary(result([])).privateSessionsCostUsd).toBeNull(); + new AnalyticsStore(db, { kind: "internal", reason: "audit all costs" }, "on").prepareSummary( + filters + ); + expect(queries[2]).not.toContain("visibility"); + expect(bindings[2]).toEqual([10, 20, "agent"]); + }); + it("uses the exact human population and no predicate for all", () => { expect(scopePredicate("human", "s.spawn_source")).toEqual({ sql: "AND s.spawn_source IN (?, ?, ?, ?)", @@ -274,14 +335,18 @@ describe("scope and breakdown merging", () => { run: vi.fn(), all: vi.fn(), }; - const store = new AnalyticsStore({ - prepare: (query) => { - sql = query; - queries.push(query); - return statement; + const store = new AnalyticsStore( + { + prepare: (query) => { + sql = query; + queries.push(query); + return statement; + }, + batch: async () => [], }, - batch: async () => [], - }); + { kind: "internal", reason: "verify unfiltered billing" }, + "on" + ); store.prepareBilling({ startAt: 10, endAt: 20, scope: "agent" }); expect(sql).toContain( "JOIN session_model_provider_auth a ON a.session_id = s.id AND a.auth_mode = 'provider_account'" diff --git a/packages/control-plane/src/db/analytics-store.ts b/packages/control-plane/src/db/analytics-store.ts index 812e23235e..852f9075d5 100644 --- a/packages/control-plane/src/db/analytics-store.ts +++ b/packages/control-plane/src/db/analytics-store.ts @@ -21,6 +21,8 @@ import { HARNESS_CATALOG, isValidHarness } from "@open-inspect/shared/harnesses" import { SUBSCRIPTION_PROVIDER_DISPLAY_METADATA } from "@open-inspect/shared/types/provider-accounts"; import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database"; import { MS_PER_DAY, utcDateFromDayIndex } from "./utc-day"; +import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; import { z } from "zod"; export interface AnalyticsFilters { @@ -60,6 +62,7 @@ const summaryRowSchema = tokenRowSchema.extend({ total_sessions: z.number(), active_users: z.number(), total_cost: z.number(), + private_sessions_cost: z.number().nullable(), total_prs: z.number(), created_count: z.number(), active_count: z.number(), @@ -151,7 +154,17 @@ export function mergeBreakdownEntries( } export class AnalyticsStore { - constructor(private readonly db: SqlDatabase) {} + constructor( + private readonly db: SqlDatabase, + private readonly readScope: SessionReadScope, + private readonly mode: TeamsEnforcementMode + ) {} + + private visible(alias: string) { + return this.readScope.kind === "internal" + ? { sql: "", params: [] } + : visibleSessionsPredicate(alias, this.readScope, { mode: this.mode, excludePrivate: true }); + } async getSummary(filters: AnalyticsFilters): Promise { const result = await this.prepareSummary(filters).all(); @@ -159,7 +172,12 @@ export class AnalyticsStore { } prepareSummary(filters: AnalyticsFilters): SqlStatement { - const { sql, binds } = scopePredicate(filters.scope, "spawn_source"); + const { sql, binds } = scopePredicate(filters.scope, "s.spawn_source"); + const visible = this.visible("s"); + const privileged = + this.readScope.kind === "user" && + (this.readScope.roleKey === "owner" || this.readScope.roleKey === "administrator"); + const privateScope = scopePredicate(filters.scope, "private.spawn_source"); return this.db .prepare( @@ -169,8 +187,15 @@ export class AnalyticsStore { -- During the Phase 4→6 rollout window, the same person may appear under both -- keys (scm_login on old sessions, user_id on new), temporarily inflating this -- count. Resolves once the Phase 6 backfill populates user_id on historical rows. - COUNT(DISTINCT COALESCE(user_id, NULLIF(scm_login, ''))) AS active_users, - COALESCE(SUM(total_cost), 0) AS total_cost, + COUNT(DISTINCT COALESCE(s.user_id, NULLIF(s.scm_login, ''))) AS active_users, + COALESCE(SUM(s.total_cost), 0) AS total_cost, + ${ + privileged + ? `(SELECT COALESCE(SUM(private.total_cost), 0) FROM sessions private + WHERE private.visibility = 'private' AND private.created_at >= ? AND private.created_at < ? + ${privateScope.sql})` + : "NULL" + } AS private_sessions_cost, COALESCE(SUM(pr_count), 0) AS total_prs, COALESCE(SUM(input_tokens), 0) AS input_tokens, COALESCE(SUM(output_tokens), 0) AS output_tokens, @@ -183,11 +208,17 @@ export class AnalyticsStore { COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count, COALESCE(SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END), 0) AS archived_count, COALESCE(SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END), 0) AS cancelled_count - FROM sessions - WHERE created_at >= ? AND created_at < ? - ${sql}` + FROM sessions s + WHERE s.created_at >= ? AND s.created_at < ? + ${sql} ${visible.sql ? `AND ${visible.sql}` : ""}` ) - .bind(filters.startAt, filters.endAt, ...binds); + .bind( + ...(privileged ? [filters.startAt, filters.endAt, ...privateScope.binds] : []), + filters.startAt, + filters.endAt, + ...binds, + ...visible.params + ); } decodeSummary(result: SqlResult): AnalyticsSummaryResponse { @@ -201,6 +232,11 @@ export class AnalyticsStore { totalSessions, activeUsers: row?.active_users ?? 0, totalCost, + privateSessionsCostUsd: + this.readScope.kind === "user" && + (this.readScope.roleKey === "owner" || this.readScope.roleKey === "administrator") + ? (row?.private_sessions_cost ?? 0) + : null, avgCost: totalSessions > 0 ? totalCost / totalSessions : 0, totalPrs: row?.total_prs ?? 0, ...tokens, @@ -223,6 +259,7 @@ export class AnalyticsStore { prepareTimeseries(filters: AnalyticsFilters): SqlStatement { const { sql, binds } = scopePredicate(filters.scope, "s.spawn_source"); + const visible = this.visible("s"); return this.db .prepare( @@ -233,11 +270,11 @@ export class AnalyticsStore { FROM sessions s LEFT JOIN users u ON s.user_id = u.id WHERE s.created_at >= ? AND s.created_at < ? - ${sql} + ${sql} ${visible.sql ? `AND ${visible.sql}` : ""} GROUP BY day_index, COALESCE(s.user_id, '__unlinked__' || COALESCE(s.scm_login, '__none__')) ORDER BY day_index ASC, group_key ASC` ) - .bind(filters.startAt, filters.endAt, ...binds); + .bind(filters.startAt, filters.endAt, ...binds, ...visible.params); } decodeTimeseries(result: SqlResult): AnalyticsTimeseriesResponse { @@ -277,16 +314,17 @@ export class AnalyticsStore { prepareBilling(filters: AnalyticsFilters): SqlStatement { const { sql, binds } = scopePredicate(filters.scope, "s.spawn_source"); + const visible = this.visible("s"); return this.db .prepare( `SELECT s.model AS model, a.provider AS provider, COUNT(*) AS sessions FROM sessions s JOIN session_model_provider_auth a ON a.session_id = s.id AND a.auth_mode = 'provider_account' WHERE s.created_at >= ? AND s.created_at < ? - ${sql} + ${sql} ${visible.sql ? `AND ${visible.sql}` : ""} GROUP BY s.model, a.provider` ) - .bind(filters.startAt, filters.endAt, ...binds); + .bind(filters.startAt, filters.endAt, ...binds, ...visible.params); } prepareBreakdown(filters: AnalyticsFilters, by: SqlBreakdownBy): SqlStatement { @@ -318,6 +356,7 @@ export class AnalyticsStore { const orderTail = isUserBreakdown || by === "automation" ? "display_name ASC" : "key ASC"; const { sql, binds } = scopePredicate(filters.scope, "s.spawn_source"); + const visible = this.visible("s"); return this.db .prepare( @@ -344,12 +383,12 @@ export class AnalyticsStore { FROM sessions s ${joinClause} WHERE s.created_at >= ? AND s.created_at < ? - ${sql} + ${sql} ${visible.sql ? `AND ${visible.sql}` : ""} ${by === "automation" ? "AND s.automation_id IS NOT NULL" : ""} GROUP BY key ORDER BY sessions DESC, ${orderTail}` ) - .bind(filters.startAt, filters.endAt, ...binds); + .bind(filters.startAt, filters.endAt, ...binds, ...visible.params); } decodeBreakdown(result: SqlResult, by: SqlBreakdownBy): AnalyticsBreakdownResponse { diff --git a/packages/control-plane/src/db/audit-event-cursor.ts b/packages/control-plane/src/db/audit-event-cursor.ts index bd6589e407..c46cf61cf1 100644 --- a/packages/control-plane/src/db/audit-event-cursor.ts +++ b/packages/control-plane/src/db/audit-event-cursor.ts @@ -6,7 +6,8 @@ export interface AuditEventCursor { } type ParseAuditEventCursorResult = - { ok: true; cursor: AuditEventCursor | null } | { ok: false; error: "Invalid cursor" }; + | { ok: true; cursor: AuditEventCursor | null } + | { ok: false; error: "Invalid cursor" }; export function encodeAuditEventCursor(cursor: AuditEventCursor): string { return `${cursor.occurredAt}:${encodeURIComponent(cursor.id)}`; diff --git a/packages/control-plane/src/db/audit-event-store.ts b/packages/control-plane/src/db/audit-event-store.ts index 0ab88d3829..077bf60976 100644 --- a/packages/control-plane/src/db/audit-event-store.ts +++ b/packages/control-plane/src/db/audit-event-store.ts @@ -36,27 +36,29 @@ export function toAuditEvent(row: AuditEventRow): AuditEvent { }); } -/** Read-only D1 access for the workspace audit log. */ +/** Read-only access for the permission-gated workspace audit log. */ export class AuditEventStore { constructor(private readonly db: SqlDatabase) {} - async list(options: { limit: number; cursor: AuditEventCursor | null }) { - const result = options.cursor - ? await this.db - .prepare( - `SELECT * FROM authorization_audit_events - WHERE (occurred_at, id) < (?, ?) - ORDER BY occurred_at DESC, id DESC LIMIT ?` - ) - .bind(options.cursor.occurredAt, options.cursor.id, options.limit + 1) - .all() - : await this.db - .prepare( - `SELECT * FROM authorization_audit_events - ORDER BY occurred_at DESC, id DESC LIMIT ?` - ) - .bind(options.limit + 1) - .all(); + async list(options: { limit: number; cursor: AuditEventCursor | null; teamId?: string }) { + const conditions: string[] = []; + const params: unknown[] = []; + if (options.teamId !== undefined) { + conditions.push("audit.team_id = ?"); + params.push(options.teamId); + } + if (options.cursor) { + conditions.push("(audit.occurred_at, audit.id) < (?, ?)"); + params.push(options.cursor.occurredAt, options.cursor.id); + } + const result = await this.db + .prepare( + `SELECT audit.* FROM authorization_audit_events audit + ${conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""} + ORDER BY audit.occurred_at DESC, audit.id DESC LIMIT ?` + ) + .bind(...params, options.limit + 1) + .all(); const rows = result.results ?? []; const hasMore = rows.length > options.limit; diff --git a/packages/control-plane/src/db/automation-store.ts b/packages/control-plane/src/db/automation-store.ts index 00390a56a1..29e25f36da 100644 --- a/packages/control-plane/src/db/automation-store.ts +++ b/packages/control-plane/src/db/automation-store.ts @@ -104,7 +104,8 @@ export function withValidatedOwnerTeam(row: AutomationRow): AutomationRow { } type AutomationListResult = { automations: AutomationRow[] } & ( - { hasMore: false; nextCursor: null } | { hasMore: true; nextCursor: CreatedAtCursor } + | { hasMore: false; nextCursor: null } + | { hasMore: true; nextCursor: CreatedAtCursor } ); /** @@ -225,7 +226,8 @@ const countRowSchema = z.object({ count: z.number() }); * concurrent PATCH cannot be overtaken by a firing holding a stale snapshot. */ export type InvocationOverlapScope = - { kind: "automation" } | { kind: "concurrencyKey"; concurrencyKey: string }; + | { kind: "automation" } + | { kind: "concurrencyKey"; concurrencyKey: string }; /** * A cron slot handover: move the schedule from the slot this firing claimed diff --git a/packages/control-plane/src/db/model-provider-account-atomic-writer.ts b/packages/control-plane/src/db/model-provider-account-atomic-writer.ts index 7299175291..5e22d4c6b0 100644 --- a/packages/control-plane/src/db/model-provider-account-atomic-writer.ts +++ b/packages/control-plane/src/db/model-provider-account-atomic-writer.ts @@ -81,10 +81,14 @@ export interface FinalizeDeviceAuthorizationReconnectInput extends DeviceAuthori } export type DeviceAuthorizationCreateOutcome = - { type: "created" } | { type: "identity_conflict" } | { type: "claim_lost" }; + | { type: "created" } + | { type: "identity_conflict" } + | { type: "claim_lost" }; export type DeviceAuthorizationReconnectOutcome = - { type: "connected" } | { type: "claim_lost" } | { type: "target_changed" }; + | { type: "connected" } + | { type: "claim_lost" } + | { type: "target_changed" }; export interface ModelProviderAccountAtomicWriter { createAccountWithCredential( diff --git a/packages/control-plane/src/db/pr-autofix-feedback-store.ts b/packages/control-plane/src/db/pr-autofix-feedback-store.ts index e30fbdf760..9bacd7a3f3 100644 --- a/packages/control-plane/src/db/pr-autofix-feedback-store.ts +++ b/packages/control-plane/src/db/pr-autofix-feedback-store.ts @@ -1,5 +1,6 @@ import type { GitHubAutofixEnvelope } from "@open-inspect/shared"; import type { SqlDatabase } from "./sql-database"; +import { visibleSessionsPredicate } from "./session-visibility"; type PrAutofixDecision = "received" | "queued" | "skipped" | "failed"; @@ -174,24 +175,27 @@ export class PrAutofixFeedbackStore { limit: number; cursor: string | null; }): Promise<{ records: PrAutofixFeedbackRecord[]; nextCursor: string | null }> { + // Deleted sessions set feedback.session_id to NULL; unattached rows retain PR metadata. const cursor = options.cursor ? decodeActivityCursor(options.cursor) : null; - const statement = cursor - ? this.db - .prepare( - `SELECT * FROM pr_autofix_feedback - WHERE last_received_at < ? - OR (last_received_at = ? AND feedback_key < ?) - ORDER BY last_received_at DESC, feedback_key DESC - LIMIT ?` - ) - .bind(cursor.lastReceivedAt, cursor.lastReceivedAt, cursor.feedbackKey, options.limit + 1) - : this.db - .prepare( - `SELECT * FROM pr_autofix_feedback - ORDER BY last_received_at DESC, feedback_key DESC - LIMIT ?` - ) - .bind(options.limit + 1); + const visibility = visibleSessionsPredicate( + "s", + { kind: "service", teamId: null }, + { mode: "on", excludePrivate: true } + ); + const statement = this.db + .prepare( + `SELECT f.* FROM pr_autofix_feedback f + LEFT JOIN sessions s ON s.id = f.session_id + WHERE (f.session_id IS NULL OR (s.id IS NOT NULL AND (${visibility.sql}))) + ${cursor ? "AND (f.last_received_at < ? OR (f.last_received_at = ? AND f.feedback_key < ?))" : ""} + ORDER BY f.last_received_at DESC, f.feedback_key DESC + LIMIT ?` + ) + .bind( + ...visibility.params, + ...(cursor ? [cursor.lastReceivedAt, cursor.lastReceivedAt, cursor.feedbackKey] : []), + options.limit + 1 + ); const result = await statement.all(); const hasMore = result.results.length > options.limit; const rows = hasMore ? result.results.slice(0, options.limit) : result.results; diff --git a/packages/control-plane/src/db/provider-account-authorizations.ts b/packages/control-plane/src/db/provider-account-authorizations.ts index 2963023ead..92047b1fac 100644 --- a/packages/control-plane/src/db/provider-account-authorizations.ts +++ b/packages/control-plane/src/db/provider-account-authorizations.ts @@ -120,7 +120,9 @@ export type ProviderAuthorization = | TerminalProviderAuthorization; export type ProviderAuthorizationLive = - InitiatingProviderAuthorization | PendingProviderAuthorization | ProcessingProviderAuthorization; + | InitiatingProviderAuthorization + | PendingProviderAuthorization + | ProcessingProviderAuthorization; function requiredString(value: string | null, field: string): string { if (!value) throw new Error(`Invalid provider authorization ${field}`); diff --git a/packages/control-plane/src/db/pull-request-analytics-store.test.ts b/packages/control-plane/src/db/pull-request-analytics-store.test.ts index cb44b1d251..053617cfad 100644 --- a/packages/control-plane/src/db/pull-request-analytics-store.test.ts +++ b/packages/control-plane/src/db/pull-request-analytics-store.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { PullRequestAnalyticsStore } from "./pull-request-analytics-store"; import type { SqlResult } from "./sql-database"; @@ -7,14 +7,18 @@ function result(results: unknown[]): SqlResult { } function store() { - return new PullRequestAnalyticsStore({ - prepare: () => { - throw new Error("not used"); - }, - batch: async () => { - throw new Error("not used"); + return new PullRequestAnalyticsStore( + { + prepare: () => { + throw new Error("not used"); + }, + batch: async () => { + throw new Error("not used"); + }, }, - }); + { kind: "internal", reason: "decode persisted rows" }, + "on" + ); } describe("PullRequestAnalyticsStore row decoding", () => { @@ -97,3 +101,50 @@ describe("PullRequestAnalyticsStore row decoding", () => { expect(() => store().decode(results)).toThrow("Invalid PR harness row"); }); }); + +it("keeps missing-session PRs in cohort metrics but not in session dimensions", () => { + const queries: string[] = []; + const bindings: unknown[][] = []; + const statement = { + bind: (...values: unknown[]) => { + bindings.push(values); + return statement; + }, + first: vi.fn(), + all: vi.fn(), + run: vi.fn(), + }; + const db = { + prepare: (sql: string) => { + queries.push(sql); + return statement; + }, + batch: async () => [], + }; + const member = { + kind: "user" as const, + userId: "member", + roleKey: "member" as const, + permissions: ["analytics.read"] as const, + suspended: false, + memberships: new Map(), + }; + new PullRequestAnalyticsStore(db, member, "on").prepare({ startAt: 10, endAt: 20, now: 30 }); + expect(queries).toHaveLength(10); + expect(queries[0]).toContain("LEFT JOIN sessions s ON s.id = p.session_id"); + expect(queries[0]).toContain("s.id IS NULL OR"); + expect(queries[1]).toContain("LEFT JOIN sessions s ON s.id = cohort.session_id"); + expect(queries[1]).toContain("s.id IS NULL OR"); + expect(queries[7]).toContain("s.spawn_source IS NOT NULL"); + expect(queries[8]).toContain("LEFT JOIN sessions x ON x.id = cohort.session_id"); + expect(queries[8]).toContain("x.id IS NULL OR"); + expect(queries[8]).toContain("s.model IS NOT NULL"); + expect(bindings[8]).toEqual([10, 20, 0, "member", 10, 20, 0, "member"]); + queries.length = 0; + bindings.length = 0; + new PullRequestAnalyticsStore(db, { kind: "internal", reason: "audit orphan PRs" }, "on").prepare( + { startAt: 10, endAt: 20, now: 30 } + ); + expect(queries.every((sql) => !sql.includes("visibility"))).toBe(true); + expect(bindings[8]).toEqual([10, 20, 10, 20]); +}); diff --git a/packages/control-plane/src/db/pull-request-analytics-store.ts b/packages/control-plane/src/db/pull-request-analytics-store.ts index 2f20baf1aa..a6d50dbc5f 100644 --- a/packages/control-plane/src/db/pull-request-analytics-store.ts +++ b/packages/control-plane/src/db/pull-request-analytics-store.ts @@ -15,6 +15,8 @@ import { getModelDisplayName, normalizeModelId } from "@open-inspect/shared/mode import { HARNESS_CATALOG, isValidHarness } from "@open-inspect/shared/harnesses"; import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database"; import { MS_PER_DAY, utcDateFromDayIndex } from "./utc-day"; +import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; import { z } from "zod"; /** `now` anchors the open-inventory age computation. */ @@ -84,7 +86,20 @@ function prCreatedAtExpr(alias = ""): string { } export class PullRequestAnalyticsStore { - constructor(private readonly db: SqlDatabase) {} + constructor( + private readonly db: SqlDatabase, + private readonly readScope: SessionReadScope, + private readonly mode: TeamsEnforcementMode + ) {} + + private visible(alias: string): { sql: string; params: unknown[] } { + if (this.readScope.kind === "internal") return { sql: "", params: [] }; + const visible = visibleSessionsPredicate(alias, this.readScope, { + mode: this.mode, + excludePrivate: true, + }); + return { sql: `(${alias}.id IS NULL OR ${visible.sql})`, params: visible.params }; + } /** * Two windows with different populations: the funnel/repos/sources cohort is @@ -102,98 +117,100 @@ export class PullRequestAnalyticsStore { } prepare(filters: PullRequestAnalyticsFilters): SqlStatement[] { - const prCreatedAt = prCreatedAtExpr(); + const prCreatedAt = prCreatedAtExpr("p"); const cohortWindow = `${prCreatedAt} >= ? AND ${prCreatedAt} < ?`; const cohortBinds = [filters.startAt, filters.endAt]; + const visible = this.visible("s"); + const whereVisible = visible.sql ? `AND ${visible.sql}` : ""; + const prSessions = `FROM session_pull_requests p LEFT JOIN sessions s ON s.id = p.session_id`; const statements = [ this.db .prepare( `SELECT COUNT(*) AS created, - COALESCE(SUM(CASE WHEN lifecycle_state = 'open' AND is_draft = 0 THEN 1 ELSE 0 END), 0) AS open, - COALESCE(SUM(CASE WHEN lifecycle_state = 'open' AND is_draft = 1 THEN 1 ELSE 0 END), 0) AS draft, - COALESCE(SUM(CASE WHEN lifecycle_state = 'merged' THEN 1 ELSE 0 END), 0) AS merged, - COALESCE(SUM(CASE WHEN lifecycle_state = 'closed' THEN 1 ELSE 0 END), 0) AS closed - FROM session_pull_requests - WHERE ${cohortWindow}` + COALESCE(SUM(CASE WHEN p.lifecycle_state = 'open' AND p.is_draft = 0 THEN 1 ELSE 0 END), 0) AS open, + COALESCE(SUM(CASE WHEN p.lifecycle_state = 'open' AND p.is_draft = 1 THEN 1 ELSE 0 END), 0) AS draft, + COALESCE(SUM(CASE WHEN p.lifecycle_state = 'merged' THEN 1 ELSE 0 END), 0) AS merged, + COALESCE(SUM(CASE WHEN p.lifecycle_state = 'closed' THEN 1 ELSE 0 END), 0) AS closed + ${prSessions} + WHERE ${cohortWindow} ${whereVisible}` ) - .bind(...cohortBinds), + .bind(...cohortBinds, ...visible.params), this.db .prepare( - `SELECT COALESCE(SUM(total_cost), 0) AS cost - FROM sessions - WHERE id IN ( - SELECT DISTINCT session_id FROM session_pull_requests WHERE ${cohortWindow} - )` + `SELECT COALESCE(SUM(s.total_cost), 0) AS cost + FROM (SELECT DISTINCT p.session_id FROM session_pull_requests p WHERE ${cohortWindow}) cohort + LEFT JOIN sessions s ON s.id = cohort.session_id + WHERE 1 = 1 ${whereVisible}` ) - .bind(...cohortBinds), + .bind(...cohortBinds, ...visible.params), this.db .prepare( `SELECT COUNT(*) AS merged, - AVG(merged_at - ${prCreatedAt}) AS avg_time_to_merge_ms - FROM session_pull_requests - WHERE lifecycle_state = 'merged' AND merged_at >= ? AND merged_at < ?` + AVG(p.merged_at - ${prCreatedAt}) AS avg_time_to_merge_ms + ${prSessions} + WHERE p.lifecycle_state = 'merged' AND p.merged_at >= ? AND p.merged_at < ? ${whereVisible}` ) - .bind(filters.startAt, filters.endAt), + .bind(filters.startAt, filters.endAt, ...visible.params), this.db .prepare( `SELECT COUNT(*) AS total, AVG(? - ${prCreatedAt}) AS avg_age_ms - FROM session_pull_requests - WHERE lifecycle_state = 'open'` + ${prSessions} + WHERE p.lifecycle_state = 'open' ${whereVisible}` ) - .bind(filters.now), + .bind(filters.now, ...visible.params), this.db .prepare( `SELECT ${prCreatedAt} / ${MS_PER_DAY} AS day_index, COUNT(*) AS count - FROM session_pull_requests - WHERE ${cohortWindow} + ${prSessions} + WHERE ${cohortWindow} ${whereVisible} GROUP BY day_index ORDER BY day_index ASC` ) - .bind(...cohortBinds), + .bind(...cohortBinds, ...visible.params), this.db .prepare( - `SELECT merged_at / ${MS_PER_DAY} AS day_index, COUNT(*) AS count - FROM session_pull_requests - WHERE lifecycle_state = 'merged' AND merged_at >= ? AND merged_at < ? + `SELECT p.merged_at / ${MS_PER_DAY} AS day_index, COUNT(*) AS count + ${prSessions} + WHERE p.lifecycle_state = 'merged' AND p.merged_at >= ? AND p.merged_at < ? ${whereVisible} GROUP BY day_index ORDER BY day_index ASC` ) - .bind(filters.startAt, filters.endAt), + .bind(filters.startAt, filters.endAt, ...visible.params), this.db .prepare( `SELECT - repo_owner || '/' || repo_name AS key, + p.repo_owner || '/' || p.repo_name AS key, COUNT(*) AS created, - COALESCE(SUM(CASE WHEN lifecycle_state = 'merged' THEN 1 ELSE 0 END), 0) AS merged, - COALESCE(SUM(CASE WHEN lifecycle_state = 'closed' THEN 1 ELSE 0 END), 0) AS closed, - AVG(CASE WHEN lifecycle_state = 'merged' AND merged_at IS NOT NULL - THEN merged_at - ${prCreatedAt} END) AS avg_time_to_merge_ms - FROM session_pull_requests - WHERE ${cohortWindow} + COALESCE(SUM(CASE WHEN p.lifecycle_state = 'merged' THEN 1 ELSE 0 END), 0) AS merged, + COALESCE(SUM(CASE WHEN p.lifecycle_state = 'closed' THEN 1 ELSE 0 END), 0) AS closed, + AVG(CASE WHEN p.lifecycle_state = 'merged' AND p.merged_at IS NOT NULL + THEN p.merged_at - ${prCreatedAt} END) AS avg_time_to_merge_ms + ${prSessions} + WHERE ${cohortWindow} ${whereVisible} GROUP BY key ORDER BY created DESC, key ASC` ) - .bind(...cohortBinds), + .bind(...cohortBinds, ...visible.params), this.db .prepare( `SELECT s.spawn_source AS source, COUNT(*) AS created, COALESCE(SUM(CASE WHEN p.lifecycle_state = 'merged' THEN 1 ELSE 0 END), 0) AS merged - FROM session_pull_requests p - JOIN sessions s ON p.session_id = s.id - WHERE ${prCreatedAtExpr("p")} >= ? AND ${prCreatedAtExpr("p")} < ? + ${prSessions} + WHERE ${cohortWindow} ${whereVisible} AND s.spawn_source IS NOT NULL GROUP BY s.spawn_source ORDER BY created DESC, source ASC` ) - .bind(...cohortBinds), + .bind(...cohortBinds, ...visible.params), ]; for (const dimension of ["model", "harness"] as const) { + const costVisible = this.visible("x"); statements.push( this.db .prepare( @@ -201,19 +218,17 @@ export class PullRequestAnalyticsStore { COUNT(*) AS created, COALESCE(SUM(CASE WHEN p.lifecycle_state = 'merged' THEN 1 ELSE 0 END), 0) AS merged, (SELECT COALESCE(SUM(x.total_cost), 0) - FROM sessions x - WHERE x.${dimension} = s.${dimension} - AND x.id IN ( - SELECT DISTINCT cost_p.session_id FROM session_pull_requests cost_p - WHERE ${prCreatedAtExpr("cost_p")} >= ? AND ${prCreatedAtExpr("cost_p")} < ? - )) AS session_cost - FROM session_pull_requests p - JOIN sessions s ON p.session_id = s.id - WHERE ${prCreatedAtExpr("p")} >= ? AND ${prCreatedAtExpr("p")} < ? + FROM (SELECT DISTINCT cost_p.session_id FROM session_pull_requests cost_p + WHERE ${prCreatedAtExpr("cost_p")} >= ? AND ${prCreatedAtExpr("cost_p")} < ?) cohort + LEFT JOIN sessions x ON x.id = cohort.session_id + WHERE x.${dimension} = s.${dimension} + ${costVisible.sql ? `AND ${costVisible.sql}` : ""}) AS session_cost + ${prSessions} + WHERE ${cohortWindow} ${whereVisible} AND s.${dimension} IS NOT NULL GROUP BY s.${dimension} ORDER BY session_cost DESC, key ASC` ) - .bind(...cohortBinds, ...cohortBinds) + .bind(...cohortBinds, ...costVisible.params, ...cohortBinds, ...visible.params) ); } return statements; diff --git a/packages/control-plane/src/db/query-limits.ts b/packages/control-plane/src/db/query-limits.ts index c6eafac96f..a9e9d1935d 100644 --- a/packages/control-plane/src/db/query-limits.ts +++ b/packages/control-plane/src/db/query-limits.ts @@ -12,3 +12,13 @@ * the list is short. Unchunked queries fail outright, they do not degrade. */ export const MAX_D1_QUERY_PARAMETERS = 100; + +export class D1QueryParameterLimitError extends Error { + constructor() { + super("Too many session filters"); + } +} + +export function assertD1QueryParameterLimit(count: number): void { + if (count > MAX_D1_QUERY_PARAMETERS) throw new D1QueryParameterLimitError(); +} diff --git a/packages/control-plane/src/db/session-audit.ts b/packages/control-plane/src/db/session-audit.ts new file mode 100644 index 0000000000..95b730e539 --- /dev/null +++ b/packages/control-plane/src/db/session-audit.ts @@ -0,0 +1,50 @@ +import type { SqlDatabase, SqlStatement } from "./sql-database"; + +export type SessionAuditAction = + | "session.visibility_changed" + | "session.collaborator_added" + | "session.collaborator_removed" + | "session.created_private"; + +export interface SessionAuditInput { + requestId: string; + actorUserId: string; + action: SessionAuditAction; + sessionId: string; + teamId: string | null; + targetUserId?: string | null; + before: unknown; + after: unknown; +} + +export class SessionAuditStore { + constructor(private readonly db: SqlDatabase) {} + + bind(input: SessionAuditInput, onlyIfPreviousChanged = false): SqlStatement { + return this.db + .prepare( + `INSERT INTO authorization_audit_events + (id, occurred_at, request_id, principal_kind, actor_user_id_snapshot, action, + resource_type, resource_id, target_user_id_snapshot, team_id, reason_code, + operation_result, metadata_json) + SELECT ?, ?, ?, 'user', ?, ?, 'session', ?, ?, ?, ?, 'applied', ? + ${onlyIfPreviousChanged ? "WHERE changes() = 1" : ""}` + ) + .bind( + crypto.randomUUID(), + Date.now(), + input.requestId, + input.actorUserId, + input.action, + input.sessionId, + input.targetUserId ?? null, + input.teamId, + input.action, + JSON.stringify({ before: input.before ?? {}, requested: {}, after: input.after ?? {} }) + ); + } + + async write(input: SessionAuditInput): Promise { + await this.bind(input).run(); + } +} diff --git a/packages/control-plane/src/db/session-collaborators.ts b/packages/control-plane/src/db/session-collaborators.ts index eb24384886..36e5ee2cb7 100644 --- a/packages/control-plane/src/db/session-collaborators.ts +++ b/packages/control-plane/src/db/session-collaborators.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import type { SqlDatabase } from "./sql-database"; +import { SessionAuditStore, type SessionAuditInput } from "./session-audit"; +import { MAX_D1_QUERY_PARAMETERS } from "./query-limits"; const collaboratorSchema = z.object({ session_id: z.string(), user_id: z.string() }); @@ -22,22 +24,60 @@ export class SessionCollaboratorStore { return rows.results.map((row) => collaboratorSchema.parse(row).session_id); } - async add(sessionId: string, userId: string, addedBy: string): Promise { - const result = await this.db + async listForSessions( + sessionIds: readonly string[], + options: { privateOnly?: boolean } = {} + ): Promise> { + const result = new Map(); + const privateFilter = options.privateOnly + ? `AND EXISTS ( + SELECT 1 FROM sessions + WHERE sessions.id = session_collaborators.session_id AND sessions.visibility = 'private' + )` + : ""; + for (let offset = 0; offset < sessionIds.length; offset += MAX_D1_QUERY_PARAMETERS) { + const ids = sessionIds.slice(offset, offset + MAX_D1_QUERY_PARAMETERS); + const rows = await this.db + .prepare( + `SELECT session_id, user_id FROM session_collaborators + WHERE session_id IN (${ids.map(() => "?").join(", ")}) + ${privateFilter}` + ) + .bind(...ids) + .all(); + for (const value of rows.results) { + const row = collaboratorSchema.parse(value); + result.set(row.session_id, [...(result.get(row.session_id) ?? []), row.user_id]); + } + } + return result; + } + + async add( + sessionId: string, + userId: string, + addedBy: string, + audit?: SessionAuditInput + ): Promise { + const statement = this.db .prepare( `INSERT INTO session_collaborators (session_id, user_id, added_by, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (session_id, user_id) DO NOTHING` ) - .bind(sessionId, userId, addedBy, Date.now()) - .run(); + .bind(sessionId, userId, addedBy, Date.now()); + const result = audit + ? (await this.db.batch([statement, new SessionAuditStore(this.db).bind(audit, true)]))[0] + : await statement.run(); return result.meta.changes > 0; } - async remove(sessionId: string, userId: string): Promise { - const result = await this.db + async remove(sessionId: string, userId: string, audit?: SessionAuditInput): Promise { + const statement = this.db .prepare("DELETE FROM session_collaborators WHERE session_id = ? AND user_id = ?") - .bind(sessionId, userId) - .run(); + .bind(sessionId, userId); + const result = audit + ? (await this.db.batch([statement, new SessionAuditStore(this.db).bind(audit, true)]))[0] + : await statement.run(); return result.meta.changes > 0; } } diff --git a/packages/control-plane/src/db/session-export-cursor.ts b/packages/control-plane/src/db/session-export-cursor.ts index a9da3b0db3..51c5654084 100644 --- a/packages/control-plane/src/db/session-export-cursor.ts +++ b/packages/control-plane/src/db/session-export-cursor.ts @@ -18,7 +18,8 @@ export interface RunsExportCursor extends CreatedAtCursor { } export type ParseSessionExportCursorResult = - { ok: true; cursor: SessionExportCursor | null } | { ok: false; error: "Invalid cursor" }; + | { ok: true; cursor: SessionExportCursor | null } + | { ok: false; error: "Invalid cursor" }; export function encodeSessionExportCursor(cursor: SessionExportCursor): string { return `${encodeCreatedAtCursor(cursor)}:${cursor.snapshotMaxRowId}`; diff --git a/packages/control-plane/src/db/session-export-store.ts b/packages/control-plane/src/db/session-export-store.ts index 0493c40c34..2f747b24f8 100644 --- a/packages/control-plane/src/db/session-export-store.ts +++ b/packages/control-plane/src/db/session-export-store.ts @@ -8,11 +8,13 @@ import { } from "@open-inspect/shared/types/sessions"; import { z } from "zod"; import { DEFAULT_BASE_BRANCH } from "../repos/default-branch"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; import { sessionRepositoryRowSchema, toSessionRepository } from "./session-list-metadata"; import { decodeSessionPullRequest } from "./session-pull-request-store"; import { sessionRowSchema, toSessionFields, type SessionRow } from "./session-row"; import type { RunsExportCursor, SessionExportCursor } from "./session-export-cursor"; import type { SqlDatabase } from "./sql-database"; +import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; export const DEFAULT_EXPORT_LIMIT = 100; @@ -85,6 +87,8 @@ function toExportRow( /** Shared filters for either export ordering. */ interface ExportFilters { + readScope: SessionReadScope; + mode: TeamsEnforcementMode; /** Page size; the store reads one extra row to answer hasMore. */ limit: number; /** Inclusive lower bound on session creation, or root creation in runs scope (epoch ms). */ @@ -99,7 +103,8 @@ export type ExportSelection = export type ListSessionsForExportOptions = ExportFilters & ExportSelection; type ExportPage = { sessions: SessionExportRow[] } & ( - { hasMore: false; nextCursor: null } | { hasMore: true; nextCursor: Cursor } + | { hasMore: false; nextCursor: null } + | { hasMore: true; nextCursor: Cursor } ); type SessionsPage = { scope: "sessions" } & ExportPage; @@ -143,7 +148,14 @@ export class SessionExportStore { options: ExportFilters & { scope?: "sessions"; cursor: SessionExportCursor | null } ): Promise { const conditions: string[] = []; - const bindings: (string | number)[] = []; + const bindings: unknown[] = []; + if (options.readScope.kind !== "internal") { + const visibility = visibleSessionsPredicate("sessions", options.readScope, { + mode: options.mode, + }); + conditions.push(`(${visibility.sql})`); + bindings.push(...visibility.params); + } const firstPage = options.cursor === null; if (options.cursor) { const cursor = options.cursor; @@ -194,7 +206,17 @@ export class SessionExportStore { } ): Promise { const conditions: string[] = []; - const bindings: (string | number)[] = []; + const bindings: unknown[] = []; + if (options.readScope.kind !== "internal") { + const rootVisibility = visibleSessionsPredicate("root", options.readScope, { + mode: options.mode, + }); + const memberVisibility = visibleSessionsPredicate("s", options.readScope, { + mode: options.mode, + }); + conditions.push(`(${rootVisibility.sql})`, `(${memberVisibility.sql})`); + bindings.push(...rootVisibility.params, ...memberVisibility.params); + } const firstPage = options.cursor === null; if (options.cursor) { const cursor = options.cursor; @@ -277,7 +299,7 @@ export class SessionExportStore { select: string; pageFrom: string; pageId: string; - bindings: (string | number)[]; + bindings: unknown[]; limit: number; snapshotMax: number | undefined; schema: z.ZodType; diff --git a/packages/control-plane/src/db/session-inbox-store.ts b/packages/control-plane/src/db/session-inbox-store.ts index 41df038388..b70e2a3f07 100644 --- a/packages/control-plane/src/db/session-inbox-store.ts +++ b/packages/control-plane/src/db/session-inbox-store.ts @@ -1,19 +1,23 @@ import { SESSION_INBOX_CATEGORIES, type SessionInboxCategory, - type SessionInboxItem, type SessionInboxSession, } from "@open-inspect/shared/types/session-inbox"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; import type { SessionStatus, SpawnSource } from "@open-inspect/shared/types/sessions"; +import { buildSessionListPredicates, type SessionListFilters } from "./session-list-predicates"; import { attachSessionListMetadata } from "./session-list-metadata"; import type { SessionInboxCursor } from "./session-inbox-cursor"; import { readStateFromRow, unreadSql, type ViewerReadStateRow } from "./session-read-state"; +import { assertD1QueryParameterLimit } from "./query-limits"; import type { SqlDatabase, SqlStatement } from "./sql-database"; /** Viewer, filtering, and pagination inputs for an inbox query. */ -export interface ListSessionInboxOptions { +export interface ListSessionInboxOptions extends Pick< + SessionListFilters, + "createdByUserIds" | "teamIds" | "ownerFilter" | "visibility" | "scope" | "readScope" | "mode" +> { category: SessionInboxCategory; - createdByUserIds?: readonly string[]; excludeAutomatedSessions?: boolean; viewerUserId: string; limit: number; @@ -21,15 +25,28 @@ export interface ListSessionInboxOptions { } export interface ListSessionInboxResult { - items: SessionInboxItem[]; + items: Array<{ + rootSession: ScopedInboxSession; + descendantSessions: ScopedInboxSession[]; + }>; hasMore: boolean; nextCursor: SessionInboxCursor | null; } +/** Persisted ownership is used for server capabilities, then stripped from the wire projection. */ +export type ScopedInboxSession = SessionInboxSession & { + userId: string | null; + ownerTeamId: string | null; + visibility: SessionVisibility; +}; + export type ListSessionInboxSnapshotResult = Record; interface InboxSessionRow extends ViewerReadStateRow { id: string; + user_id: string | null; + owner_team_id: string | null; + visibility: SessionVisibility; title: string | null; repo_owner: string | null; repo_name: string | null; @@ -52,9 +69,12 @@ interface InboxPageData { nextCursor: SessionInboxCursor | null; } -function toListItem(row: InboxSessionRow): SessionInboxSession { +function toListItem(row: InboxSessionRow): ScopedInboxSession { return { id: row.id, + userId: row.user_id, + ownerTeamId: row.owner_team_id, + visibility: row.visibility, title: row.title, repoOwner: row.repo_owner, repoName: row.repo_name, @@ -115,6 +135,19 @@ export class SessionInboxStore { /** Select one ordered category page plus one extra root for cursor metadata. */ private bindInboxQuery(options: ListSessionInboxOptions): SqlStatement { const { sql, params } = this.inboxCtes(options); + const binds = [ + ...params, + options.category, + ...(options.cursor + ? [ + options.cursor.latestUpdatedAt, + options.cursor.latestUpdatedAt, + options.cursor.rootSessionId, + ] + : []), + options.limit + 1, + ]; + assertD1QueryParameterLimit(binds.length); const cursorCondition = options.cursor ? `AND (latest_updated_at < ? OR (latest_updated_at = ? AND effective_root_session_id < ?))` : ""; @@ -139,18 +172,7 @@ export class SessionInboxStore { effective_sessions.updated_at DESC, effective_sessions.id DESC` ) - .bind( - ...params, - options.category, - ...(options.cursor - ? [ - options.cursor.latestUpdatedAt, - options.cursor.latestUpdatedAt, - options.cursor.rootSessionId, - ] - : []), - options.limit + 1 - ); + .bind(...binds); } /** Select the first page of every category through one shared recursive traversal. */ @@ -158,6 +180,7 @@ export class SessionInboxStore { options: Omit ): SqlStatement { const { sql, params } = this.inboxCtes(options); + assertD1QueryParameterLimit(params.length + 1); return this.db .prepare( `${sql}, @@ -193,19 +216,33 @@ export class SessionInboxStore { private inboxCtes( options: Pick< ListSessionInboxOptions, - "createdByUserIds" | "excludeAutomatedSessions" | "viewerUserId" + | "createdByUserIds" + | "excludeAutomatedSessions" + | "teamIds" + | "ownerFilter" + | "visibility" + | "scope" + | "readScope" + | "mode" + | "viewerUserId" > ): { sql: string; params: unknown[] } { - const { conditions, params } = this.eligibility(options); + const { where, params } = buildSessionListPredicates(options); return { sql: `WITH RECURSIVE eligible_sessions AS ( SELECT sessions.*, ${unreadSql("sessions")} AS unread - FROM sessions + -- Filter before viewer joins to keep shared predicates' columns unambiguous. + FROM ( + SELECT * FROM sessions + ${where} + ) sessions LEFT JOIN users viewer ON viewer.id = ? LEFT JOIN session_read_states read_state ON read_state.session_id = sessions.id AND read_state.user_id = viewer.id - WHERE ${conditions.join(" AND ")} + WHERE sessions.status != 'archived' + AND sessions.root_session_id IS NOT NULL + ${options.excludeAutomatedSessions ? "AND sessions.spawn_source NOT IN ('automation', 'github-bot')" : ""} ), -- Filtering can hide an ancestor. Re-root each resulting visible subtree -- while retaining the persisted root for uninterrupted lineages. @@ -245,27 +282,10 @@ export class SessionInboxStore { FROM effective_sessions GROUP BY effective_root_session_id )`, - params: [options.viewerUserId, ...params], + params: [...params, options.viewerUserId], }; } - private eligibility( - options: Pick - ): { conditions: string[]; params: unknown[] } { - const conditions = ["sessions.status != 'archived'", "sessions.root_session_id IS NOT NULL"]; - const params: unknown[] = []; - if (options.excludeAutomatedSessions) { - conditions.push("sessions.spawn_source NOT IN ('automation', 'github-bot')"); - } - if (options.createdByUserIds?.length) { - conditions.push( - `sessions.user_id IN (${options.createdByUserIds.map(() => "?").join(", ")})` - ); - params.push(...options.createdByUserIds); - } - return { conditions, params }; - } - /** Group ordered SQL rows into complete lineages and derive cursor metadata. */ private buildPageData(limit: number, rows: InboxSessionRow[]): InboxPageData { const rowsByRoot = new Map(); @@ -294,7 +314,7 @@ export class SessionInboxStore { /** Replace selected D1 rows with their metadata-enriched list items. */ private assemblePage( page: InboxPageData, - sessionsById: Map + sessionsById: Map ): ListSessionInboxResult { const items = page.roots.map(([rootId, lineage]) => { const rootRow = lineage.find(({ id }) => id === rootId) ?? lineage[0]; diff --git a/packages/control-plane/src/db/session-index.test.ts b/packages/control-plane/src/db/session-index.test.ts index c269744ff9..4ebbde1f4b 100644 --- a/packages/control-plane/src/db/session-index.test.ts +++ b/packages/control-plane/src/db/session-index.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { HarnessId } from "@open-inspect/shared/harnesses"; import type { SpawnSource } from "@open-inspect/shared/types/sessions"; import { SessionIndexStore } from "./session-index"; -import type { SessionEntry } from "./session-index"; +import type { CreateSessionCommand } from "./session-index"; type SessionRow = { id: string; @@ -369,7 +369,7 @@ class FakePreparedStatement { } } -function makeSession(overrides: Partial = {}): SessionEntry { +function makeSession(overrides: Partial = {}): CreateSessionCommand { return { id: "test-id", title: "Test Session", @@ -541,12 +541,35 @@ describe("SessionIndexStore", () => { }); describe("list", () => { + const internal = { + readScope: { kind: "internal", reason: "index store test" }, + mode: "on", + } as const; + it("rejects combined team and creator filters above the D1 parameter limit", async () => { + await expect( + store.list({ + readScope: { + kind: "user", + userId: "viewer", + roleKey: "member", + permissions: ["sessions.read"], + suspended: false, + memberships: new Map(), + }, + mode: "on", + teamIds: Array.from({ length: 50 }, (_, i) => `team_${i}`), + createdByUserIds: Array.from({ length: 45 }, (_, i) => `user_${i}`), + viewerUserId: "viewer", + }) + ).rejects.toThrow("Too many session filters"); + }); + it("returns sessions sorted by updatedAt descending", async () => { await store.create(makeSession({ id: "old", updatedAt: 1000 })); await store.create(makeSession({ id: "new", updatedAt: 3000 })); await store.create(makeSession({ id: "mid", updatedAt: 2000 })); - const result = await store.list(); + const result = await store.list(internal); expect(result.sessions.map((s) => s.id)).toEqual(["new", "mid", "old"]); expect(result.hasMore).toBe(false); }); @@ -555,7 +578,7 @@ describe("SessionIndexStore", () => { await store.create(makeSession({ id: "a", status: "active" })); await store.create(makeSession({ id: "b", status: "archived" })); - const result = await store.list({ status: "active" }); + const result = await store.list({ ...internal, status: "active" }); expect(result.sessions).toHaveLength(1); expect(result.sessions[0].id).toBe("a"); }); @@ -565,7 +588,7 @@ describe("SessionIndexStore", () => { await store.create(makeSession({ id: "b", status: "archived", updatedAt: 1000 })); await store.create(makeSession({ id: "c", status: "created", updatedAt: 3000 })); - const result = await store.list({ excludeStatus: "archived" }); + const result = await store.list({ ...internal, excludeStatus: "archived" }); expect(result.sessions).toHaveLength(2); expect(result.sessions.map((s) => s.id)).toEqual(["c", "a"]); }); @@ -576,7 +599,7 @@ describe("SessionIndexStore", () => { await store.create(makeSession({ id: "alice-new", userId: "alice", updatedAt: 4000 })); await store.create(makeSession({ id: "historical", userId: null, updatedAt: 5000 })); - const result = await store.list({ createdByUserIds: ["alice"] }); + const result = await store.list({ ...internal, createdByUserIds: ["alice"] }); expect(result.sessions.map((s) => s.id)).toEqual(["alice-new", "alice-old"]); expect(result.hasMore).toBe(false); @@ -606,7 +629,7 @@ describe("SessionIndexStore", () => { await store.create(makeSession({ id: "manual-old", spawnSource: "user", updatedAt: 2000 })); await store.delete("automation"); - const result = await store.list({ excludeAutomationLineage: true, limit: 2 }); + const result = await store.list({ ...internal, excludeAutomationLineage: true, limit: 2 }); expect(result.sessions.map((session) => session.id)).toEqual(["manual-new", "manual-old"]); expect(result.hasMore).toBe(false); @@ -617,7 +640,7 @@ describe("SessionIndexStore", () => { await store.create(makeSession({ id: "bob", userId: "bob", updatedAt: 3000 })); await store.create(makeSession({ id: "carol", userId: "carol", updatedAt: 4000 })); - const result = await store.list({ createdByUserIds: ["alice", "bob"] }); + const result = await store.list({ ...internal, createdByUserIds: ["alice", "bob"] }); expect(result.sessions.map((s) => s.id)).toEqual(["bob", "alice"]); }); @@ -627,15 +650,15 @@ describe("SessionIndexStore", () => { await store.create(makeSession({ id: `s${i}`, updatedAt: i * 1000 })); } - const page1 = await store.list({ limit: 2, offset: 0 }); + const page1 = await store.list({ ...internal, limit: 2, offset: 0 }); expect(page1.sessions).toHaveLength(2); expect(page1.hasMore).toBe(true); - const page2 = await store.list({ limit: 2, offset: 2 }); + const page2 = await store.list({ ...internal, limit: 2, offset: 2 }); expect(page2.sessions).toHaveLength(2); expect(page2.hasMore).toBe(true); - const page3 = await store.list({ limit: 2, offset: 4 }); + const page3 = await store.list({ ...internal, limit: 2, offset: 4 }); expect(page3.sessions).toHaveLength(1); expect(page3.hasMore).toBe(false); }); @@ -646,7 +669,7 @@ describe("SessionIndexStore", () => { } db.preparedQueries.length = 0; - const result = await store.list({ limit: 2 }); + const result = await store.list({ ...internal, limit: 2 }); expect(result.sessions.map((s) => s.id)).toEqual(["s2", "s1"]); expect(result.hasMore).toBe(true); diff --git a/packages/control-plane/src/db/session-index.ts b/packages/control-plane/src/db/session-index.ts index dbbd8bedb2..beab2e1862 100644 --- a/packages/control-plane/src/db/session-index.ts +++ b/packages/control-plane/src/db/session-index.ts @@ -13,6 +13,9 @@ import { } from "@open-inspect/shared/session-list-query"; import type { SessionListRepository } from "@open-inspect/shared/types/repositories"; import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; +import { assertD1QueryParameterLimit } from "./query-limits"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; import { sessionModelProviderAuthSchema, SUBSCRIPTION_PROVIDER_IDS, @@ -36,6 +39,7 @@ import { import { INACTIVE_SESSION_STATUS_SQL } from "@open-inspect/shared/types/session-activity"; import { readStateFromRow, unreadSql, type ViewerReadStateRow } from "./session-read-state"; import { parseSessionRow, toSessionFields as toEntry, type SessionRow } from "./session-row"; +import { SessionAuditStore } from "./session-audit"; import type { SqlDatabase, SqlStatement } from "./sql-database"; const CHILD_ADMISSION_LEASE_TTL_MS = 5 * 60 * 1000; @@ -109,12 +113,19 @@ export interface SessionEntry { */ pullRequestSummary?: PullRequestSummary; readState?: SessionReadState; +} + +/** Declarative fields used only when creating a session index row. */ +export interface CreateSessionCommand extends SessionEntry { /** Resolved manifest to persist atomically with a new top-level session. */ skillManifest?: SessionSkillManifestInput; /** Parent manifest to copy atomically for an agent-spawned child. */ skillManifestSourceSessionId?: string; /** Complete immutable model-provider authentication snapshot. */ providerAuth?: SessionModelProviderAuthInput[]; + /** Copy access grants with the parent row in the creation batch. */ + collaboratorSourceSessionId?: string; + privateCreationActor?: { requestId: string; actorUserId: string }; } interface SessionModelProviderAuthRow { @@ -200,7 +211,7 @@ export class SessionIndexStore { return result !== null; } - async create(session: SessionEntry): Promise { + async create(session: CreateSessionCommand): Promise { const repository = normalizeSessionRepositoryFields(session); if (session.skillManifest && session.skillManifestSourceSessionId) { @@ -304,6 +315,50 @@ export class SessionIndexStore { ...repositoryStmts, ...manifestStmts, ...providerAuthStmts, + ...(session.collaboratorSourceSessionId + ? [ + this.db + .prepare( + `INSERT INTO session_collaborators (session_id, user_id, added_by, created_at) + SELECT ?, user_id, added_by, ? FROM session_collaborators WHERE session_id = ?` + ) + .bind(session.id, session.createdAt, session.collaboratorSourceSessionId), + ] + : []), + ...(session.collaboratorSourceSessionId && session.visibility === "private" + ? [ + this.db + .prepare( + `INSERT INTO session_collaborators (session_id, user_id, added_by, created_at) + SELECT ?, parent.user_id, parent.user_id, ? FROM sessions parent + WHERE parent.id = ? AND parent.user_id IS NOT NULL AND parent.user_id != ? + ON CONFLICT (session_id, user_id) DO NOTHING` + ) + .bind( + session.id, + session.createdAt, + session.collaboratorSourceSessionId, + session.userId + ), + ] + : []), + ...(session.visibility === "private" && session.privateCreationActor + ? [ + new SessionAuditStore(this.db).bind({ + requestId: session.privateCreationActor.requestId, + actorUserId: session.privateCreationActor.actorUserId, + action: "session.created_private", + sessionId: session.id, + teamId: session.ownerTeamId, + before: {}, + after: { + ownerUserId: session.userId ?? null, + teamId: session.ownerTeamId, + visibility: "private", + }, + }), + ] + : []), ]); // Session ids are always freshly generated, so a skipped insert is a bug; @@ -468,7 +523,7 @@ export class SessionIndexStore { } /** List sessions with optional viewer-specific read state. */ - async list(options: ListSessionsOptions = {}): Promise { + async list(options: ListSessionsOptions): Promise { const { limit = DEFAULT_SESSION_LIST_LIMIT, offset = DEFAULT_SESSION_LIST_OFFSET, @@ -479,6 +534,7 @@ export class SessionIndexStore { // `id DESC` breaks updated_at ties so offset pages never overlap or skip. const pageSql = `SELECT * FROM sessions ${where} ORDER BY updated_at DESC, id DESC LIMIT ? OFFSET ?`; const pageParams = [...params, limit + 1, offset]; + assertD1QueryParameterLimit(pageParams.length + (viewerUserId ? 1 : 0)); const result = viewerUserId ? await this.db .prepare( @@ -759,10 +815,20 @@ export class SessionIndexStore { } /** List children of a parent session, newest first. */ - async listByParent(parentSessionId: string): Promise { + async listByParent( + parentSessionId: string, + readScope: SessionReadScope, + mode: TeamsEnforcementMode + ): Promise { + const visibility = + readScope.kind === "internal" + ? { sql: "", params: [] } + : visibleSessionsPredicate("sessions", readScope, { mode }); const result = await this.db - .prepare(`SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY created_at DESC`) - .bind(parentSessionId) + .prepare( + `SELECT * FROM sessions WHERE parent_session_id = ? ${visibility.sql ? `AND ${visibility.sql}` : ""} ORDER BY created_at DESC` + ) + .bind(parentSessionId, ...visibility.params) .all(); return this.attachListMetadata((result.results || []).map(toEntry)); } diff --git a/packages/control-plane/src/db/session-list-predicates.test.ts b/packages/control-plane/src/db/session-list-predicates.test.ts index d9b5ab05b5..3f28824f79 100644 --- a/packages/control-plane/src/db/session-list-predicates.test.ts +++ b/packages/control-plane/src/db/session-list-predicates.test.ts @@ -2,14 +2,43 @@ import { describe, expect, it } from "vitest"; import { buildSessionListPredicates } from "./session-list-predicates"; const collapse = (sql: string) => sql.replace(/\s+/g, " ").trim(); +const internal = { + readScope: { kind: "internal", reason: "test filter SQL" }, + mode: "on", +} as const; describe("buildSessionListPredicates", () => { - it("returns no clause for an unfiltered list", () => { - expect(buildSessionListPredicates({})).toEqual({ where: "", params: [] }); + it("combines participation, workspace, and row visibility before pagination", () => { + const result = buildSessionListPredicates({ + ownerFilter: "participating", + scope: "workspace", + teamIds: ["team-a"], + visibility: "team", + readScope: { + kind: "user", + userId: "user-a", + roleKey: "member", + suspended: false, + permissions: ["sessions.read"], + memberships: new Map(), + }, + mode: "on", + }); + expect(result.where).toContain("session_read_states"); + expect(result.where).toContain("owner_team_id IS NULL"); + expect(result.where).toContain("owner_team_id IN (?)"); + expect(result.where).toContain("visibility = ?"); + expect(result.where).toContain("team_memberships"); + expect(result.params).toContain("user-a"); + }); + + it("requires an explicit internal scope for an unfiltered list", () => { + expect(buildSessionListPredicates(internal)).toEqual({ where: "", params: [] }); }); it("binds each filter in clause order", () => { const { where, params } = buildSessionListPredicates({ + ...internal, status: "active", excludeStatus: "archived", excludeAutomationLineage: true, @@ -25,6 +54,7 @@ describe("buildSessionListPredicates", () => { it("matches a repository by normalized identity through the scalar primary or any member row", () => { const { where, params } = buildSessionListPredicates({ + ...internal, repository: { repoOwner: " Acme ", repoName: "Web-App" }, }); expect(collapse(where)).toBe( @@ -35,7 +65,7 @@ describe("buildSessionListPredicates", () => { }); it("binds escaped LIKE patterns for title, id prefix, and repository labels", () => { - const { where, params } = buildSessionListPredicates({ search: "50%_off\\" }); + const { where, params } = buildSessionListPredicates({ ...internal, search: "50%_off\\" }); expect(where).not.toContain("50%"); expect(collapse(where)).toBe( "WHERE (title LIKE ? ESCAPE '\\' OR id LIKE ? ESCAPE '\\' OR (repo_owner || '/' || repo_name) LIKE ? ESCAPE '\\' OR EXISTS ( SELECT 1 FROM session_repositories sr WHERE sr.session_id = sessions.id AND (sr.repo_owner || '/' || sr.repo_name) LIKE ? ESCAPE '\\' ))" diff --git a/packages/control-plane/src/db/session-list-predicates.ts b/packages/control-plane/src/db/session-list-predicates.ts index d727a63c6f..eed212e62c 100644 --- a/packages/control-plane/src/db/session-list-predicates.ts +++ b/packages/control-plane/src/db/session-list-predicates.ts @@ -1,6 +1,9 @@ import { normalizeOptionalRepositoryPair } from "@open-inspect/shared/types/repositories"; import type { SessionStatus, SpawnSource } from "@open-inspect/shared/types/sessions"; +import type { SessionListQuery } from "@open-inspect/shared/session-list-query"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; import { LIKE_ESCAPE_CLAUSE, likeContains, likePrefix } from "./like-pattern"; +import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; /** Filters for a session index list query; each maps to one `SessionListQuery` field. */ export interface SessionListFilters { @@ -19,6 +22,12 @@ export interface SessionListFilters { environmentId?: string; /** Exact persisted `spawn_source`; see `SessionListQuery.origin`. */ spawnSource?: SpawnSource; + teamIds?: readonly string[]; + ownerFilter?: SessionListQuery["ownerFilter"]; + visibility?: SessionListQuery["visibility"]; + scope?: SessionListQuery["scope"]; + readScope: SessionReadScope; + mode: TeamsEnforcementMode; } export interface SessionListPredicates { @@ -55,6 +64,12 @@ export function buildSessionListPredicates(filters: SessionListFilters): Session repository, environmentId, spawnSource, + teamIds, + ownerFilter, + visibility, + scope, + readScope, + mode, } = filters; const conditions: string[] = []; const params: unknown[] = []; @@ -82,6 +97,29 @@ export function buildSessionListPredicates(filters: SessionListFilters): Session params.push(...createdByUserIds); } + if (teamIds?.length) { + conditions.push(`owner_team_id IN (${teamIds.map(() => "?").join(", ")})`); + params.push(...teamIds); + } + + if (scope === "workspace") conditions.push("owner_team_id IS NULL"); + if (visibility) { + conditions.push("visibility = ?"); + params.push(visibility); + } + if (ownerFilter === "started" && readScope.kind === "user") { + conditions.push("user_id = ?"); + params.push(readScope.userId); + } + if (ownerFilter === "participating" && readScope.kind === "user") { + conditions.push(`(user_id = ? OR EXISTS ( + SELECT 1 FROM session_collaborators sc WHERE sc.session_id = sessions.id AND sc.user_id = ? + ) OR EXISTS ( + SELECT 1 FROM session_read_states rs WHERE rs.session_id = sessions.id AND rs.user_id = ? + ))`); + params.push(readScope.userId, readScope.userId, readScope.userId); + } + if (environmentId) { conditions.push("environment_id = ?"); params.push(environmentId); @@ -121,6 +159,12 @@ export function buildSessionListPredicates(filters: SessionListFilters): Session params.push(contains, likePrefix(search), contains, contains); } + if (readScope.kind !== "internal") { + const visible = visibleSessionsPredicate("sessions", readScope, { mode }); + conditions.push(visible.sql); + params.push(...visible.params); + } + return { where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "", params, diff --git a/packages/control-plane/src/db/session-run-store.test.ts b/packages/control-plane/src/db/session-run-store.test.ts index f4ebbac732..13ab2d729d 100644 --- a/packages/control-plane/src/db/session-run-store.test.ts +++ b/packages/control-plane/src/db/session-run-store.test.ts @@ -18,7 +18,11 @@ describe("SessionRunStore row validation", () => { it("rejects malformed list rows rather than returning unchecked data", async () => { await expect( - new SessionRunStore(database).list({ + new SessionRunStore( + database, + { kind: "internal", reason: "validate persisted rows" }, + "on" + ).list({ startAt: 0, endAt: 1, limit: 1, @@ -29,9 +33,13 @@ describe("SessionRunStore row validation", () => { }); it("rejects malformed single-run rows", async () => { - await expect(new SessionRunStore(database).get("root")).rejects.toThrow( - "Invalid session run row" - ); + await expect( + new SessionRunStore( + database, + { kind: "internal", reason: "validate persisted rows" }, + "on" + ).get("root") + ).rejects.toThrow("Invalid session run row"); }); }); @@ -45,13 +53,17 @@ it("prepares root-windowed scoped runs and decodes a nullable title", () => { return statement; }, }; - const store = new SessionRunStore({ - prepare: (sql) => { - query = sql; - return statement; + const store = new SessionRunStore( + { + prepare: (sql) => { + query = sql; + return statement; + }, + batch: async () => [], }, - batch: async () => [], - }); + { kind: "internal", reason: "verify unfiltered run rollup" }, + "on" + ); store.prepareList({ startAt: 10, endAt: 20, limit: 2, orderBy: "cost", scope: "human" }); expect(query).toContain("root.created_at >= ? AND root.created_at < ?"); expect(query).toContain("root.spawn_source IN (?, ?, ?, ?)"); diff --git a/packages/control-plane/src/db/session-run-store.ts b/packages/control-plane/src/db/session-run-store.ts index 2bb7ed003b..752045c810 100644 --- a/packages/control-plane/src/db/session-run-store.ts +++ b/packages/control-plane/src/db/session-run-store.ts @@ -7,6 +7,8 @@ import { spawnSourceSchema } from "@open-inspect/shared/types/sessions"; import { z } from "zod"; import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database"; import { scopePredicate } from "./analytics-store"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; +import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; export interface ListSessionRunsOptions { startAt: number; @@ -87,21 +89,33 @@ function toRun(value: unknown): SessionRun { } export class SessionRunStore { - constructor(private readonly db: SqlDatabase) {} + constructor( + private readonly db: SqlDatabase, + private readonly readScope: SessionReadScope, + private readonly mode: TeamsEnforcementMode + ) {} + + private visible(alias: string) { + return this.readScope.kind === "internal" + ? { sql: "", params: [] } + : visibleSessionsPredicate(alias, this.readScope, { mode: this.mode, excludePrivate: true }); + } prepareList({ startAt, endAt, limit, orderBy, scope }: ListSessionRunsOptions): SqlStatement { const order = orderBy === "cost" ? "total_cost" : "created_at"; const { sql, binds } = scopePredicate(scope, "root.spawn_source"); + const rootVisible = this.visible("root"); + const childVisible = this.visible("s"); return this.db .prepare( `${RUN_SELECT} WHERE root.created_at >= ? AND root.created_at < ? - ${sql} + ${sql} ${rootVisible.sql ? `AND ${rootVisible.sql}` : ""} ${childVisible.sql ? `AND ${childVisible.sql}` : ""} ${RUN_GROUP} ORDER BY ${order} DESC, root_session_id ASC LIMIT ?` ) - .bind(startAt, endAt, ...binds, limit); + .bind(startAt, endAt, ...binds, ...rootVisible.params, ...childVisible.params, limit); } decodeList(result: SqlResult): SessionRun[] { @@ -113,9 +127,13 @@ export class SessionRunStore { } async get(rootSessionId: string): Promise { + const rootVisible = this.visible("root"); + const childVisible = this.visible("s"); const row = await this.db - .prepare(`${RUN_SELECT} WHERE root.id = ? ${RUN_GROUP}`) - .bind(rootSessionId) + .prepare( + `${RUN_SELECT} WHERE root.id = ? ${rootVisible.sql ? `AND ${rootVisible.sql}` : ""} ${childVisible.sql ? `AND ${childVisible.sql}` : ""} ${RUN_GROUP}` + ) + .bind(rootSessionId, ...rootVisible.params, ...childVisible.params) .first(); return row === null ? null : toRun(row); } diff --git a/packages/control-plane/src/db/session-scope-store.ts b/packages/control-plane/src/db/session-scope-store.ts new file mode 100644 index 0000000000..a3cd9e6006 --- /dev/null +++ b/packages/control-plane/src/db/session-scope-store.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; + +type SessionAuditStatement = { sessionId: string; statement: SqlStatement }; + +/** D1 reads and atomic writes for session scope changes. */ +export class SessionScopeStore { + constructor(private readonly db: SqlDatabase) {} + + async listDescendantIds(id: string): Promise { + const rows = await this.db + .prepare( + `WITH RECURSIVE descendants(id) AS ( + SELECT id FROM sessions WHERE parent_session_id = ? + UNION + SELECT child.id FROM sessions child + JOIN descendants ON child.parent_session_id = descendants.id + ) SELECT id FROM descendants` + ) + .bind(id) + .all(); + return z + .array(z.object({ id: z.string() })) + .parse(rows.results) + .map((row) => row.id); + } + + async updateVisibility( + ids: string[], + visibility: SessionVisibility, + audits: SessionAuditStatement[] = [] + ): Promise { + if (!ids.length) return; + const auditBySessionId = new Map( + audits.map(({ sessionId, statement }) => [sessionId, statement]) + ); + await this.db.batch( + ids.flatMap((id) => { + const update = this.db + .prepare("UPDATE sessions SET visibility = ? WHERE id = ?") + .bind(visibility, id); + const audit = auditBySessionId.get(id); + return audit ? [update, audit] : [update]; + }) + ); + } +} diff --git a/packages/control-plane/src/db/session-visibility.test.ts b/packages/control-plane/src/db/session-visibility.test.ts new file mode 100644 index 0000000000..3c76b69600 --- /dev/null +++ b/packages/control-plane/src/db/session-visibility.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import type { SessionViewer } from "@open-inspect/shared"; +import { visibleSessionsPredicate } from "./session-visibility"; + +const member: SessionViewer = { + kind: "user", + userId: "user-a", + roleKey: "member", + permissions: ["sessions.read"], + suspended: false, + memberships: new Map([["team-a", "member"]]), +}; + +describe("visibleSessionsPredicate", () => { + it("uses the persisted row and membership for team access when enforcement is on", () => { + const { sql, params } = visibleSessionsPredicate("s", member, { mode: "on" }); + expect(sql).toContain("s.visibility = 'workspace'"); + expect(sql).toContain("tm.team_id = s.owner_team_id AND tm.user_id = ?"); + expect(sql).toContain("sc.session_id = s.id AND sc.user_id = ?"); + expect(params).toEqual([0, "user-a", "user-a", "user-a"]); + }); + + it.each(["off", "shadow"] as const)( + "never lists another user's private session in %s", + (mode) => { + const { sql, params } = visibleSessionsPredicate("root", member, { mode }); + // Only the private collaborator check consults membership outside enforcement. + expect(sql).not.toMatch(/\btm\.team_id/); + expect(sql).toContain("ctm.team_id = root.owner_team_id AND ctm.user_id = sc.user_id"); + expect(sql).toContain("root.visibility != 'private'"); + expect(sql).toContain("root.user_id = ?"); + expect(params).toEqual(["user-a", "user-a"]); + } + ); + + it("excludes private rows for owners even when they can break glass by ID", () => { + const owner: SessionViewer = { ...member, roleKey: "owner" }; + const result = visibleSessionsPredicate("s", owner, { mode: "on", excludePrivate: true }); + expect(result.sql).not.toContain("session_collaborators"); + expect(result.params).toEqual([1, "user-a"]); + }); + + it("keeps service readers outside private sessions and scopes bound services", () => { + expect( + visibleSessionsPredicate("s", { kind: "service", teamId: null }, { mode: "on" }) + ).toEqual({ + sql: "s.visibility != 'private'", + params: [], + }); + const bound = visibleSessionsPredicate( + "s", + { kind: "service", teamId: "team-a" }, + { mode: "on" } + ); + expect(bound.sql).toContain("s.owner_team_id = ?"); + expect(bound.params).toEqual(["team-a"]); + }); + + it.each(["off", "shadow"] as const)("does not bind services to teams in %s mode", (mode) => { + expect(visibleSessionsPredicate("s", { kind: "service", teamId: "team-a" }, { mode })).toEqual({ + sql: "s.visibility != 'private'", + params: [], + }); + }); +}); diff --git a/packages/control-plane/src/db/session-visibility.ts b/packages/control-plane/src/db/session-visibility.ts new file mode 100644 index 0000000000..a4998f1a72 --- /dev/null +++ b/packages/control-plane/src/db/session-visibility.ts @@ -0,0 +1,50 @@ +import type { SessionViewer } from "@open-inspect/shared"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; + +export type SessionReadScope = SessionViewer | { kind: "internal"; reason: string }; + +/** A SQL visibility check on a persisted session row, before pagination or aggregation. */ +export function visibleSessionsPredicate( + alias: string, + viewer: SessionViewer, + options: { mode: TeamsEnforcementMode; excludePrivate?: boolean } +): { sql: string; params: unknown[] } { + if (viewer.kind === "service") { + if (options.mode !== "on") return { sql: `${alias}.visibility != 'private'`, params: [] }; + return viewer.teamId === null + ? { sql: `${alias}.visibility != 'private'`, params: [] } + : { + sql: `(${alias}.visibility = 'workspace' OR (${alias}.visibility = 'team' AND ${alias}.owner_team_id = ?))`, + params: [viewer.teamId], + }; + } + + const teamsEnforced = options.mode === "on"; + const teamSql = teamsEnforced + ? `(${alias}.visibility = 'team' AND (? = 1 OR EXISTS ( + SELECT 1 FROM team_memberships tm + WHERE tm.team_id = ${alias}.owner_team_id AND tm.user_id = ?)) )` + : `${alias}.visibility != 'private'`; + const params: unknown[] = teamsEnforced + ? [viewer.roleKey === "owner" || viewer.roleKey === "administrator" ? 1 : 0, viewer.userId] + : []; + if (options.excludePrivate) { + return { + sql: teamsEnforced ? `(${alias}.visibility = 'workspace' OR ${teamSql})` : teamSql, + params, + }; + } + // Mirrors the resolver: team-owned collaborator grants require current team membership. + const privateSql = `(${alias}.visibility = 'private' AND (${alias}.user_id = ? OR EXISTS ( + SELECT 1 FROM session_collaborators sc WHERE sc.session_id = ${alias}.id AND sc.user_id = ? + AND (${alias}.owner_team_id IS NULL OR EXISTS ( + SELECT 1 FROM team_memberships ctm + WHERE ctm.team_id = ${alias}.owner_team_id AND ctm.user_id = sc.user_id)))))`; + params.push(viewer.userId, viewer.userId); + return { + sql: teamsEnforced + ? `(${alias}.visibility = 'workspace' OR ${teamSql} OR ${privateSql})` + : `(${teamSql} OR ${privateSql})`, + params, + }; +} diff --git a/packages/control-plane/src/db/team-audit.ts b/packages/control-plane/src/db/team-audit.ts index 97ac36abf8..876ec671e8 100644 --- a/packages/control-plane/src/db/team-audit.ts +++ b/packages/control-plane/src/db/team-audit.ts @@ -14,14 +14,15 @@ export interface TeamAuditInput { export class TeamAuditStore { constructor(private readonly db: SqlDatabase) {} - bind(input: TeamAuditInput): SqlStatement { + bind(input: TeamAuditInput, onlyIfPreviousChanged = false): SqlStatement { return this.db .prepare( `INSERT INTO authorization_audit_events (id, occurred_at, request_id, principal_kind, actor_user_id_snapshot, action, resource_type, resource_id, target_user_id_snapshot, team_id, reason_code, operation_result, metadata_json) - VALUES (?, ?, ?, 'user', ?, ?, 'team', ?, ?, ?, ?, 'applied', ?)` + SELECT ?, ?, ?, 'user', ?, ?, 'team', ?, ?, ?, ?, 'applied', ? + ${onlyIfPreviousChanged ? "WHERE changes() = 1" : ""}` ) .bind( crypto.randomUUID(), diff --git a/packages/control-plane/src/db/team-memberships.ts b/packages/control-plane/src/db/team-memberships.ts index 1360fba5e6..13ffbe4bc2 100644 --- a/packages/control-plane/src/db/team-memberships.ts +++ b/packages/control-plane/src/db/team-memberships.ts @@ -6,7 +6,7 @@ import { type TeamMembership, type TeamRole, } from "@open-inspect/shared/types/teams"; -import type { SqlDatabase } from "./sql-database"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; export class LastLeadError extends Error { constructor() { @@ -99,10 +99,10 @@ export class TeamMembershipStore { ); } - async listMembersWithUsers(teamId: string) { + async listMembersWithUsers(teamId: string, { includeEmail }: { includeEmail: boolean }) { const rows = await this.db .prepare( - `SELECT m.*, u.display_name, u.email, u.avatar_url + `SELECT m.*, u.display_name, ${includeEmail ? "u.email" : "NULL AS email"}, u.avatar_url FROM team_memberships m JOIN users u ON u.id = m.user_id WHERE m.team_id = ? ORDER BY m.created_at, m.user_id` ) @@ -138,16 +138,19 @@ export class TeamMembershipStore { } async addIfJoinable(teamId: string, userId: string): Promise { - const result = await this.db + const result = await this.bindAddIfJoinable(teamId, userId).run(); + return result.meta.changes > 0; + } + + private bindAddIfJoinable(teamId: string, userId: string): SqlStatement { + return this.db .prepare( `INSERT INTO team_memberships (team_id, user_id, role, source, created_at) SELECT id, ?, 'member', 'manual', ? FROM teams WHERE id = ? AND join_policy = 'open' AND archived_at IS NULL ON CONFLICT DO NOTHING` ) - .bind(userId, Date.now(), teamId) - .run(); - return result.meta.changes > 0; + .bind(userId, Date.now(), teamId); } async setRole(teamId: string, userId: string, role: TeamRole): Promise { diff --git a/packages/control-plane/src/db/team-repository-grants.ts b/packages/control-plane/src/db/team-repository-grants.ts new file mode 100644 index 0000000000..4d2fe2b0b1 --- /dev/null +++ b/packages/control-plane/src/db/team-repository-grants.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import type { SqlDatabase } from "./sql-database"; + +const grantSchema = z.object({ + grant_kind: z.enum(["installation", "repository"]), + repo_external_id: z.number().nullable(), +}); + +export class TeamRepositoryGrantStore { + constructor(private readonly db: SqlDatabase) {} + + async listForTeam(teamId: string) { + const rows = await this.db + .prepare("SELECT grant_kind, repo_external_id FROM team_repository_grants WHERE team_id = ?") + .bind(teamId) + .all(); + return z.array(grantSchema).parse(rows.results); + } + + async covers(teamId: string, repoIds: readonly (number | null)[]): Promise { + if (repoIds.length === 0) return true; + const grants = await this.listForTeam(teamId); + if (grants.some((grant) => grant.grant_kind === "installation")) return true; + const allowed = new Set(grants.map((grant) => grant.repo_external_id)); + return repoIds.every((id) => id !== null && allowed.has(id)); + } +} diff --git a/packages/control-plane/src/db/team-secrets.ts b/packages/control-plane/src/db/team-secrets.ts new file mode 100644 index 0000000000..20795e5a31 --- /dev/null +++ b/packages/control-plane/src/db/team-secrets.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; +import { + assertScopeKeyCapacity, + decryptSecretRows, + encryptSecretEntries, + prepareSecretsForWrite, + toSecretMetadata, + type SecretsWriteResult, +} from "./scoped-secrets"; +import { normalizeKey, validateKey, type SecretMetadata } from "./secrets-validation"; +import type { SqlDatabase, SqlStatement } from "./sql-database"; +import { TeamAuditStore, type TeamAuditInput } from "./team-audit"; + +const keyRowSchema = z.object({ key: z.string().min(1) }); +const metadataRowSchema = keyRowSchema.extend({ + created_at: z.number().int().nonnegative(), + updated_at: z.number().int().nonnegative(), +}); +const encryptedRowSchema = keyRowSchema.extend({ encrypted_value: z.string().min(1) }); +type SecretAuditInput = Pick; + +export class TeamSecretsStore { + constructor( + private readonly db: SqlDatabase, + private readonly encryptionKey: string + ) {} + + async setSecrets( + teamId: string, + secrets: Record, + audit?: SecretAuditInput + ): Promise { + const normalized = prepareSecretsForWrite(secrets); + const existing = await this.db + .prepare("SELECT key FROM team_secrets WHERE team_id = ?") + .bind(teamId) + .all(); + const existingKeys = new Set(existing.results.map((row) => keyRowSchema.parse(row).key)); + const keys = Object.keys(normalized); + assertScopeKeyCapacity("Team", existingKeys, keys); + const { entries, created, updated } = await encryptSecretEntries( + normalized, + existingKeys, + this.encryptionKey + ); + const now = Date.now(); + const statements = entries.map((entry) => + this.db + .prepare( + `INSERT INTO team_secrets (team_id, key, encrypted_value, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(team_id, key) DO UPDATE SET + encrypted_value = excluded.encrypted_value, + updated_at = excluded.updated_at` + ) + .bind(teamId, entry.key, entry.encryptedValue, now, now) + ); + if (statements.length > 0) { + if (audit) { + statements.push( + new TeamAuditStore(this.db).bind({ + ...audit, + teamId, + action: "team.secret_set", + before: { keys: keys.filter((key) => existingKeys.has(key)) }, + after: { keys }, + }) + ); + } + statements.push(this.bindSupersedeEnvironmentImages(teamId)); + await this.db.batch(statements); + } + return { created, updated, keys }; + } + + async listSecretKeys(teamId: string): Promise { + const rows = await this.db + .prepare( + "SELECT key, created_at, updated_at FROM team_secrets WHERE team_id = ? ORDER BY key" + ) + .bind(teamId) + .all(); + return toSecretMetadata(rows.results.map((row) => metadataRowSchema.parse(row))); + } + + async getDecryptedSecrets(teamId: string): Promise> { + const rows = await this.db + .prepare("SELECT key, encrypted_value FROM team_secrets WHERE team_id = ?") + .bind(teamId) + .all(); + return decryptSecretRows( + rows.results.map((row) => encryptedRowSchema.parse(row)), + this.encryptionKey + ); + } + + async deleteSecret(teamId: string, key: string, audit?: SecretAuditInput): Promise { + const normalizedKey = normalizeKey(key); + validateKey(normalizedKey); + const statements = [ + this.db + .prepare("DELETE FROM team_secrets WHERE team_id = ? AND key = ?") + .bind(teamId, normalizedKey), + ]; + if (audit) { + statements.push( + new TeamAuditStore(this.db).bind( + { + ...audit, + teamId, + action: "team.secret_deleted", + before: { keys: [normalizedKey] }, + after: { keys: [] }, + }, + true + ) + ); + } + statements.push(this.bindSupersedeEnvironmentImages(teamId)); + const [deleted] = await this.db.batch(statements); + return deleted.meta.changes > 0; + } + + private bindSupersedeEnvironmentImages(teamId: string): SqlStatement { + // Keep this after the mutation and its audit: either changes one row, but a missing delete does not. + return this.db + .prepare( + `UPDATE image_builds SET status = 'superseded' + WHERE scope_kind = 'environment' + AND scope_id IN (SELECT id FROM environments WHERE owner_team_id = ?) + AND status IN ('building', 'ready') + AND changes() = 1` + ) + .bind(teamId); + } +} diff --git a/packages/control-plane/src/db/team-settings.ts b/packages/control-plane/src/db/team-settings.ts new file mode 100644 index 0000000000..6141c732a4 --- /dev/null +++ b/packages/control-plane/src/db/team-settings.ts @@ -0,0 +1,21 @@ +import { teamSettingsSchema, type TeamSettings } from "@open-inspect/shared/types/teams"; +import { IntegrationSettingsStore } from "./integration-settings"; +import type { SqlDatabase } from "./sql-database"; + +export { teamSettingsSchema }; + +export class TeamSettingsStore { + private readonly settings: IntegrationSettingsStore; + + constructor(db: SqlDatabase) { + this.settings = new IntegrationSettingsStore(db); + } + + async get(): Promise { + return (await this.settings.getGlobal("teams"))?.defaults ?? { requireTeamOnCreate: false }; + } + + async set(settings: TeamSettings): Promise { + await this.settings.setGlobal("teams", { defaults: teamSettingsSchema.parse(settings) }); + } +} diff --git a/packages/control-plane/src/db/teams.ts b/packages/control-plane/src/db/teams.ts index 8659b5ac3e..6253ee4f40 100644 --- a/packages/control-plane/src/db/teams.ts +++ b/packages/control-plane/src/db/teams.ts @@ -74,15 +74,6 @@ export class TeamStore { return rows.results.map(toTeam); } - async isActive(id: string): Promise { - return ( - (await this.db - .prepare("SELECT 1 AS ok FROM teams WHERE id = ? AND archived_at IS NULL") - .bind(id) - .first()) !== null - ); - } - private insertStatement( input: { slug: string; diff --git a/packages/control-plane/src/db/user-store.ts b/packages/control-plane/src/db/user-store.ts index c889748beb..507052adf0 100644 --- a/packages/control-plane/src/db/user-store.ts +++ b/packages/control-plane/src/db/user-store.ts @@ -1,4 +1,9 @@ +import { z } from "zod"; import { getSignInProviderIssuer } from "@open-inspect/shared/sign-in-provider"; +import { + sessionCollaboratorCandidatesResponseSchema, + type SessionCollaboratorCandidate, +} from "@open-inspect/shared/types/sessions"; import { generateId } from "../auth/crypto"; import { normalizeEmail } from "./email"; import { isUniqueConstraintError } from "./errors"; @@ -133,11 +138,66 @@ function toUserIdentity(row: UserIdentityRow): UserIdentity { }; } +export type CollaboratorEligibility = "eligible" | "not_found" | "inactive" | "not_team_member"; + +const collaboratorEligibilityRowSchema = z.object({ + suspended_at: z.number().nullable(), + role_id: z.string().nullable(), + team_member: z.number(), +}); + // ── UserStore ─────────────────────────────────────────────────────── export class UserStore { constructor(private readonly db: SqlDatabase) {} + /** Lists active users; a team ID restricts candidates to that team's members. */ + async listCollaboratorCandidates({ + includeEmail, + teamId = null, + }: { + includeEmail: boolean; + teamId?: string | null; + }): Promise { + const { results } = await this.db + .prepare( + `SELECT users.id AS userId, users.display_name AS displayName, + ${includeEmail ? "users.email" : "NULL AS email"}, users.avatar_url AS avatarUrl + FROM users + JOIN user_role_assignments assignment ON assignment.user_id = users.id + ${teamId === null ? "" : "JOIN team_memberships m ON m.user_id = users.id AND m.team_id = ?"} + WHERE users.suspended_at IS NULL AND assignment.role_id IS NOT NULL + ORDER BY LOWER(COALESCE(users.display_name, ${includeEmail ? "users.email" : "NULL"}, users.id)), users.id` + ) + .bind(...(teamId === null ? [] : [teamId])) + .all(); + return sessionCollaboratorCandidatesResponseSchema.parse(results); + } + + /** Applies the candidate-list rules to one user; a team ID also requires membership in that team. */ + async getCollaboratorEligibility( + userId: string, + teamId: string | null + ): Promise { + const row = collaboratorEligibilityRowSchema.nullable().parse( + await this.db + .prepare( + `SELECT users.suspended_at, assignment.role_id, + EXISTS (SELECT 1 FROM team_memberships m + WHERE m.team_id = ? AND m.user_id = users.id) AS team_member + FROM users + LEFT JOIN user_role_assignments assignment ON assignment.user_id = users.id + WHERE users.id = ?` + ) + .bind(teamId, userId) + .first() + ); + if (!row) return "not_found"; + if (row.suspended_at !== null || row.role_id === null) return "inactive"; + if (teamId !== null && !row.team_member) return "not_team_member"; + return "eligible"; + } + async getUsersByIds(userIds: readonly string[]): Promise { const uniqueIds = [...new Set(userIds)]; if (uniqueIds.length === 0) return []; diff --git a/packages/control-plane/src/http/bounded-body.ts b/packages/control-plane/src/http/bounded-body.ts index 779806ab1c..be2e3dc622 100644 --- a/packages/control-plane/src/http/bounded-body.ts +++ b/packages/control-plane/src/http/bounded-body.ts @@ -1,5 +1,6 @@ export type BoundedBytesResult = - { ok: true; bytes: Uint8Array } | { ok: false; byteLength: number }; + | { ok: true; bytes: Uint8Array } + | { ok: false; byteLength: number }; /** Read a byte stream without retaining more than the caller's budget. */ export async function readBoundedBytes( diff --git a/packages/control-plane/src/image-builds/daytona-adapter.test.ts b/packages/control-plane/src/image-builds/daytona-adapter.test.ts index 1db3fb7a5c..ef1ee7a321 100644 --- a/packages/control-plane/src/image-builds/daytona-adapter.test.ts +++ b/packages/control-plane/src/image-builds/daytona-adapter.test.ts @@ -17,19 +17,23 @@ const correlation = { request_id: "request-1", trace_id: "trace-1" }; function baseResources() { return { triggerImageBuild: vi.fn(async (_config: ImageBuildProviderTriggerConfig) => undefined), - getBuildSandbox: vi.fn(async (): Promise => ({ - id: SOURCE_ID, - state: "started", - labels: { openinspect_expires_at: String(Date.now() + 60 * 60_000) }, - })), + getBuildSandbox: vi.fn( + async (): Promise => ({ + id: SOURCE_ID, + state: "started", + labels: { openinspect_expires_at: String(Date.now() + 60 * 60_000) }, + }) + ), stopBuildSandboxForCapture: vi.fn(async (): Promise<"stopped" | "stopping"> => "stopped"), captureBuildSnapshot: vi.fn(async () => undefined), - getBuildSnapshot: vi.fn(async (): Promise => ({ - id: "snapshot-1", - name: "oi-image-abc", - state: "active", - sourceSandboxId: SOURCE_ID, - })), + getBuildSnapshot: vi.fn( + async (): Promise => ({ + id: "snapshot-1", + name: "oi-image-abc", + state: "active", + sourceSandboxId: SOURCE_ID, + }) + ), deleteBuildSandbox: vi.fn(async () => undefined), deleteProviderImage: vi.fn(async () => undefined), findBuildSandboxByName: vi.fn(async (): Promise => null), diff --git a/packages/control-plane/src/image-builds/finalizer.ts b/packages/control-plane/src/image-builds/finalizer.ts index b41ad1f453..2fdb8ba5c8 100644 --- a/packages/control-plane/src/image-builds/finalizer.ts +++ b/packages/control-plane/src/image-builds/finalizer.ts @@ -34,7 +34,8 @@ const LEASE_EXPIRY_HEADROOM_MS = 5_000; * settles, so that retry is the one worth republishing with a fresh budget. */ export type ImageBuildFinalizationResult = - { type: "completed" } | { type: "retry"; delayMs: number; reason?: "pending_operation" }; + | { type: "completed" } + | { type: "retry"; delayMs: number; reason?: "pending_operation" }; const completed = (): ImageBuildFinalizationResult => ({ type: "completed" }); const retrySoon = (): ImageBuildFinalizationResult => ({ diff --git a/packages/control-plane/src/image-builds/lookup.ts b/packages/control-plane/src/image-builds/lookup.ts index 1b5fadc45f..94278a100c 100644 --- a/packages/control-plane/src/image-builds/lookup.ts +++ b/packages/control-plane/src/image-builds/lookup.ts @@ -5,6 +5,8 @@ */ import { ImageBuildStore } from "../db/image-builds"; +import { EnvironmentStore } from "../db/environments"; +import { SessionIndexStore } from "../db/session-index"; import type { ImageBuildLookup } from "../sandbox/lifecycle/image-selection"; import type { ImageBuildProvider } from "./model"; import { resolveScopeEnabled } from "./scope"; @@ -12,14 +14,21 @@ import type { SqlDatabase } from "../db/sql-database"; export function createImageBuildLookup( db: SqlDatabase, - provider: ImageBuildProvider + provider: ImageBuildProvider, + getSessionId: () => string ): ImageBuildLookup { const store = new ImageBuildStore(db); return { getLatestReady: async (scope) => { - // Enablement (and entity existence) is the scope resolver's answer; - // the store read is a plain row lookup. - if (!(await resolveScopeEnabled(db, scope))) return null; + if (scope.kind === "environment") { + const environment = await new EnvironmentStore(db).getById(scope.id); + if (environment?.prebuild_enabled !== 1) return null; + // Environment images may contain their owner's secrets in baked files. + if (environment.owner_team_id !== null) { + const session = await new SessionIndexStore(db).get(getSessionId()); + if (!session || session.ownerTeamId !== environment.owner_team_id) return null; + } + } else if (!(await resolveScopeEnabled(db, scope))) return null; return store.getLatestReadyForSpawn(scope, provider); }, markRestoreFailed: (imageBuildId, error) => store.markRestoreFailed(imageBuildId, error), diff --git a/packages/control-plane/src/image-builds/scheduler.test.ts b/packages/control-plane/src/image-builds/scheduler.test.ts index c659d84aa4..bb8029810a 100644 --- a/packages/control-plane/src/image-builds/scheduler.test.ts +++ b/packages/control-plane/src/image-builds/scheduler.test.ts @@ -38,9 +38,9 @@ function harness( }, ]); const clearProviderSessionCleanup = vi.fn(async () => true); - const listScopes = vi.fn(async (): Promise => [ - { kind: "repo", id: "acme/web" }, - ]); + const listScopes = vi.fn( + async (): Promise => [{ kind: "repo", id: "acme/web" }] + ); const listRecoverableFinalizations = vi.fn( async (): Promise< Array<{ id: string; completion_hash: string; callback_token_used_at: number }> diff --git a/packages/control-plane/src/image-builds/scope.test.ts b/packages/control-plane/src/image-builds/scope.test.ts index c034da68d3..e0822afb5b 100644 --- a/packages/control-plane/src/image-builds/scope.test.ts +++ b/packages/control-plane/src/image-builds/scope.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type * as SourceControlModule from "../source-control"; import type * as IntegrationSettingsResolutionModule from "../session/integration-settings-resolution"; import type { Env } from "../types"; +import { SecretDecryptionError } from "../db/scoped-secrets"; import { ImageBuildPlanningError, ImageBuildScopeNotFoundError } from "./errors"; import { computeRepositoriesFingerprint } from "./fingerprint"; import { repoImageBuildScope, type ImageBuildScope } from "./model"; @@ -25,6 +26,7 @@ const integrationSettings = vi.hoisted(() => ({ const secretsStores = vi.hoisted(() => ({ global: vi.fn(async (): Promise> => ({})), + team: vi.fn(async (_teamId: string): Promise> => ({})), repo: vi.fn(async (_repoId: number): Promise> => ({})), environment: vi.fn(async (_id: string): Promise> => ({})), })); @@ -51,6 +53,12 @@ vi.mock("../db/global-secrets", () => ({ }, })); +vi.mock("../db/team-secrets", () => ({ + TeamSecretsStore: class { + getDecryptedSecrets = secretsStores.team; + }, +})); + vi.mock("../db/repo-secrets", () => ({ RepoSecretsStore: class { getDecryptedSecrets = secretsStores.repo; @@ -121,6 +129,7 @@ beforeEach(() => { vi.clearAllMocks(); integrationSettings.resolveSandboxSettings.mockResolvedValue({}); secretsStores.global.mockResolvedValue({}); + secretsStores.team.mockResolvedValue({}); secretsStores.repo.mockResolvedValue({}); secretsStores.environment.mockResolvedValue({}); }); @@ -359,6 +368,65 @@ describe("resolveScopeSandboxSettings", () => { describe("loadScopeBuildSecrets", () => { const encryptedEnv = (db: D1Database) => envWith(db, { REPO_SECRETS_ENCRYPTION_KEY: "key" }); + it("loads the environment owner's team before environment secrets", async () => { + secretsStores.global.mockResolvedValue({ SHARED: "global", GLOBAL_TEAM: "global" }); + secretsStores.team.mockResolvedValue({ SHARED: "team", GLOBAL_TEAM: "team" }); + secretsStores.environment.mockResolvedValue({ SHARED: "environment" }); + const db = fakeDb({ environment: { id: "env_1", owner_team_id: "team_build" } }); + const merged = await loadScopeBuildSecrets(encryptedEnv(db), db, ENV_SCOPE, { + kind: "environment", + repositories: [], + repositoriesFingerprint: "fp-env", + }); + expect(secretsStores.team).toHaveBeenCalledWith("team_build"); + expect(merged).toEqual({ SHARED: "environment", GLOBAL_TEAM: "team" }); + }); + + it.each([new SecretDecryptionError("SHARED"), new Error("Team secrets storage unavailable")])( + "aborts a team-owned build when its team layer fails: %s", + async (failure) => { + secretsStores.global.mockResolvedValue({ SHARED: "global" }); + secretsStores.team.mockRejectedValue(failure); + secretsStores.environment.mockResolvedValue({ SHARED: "environment" }); + const db = fakeDb({ environment: { id: "env_1", owner_team_id: "team_build" } }); + + await expect( + loadScopeBuildSecrets(encryptedEnv(db), db, ENV_SCOPE, { + kind: "environment", + repositories: [], + repositoriesFingerprint: "fp-env", + }) + ).rejects.toBe(failure); + + expect(secretsStores.team).toHaveBeenCalledWith("team_build"); + expect(secretsStores.environment).not.toHaveBeenCalled(); + } + ); + + it("accepts a readable empty team layer without dropping other scopes", async () => { + secretsStores.global.mockResolvedValue({ SHARED: "global", GLOBAL_ONLY: "global" }); + secretsStores.environment.mockResolvedValue({ SHARED: "environment" }); + const db = fakeDb({ environment: { id: "env_1", owner_team_id: "team_build" } }); + expect( + await loadScopeBuildSecrets(encryptedEnv(db), db, ENV_SCOPE, { + kind: "environment", + repositories: [], + repositoriesFingerprint: "fp-env", + }) + ).toEqual({ SHARED: "environment", GLOBAL_ONLY: "global" }); + expect(secretsStores.team).toHaveBeenCalledWith("team_build"); + }); + + it("never loads a team layer for repository-shared images", async () => { + secretsStores.team.mockResolvedValue({ TEAM_ONLY: "private" }); + secretsStores.global.mockResolvedValue({ GLOBAL_ONLY: "global" }); + const db = fakeDb({ environment: { id: "env_1", owner_team_id: "team_build" } }); + expect(await loadScopeBuildSecrets(encryptedEnv(db), db, REPO_SCOPE, repoTarget())).toEqual({ + GLOBAL_ONLY: "global", + }); + expect(secretsStores.team).not.toHaveBeenCalled(); + }); + it("returns undefined without an encryption key", async () => { expect( await loadScopeBuildSecrets(envWith(fakeDb({})), fakeDb({}), REPO_SCOPE, repoTarget()) diff --git a/packages/control-plane/src/image-builds/scope.ts b/packages/control-plane/src/image-builds/scope.ts index 073ec847f8..441680ec4a 100644 --- a/packages/control-plane/src/image-builds/scope.ts +++ b/packages/control-plane/src/image-builds/scope.ts @@ -15,6 +15,7 @@ import { EnvironmentStore } from "../db/environments"; import { GlobalSecretsStore } from "../db/global-secrets"; import { RepoMetadataStore } from "../db/repo-metadata"; import { RepoSecretsStore } from "../db/repo-secrets"; +import { TeamSecretsStore } from "../db/team-secrets"; import { auditSecretsMerge, mergeSecretSources, @@ -267,10 +268,10 @@ export async function resolveScopeSandboxSettings( } /** - * Build-time secrets: the same fold the scope's sessions get. Environment - * scopes fold global + environment — repo-scoped secrets never inherit — - * and repo scopes fold global + that repository's secrets (build/session - * parity in both cases). Source labels match the session fold + * Environment builds fold global + owning team + environment; repository + * secrets never inherit. Repository images are shared across teams, so they + * fold only global + repository secrets, never a team's secrets. + * Source labels match the session fold * (session-target-secrets.ts) so collision/cap logs attribute identically at * build and session time. */ @@ -330,6 +331,13 @@ async function loadScopeSecretSources( switch (target.kind) { case "environment": { + const environment = await new EnvironmentStore(db).getById(scope.id); + // Team credentials must not silently fall back to lower-precedence scopes. + const teamSecrets = environment?.owner_team_id + ? await new TeamSecretsStore(db, encryptionKey).getDecryptedSecrets( + environment.owner_team_id + ) + : {}; let environmentSecrets: Record = {}; try { environmentSecrets = await new EnvironmentSecretsStore( @@ -346,10 +354,12 @@ async function loadScopeSecretSources( return { sources: [ { label: "global", secrets: globalSecrets }, + { label: "team", secrets: teamSecrets }, { label: "environment", secrets: environmentSecrets }, ], counts: { global_count: Object.keys(globalSecrets).length, + team_count: Object.keys(teamSecrets).length, environment_count: Object.keys(environmentSecrets).length, }, }; diff --git a/packages/control-plane/src/image-builds/types.ts b/packages/control-plane/src/image-builds/types.ts index 449a4560e4..cc06769080 100644 --- a/packages/control-plane/src/image-builds/types.ts +++ b/packages/control-plane/src/image-builds/types.ts @@ -135,7 +135,9 @@ export interface ReconcileOrphanOperationInput { * than losing a resource nothing else records. */ export type ReconcileOrphanOperationOutcome = - { type: "absent" } | { type: "deleted" } | { type: "pending" }; + | { type: "absent" } + | { type: "deleted" } + | { type: "pending" }; /** Input for finding a build source whose create response was never seen. */ export interface RecoverUnboundSourceInput { diff --git a/packages/control-plane/src/image-builds/workflow.test.ts b/packages/control-plane/src/image-builds/workflow.test.ts index 7614f87135..3a3e151b6a 100644 --- a/packages/control-plane/src/image-builds/workflow.test.ts +++ b/packages/control-plane/src/image-builds/workflow.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ImageBuildStore } from "../db/image-builds"; +import { SecretDecryptionError } from "../db/scoped-secrets"; import { createTestEnv } from "../router.test-support"; import type { Env } from "../types"; import { @@ -314,6 +315,23 @@ describe("ImageBuildWorkflow", () => { ); }); + it("fails the registered build without starting the provider when secret loading fails", async () => { + const failure = new SecretDecryptionError("TEAM_TOKEN"); + const { workflow, store, adapter } = createWorkflow({ + planBuild: vi.fn().mockRejectedValue(failure), + }); + + await expect(workflow.triggerBuild(ENV_SCOPE, ctx)).rejects.toBeInstanceOf( + ImageBuildTriggerFailedError + ); + expect(store.markBuildFailed).toHaveBeenCalledWith( + expect.stringMatching(/^imgb-env_1-/), + "modal", + failure.message + ); + expect(adapter.startBuild).not.toHaveBeenCalled(); + }); + it("fails a misconfigured provider closed without writing a row", async () => { const factoryError = vi.fn(() => { throw new Error("MODAL_WORKSPACE not configured"); diff --git a/packages/control-plane/src/node/host-alarm-index.ts b/packages/control-plane/src/node/host-alarm-index.ts index 8c9e12476e..9d2f2c867f 100644 --- a/packages/control-plane/src/node/host-alarm-index.ts +++ b/packages/control-plane/src/node/host-alarm-index.ts @@ -265,7 +265,8 @@ export function openHostAlarmIndex(dataDir: string): HostAlarmIndex { claim: (sessionId, leaseUntil) => { const token = crypto.randomUUID(); const row = claimRow.get(token, leaseUntil, sessionId) as - { in_flight: number; failures: number } | undefined; + | { in_flight: number; failures: number } + | undefined; return row === undefined ? null : { deadline: row.in_flight, failures: row.failures, token }; }, complete: (sessionId, token) => { diff --git a/packages/control-plane/src/router.create-session.test.ts b/packages/control-plane/src/router.create-session.test.ts index 9c3b67480b..a71e3c472e 100644 --- a/packages/control-plane/src/router.create-session.test.ts +++ b/packages/control-plane/src/router.create-session.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { generateEncryptionKey } from "./auth/crypto"; import { SessionIndexStore } from "./db/session-index"; import { UserStore } from "./db/user-store"; @@ -16,6 +16,9 @@ import { resolveManagedSkills } from "./session/skill-resolution"; import { resolveSessionProviderAuth } from "./session/provider-account-resolution"; import { ProviderAccountSelectionPolicyError } from "./model-provider-accounts/selection-policy"; import { resolveEnvironmentTarget, resolveSessionRepositories } from "./repos/resolve"; +import { TeamStore } from "./db/teams"; +import { TeamMembershipStore } from "./db/team-memberships"; +import { TeamRepositoryGrantStore } from "./db/team-repository-grants"; const { getAccessToken } = vi.hoisted(() => ({ getAccessToken: vi.fn(async () => ({ @@ -84,6 +87,7 @@ vi.mock("./repos/resolve", async (importOriginal) => { }); describe("handleCreateSession D1 ordering", () => { + afterEach(() => vi.restoreAllMocks()); beforeEach(() => { vi.clearAllMocks(); vi.mocked(resolveManagedSkills).mockResolvedValue({ @@ -228,6 +232,74 @@ describe("handleCreateSession D1 ordering", () => { }; } + it("rejects a team session when its repository is not granted", async () => { + vi.spyOn(TeamStore.prototype, "getById").mockResolvedValue({ + id: "team_alpha", + slug: "alpha", + name: "Alpha", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + }); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "member"]]) + ); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + const response = await createSessionRequestWithBody(createEnv(vi.fn()), { + teamId: "team_alpha", + repoOwner: "acme", + repoName: "web-app", + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/web-app", + }); + }); + + it("uses the team's default visibility when creating an owned session", async () => { + vi.spyOn(TeamStore.prototype, "getById").mockResolvedValue({ + id: "team_alpha", + slug: "alpha", + name: "Alpha", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + }); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "member"]]) + ); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + const create = vi.fn().mockResolvedValue(undefined); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return { create } as never; + }); + const response = await createSessionRequestWithBody( + createEnv(vi.fn(async () => Response.json({ status: "created" }))), + { teamId: "team_alpha", repoOwner: "acme", repoName: "web-app" } + ); + expect(response.status).toBe(201); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + ownerTeamId: "team_alpha", + visibility: "team", + userId: "user-1", + }) + ); + }); + it.each([ { target: "environment", diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index b81e1ee9a7..d90c4d93ad 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -15,12 +15,20 @@ function routeFor(method: string, path: string) { } describe("route policy table", () => { + it("does not expose a member-facing team activity route", () => { + expect(routeFor("GET", "/teams/team-1/activity")).toBeUndefined(); + expect(routeFor("GET", "/audit-events")?.authorization).toMatchObject({ + allOf: [{ kind: "permission", permission: "workspace.audit.read" }], + service: { kind: "deny" }, + }); + }); + it("publishes the complete canonical route catalog", () => { - expect(routes).toHaveLength(206); + expect(routes).toHaveLength(216); const paths = routes.map((route) => route.path); - expect(new Set(paths).size).toBe(158); - expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(206); + expect(new Set(paths).size).toBe(165); + expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(216); }); it("gates run analytics with analytics.read", () => { @@ -31,6 +39,82 @@ describe("route policy table", () => { }); }); + it("gates collaborator candidates on always-enforced human session management", () => { + expect(routeFor("GET", "/sessions/session-1/collaborator-candidates")).toMatchObject({ + authentication: { kind: "user" }, + authorization: { + kind: "active-user", + allOf: [ + { + kind: "session", + action: "manageCollaborators", + sessionIdParam: "id", + enforceAlways: true, + }, + ], + service: { kind: "deny" }, + auditAllowed: false, + }, + supportedScmProviders: "all", + cacheControl: "private, no-store", + }); + }); + + it("requires human team membership and session read permission for team sessions", () => { + expect(routeFor("GET", "/teams/team-1/sessions")).toMatchObject({ + authentication: { kind: "user" }, + authorization: { + kind: "active-user", + allOf: [ + { kind: "team", teamIdParam: "id", need: "member" }, + { kind: "permission", permission: "sessions.read" }, + ], + service: { kind: "deny" }, + auditAllowed: false, + }, + cacheControl: "private, no-store", + }); + }); + + it("requires target-aware member removal admission", () => { + expect(routeFor("DELETE", "/teams/team-1/members/user-1")?.authorization).toMatchObject({ + allOf: [{ kind: "team", need: "removeMember", targetUserIdParam: "userId" }], + service: { kind: "deny" }, + auditAllowed: true, + }); + }); + + it.each(["/teams/team-1", "/teams/team-1/members"])( + "does not audit allowed directory reads at %s", + (path) => { + expect(routeFor("GET", path)?.authorization).toMatchObject({ auditAllowed: false }); + } + ); + + it("still audits allowed team capability writes", () => { + expect(routeFor("PATCH", "/teams/team-1")?.authorization).toMatchObject({ + allOf: [{ kind: "team", need: "canEditMetadata" }], + auditAllowed: true, + }); + }); + + it.each([ + ["GET", "/teams/team-1/secrets"], + ["PUT", "/teams/team-1/secrets"], + ["DELETE", "/teams/team-1/secrets/TOKEN"], + ])("requires human team secret management for %s %s", (method, path) => { + expect(routeFor(method, path)).toMatchObject({ + authentication: { kind: "user" }, + authorization: { + kind: "active-user", + allOf: [{ kind: "team", teamIdParam: "id", need: "canManageSecrets" }], + service: { kind: "deny" }, + }, + supportedScmProviders: "all", + cacheControl: "private, no-store", + }); + }); + it("gates a single-session export with session read and sessions.export", () => { expect(routeFor("GET", "/sessions/session-1/export")?.authorization).toMatchObject({ kind: "active-user", diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts index d80e08680b..9d3a5a0fd1 100644 --- a/packages/control-plane/src/router.spawn-child.test.ts +++ b/packages/control-plane/src/router.spawn-child.test.ts @@ -100,7 +100,10 @@ describe("handleSpawnChild prompt enqueue handling", () => { environmentId: string | null = "env_parent" ) => ({ get: vi.fn().mockResolvedValue({ + id: parentId, userId: parentUserId, + ownerTeamId: null, + visibility: "workspace", repoOwner: context.repoOwner, repoName: context.repoName, environmentId, diff --git a/packages/control-plane/src/routes/analytics.test.ts b/packages/control-plane/src/routes/analytics.test.ts index a6c6914f8b..f20503cc6e 100644 --- a/packages/control-plane/src/routes/analytics.test.ts +++ b/packages/control-plane/src/routes/analytics.test.ts @@ -3,11 +3,14 @@ import type * as AuthenticateModule from "../auth/authenticate"; import { authorizationDatabase, createTestRequestHandler, + emptyStatement, ownerAuthorizationDatabase, TEST_BACKGROUND_TASK_CONTEXT, TEST_SERVICE_SECRETS, } from "../router.test-support"; import type { Env } from "../types"; +import type { SqlStatement } from "../db/sql-database"; +import { AnalyticsDashboardStore } from "../db/analytics-dashboard-store"; import { analyticsRoutes, DEFAULT_ANALYTICS_DAYS } from "./analytics"; const FIXED_NOW = 1_700_000_000_000; @@ -101,6 +104,39 @@ describe("analytics route handlers", () => { expect(response.status).toBe(400); expect(mockDashboardStore.get).not.toHaveBeenCalled(); }); + + it("passes the authorized viewer, team memberships and enforcement mode to the dashboard", async () => { + mockDashboardStore.get.mockResolvedValue({}); + const database = authorizationDatabase({ + statement: (sql) => { + const statement: SqlStatement = { + ...emptyStatement(), + bind: () => statement, + all: async () => ({ + results: [{ team_id: "team-a", role: "member" }] as T[], + meta: { changes: 0 }, + }), + }; + if (sql.includes("FROM team_memberships")) return statement; + throw new Error(`Unexpected query: ${sql}`); + }, + }); + const response = await handleRequest( + new Request("https://test.local/analytics/dashboard"), + { ...env, DB: database, TEAMS_ENFORCEMENT: "on" }, + TEST_BACKGROUND_TASK_CONTEXT + ); + expect(response.status).toBe(200); + expect(vi.mocked(AnalyticsDashboardStore)).toHaveBeenCalledWith( + expect.objectContaining({ prepare: expect.any(Function) }), + expect.objectContaining({ + kind: "user", + userId: "user-1", + memberships: new Map([["team-a", "member"]]), + }), + "on" + ); + }); }); describe("summary", () => { diff --git a/packages/control-plane/src/routes/analytics.ts b/packages/control-plane/src/routes/analytics.ts index b5c97d07b1..3b33d3bd01 100644 --- a/packages/control-plane/src/routes/analytics.ts +++ b/packages/control-plane/src/routes/analytics.ts @@ -10,6 +10,9 @@ import { import { type AnalyticsFilters, AnalyticsStore } from "../db/analytics-store"; import { AnalyticsDashboardStore } from "../db/analytics-dashboard-store"; import { SessionRunStore } from "../db/session-run-store"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { teamsEnforcementMode, viewerFromContext } from "../authorization/session-admission"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; import { type PullRequestAnalyticsFilters, PullRequestAnalyticsStore, @@ -88,9 +91,21 @@ function getPullRequestFilters(days: AnalyticsDays): PullRequestAnalyticsFilters return { startAt: now - days * 24 * 60 * 60 * 1000, endAt: now, now }; } +async function analyticsAccess(ctx: RequestContext, env: Env) { + const memberships = ctx.authorization + ? (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( + ctx.authorization.userId + )) + : new Map(); + return { + viewer: viewerFromContext(ctx, memberships), + mode: teamsEnforcementMode(ctx, env), + }; +} + async function handleDashboard( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { @@ -99,7 +114,8 @@ async function handleDashboard( const { days, scope } = query; const generatedAt = Date.now(); - const store = new AnalyticsDashboardStore(ctx.db); + const { viewer, mode } = await analyticsAccess(ctx, env); + const store = new AnalyticsDashboardStore(ctx.db, viewer, mode); return json( await store.get({ days, @@ -112,7 +128,7 @@ async function handleDashboard( async function handleSummary( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { @@ -120,13 +136,14 @@ async function handleSummary( if (query instanceof Response) return query; const { days, scope } = query; - const store = new AnalyticsStore(ctx.db); + const { viewer, mode } = await analyticsAccess(ctx, env); + const store = new AnalyticsStore(ctx.db, viewer, mode); return json(await store.getSummary(getFilters(days, scope))); } async function handleTimeseries( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { @@ -134,13 +151,14 @@ async function handleTimeseries( if (query instanceof Response) return query; const { days, scope } = query; - const store = new AnalyticsStore(ctx.db); + const { viewer, mode } = await analyticsAccess(ctx, env); + const store = new AnalyticsStore(ctx.db, viewer, mode); return json(await store.getTimeseries(getFilters(days, scope))); } async function handleBreakdown( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { @@ -148,13 +166,14 @@ async function handleBreakdown( if (query instanceof Response) return query; const { days, scope, by } = query; - const store = new AnalyticsStore(ctx.db); + const { viewer, mode } = await analyticsAccess(ctx, env); + const store = new AnalyticsStore(ctx.db, viewer, mode); return json(await store.getBreakdown(getFilters(days, scope), by)); } async function handlePullRequests( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { @@ -162,20 +181,22 @@ async function handlePullRequests( if (query instanceof Response) return query; const { days } = query; - const store = new PullRequestAnalyticsStore(ctx.db); + const { viewer, mode } = await analyticsAccess(ctx, env); + const store = new PullRequestAnalyticsStore(ctx.db, viewer, mode); return json(await store.get(getPullRequestFilters(days))); } async function handleRuns( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { const query = parseQuery(request, runsQuery); if (query instanceof Response) return query; const endAt = Date.now(); - const store = new SessionRunStore(ctx.db); + const { viewer, mode } = await analyticsAccess(ctx, env); + const store = new SessionRunStore(ctx.db, viewer, mode); return json({ runs: await store.list({ startAt: endAt - query.days * 24 * 60 * 60 * 1000, diff --git a/packages/control-plane/src/routes/audit-events.test.ts b/packages/control-plane/src/routes/audit-events.test.ts index 31521f20fc..9661ebc2e5 100644 --- a/packages/control-plane/src/routes/audit-events.test.ts +++ b/packages/control-plane/src/routes/audit-events.test.ts @@ -66,6 +66,15 @@ describe("audit events route", () => { expect(mockStore.list).toHaveBeenCalledWith({ limit: 100, cursor: null }); }); + it("passes the workspace team filter without a feed visibility scope", async () => { + expect((await list("?teamId=team_alpha")).status).toBe(200); + expect(mockStore.list).toHaveBeenCalledWith({ + limit: DEFAULT_AUDIT_EVENT_LIMIT, + cursor: null, + teamId: "team_alpha", + }); + }); + it("round-trips a cursor through the store and the next page", async () => { const cursor = { occurredAt: 1_700_000_000_000, id: "event-1" }; const next = { occurredAt: 1_699_999_999_000, id: "event-2" }; @@ -106,6 +115,7 @@ describe("audit events route", () => { it.each([ ["limit=1&limit=2", "Invalid limit"], ["cursor=one&cursor=two", "Invalid cursor"], + ["teamId=one&teamId=two", "Invalid teamId"], ])("rejects a repeated key (%s)", async (query, message) => { const response = await list(`?${query}`); diff --git a/packages/control-plane/src/routes/audit-events.ts b/packages/control-plane/src/routes/audit-events.ts index 82dca06f51..ad38b22865 100644 --- a/packages/control-plane/src/routes/audit-events.ts +++ b/packages/control-plane/src/routes/audit-events.ts @@ -44,7 +44,12 @@ async function handleListAuditEvents( _params: object, ctx: RequestContext ): Promise { - const query = parseQuery(request, auditEventQuery); + const query = parseQuery( + request, + auditEventQuery.extend({ + teamId: z.string().min(1, { error: "Invalid teamId" }).optional(), + }) + ); if (query instanceof Response) return query; const result = await new AuditEventStore(ctx.db).list(query); diff --git a/packages/control-plane/src/routes/automation-lifecycle.test.ts b/packages/control-plane/src/routes/automation-lifecycle.test.ts index 3182e5a5d0..4326e14db4 100644 --- a/packages/control-plane/src/routes/automation-lifecycle.test.ts +++ b/packages/control-plane/src/routes/automation-lifecycle.test.ts @@ -234,7 +234,7 @@ describe("automation lifecycle routes", () => { }); expect(res.status).toBe(201); - expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", undefined); + expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", null); }); it("triggers for an access token through the real credential resolver", async () => { @@ -255,7 +255,7 @@ describe("automation lifecycle routes", () => { }); expect(res.status).toBe(201); - expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", undefined); + expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", null); }); it("refuses an access token an automation it does not own", async () => { diff --git a/packages/control-plane/src/routes/automation-lifecycle.ts b/packages/control-plane/src/routes/automation-lifecycle.ts index f34ae36820..da19d1f932 100644 --- a/packages/control-plane/src/routes/automation-lifecycle.ts +++ b/packages/control-plane/src/routes/automation-lifecycle.ts @@ -119,7 +119,7 @@ async function handleTriggerAutomation( triggerResult = await new Scheduler(ctx.db, env, ctx.executionCtx).trigger( id, requesterUserId, - requesterEnrichment ?? undefined + requesterEnrichment ); } catch (triggerError) { logger.error("automation.trigger_failed", { diff --git a/packages/control-plane/src/routes/automation-validation.ts b/packages/control-plane/src/routes/automation-validation.ts index ac88424632..2e5c7175cf 100644 --- a/packages/control-plane/src/routes/automation-validation.ts +++ b/packages/control-plane/src/routes/automation-validation.ts @@ -129,7 +129,8 @@ export function resolveReasoningEffort( type NormalizedRepositoryInput = NonNullable[number]; type RepositorySelectionRequest = - { kind: "unchanged" } | { kind: "replace"; repositories: NormalizedRepositoryInput[] }; + | { kind: "unchanged" } + | { kind: "replace"; repositories: NormalizedRepositoryInput[] }; /** * Thrown when selection semantics cannot be satisfied. Route handlers catch it @@ -174,7 +175,8 @@ export function validateTargetCounts( } type EnvironmentSelectionRequest = - { kind: "unchanged" } | { kind: "replace"; environmentIds: string[] }; + | { kind: "unchanged" } + | { kind: "replace"; environmentIds: string[] }; /** * Select the environments from an already-parsed create/update body (design diff --git a/packages/control-plane/src/routes/catalog.ts b/packages/control-plane/src/routes/catalog.ts index 7bb66d4d52..c01ef14894 100644 --- a/packages/control-plane/src/routes/catalog.ts +++ b/packages/control-plane/src/routes/catalog.ts @@ -35,6 +35,8 @@ import { slackNotifyRoutes } from "./slack-notify"; import { signInProviderRoutes } from "./sign-in-providers"; import { skillRoutes } from "./skills"; import { teamRoutes } from "./teams"; +import { teamSecretsRoutes } from "./team-secrets"; +import { teamSettingsRoutes } from "./settings-teams"; /** Registration order is the precedence order: each module is mounted where it appears. */ export const catalog: readonly RouteModule[] = [ @@ -44,6 +46,7 @@ export const catalog: readonly RouteModule[] = [ signInProviderRoutes, teamRoutes, + teamSettingsRoutes, // Session management, then the agent-initiated Slack notification sessionRoutes, @@ -109,6 +112,8 @@ export const catalog: readonly RouteModule[] = [ // Personal access tokens (human-only credential management; no path // overlap with any other module, so registration order is immaterial here) accessTokenRoutes, + // Team secrets + teamSecretsRoutes, // Webhooks (public routes — auth handled per-route) webhookRoutes, diff --git a/packages/control-plane/src/routes/session-batch-archive.ts b/packages/control-plane/src/routes/session-batch-archive.ts index b69c71c0a3..ef12524377 100644 --- a/packages/control-plane/src/routes/session-batch-archive.ts +++ b/packages/control-plane/src/routes/session-batch-archive.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import type { AccessDenialReason } from "@open-inspect/shared"; import { sessionBatchArchiveRequestSchema, type SessionBatchArchiveResponse, @@ -18,6 +19,19 @@ import { type UserRouteContext, } from "./shared"; +/** Exhaustive so a new denial reason must choose its public batch-archive reason. */ +const SKIPPED_REASON_BY_DENIAL = { + not_member: "not_member", + suspended: "missing_permission", + private: "missing_permission", + missing_permission: "missing_permission", + not_owner_or_lead: "missing_permission", + not_collaborator: "missing_permission", +} as const satisfies Record< + AccessDenialReason, + SessionBatchArchiveResponse["skipped"][number]["reason"] +>; + export const sessionBatchArchiveRoutes = new Hono(); sessionBatchArchiveRoutes.post( @@ -54,11 +68,12 @@ sessionBatchArchiveRoutes.post( const skipped: SessionBatchArchiveResponse["skipped"] = []; for (const sessionId of body.sessionIds) { const admission = await evaluateSessionAdmission(ctx, env, sessionId, "lifecycle", null); - if (admission.kind !== "allowed") { - skipped.push({ - sessionId, - reason: admission.kind === "not_found" ? "not_found" : "missing_permission", - }); + if (admission.kind === "not_found") { + skipped.push({ sessionId, reason: "not_found" }); + continue; + } + if (admission.kind === "action_denied") { + skipped.push({ sessionId, reason: SKIPPED_REASON_BY_DENIAL[admission.reason] }); continue; } eligible.push(sessionId); diff --git a/packages/control-plane/src/routes/session-child-spawn.ts b/packages/control-plane/src/routes/session-child-spawn.ts index ea35d37d72..3a598aa8ad 100644 --- a/packages/control-plane/src/routes/session-child-spawn.ts +++ b/packages/control-plane/src/routes/session-child-spawn.ts @@ -267,7 +267,9 @@ export async function handleSpawnChild( model, reasoningEffort, participantUserId: spawnContext.promptAuthor.userId, - platformUserId: spawnContext.promptAuthor.canonicalUserId ?? null, + platformUserId: spawnContext.promptAuthor.canonicalUserId ?? parentSession?.userId ?? null, + participantCanonicalUserId: spawnContext.promptAuthor.canonicalUserId ?? null, + collaboratorSourceSessionId: parentId, scmLogin: spawnContext.promptAuthor.scmLogin, scmName: spawnContext.promptAuthor.scmName, scmEmail: spawnContext.promptAuthor.scmEmail, diff --git a/packages/control-plane/src/routes/session-children.test.ts b/packages/control-plane/src/routes/session-children.test.ts index 8038d73c47..7d07dbabce 100644 --- a/packages/control-plane/src/routes/session-children.test.ts +++ b/packages/control-plane/src/routes/session-children.test.ts @@ -1,6 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { evaluateSessionAdmission } from "../authorization/session-admission"; -import type * as SessionAdmissionModule from "../authorization/session-admission"; import { SessionIndexStore } from "../db/session-index"; import { resolveSandboxSettings } from "../session/integration-settings-resolution"; import type { SessionRuntimeClient } from "../session/runtime-client"; @@ -15,11 +13,6 @@ vi.mock("../session/integration-settings-resolution", () => ({ resolveSandboxSettings: vi.fn(), })); -vi.mock("../authorization/session-admission", async (importOriginal) => ({ - ...(await importOriginal()), - evaluateSessionAdmission: vi.fn(), -})); - function routeMatch(path: string, pattern: string): { id: string; childId: string } { const match = path.match(routePathPattern(pattern)); if (!match?.groups?.id || !match.groups.childId) throw new Error("Expected route match"); @@ -59,11 +52,91 @@ function routeContext( describe("handleListChildren", () => { afterEach(() => vi.restoreAllMocks()); + it("uses the internal read scope for a parent-bound sandbox", async () => { + const listByParent = vi + .spyOn(SessionIndexStore.prototype, "listByParent") + .mockResolvedValue([]); + const ctx = routeContext(vi.fn()); + ctx.principal = { kind: "sandbox", sessionId: "parent" }; + ctx.teamsEnforcementMode = "on"; + const response = await handleListChildren( + new Request("https://test.local/sessions/parent/children"), + {} as Env, + { id: "parent" }, + ctx + ); + expect(response.status).toBe(200); + expect(listByParent).toHaveBeenCalledWith( + "parent", + { kind: "internal", reason: "parent-bound sandbox" }, + "on" + ); + }); + + it("passes the admitted parent viewer and enforcement mode to child selection", async () => { + const listByParent = vi + .spyOn(SessionIndexStore.prototype, "listByParent") + .mockResolvedValue([]); + const ctx = routeContext(vi.fn()); + const viewer = { + kind: "user" as const, + userId: "viewer", + roleKey: "member" as const, + permissions: ["sessions.read"] as const, + suspended: false, + memberships: new Map([["team-a", "member" as const]]), + }; + ctx.sessionAdmission = { row: {} as never, viewer }; + ctx.teamsEnforcementMode = "on"; + + await handleListChildren( + new Request("https://test.local/sessions/parent/children"), + {} as Env, + { id: "parent" }, + ctx + ); + expect(listByParent).toHaveBeenCalledWith("parent", viewer, "on"); + }); + + it("derives the viewer on the off-mode admission fast path", async () => { + const listByParent = vi + .spyOn(SessionIndexStore.prototype, "listByParent") + .mockResolvedValue([]); + const ctx = routeContext(vi.fn()); + ctx.teamsEnforcementMode = "off"; + ctx.authorization = { + userId: "viewer", + role: { id: "member", key: "member", name: "Member" }, + permissions: ["sessions.read"], + suspendedAt: null, + }; + + await handleListChildren( + new Request("https://test.local/sessions/parent/children"), + {} as Env, + { id: "parent" }, + ctx + ); + expect(listByParent).toHaveBeenCalledWith( + "parent", + { + kind: "user", + userId: "viewer", + roleKey: "member", + permissions: ["sessions.read"], + suspended: false, + memberships: new Map(), + }, + "off" + ); + }); + it("projects viewer-neutral child summaries through the shared schema", async () => { - vi.mocked(evaluateSessionAdmission).mockResolvedValue({ - kind: "allowed", - legacyPermission: "sessions.read", - }); + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ + id: "parent", + ownerTeamId: null, + visibility: "workspace", + } as never); vi.spyOn(SessionIndexStore.prototype, "listByParent").mockResolvedValue([ { id: "child", @@ -99,12 +172,17 @@ describe("handleListChildren", () => { new Request("https://test.local/sessions/parent/children"), {} as Env, { id: "parent" }, - routeContext(vi.fn()) + Object.assign(routeContext(vi.fn()), { + principal: { kind: "sandbox", sessionId: "parent" }, + teamsEnforcementMode: "on", + }) ); await expect(response.json()).resolves.toEqual({ children: [ { id: "child", + ownerTeamId: null, + visibility: "workspace", title: "Child", repoOwner: "acme", repoName: "web", @@ -130,13 +208,6 @@ describe("handleListChildren", () => { }, ], }); - expect(evaluateSessionAdmission).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - "child", - "read", - null - ); }); }); @@ -400,6 +471,9 @@ describe("handleCancelChild", () => { it("attempts every descendant and aggregates non-conflict failures", async () => { vi.spyOn(SessionIndexStore.prototype, "isChildOf").mockResolvedValue(true); + vi.spyOn(SessionIndexStore.prototype, "get").mockImplementation( + async (id) => ({ id, ownerTeamId: null, visibility: "workspace" }) as never + ); vi.spyOn(SessionIndexStore.prototype, "listActiveDescendantIds").mockResolvedValue([ "deep-failure", "later-success", @@ -418,11 +492,13 @@ describe("handleCancelChild", () => { "/sessions/:id/children/:childId/cancel" ); + const ctx = routeContext(fetch); + ctx.principal = { kind: "sandbox", sessionId: "parent" }; const response = await handleCancelChild( new Request("https://test.local/sessions/parent/children/child/cancel", { method: "POST" }), {} as Env, match, - routeContext(fetch) + ctx ); expect(fetch.mock.calls.map(([sessionId]) => sessionId)).toEqual([ diff --git a/packages/control-plane/src/routes/session-children.ts b/packages/control-plane/src/routes/session-children.ts index 276332e416..07ef52e952 100644 --- a/packages/control-plane/src/routes/session-children.ts +++ b/packages/control-plane/src/routes/session-children.ts @@ -1,6 +1,6 @@ import { parseBody } from "./body"; import { Hono } from "hono"; -import { admit, dispatch } from "../routing/admit"; +import { admit } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { cancelChildSessionRequestSchema, @@ -10,12 +10,24 @@ import { } from "@open-inspect/shared/types/session-api"; import { DEFAULT_MAX_CONCURRENT_CHILD_SESSIONS } from "@open-inspect/shared/types/integrations"; import { childSessionListResponseSchema } from "@open-inspect/shared/types/sessions"; -import { SessionIndexStore, type ChildAdmissionLease } from "../db/session-index"; -import { evaluateSessionAdmission } from "../authorization/session-admission"; +import { + SessionIndexStore, + type ChildAdmissionLease, + type SessionEntry, +} from "../db/session-index"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { AuthorizationError, AuthorizationService } from "../authorization/service"; +import { checkSessionAccess, type SessionAction, type SessionViewer } from "@open-inspect/shared"; +import { + evaluateSessionAdmission, + teamsEnforcementMode, + viewerFromContext, +} from "../authorization/session-admission"; import { createLogger } from "../logger"; import { SessionInternalPaths } from "../session/contracts"; import { resolveSandboxSettings } from "../session/integration-settings-resolution"; -import { activePromptAuthorSchema } from "../session/active-prompt-author"; +import { activePromptAuthorSchema, type ActivePromptAuthor } from "../session/active-prompt-author"; import type { Env } from "../types"; import { error, @@ -26,33 +38,111 @@ import { requireSession, sessionRequirement, SCM_AGNOSTIC_SANDBOX_ROUTE, - type RequestContext, } from "./shared"; import { type SessionRouteContext, dispatchSession } from "./session-route"; const logger = createLogger("router:session-children"); +function sandboxChildAccess( + ctx: SessionRouteContext, + parent: SessionEntry, + activeAuthor?: ActivePromptAuthor +) { + let viewerPromise: Promise | undefined; + return async (child: SessionEntry, action: SessionAction): Promise => { + if (ctx.principal?.kind !== "sandbox" || ctx.principal.sessionId !== parent.id) return false; + if (child.ownerTeamId !== parent.ownerTeamId) return false; + if ( + child.visibility === "workspace" || + (child.visibility === "team" && parent.visibility === "team") + ) + return true; + + viewerPromise ??= (async () => { + let author = activeAuthor; + if (!author) { + const response = await ctx.sessionRuntime.fetch( + parent.id, + SessionInternalPaths.activePromptAuthor + ); + if (!response.ok) return null; + const parsed = activePromptAuthorSchema.safeParse(await response.json()); + if (!parsed.success) return null; + author = parsed.data; + } + const userId = author.canonicalUserId; + if (!userId) return null; + let authorization; + try { + authorization = await new AuthorizationService(ctx.db).getEffectiveAuthorization(userId); + } catch (cause) { + if (cause instanceof AuthorizationError) return null; + throw cause; + } + return { + kind: "user" as const, + userId, + roleKey: authorization.role.key, + permissions: authorization.permissions, + suspended: authorization.suspendedAt !== null, + memberships: await new TeamMembershipStore(ctx.db).listForUser(userId), + }; + })(); + const viewer = await viewerPromise; + if (!viewer || viewer.kind !== "user") return false; + const collaboratorIds = await new SessionCollaboratorStore(ctx.db).listUserIds(child.id); + if ( + child.visibility === "private" && + child.userId !== viewer.userId && + !collaboratorIds.includes(viewer.userId) + ) + return false; + return checkSessionAccess( + viewer, + { + id: child.id, + ownerUserId: child.userId ?? null, + ownerTeamId: child.ownerTeamId, + visibility: child.visibility, + collaboratorIds, + }, + action + ).allowed; + }; +} + export async function handleListChildren( _request: Request, env: Env, params: { id: string }, - ctx: RequestContext + ctx: SessionRouteContext ): Promise { const parentId = params.id; const sessionStore = new SessionIndexStore(ctx.db); - const children = await sessionStore.listByParent(parentId); - const visible = []; - for (const child of children) { - if ( - ctx.principal?.kind === "sandbox" || - (await evaluateSessionAdmission(ctx, env, child.id, "read", null)).kind === "allowed" - ) { - visible.push(child); + const readScope = + ctx.principal?.kind === "sandbox" + ? { kind: "internal" as const, reason: "parent-bound sandbox" } + : (ctx.sessionAdmission?.viewer ?? + viewerFromContext(ctx, ctx.sessionMemberships ?? new Map())); + const children = await sessionStore.listByParent( + parentId, + readScope, + teamsEnforcementMode(ctx, env) + ); + + if (ctx.principal?.kind === "sandbox" && children.length) { + const parent = await sessionStore.get(parentId); + if (!parent) return error("Session not found", 404); + const canAccess = sandboxChildAccess(ctx, parent); + const visible: SessionEntry[] = []; + for (const child of children) { + if (await canAccess(child, "read")) visible.push(child); } + return json(childSessionListResponseSchema.parse({ children: visible })); } - return json(childSessionListResponseSchema.parse({ children: visible })); + return json(childSessionListResponseSchema.parse({ children })); } export async function handleGetChild( @@ -70,6 +160,15 @@ export async function handleGetChild( if (!isChild) { return error("Child session not found", 404); } + if (ctx.principal?.kind === "sandbox") { + const [parent, child] = await Promise.all([ + sessionStore.get(parentId), + sessionStore.get(childId), + ]); + if (!parent || !child || !(await sandboxChildAccess(ctx, parent)(child, "read"))) { + return error("Child session not found", 404); + } + } const url = new URL(request.url); return ctx.sessionRuntime.fetch( @@ -98,6 +197,11 @@ export async function handlePromptChild( if (!childSession || childSession.parentSessionId !== parentId) { return error("Child session not found", 404); } + const parentSession = await sessionStore.get(parentId); + if (!parentSession) return error("Parent session not found", 404); + if (childSession.ownerTeamId !== parentSession.ownerTeamId) { + return json({ error: "Child has moved", code: "child_moved" }, 409); + } const authorResponse = await ctx.sessionRuntime.fetch( parentId, @@ -106,11 +210,14 @@ export async function handlePromptChild( if (!authorResponse.ok) return authorResponse; const author = activePromptAuthorSchema.safeParse(await authorResponse.json()); if (!author.success) return error("Failed to get active prompt author", 500); + if ( + childSession.visibility === "private" && + !(await sandboxChildAccess(ctx, parentSession, author.data)(childSession, "collaborate")) + ) + return error("Child session not found", 404); let admissionLease: ChildAdmissionLease | null = null; if (childSession.status === "completed" || childSession.status === "failed") { - const parentSession = await sessionStore.get(parentId); - if (!parentSession) return error("Parent session not found", 404); const parentSettings = await resolveSandboxSettings( ctx.db, parentSession.repoOwner, @@ -208,6 +315,16 @@ export async function handleCancelChild( if (!isChild) { return error("Child session not found", 404); } + let canAccess: ReturnType | null = null; + if (ctx.principal?.kind === "sandbox") { + const [parent, child] = await Promise.all([ + sessionStore.get(parentId), + sessionStore.get(childId), + ]); + if (!parent || !child) return error("Child session not found", 404); + canAccess = sandboxChildAccess(ctx, parent); + if (!(await canAccess(child, "lifecycle"))) return error("Child session not found", 404); + } // An empty body means "no options"; older clients POST without one. let body: CancelChildSessionRequest = {}; @@ -227,13 +344,31 @@ export async function handleCancelChild( } const cancelNested = body.cancelNested ?? true; + const descendantIds = cancelNested ? await sessionStore.listActiveDescendantIds(childId) : []; + for (const descendantId of descendantIds) { + if (canAccess) { + const descendant = await sessionStore.get(descendantId); + if (!descendant || !(await canAccess(descendant, "lifecycle"))) { + return error("Child session not found", 404); + } + } else { + const result = await evaluateSessionAdmission(ctx, env, descendantId, "lifecycle", null); + if (result.kind === "not_found") return error("Child session not found", 404); + if (result.kind === "action_denied") { + return json( + { error: "Forbidden", code: "session_action_denied", reason_code: result.reason }, + 403 + ); + } + } + } + const response = await ctx.sessionRuntime.fetch(childId, SessionInternalPaths.cancel, { method: "POST", }); if (!response.ok && response.status !== 409) return response; if (!cancelNested) return response; - const descendantIds = await sessionStore.listActiveDescendantIds(childId); const cancelledDescendantIds: string[] = []; const failedDescendantIds: string[] = []; for (const descendantId of descendantIds) { @@ -273,7 +408,7 @@ export const sessionChildRoutes = new Hono(); sessionChildRoutes.get( "/sessions/:id/children", admit({ ...GITHUB_SANDBOX_FALLBACK_ROUTE, authorization: requireSession("read") }), - (c) => dispatch(c, handleListChildren) + (c) => dispatchSession(c, handleListChildren) ); sessionChildRoutes.get( "/sessions/:id/children/:childId", diff --git a/packages/control-plane/src/routes/session-create.ts b/packages/control-plane/src/routes/session-create.ts index 449d54c010..9193bb494a 100644 --- a/packages/control-plane/src/routes/session-create.ts +++ b/packages/control-plane/src/routes/session-create.ts @@ -18,6 +18,10 @@ import { resolveEnvironmentTarget, resolveSessionRepositories } from "../repos/r import { resolveScmProviderFromEnv } from "../source-control"; import { EnvironmentStore } from "../db/environments"; import { UserStore } from "../db/user-store"; +import { TeamStore } from "../db/teams"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamSettingsStore } from "../db/team-settings"; +import { missingTeamRepository } from "./session-team-grants"; import { createLogger } from "../logger"; import { parseCreateSessionInput } from "../session/create-session-input"; import { @@ -177,6 +181,42 @@ export async function handleCreateSession( const resolution = requireAdmittedCanonicalUserId(ctx, enforced); if (resolution instanceof Response) return resolution; const resolvedUserId = resolution; + const teamId = body.teamId ?? null; + if (!teamId && (await new TeamSettingsStore(ctx.db).get()).requireTeamOnCreate) { + return json({ error: "A team is required", code: "team_required" }, 400); + } + let team = null; + if (teamId) { + team = await new TeamStore(ctx.db).getById(teamId); + if (!team) return error("Team not found", 404); + if (team.archivedAt !== null) + return json({ error: "Team archived", code: "team_archived" }, 409); + if ( + !resolvedUserId || + !(await new TeamMembershipStore(ctx.db).listForUser(resolvedUserId)).has(teamId) + ) { + return json({ error: "Not a team member", code: "not_member" }, 403); + } + const missing = await missingTeamRepository( + ctx.db, + teamId, + repositories ?? (repoOwner && repoName ? [{ repoOwner, repoName, repoId }] : []) + ); + if (missing) + return json( + { + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: `${missing.repoOwner}/${missing.repoName}`, + }, + 409 + ); + } + const visibility = body.visibility ?? team?.defaultVisibility ?? "workspace"; + if (visibility === "team" && !teamId) + return json({ error: "A team is required", code: "team_required" }, 400); + if (visibility === "private" && !resolvedUserId) + return json({ error: "Session owner required", code: "owner_required" }, 400); const githubDeployment = resolveScmProviderFromEnv(env.SCM_PROVIDER) === "github"; let scmLogin = body.scmLogin; @@ -260,8 +300,8 @@ export async function handleCreateSession( } const input: SessionInitInput = { - ownerTeamId: null, - visibility: "workspace", + ownerTeamId: teamId, + visibility, sessionId, repoOwner, repoName, @@ -276,6 +316,7 @@ export async function handleCreateSession( reasoningEffort, participantUserId, platformUserId: resolvedUserId, + participantCanonicalUserId: resolvedUserId, scmLogin, scmName, scmEmail, diff --git a/packages/control-plane/src/routes/session-export.test.ts b/packages/control-plane/src/routes/session-export.test.ts index 2eb2208851..ef5e7948c2 100644 --- a/packages/control-plane/src/routes/session-export.test.ts +++ b/packages/control-plane/src/routes/session-export.test.ts @@ -26,6 +26,7 @@ import { import type { PermissionId } from "@open-inspect/shared/rbac"; import type { ListSessionsForExportOptions, SessionExportRow } from "../db/session-export-store"; import type * as ExportStoreModule from "../db/session-export-store"; +import type { SqlStatement } from "../db/sql-database"; import { encodeRunsExportCursor } from "../db/session-export-cursor"; import { MAX_INCLUDED_BYTES_PER_SESSION } from "../session/contracts"; import type { Env } from "../types"; @@ -412,6 +413,48 @@ describe("GET /sessions/export", () => { expect(await readLines(response)).toMatchObject([{ type: "session", id: "session-1" }]); }); + it("passes enabled enforcement and the current user's memberships to the store", async () => { + mocks.list.mockResolvedValue({ sessions: [], hasMore: false, nextCursor: null }); + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: USER_PRINCIPAL, + request, + })); + const db = authorizationDatabase({ + statement: (sql) => { + if (sql.includes("FROM team_memberships WHERE user_id")) { + const statement: SqlStatement = { + ...emptyStatement(), + bind: () => statement, + all: async () => ({ + results: [{ team_id: "team-1", role: "member" }] as T[], + meta: { changes: 0 }, + }), + }; + return statement; + } + return emptyStatement(); + }, + }); + const response = await createHandler()( + new Request("https://test.local/sessions/export"), + createTestEnv({ ...TEST_SERVICE_SECRETS, DB: db, TEAMS_ENFORCEMENT: "on" }), + TEST_BACKGROUND_TASK_CONTEXT + ); + expect(response.status).toBe(200); + await readLines(response); + expect(mocks.list).toHaveBeenCalledWith({ + scope: "sessions", + cursor: null, + limit: 100, + mode: "on", + readScope: expect.objectContaining({ + kind: "user", + userId: "user-1", + memberships: new Map([["team-1", "member"]]), + }), + }); + }); + it("streams one valid NDJSON session line per row with the export content type", async () => { mocks.list.mockResolvedValue({ sessions: [sampleRow], hasMore: false, nextCursor: null }); @@ -454,7 +497,17 @@ describe("GET /sessions/export", () => { updatedAt: 2_000, }); expect(lines[0]).not.toHaveProperty("messages"); - expect(mocks.list).toHaveBeenCalledWith({ scope: "sessions", cursor: null, limit: 100 }); + expect(mocks.list).toHaveBeenCalledWith({ + scope: "sessions", + cursor: null, + limit: 100, + mode: "shadow", + readScope: expect.objectContaining({ + kind: "user", + userId: "user-1", + memberships: new Map(), + }), + }); }); it("emits a trailing cursor line when more pages remain, and parses it back", async () => { @@ -480,6 +533,8 @@ describe("GET /sessions/export", () => { scope: "sessions", cursor: { createdAt: 1_000, id: "session-1", snapshotMaxRowId: 42 }, limit: 100, + mode: "shadow", + readScope: expect.objectContaining({ kind: "user", userId: "user-1" }), }); }); @@ -503,9 +558,35 @@ describe("GET /sessions/export", () => { scope: "sessions", cursor: null, limit: MAX_INCLUDED_EXPORT_LIMIT, + mode: "shadow", + readScope: expect.objectContaining({ kind: "user", userId: "user-1" }), }); }); + it("does not fetch a hidden bulk row's trace with include=events", async () => { + const hidden = { ...sampleRow, id: "hidden", createdAt: 2_000 }; + mocks.list.mockImplementation(async (options: ListSessionsForExportOptions) => { + expect(options.readScope).toMatchObject({ kind: "user", userId: "user-1" }); + const rows = [hidden, sampleRow].filter((row) => row.id !== hidden.id); + return { sessions: rows, hasMore: false, nextCursor: null }; + }); + mocks.runtimeFetch.mockResolvedValue(traceResponse({ events: [] })); + + const lines = await readLines(await callExport({ include: "events" })); + + expect(lines).toMatchObject([{ type: "session", id: "session-1", events: [] }]); + expect(mocks.runtimeFetch).toHaveBeenCalledTimes(1); + expect(mocks.runtimeFetch).toHaveBeenCalledWith( + "session-1", + "/internal/trace-export", + expect.anything(), + "?include=events" + ); + expect(mocks.runtimeFetch.mock.calls.some(([sessionId]) => sessionId === hidden.id)).toBe( + false + ); + }); + it("inlines the prompt, tool activity, step tokens and outcome on one session line", async () => { mocks.list.mockResolvedValue({ sessions: [sampleRow], hasMore: false, nextCursor: null }); const trace = { @@ -574,6 +655,8 @@ describe("GET /sessions/export", () => { createdAfter: 800, createdBefore: 950, limit: MAX_INCLUDED_EXPORT_LIMIT, + mode: "shadow", + readScope: expect.objectContaining({ kind: "user", userId: "user-1" }), }); expect(mocks.runtimeFetch.mock.calls[0][3]).toBe("?include=events&format=compact"); }); diff --git a/packages/control-plane/src/routes/session-export.ts b/packages/control-plane/src/routes/session-export.ts index a2564b4fef..723c2a750b 100644 --- a/packages/control-plane/src/routes/session-export.ts +++ b/packages/control-plane/src/routes/session-export.ts @@ -35,6 +35,8 @@ import { type SessionExportRow, } from "../db/session-export-store"; import { createLogger, type Logger } from "../logger"; +import { teamsEnforcementMode, viewerFromContext } from "../authorization/session-admission"; +import { TeamMembershipStore } from "../db/team-memberships"; import { readBoundedBytes } from "../http/bounded-body"; import { admit } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; @@ -278,7 +280,7 @@ function streamExport( async function handleExport( request: Request, - _env: Env, + env: Env, _params: object, ctx: SessionRouteContext ): Promise { @@ -303,10 +305,19 @@ async function handleExport( } const store = new SessionExportStore(ctx.db); + const mode = teamsEnforcementMode(ctx, env); + const memberships = ctx.authorization + ? (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( + ctx.authorization.userId + )) + : new Map(); + const viewer = viewerFromContext(ctx, memberships); const { createdAfter, createdBefore } = query; async function* records(): AsyncGenerator { const page = await store.list({ ...selection, + readScope: viewer, + mode, limit, ...(createdAfter === undefined ? {} : { createdAfter }), ...(createdBefore === undefined ? {} : { createdBefore }), diff --git a/packages/control-plane/src/routes/session-index.test.ts b/packages/control-plane/src/routes/session-index.test.ts index 1b6c27e076..6a906dbcc4 100644 --- a/packages/control-plane/src/routes/session-index.test.ts +++ b/packages/control-plane/src/routes/session-index.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { handleListSessionInbox, handleListSessions, handlePatchReadState } from "./session-index"; +import { MAX_SESSION_LIST_FILTER_IDS } from "@open-inspect/shared/session-list-query"; import type { RequestContext, UserRouteContext } from "./shared"; import type { SqlDatabase } from "../db/sql-database"; +import { D1QueryParameterLimitError } from "../db/query-limits"; import type { Env } from "../types"; import type { Principal } from "../auth/principal"; import { createTestEnv, TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; const mockSessionIndexStore = { list: vi.fn(), @@ -66,7 +69,7 @@ async function listSessions(query = "", principal?: Principal): Promise { +async function listInbox( + query = "", + ctx = createCtx(USER_PRINCIPAL) as UserRouteContext, + env = createEnv() +): Promise { return handleListSessionInbox( new Request(`https://test.local/sessions/inbox${query}`), - createEnv(), + env, {}, - createCtx(USER_PRINCIPAL) as UserRouteContext + ctx ); } @@ -162,28 +174,32 @@ describe("session index routes", () => { const response = await listSessions("?limit=abc&offset=nope"); expect(response.status).toBe(200); - expect(mockSessionIndexStore.list).toHaveBeenCalledWith({ - status: undefined, - excludeStatus: undefined, - excludeAutomationLineage: false, - createdByUserIds: [], - limit: 50, - offset: 0, - }); + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ + status: undefined, + excludeStatus: undefined, + excludeAutomationLineage: false, + createdByUserIds: [], + limit: 50, + offset: 0, + }) + ); }); it("clamps pagination values before querying the store", async () => { const response = await listSessions("?limit=500&offset=-10"); expect(response.status).toBe(200); - expect(mockSessionIndexStore.list).toHaveBeenCalledWith({ - status: undefined, - excludeStatus: undefined, - excludeAutomationLineage: false, - createdByUserIds: [], - limit: 100, - offset: 0, - }); + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ + status: undefined, + excludeStatus: undefined, + excludeAutomationLineage: false, + createdByUserIds: [], + limit: 100, + offset: 0, + }) + ); }); it("passes validated status filters through to the store", async () => { @@ -212,14 +228,16 @@ describe("session index routes", () => { ); expect(response.status).toBe(200); - expect(mockSessionIndexStore.list).toHaveBeenCalledWith({ - status: undefined, - excludeStatus: undefined, - excludeAutomationLineage: false, - createdByUserIds: ["0123456789abcdef0123456789abcdef"], - limit: 50, - offset: 0, - }); + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ + status: undefined, + excludeStatus: undefined, + excludeAutomationLineage: false, + createdByUserIds: ["0123456789abcdef0123456789abcdef"], + limit: 50, + offset: 0, + }) + ); }); it("resolves createdBy=me from the authenticated user principal", async () => { @@ -230,15 +248,119 @@ describe("session index routes", () => { expect(response.status).toBe(200); expect(response.headers.get("Cache-Control")).toBe("private, no-store"); - expect(mockSessionIndexStore.list).toHaveBeenCalledWith({ - status: undefined, - excludeStatus: undefined, - excludeAutomationLineage: false, - createdByUserIds: ["0123456789abcdef0123456789abcdef"], - limit: 50, - offset: 0, - viewerUserId: "0123456789abcdef0123456789abcdef", + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ + status: undefined, + excludeStatus: undefined, + excludeAutomationLineage: false, + createdByUserIds: [], + limit: 50, + offset: 0, + viewerUserId: "0123456789abcdef0123456789abcdef", + ownerFilter: "started", + }) + ); + }); + + it("passes team and participation filters with the admitted viewer", async () => { + const response = await listSessions( + "?teamIds%5B%5D=team_alpha&ownerFilter=participating&visibility=team&scope=workspace", + USER_PRINCIPAL + ); + expect(response.status).toBe(200); + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ + teamIds: ["team_alpha"], + ownerFilter: "participating", + visibility: "team", + scope: "workspace", + readScope: expect.objectContaining({ userId: "user-1" }), + mode: "shadow", + }) + ); + }); + + it("keeps createdBy=me when an explicit ownerFilter is present", async () => { + const userId = "0123456789abcdef0123456789abcdef"; + const response = await listSessions("?createdBy=me&ownerFilter=participating", { + kind: "user", + userId, }); + expect(response.status).toBe(200); + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ createdByUserIds: [userId], ownerFilter: "participating" }) + ); + }); + + it("rejects scope=all for a non-administrator", async () => { + const response = await listSessions("?scope=all"); + expect(response.status).toBe(403); + expect(mockSessionIndexStore.list).not.toHaveBeenCalled(); + }); + + it("allows scope=all for an administrator", async () => { + const response = await listSessions("?scope=all", USER_PRINCIPAL); + expect(response.status).toBe(200); + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "all", + readScope: expect.objectContaining({ roleKey: "owner" }), + }) + ); + }); + + it("rejects ownerFilter for an actorless service", async () => { + const response = await listSessions("?ownerFilter=started", { + kind: "service", + service: "linear-bot", + actor: null, + }); + expect(response.status).toBe(400); + expect(mockSessionIndexStore.list).not.toHaveBeenCalled(); + }); + + it("passes inbox team filters before grouping", async () => { + const response = await listInbox( + "?teamIds%5B%5D=team_alpha&teamIds%5B%5D=team_alpha&teamIds%5B%5D=team_beta" + ); + expect(response.status).toBe(200); + expect(mockSessionIndexStore.listInboxSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + teamIds: ["team_alpha", "team_beta"], + readScope: expect.objectContaining({ userId: "user-1" }), + }) + ); + }); + + it("returns 400 for a combined list filter exceeding the D1 parameter budget", async () => { + mockSessionIndexStore.list.mockRejectedValueOnce(new D1QueryParameterLimitError()); + const response = await listSessions("?teamIds[]=team_alpha"); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Too many session filters" }); + }); + + it.each(["", "?category=finished"])( + "returns 400 for an inbox filter exceeding the D1 parameter budget (%s)", + async (query) => { + const method = query + ? mockSessionIndexStore.listInbox + : mockSessionIndexStore.listInboxSnapshot; + method.mockRejectedValueOnce(new D1QueryParameterLimitError()); + const response = await listInbox(query); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Too many session filters" }); + } + ); + + it("rejects inbox team filters that exceed the query budget before reading D1", async () => { + const params = new URLSearchParams(); + for (let i = 0; i <= MAX_SESSION_LIST_FILTER_IDS; i++) { + params.append("teamIds[]", `team_${i}`); + } + const response = await listInbox(`?${params}`); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid teamIds[]" }); + expect(mockSessionIndexStore.listInboxSnapshot).not.toHaveBeenCalled(); }); it("does not mark service session lists as private viewer data", async () => { @@ -266,7 +388,13 @@ describe("session index routes", () => { expect(response.headers.get("Cache-Control")).toBe("private, no-store"); await expect(response.json()).resolves.toEqual({ - sessions: [{ ...listSession, readState }], + sessions: [ + { + ...listSession, + readState, + capabilities: expect.objectContaining({ canRead: true, canDelete: true }), + }, + ], hasMore: false, }); }); @@ -279,13 +407,25 @@ describe("session index routes", () => { const response = await listSessions(); await expect(response.json()).resolves.toEqual({ - sessions: [listSession], + sessions: [ + { + ...listSession, + capabilities: expect.objectContaining({ canRead: true, canDelete: false }), + }, + ], hasMore: true, }); }); it("projects inbox page and snapshot responses through their shared schemas", async () => { - const item = { rootSession: inboxSession, descendantSessions: [] }; + const { userId: _userId, ...projectedSession } = inboxSession; + const item = { + rootSession: { + ...projectedSession, + capabilities: expect.objectContaining({ canRead: true, canDelete: true }), + }, + descendantSessions: [], + }; const storedItem = { rootSession: { ...inboxSession, providerAuth: [{ secret: "internal" }] }, descendantSessions: [], @@ -452,6 +592,163 @@ describe("session index routes", () => { }); }); +describe("scoped inbox routes", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + mockSessionIndexStore.listInbox.mockResolvedValue(emptyInboxResult); + mockSessionIndexStore.listInboxSnapshot.mockResolvedValue({ + needs_attention: emptyInboxResult, + in_progress: emptyInboxResult, + finished: emptyInboxResult, + }); + }); + + it.each(["", "category=finished&cursor=200:session-1&"])( + "passes scoped filters to snapshot and paged reads (%s)", + async (pagination) => { + const response = await listInbox( + `?${pagination}ownerFilter=participating&visibility=private&scope=workspace&teamIds[]=team_alpha` + ); + expect(response.status).toBe(200); + const read = pagination + ? mockSessionIndexStore.listInbox + : mockSessionIndexStore.listInboxSnapshot; + expect(read).toHaveBeenCalledWith( + expect.objectContaining({ + ownerFilter: "participating", + visibility: "private", + scope: "workspace", + teamIds: ["team_alpha"], + readScope: expect.objectContaining({ userId: "user-1" }), + }) + ); + } + ); + + it.each([ + ["member", "member"], + ["viewer", "viewer"], + ["custom", null], + ] as const)("rejects all scope for %s", async (_roleName, roleKey) => { + const ctx = createCtx(USER_PRINCIPAL) as UserRouteContext; + ctx.authorization!.role.key = roleKey; + for (const query of ["?scope=all", "?category=finished&scope=all"]) { + const response = await listInbox(query, ctx); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "Invalid scope" }); + } + expect(mockSessionIndexStore.listInboxSnapshot).not.toHaveBeenCalled(); + expect(mockSessionIndexStore.listInbox).not.toHaveBeenCalled(); + }); + + it.each(["owner", "administrator"] as const)("allows all scope for %s", async (roleKey) => { + const ctx = createCtx(USER_PRINCIPAL) as UserRouteContext; + ctx.authorization!.role.key = roleKey; + expect((await listInbox("?category=finished&scope=all", ctx)).status).toBe(200); + expect(mockSessionIndexStore.listInbox).toHaveBeenCalledWith( + expect.objectContaining({ scope: "all" }) + ); + }); + + it.each(["started", "participating", "anyone"])( + "preserves creator-only automated exclusions when mine composes with %s", + async (ownerFilter) => { + expect((await listInbox(`?mine=true&ownerFilter=${ownerFilter}`)).status).toBe(200); + expect(mockSessionIndexStore.listInboxSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + createdByUserIds: ["user-1"], + excludeAutomatedSessions: true, + ownerFilter, + }) + ); + } + ); + + it.each([ + ["ownerFilter=mine", "Invalid ownerFilter"], + ["ownerFilter=", "Invalid ownerFilter"], + ["ownerFilter=started&ownerFilter=anyone", "Invalid ownerFilter"], + ["visibility=public", "Invalid visibility"], + ["visibility=", "Invalid visibility"], + ["visibility=team&visibility=workspace", "Invalid visibility"], + ["scope=team", "Invalid scope"], + ["scope=", "Invalid scope"], + ["scope=all&scope=workspace", "Invalid scope"], + ])("rejects invalid scoped query %s", async (query, message) => { + const response = await listInbox(`?${query}`); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: message }); + expect(mockSessionIndexStore.listInboxSnapshot).not.toHaveBeenCalled(); + expect(mockSessionIndexStore.listInbox).not.toHaveBeenCalled(); + }); + + it.each(["off", "shadow", "on"] as const)( + "decorates snapshot and page roots and descendants with effective capabilities in %s", + async (mode) => { + const ctx = createCtx(USER_PRINCIPAL) as UserRouteContext; + ctx.authorization!.role.key = "member"; + ctx.authorization!.permissions = [ + "sessions.read", + "sessions.collaborate", + "sessions.sandbox_access", + "sessions.lifecycle", + "sessions.delete", + ]; + const item = { + rootSession: { ...inboxSession, userId: "another-user", providerAuth: "secret" }, + descendantSessions: [ + { + ...inboxSession, + id: "private-child", + parentSessionId: inboxSession.id, + userId: "another-user", + visibility: "private", + }, + ], + }; + const page = { items: [item], hasMore: false, nextCursor: null }; + mockSessionIndexStore.listInbox.mockResolvedValue(page); + mockSessionIndexStore.listInboxSnapshot.mockResolvedValue({ + needs_attention: page, + in_progress: emptyInboxResult, + finished: emptyInboxResult, + }); + vi.spyOn(SessionCollaboratorStore.prototype, "listForSessions").mockResolvedValue( + new Map([["private-child", ["user-1"]]]) + ); + const env = { ...createEnv(), TEAMS_ENFORCEMENT: mode }; + const paged = await (await listInbox("?category=needs_attention", ctx, env)).json(); + const snapshot = (await (await listInbox("", ctx, env)).json()) as { + categories: { needs_attention: unknown }; + }; + expect(paged).toEqual(snapshot.categories.needs_attention); + expect(paged).toMatchObject({ + items: [ + { + rootSession: { + capabilities: { canRead: true, canDelete: mode !== "on" }, + }, + descendantSessions: [ + { + capabilities: { + canRead: true, + canCollaborate: true, + canSandbox: true, + canDelete: false, + canManageCollaborators: false, + }, + }, + ], + }, + ], + }); + expect(JSON.stringify(paged)).not.toContain("providerAuth"); + expect(JSON.stringify(paged)).not.toContain("userId"); + } + ); +}); + describe("session discovery filters", () => { beforeEach(() => { vi.clearAllMocks(); @@ -465,19 +762,22 @@ describe("session discovery filters", () => { ); expect(response.status).toBe(200); - expect(mockSessionIndexStore.list).toHaveBeenCalledWith({ - status: undefined, - excludeStatus: "archived", - excludeAutomationLineage: false, - createdByUserIds: ["0123456789abcdef0123456789abcdef"], - search: "login", - repository: { repoOwner: "acme", repoName: "web-app" }, - environmentId: "env-1", - spawnSource: "automation", - limit: 50, - offset: 0, - viewerUserId: "0123456789abcdef0123456789abcdef", - }); + expect(mockSessionIndexStore.list).toHaveBeenCalledWith( + expect.objectContaining({ + status: undefined, + excludeStatus: "archived", + excludeAutomationLineage: false, + createdByUserIds: [], + ownerFilter: "started", + search: "login", + repository: { repoOwner: "acme", repoName: "web-app" }, + environmentId: "env-1", + spawnSource: "automation", + limit: 50, + offset: 0, + viewerUserId: "0123456789abcdef0123456789abcdef", + }) + ); }); it("omits discovery filters that were not supplied", async () => { diff --git a/packages/control-plane/src/routes/session-index.ts b/packages/control-plane/src/routes/session-index.ts index 7a49764130..f69c95ae64 100644 --- a/packages/control-plane/src/routes/session-index.ts +++ b/packages/control-plane/src/routes/session-index.ts @@ -5,14 +5,16 @@ import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { parseSessionListQuery, + parseSessionListTeamIds, SESSION_LIST_CURRENT_USER, } from "@open-inspect/shared/session-list-query"; import { SESSION_INBOX_CATEGORIES, sessionInboxCategorySchema, sessionInboxPageSchema, - sessionInboxSnapshotSchema, } from "@open-inspect/shared/types/session-inbox"; +import type { SessionViewer } from "@open-inspect/shared"; +import { sessionVisibilitySchema } from "@open-inspect/shared/types/teams"; import { sessionListResponseSchema, sessionReadActionSchema, @@ -34,6 +36,16 @@ import type { Env } from "../types"; import { createLogger } from "../logger"; import { encodeSessionInboxCursor, parseSessionInboxCursor } from "../db/session-inbox-cursor"; import { parseQuery } from "./query"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { D1QueryParameterLimitError } from "../db/query-limits"; +import { + effectiveSessionCapabilities, + teamsEnforcementMode, + viewerFromContext, +} from "../authorization/session-admission"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; +import type { ListSessionInboxResult } from "../db/session-inbox-store"; const sessionInboxQuerySchema = z.object({ category: z @@ -50,10 +62,24 @@ const sessionInboxQuerySchema = z.object({ }), cursor: z.string().min(1, { error: "Invalid cursor" }).optional(), mine: z.literal("true", { error: "Invalid mine" }).optional(), + ownerFilter: z + .enum(["started", "participating", "anyone"], { error: "Invalid ownerFilter" }) + .optional(), + visibility: z.enum(sessionVisibilitySchema.options, { error: "Invalid visibility" }).optional(), + scope: z.enum(["workspace", "all"], { error: "Invalid scope" }).optional(), }); const log = createLogger("session-read-state"); -const SESSION_INBOX_LIMIT = 20; +export const SESSION_INBOX_LIMIT = 20; + +async function readSessionList(read: () => Promise): Promise { + try { + return await read(); + } catch (cause) { + if (cause instanceof D1QueryParameterLimitError) return error(cause.message, 400); + throw cause; + } +} function parseCreatedByFilters( values: readonly string[], @@ -100,29 +126,86 @@ export async function handleListSessions( origin, limit, offset, + teamIds, + ownerFilter, + visibility, + scope, } = parsedQuery.data; const viewerUserId = canonicalUserIdOf(ctx.principal) ?? ctx.authorization?.userId ?? undefined; - const createdByUserIds = parseCreatedByFilters(createdBy, viewerUserId ?? null); + const legacyStarted = + ownerFilter === undefined && + createdBy.length === 1 && + createdBy[0] === SESSION_LIST_CURRENT_USER; + if (legacyStarted && !isCanonicalUserId(viewerUserId)) return error("Invalid createdBy", 400); + const createdByUserIds = parseCreatedByFilters( + legacyStarted ? [] : createdBy, + viewerUserId ?? null + ); if (createdByUserIds instanceof Response) { return createdByUserIds; } + const viewer = viewerFromContext( + ctx, + ctx.authorization + ? (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( + ctx.authorization.userId + )) + : new Map() + ); + if ( + scope === "all" && + (viewer.kind !== "user" || !["owner", "administrator"].includes(viewer.roleKey ?? "")) + ) { + return error("Invalid scope", 403); + } + if (ownerFilter && ownerFilter !== "anyone" && viewer.kind !== "user") { + return error("Invalid ownerFilter", 400); + } + const store = new SessionIndexStore(ctx.db); const listStartedAt = Date.now(); - const result = await store.list({ - status, - excludeStatus, - excludeAutomationLineage, - createdByUserIds, - ...(q ? { search: q } : {}), - ...(repoOwner && repoName ? { repository: { repoOwner, repoName } } : {}), - ...(environmentId ? { environmentId } : {}), - ...(origin ? { spawnSource: origin } : {}), - limit, - offset, - ...(viewerUserId ? { viewerUserId } : {}), - }); + const result = await readSessionList(() => + store.list({ + status, + excludeStatus, + excludeAutomationLineage, + createdByUserIds, + ...(teamIds ? { teamIds } : {}), + ownerFilter: ownerFilter ?? (legacyStarted ? "started" : "anyone"), + visibility, + scope, + readScope: viewer, + mode: teamsEnforcementMode(ctx, env), + ...(q ? { search: q } : {}), + ...(repoOwner && repoName ? { repository: { repoOwner, repoName } } : {}), + ...(environmentId ? { environmentId } : {}), + ...(origin ? { spawnSource: origin } : {}), + limit, + offset, + ...(viewerUserId ? { viewerUserId } : {}), + }) + ); + if (result instanceof Response) return result; + const collaborators = await new SessionCollaboratorStore(ctx.db).listForSessions( + result.sessions.map((row) => row.id), + { privateOnly: true } + ); + const sessions = result.sessions.map((row) => ({ + ...row, + capabilities: effectiveSessionCapabilities( + viewer, + { + id: row.id, + ownerUserId: row.userId ?? null, + ownerTeamId: row.ownerTeamId, + visibility: row.visibility, + collaboratorIds: collaborators.get(row.id) ?? [], + }, + teamsEnforcementMode(ctx, env) + ), + })); if (viewerUserId) { log.info("session_read_state.decorated", { event: "session_read_state.decorated", @@ -135,7 +218,7 @@ export async function handleListSessions( const response = json( sessionListResponseSchema.parse({ - sessions: result.sessions, + sessions, hasMore: result.hasMore, }) ); @@ -147,56 +230,78 @@ export async function handleListSessions( export async function handleListSessionInbox( request: Request, - _env: Env, + env: Env, _params: object, ctx: UserRouteContext ): Promise { const query = parseQuery(request, sessionInboxQuerySchema); if (query instanceof Response) return query; - const { category, mine } = query; + const { category, mine, ownerFilter, visibility, scope } = query; if (query.cursor !== undefined && category === null) { return error("Category required for pagination", 400); } const parsedCursor = parseSessionInboxCursor(query.cursor); if (!parsedCursor.ok) return error(parsedCursor.error, 400); + const teamIds = parseSessionListTeamIds(new URL(request.url).searchParams); + if (teamIds === null) { + return error("Invalid teamIds[]", 400); + } + const viewer = viewerFromContext( + ctx, + (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( + ctx.principal.userId + )) + ); + if ( + scope === "all" && + (viewer.kind !== "user" || !["owner", "administrator"].includes(viewer.roleKey ?? "")) + ) { + return error("Invalid scope", 403); + } const startedAt = Date.now(); const store = new SessionIndexStore(ctx.db); + const mode = teamsEnforcementMode(ctx, env); const commonOptions = { limit: SESSION_INBOX_LIMIT, createdByUserIds: mine === "true" ? [ctx.principal.userId] : [], excludeAutomatedSessions: mine === "true", viewerUserId: ctx.principal.userId, + readScope: viewer, + mode, + teamIds, + ownerFilter, + visibility, + scope, }; if (category === null) { - const snapshot = await store.listInboxSnapshot(commonOptions); - const body = sessionInboxSnapshotSchema.parse({ + const snapshot = await readSessionList(() => store.listInboxSnapshot(commonOptions)); + if (snapshot instanceof Response) return snapshot; + const body = { categories: Object.fromEntries( - SESSION_INBOX_CATEGORIES.map((inboxCategory) => [ - inboxCategory, - encodeInboxPage(snapshot[inboxCategory]), - ]) + await Promise.all( + SESSION_INBOX_CATEGORIES.map(async (inboxCategory) => [ + inboxCategory, + await encodeInboxPage(snapshot[inboxCategory], ctx, viewer, mode), + ]) + ) ), - }); + }; const response = json(body); response.headers.set("Cache-Control", "private, no-store"); return response; } - const result = await store.listInbox({ - ...commonOptions, - category, - cursor: parsedCursor.cursor, - }); - const nextCursor = result.nextCursor ? encodeSessionInboxCursor(result.nextCursor) : null; - const response = json( - sessionInboxPageSchema.parse({ - items: result.items, - hasMore: result.hasMore, - nextCursor, + const result = await readSessionList(() => + store.listInbox({ + ...commonOptions, + category, + cursor: parsedCursor.cursor, }) ); + if (result instanceof Response) return result; + const response = json(await encodeInboxPage(result, ctx, viewer, mode)); response.headers.set("Cache-Control", "private, no-store"); log.info("session_inbox.listed", { event: "session_inbox.listed", @@ -213,12 +318,40 @@ export async function handleListSessionInbox( return response; } -function encodeInboxPage(result: Awaited>) { - return { - items: result.items, +async function encodeInboxPage( + result: ListSessionInboxResult, + ctx: RequestContext, + viewer: SessionViewer, + mode: TeamsEnforcementMode +) { + const sessions = result.items.flatMap((item) => [item.rootSession, ...item.descendantSessions]); + const collaborators = await new SessionCollaboratorStore(ctx.db).listForSessions( + sessions.map((row) => row.id), + { privateOnly: true } + ); + const capabilities = (row: ListSessionInboxResult["items"][number]["rootSession"]) => + effectiveSessionCapabilities( + viewer, + { + id: row.id, + ownerUserId: row.userId, + ownerTeamId: row.ownerTeamId, + visibility: row.visibility, + collaboratorIds: collaborators.get(row.id) ?? [], + }, + mode + ); + return sessionInboxPageSchema.parse({ + items: result.items.map((item) => ({ + rootSession: { ...item.rootSession, capabilities: capabilities(item.rootSession) }, + descendantSessions: item.descendantSessions.map((row) => ({ + ...row, + capabilities: capabilities(row), + })), + })), hasMore: result.hasMore, nextCursor: result.nextCursor ? encodeSessionInboxCursor(result.nextCursor) : null, - }; + }); } export async function handlePatchReadState( diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts index 1ca6cdff4b..78f734dd8d 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts @@ -36,6 +36,9 @@ type DatabaseOptions = { /** Custom-role grants for user-1; omitted means the owner role with every permission. */ permissions?: PermissionId[]; visibility?: "private"; + ownerTeamId?: string; + teamMember?: boolean; + userId?: string | null; /** Answers every statement admission and the proxy's own reads do not own. */ delegate?: SqlDatabase; }; @@ -52,15 +55,23 @@ function createDatabase(options: DatabaseOptions = {}): SqlDatabase { if (sql.includes("FROM role_permissions")) { return (options.permissions ?? []).map((permission_id) => ({ permission_id })); } - if (sql.includes("FROM team_memberships") || sql.includes("FROM session_collaborators")) - return []; + if (sql.includes("FROM team_memberships")) + return options.teamMember ? [{ team_id: options.ownerTeamId, role: "member" }] : []; + if (sql.includes("FROM session_collaborators")) return []; return null; }; const row = (sql: string): unknown => { - if (sql.includes("SELECT * FROM sessions")) - return options.visibility === "private" - ? { ...TEST_SESSION_ROW, visibility: "private", user_id: "another-user" } - : TEST_SESSION_ROW; + if (sql.includes("SELECT * FROM sessions")) { + const session = + options.visibility === "private" + ? { ...TEST_SESSION_ROW, visibility: "private", user_id: "another-user" } + : TEST_SESSION_ROW; + return { + ...session, + ...(options.ownerTeamId ? { owner_team_id: options.ownerTeamId } : {}), + ...(options.userId === undefined ? {} : { user_id: options.userId }), + }; + } if (sql.includes("FROM users u")) return { user_id: "user-1", suspended_at: null, ...role }; if (sql.includes("FROM session_model_provider_auth")) { return { @@ -195,6 +206,44 @@ describe("session runtime proxy routes", () => { } ); + it.each(["canonical-owner", null])( + "decorates snapshot ownerUserId from the admitted D1 row (%s)", + async (ownerUserId) => { + const get = vi.spyOn(SessionIndexStore.prototype, "get"); + const fetch = vi.fn(async (_request: Request) => + Response.json({ + session: { + id: "session-1", + ownerUserId: "runtime-owner", + title: "Session", + repoOwner: "acme", + repoName: "web", + baseBranch: "main", + branchName: "feature", + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + }, + artifacts: [], + promptQueue: [], + timeline: { events: [], hasMore: false, cursor: null }, + }) + ); + + const response = await dispatch(new Request("https://test.local/sessions/session-1"), { + ...createEnv(fetch, { userId: ownerUserId }), + TEAMS_ENFORCEMENT: "on", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ session: { ownerUserId } }); + expect(get).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledOnce(); + expect(new URL(fetch.mock.calls[0][0].url).pathname).toBe(SessionInternalPaths.snapshot); + } + ); + it("forwards sandbox access for users", async () => { const requests: Request[] = []; const fetch = vi.fn(async (request: Request) => { @@ -231,6 +280,16 @@ describe("session runtime proxy routes", () => { mode, visibility: "private" as const, })), + ...(["off", "shadow", "on"] as const).flatMap((mode) => + [false, true].map((teamMember) => ({ + permissions: undefined, + exposed: teamMember, + mode, + visibility: "workspace" as const, + ownerTeamId: "team_one", + teamMember, + })) + ), ])( "scopes $mode $visibility snapshot sandbox locations to sandbox access ($exposed)", async (input) => { @@ -263,12 +322,18 @@ describe("session runtime proxy routes", () => { ...createEnv(fetch, { permissions: input.permissions, visibility: input.visibility === "private" ? "private" : undefined, + ownerTeamId: "ownerTeamId" in input ? input.ownerTeamId : undefined, + teamMember: "teamMember" in input ? input.teamMember : undefined, }), TEAMS_ENFORCEMENT: input.mode, }); const snapshot = (await response.json()) as { session: Record }; expect(response.status).toBe(200); + expect(snapshot.session).toHaveProperty( + "ownerUserId", + input.visibility === "private" ? "another-user" : "user-1" + ); if (input.exposed) { expect(snapshot.session).toHaveProperty("codeServerUrl", "https://code.example"); } else { @@ -278,6 +343,28 @@ describe("session runtime proxy routes", () => { expect(snapshot.session).not.toHaveProperty("tunnelUrls"); expect(snapshot.session).not.toHaveProperty("sandboxDashboardUrl"); } + if ("ownerTeamId" in input) { + expect(snapshot.session.capabilities).toMatchObject({ + canRead: true, + canCollaborate: input.teamMember, + canManageLifecycle: input.teamMember, + canSandbox: input.teamMember, + }); + } + } + ); + + it.each(["off", "shadow", "on"] as const)( + "refuses non-member sandbox access before runtime dispatch in %s mode", + async (mode) => { + const fetch = vi.fn(async () => Response.json({ sessionId: "session-1" })); + const response = await dispatch( + new Request("https://test.local/sessions/session-1/sandbox-access"), + { ...createEnv(fetch, { ownerTeamId: "team_one" }), TEAMS_ENFORCEMENT: mode } + ); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ reason_code: "not_member" }); + expect(fetch).not.toHaveBeenCalled(); } ); @@ -713,6 +800,8 @@ describe("session runtime proxy routes", () => { vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ id: "session-1", userId, + ownerTeamId: null, + visibility: "workspace", } as Awaited>); } diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts index b77f2e4ff1..96a80f8161 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -16,6 +16,14 @@ import { z } from "zod"; import { UserStore } from "../db/user-store"; import { SessionIndexStore } from "../db/session-index"; import { checkSessionAccess } from "@open-inspect/shared"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { + effectiveSessionCapabilities, + teamsEnforcementMode, + viewerFromContext, +} from "../authorization/session-admission"; +import { resolverDecides } from "../authorization/teams-enforcement"; import type { SubscriptionProviderId } from "@open-inspect/shared/types/provider-accounts"; import { SessionInternalPaths, type SessionInternalPath } from "../session/contracts"; import type { Env } from "../types"; @@ -163,7 +171,7 @@ async function handleParticipantProfiles( async function handleSessionSnapshot( _request: Request, - _env: Env, + env: Env, params: SessionParams, ctx: SessionRouteContext ): Promise { @@ -175,12 +183,34 @@ async function handleSessionSnapshot( const parsed = sessionSnapshotSchema.safeParse(await response.json().catch(() => null)); if (!parsed.success) return error("Invalid session snapshot", 502); - const admission = ctx.sessionAdmission; - const sandboxAllowed = - admission && (admission.row.visibility === "private" || ctx.teamsEnforcementMode === "on") - ? checkSessionAccess(admission.viewer, admission.row, "sandbox").allowed - : ctx.authorization?.permissions.includes("sessions.sandbox_access"); + const row = ctx.sessionAdmission?.row ?? (await new SessionIndexStore(ctx.db).get(sessionId)); + if (!row) return error("Session not found", 404); + const viewer = + ctx.sessionAdmission?.viewer ?? + viewerFromContext( + ctx, + ctx.authorization + ? (ctx.sessionMemberships ??= await new TeamMembershipStore(ctx.db).listForUser( + ctx.authorization.userId + )) + : new Map() + ); + const collaborators = + ctx.sessionAdmission?.row.collaboratorIds ?? + (await new SessionCollaboratorStore(ctx.db).listUserIds(sessionId)); + const accessRow = { ...row, ownerUserId: row.userId ?? null, collaboratorIds: collaborators }; + const sandboxAllowed = resolverDecides(teamsEnforcementMode(ctx, env), accessRow, "sandbox") + ? checkSessionAccess(viewer, accessRow, "sandbox").allowed + : ctx.authorization?.permissions.includes("sessions.sandbox_access"); const snapshot = sandboxAllowed ? parsed.data : redactSessionSnapshotSandboxAccess(parsed.data); + snapshot.session = { + ...snapshot.session, + ownerUserId: row.userId ?? null, + ownerTeamId: row.ownerTeamId, + visibility: row.visibility, + collaborators: [...collaborators], + capabilities: effectiveSessionCapabilities(viewer, accessRow, teamsEnforcementMode(ctx, env)), + }; const headers = new Headers(response.headers); headers.delete("Content-Length"); return Response.json(snapshot, { headers }); diff --git a/packages/control-plane/src/routes/session-scope.ts b/packages/control-plane/src/routes/session-scope.ts new file mode 100644 index 0000000000..c1a447052c --- /dev/null +++ b/packages/control-plane/src/routes/session-scope.ts @@ -0,0 +1,190 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { checkSessionAccess, type SessionAction } from "@open-inspect/shared"; +import { sessionVisibilitySchema } from "@open-inspect/shared/types/teams"; +import { SessionAuditStore } from "../db/session-audit"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import { SessionIndexStore } from "../db/session-index"; +import { SessionScopeStore } from "../db/session-scope-store"; +import { evaluateSessionAdmission } from "../authorization/session-admission"; +import { UserStore } from "../db/user-store"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import type { Env } from "../types"; +import { parseBody } from "./body"; +import { + error, + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + json, + requireSession, + type RequestContext, +} from "./shared"; + +const visibilityBody = z.strictObject({ + visibility: sessionVisibilitySchema, + includeChildren: z.boolean().default(true), +}); + +function denied(reason: string): Response { + return json({ error: "Forbidden", code: "session_action_denied", reason_code: reason }, 403); +} + +async function admitDescendants( + ctx: RequestContext, + env: Env, + ids: readonly string[], + action: SessionAction +): Promise { + for (const id of ids.slice(1)) { + const result = await evaluateSessionAdmission(ctx, env, id, action, null, true); + if (result.kind === "not_found") return error("Session not found", 404); + if (result.kind === "action_denied") return denied(result.reason); + } + return null; +} + +async function changeVisibility( + request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +) { + const body = await parseBody(request, visibilityBody, "Invalid visibility"); + if (body instanceof Response) return body; + const admission = ctx.sessionAdmission!; + if (admission.viewer.kind !== "user") return denied("missing_permission"); + const actorUserId = admission.viewer.userId; + if (body.visibility === "team" && !admission.row.ownerTeamId) + return json({ error: "A team is required", code: "team_required" }, 400); + const store = new SessionIndexStore(ctx.db); + const scope = new SessionScopeStore(ctx.db); + const ids = [ + params.id, + ...(body.includeChildren ? await scope.listDescendantIds(params.id) : []), + ]; + const descendantDenial = await admitDescendants(ctx, env, ids, "changeVisibility"); + if (descendantDenial) return descendantDenial; + const rows = [admission.row, ...(await Promise.all(ids.slice(1).map((id) => store.get(id))))]; + if (body.visibility === "private" && rows.some((row) => !row?.userId)) + return json({ error: "Session owner required", code: "owner_required" }, 400); + if (body.visibility === "team" && rows.some((row) => !row?.ownerTeamId)) + return json({ error: "A team is required", code: "team_required" }, 400); + const auditStore = new SessionAuditStore(ctx.db); + const audits = rows.flatMap((row) => + row && row.visibility !== body.visibility + ? [ + { + sessionId: row.id, + statement: auditStore.bind( + { + requestId: ctx.request_id, + actorUserId, + action: "session.visibility_changed", + sessionId: row.id, + teamId: row.ownerTeamId, + before: { visibility: row.visibility }, + after: { visibility: body.visibility }, + }, + true + ), + }, + ] + : [] + ); + await scope.updateVisibility(ids, body.visibility, audits); + return json({ sessionId: params.id, visibility: body.visibility, affectedSessionIds: ids }); +} + +async function changeCollaborator( + _request: Request, + _env: Env, + params: { id: string; userId: string }, + ctx: RequestContext, + remove: boolean +) { + const admission = ctx.sessionAdmission!; + if (admission.viewer.kind !== "user") return denied("missing_permission"); + if (remove && admission.viewer.userId !== params.userId) { + const access = checkSessionAccess(admission.viewer, admission.row, "manageCollaborators"); + if (!access.allowed) return denied(access.reason); + } + if (!remove) { + const eligibility = await new UserStore(ctx.db).getCollaboratorEligibility( + params.userId, + admission.row.ownerTeamId + ); + if (eligibility === "not_found") return error("User not found", 404); + if (eligibility === "inactive") + return json({ error: "User inactive", code: "user_inactive" }, 409); + // Team-owned actions require membership, so a non-member grant could never be exercised. + if (eligibility === "not_team_member") + return json( + { error: "User is not a member of the owning team", code: "not_team_member" }, + 409 + ); + } + if (admission.row.collaboratorIds.includes(params.userId) === !remove) { + return json({ sessionId: params.id, userId: params.userId, status: "unchanged" }); + } + const audit = { + requestId: ctx.request_id, + actorUserId: admission.viewer.userId, + sessionId: params.id, + action: remove ? "session.collaborator_removed" : "session.collaborator_added", + teamId: admission.row.ownerTeamId, + targetUserId: params.userId, + before: { collaborator: remove }, + after: { collaborator: !remove }, + } as const; + const store = new SessionCollaboratorStore(ctx.db); + const changed = remove + ? await store.remove(params.id, params.userId, audit) + : await store.add(params.id, params.userId, admission.viewer.userId, audit); + return json({ + sessionId: params.id, + userId: params.userId, + status: changed ? "updated" : "unchanged", + }); +} + +async function listCollaboratorCandidates( + _request: Request, + _env: Env, + _params: { id: string }, + ctx: RequestContext +) { + return json( + await new UserStore(ctx.db).listCollaboratorCandidates({ + includeEmail: ctx.authorization?.permissions.includes("workspace.members.read") ?? false, + teamId: ctx.sessionAdmission!.row.ownerTeamId, + }) + ); +} + +export const sessionScopeRoutes = new Hono(); +const always = (action: "changeVisibility" | "manageCollaborators" | "read") => + admit({ + ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, + authorization: requireSession(action, { enforceAlways: true }), + }); +sessionScopeRoutes.put("/sessions/:id/visibility", always("changeVisibility"), (c) => + dispatch(c, changeVisibility) +); +sessionScopeRoutes.put("/sessions/:id/collaborators/:userId", always("manageCollaborators"), (c) => + dispatch(c, (request, env, params, ctx) => changeCollaborator(request, env, params, ctx, false)) +); +sessionScopeRoutes.delete("/sessions/:id/collaborators/:userId", always("read"), (c) => + dispatch(c, (request, env, params, ctx) => changeCollaborator(request, env, params, ctx, true)) +); +sessionScopeRoutes.get( + "/sessions/:id/collaborator-candidates", + admit({ + ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, + authorization: { + ...requireSession("manageCollaborators", { enforceAlways: true }), + auditAllowed: false, + }, + cacheControl: "private, no-store", + }), + (c) => dispatch(c, listCollaboratorCandidates) +); diff --git a/packages/control-plane/src/routes/session-team-grants.ts b/packages/control-plane/src/routes/session-team-grants.ts new file mode 100644 index 0000000000..e53485518d --- /dev/null +++ b/packages/control-plane/src/routes/session-team-grants.ts @@ -0,0 +1,26 @@ +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import type { SqlDatabase } from "../db/sql-database"; + +export interface GrantRepository { + repoOwner: string; + repoName: string; + repoId: number | null; +} + +export async function missingTeamRepository( + db: SqlDatabase, + teamId: string, + repositories: readonly GrantRepository[] +): Promise { + const store = new TeamRepositoryGrantStore(db); + if ( + await store.covers( + teamId, + repositories.map((repo) => repo.repoId) + ) + ) + return null; + const grants = await store.listForTeam(teamId); + const grantedIds = new Set(grants.map((grant) => grant.repo_external_id)); + return repositories.find((repo) => repo.repoId === null || !grantedIds.has(repo.repoId)) ?? null; +} diff --git a/packages/control-plane/src/routes/session-ws-token.test.ts b/packages/control-plane/src/routes/session-ws-token.test.ts index be3975d7d0..868ec49942 100644 --- a/packages/control-plane/src/routes/session-ws-token.test.ts +++ b/packages/control-plane/src/routes/session-ws-token.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TEST_BACKGROUND_TASK_CONTEXT, fakeSessionRuntimeDispatch, @@ -8,6 +8,11 @@ import { handleSessionWsToken } from "./session-ws-token"; import type { RequestContext } from "./shared"; import type { Env } from "../types"; import type { SqlDatabase } from "../db/sql-database"; +import { APIError } from "better-auth/api"; +import { OAuthProviderError } from "../auth/user/providers/types"; +import { AdmissionDeniedError } from "../auth/user/admission-policy"; +import { UserStore } from "../db/user-store"; +import type { ProviderAccountClient } from "../source-control/github-credential-authority"; import { withSessionRuntime } from "./session-route"; function routeFor(path: string): { handler: typeof handleSessionWsToken; params: { id: string } } { @@ -53,11 +58,12 @@ function createContext(db: SqlDatabase = accessDatabase().db): RequestContext { function createEnv(fetch: (request: Request) => Promise): Env { return { + SCM_PROVIDER: "gitlab", SESSION: fakeSessionRuntimeDispatch(fetch), } as unknown as Env; } -describe("session ws-token route", () => { +describe("session ws-token route (non-GitHub display fields)", () => { it("forwards validated optional SCM display fields", async () => { const forwarded: Request[] = []; const fetch = vi.fn(async (request: Request) => { @@ -177,3 +183,144 @@ describe("session ws-token route", () => { expect(fetch).not.toHaveBeenCalled(); }); }); + +describe("GitHub browser attribution availability", () => { + const subject = "583231"; + let api: ProviderAccountClient; + let fetch: ReturnType Promise>>; + beforeEach(() => { + vi.spyOn(UserStore.prototype, "getIdentitiesForUser").mockResolvedValue([ + { provider: "github", providerUserId: subject, providerLogin: null } as never, + ]); + vi.spyOn(UserStore.prototype, "getUserById").mockResolvedValue(null); + api = { + listUserAccounts: vi.fn(async () => [ + { providerId: "github", accountId: subject, userId: "user-1" }, + ]), + getAccessToken: vi.fn(async () => ({ accessToken: "grant" })), + refreshToken: vi.fn(), + accountInfo: vi.fn(async () => ({ + user: { id: subject }, + data: { + provider: "github", + issuer: "https://github.com", + subject, + login: "octocat", + verifiedEmails: [], + primaryEmail: null, + }, + })), + }; + fetch = vi.fn(async () => Response.json({ token: "token-1" })); + }); + afterEach(() => vi.restoreAllMocks()); + function join(body = {}) { + const env = { ...createEnv(fetch), SCM_PROVIDER: "github" } as Env; + const ctx = { + ...createContext(), + authentication: { + mechanism: "browser_session", + credentialId: "session", + channel: { kind: "sig1", service: "web" }, + }, + getUserAuth: () => ({ api }), + } as unknown as RequestContext; + return handleSessionWsToken( + new Request("https://test.local/sessions/session-1/ws-token", { + method: "POST", + body: JSON.stringify(body), + }), + env, + { id: "session-1" }, + withSessionRuntime(env, ctx) + ); + } + it.each(["listUserAccounts", "getAccessToken", "accountInfo"] as const)( + "still mints a WS token when optional %s retrieval fails", + async (method) => { + vi.mocked(api[method]).mockRejectedValue(new Error("provider unavailable")); + expect((await join()).status).toBe(200); + expect(fetch).toHaveBeenCalledOnce(); + expect(await (fetch.mock.calls[0][0] as Request).json()).not.toHaveProperty( + "scmUserId", + subject + ); + } + ); + it.each(["listUserAccounts", "getAccessToken", "accountInfo"] as const)( + "mints a WS token before the proxy deadline when optional %s retrieval stalls", + async (method) => { + vi.useFakeTimers(); + try { + vi.mocked(api[method]).mockImplementation( + () => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error("GitHub request timed out")), 20_000); + }) + ); + let response: Response | undefined; + const joined = join().then((value) => { + response = value; + }); + await vi.advanceTimersByTimeAsync(14_000); + const beforeProxyDeadline = response; + // The delayed rejection must be consumed even if token minting already finished. + await vi.advanceTimersByTimeAsync(20_000); + await joined; + expect(beforeProxyDeadline?.status).toBe(200); + expect(fetch).toHaveBeenCalledOnce(); + const body = await (fetch.mock.calls[0][0] as Request).json(); + expect(body).toMatchObject({ replaceScmIdentity: true, scmUserId: null, scmLogin: null }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + } + ); + + it("does not pair a trusted subject with caller display fields when the grant is empty", async () => { + vi.mocked(api.getAccessToken).mockResolvedValue({ accessToken: "" }); + expect( + (await join({ scmLogin: "unrelated", scmName: "Caller", scmEmail: "caller@test.local" })) + .status + ).toBe(200); + const body = (await (fetch.mock.calls[0][0] as Request).json()) as Record; + expect(body.scmUserId).toBe(subject); + expect(body.scmLogin ?? null).toBeNull(); + expect(body.scmName ?? null).toBeNull(); + expect(body.scmEmail ?? null).toBeNull(); + }); + it("rejects a mismatched verified profile", async () => { + vi.mocked(api.accountInfo).mockResolvedValue({ + user: { id: "other" }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "other", + login: "other", + verifiedEmails: [], + primaryEmail: null, + }, + }); + await expect(join()).rejects.toThrow("mismatched GitHub account"); + expect(fetch).not.toHaveBeenCalled(); + }); + it.each([ + new OAuthProviderError("malformed_response", "invalid provider profile"), + new OAuthProviderError("invalid_configuration", "invalid provider config"), + new AdmissionDeniedError(), + APIError.from("BAD_REQUEST", { + code: "AMBIGUOUS_ACCOUNT", + message: "Multiple accounts share this ID", + }), + ])("rejects provider integrity/admission errors before minting: %s", async (cause) => { + vi.mocked(api.accountInfo).mockRejectedValue(cause); + await expect(join()).rejects.toBe(cause); + expect(fetch).not.toHaveBeenCalled(); + }); + it("rejects malformed authority responses rather than treating them as unavailability", async () => { + vi.mocked(api.getAccessToken).mockResolvedValue({ accessToken: 123 }); + await expect(join()).rejects.toThrow(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/routes/session-ws-token.ts b/packages/control-plane/src/routes/session-ws-token.ts index d1a41e6c55..5161a4513d 100644 --- a/packages/control-plane/src/routes/session-ws-token.ts +++ b/packages/control-plane/src/routes/session-ws-token.ts @@ -3,14 +3,24 @@ import { admit } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { applyIdentityEnforcement } from "../routing/identity-enforcement"; import { SessionInternalPaths, sessionScmDisplayFieldsSchema } from "../session/contracts"; +import { UserStore } from "../db/user-store"; +import { resolveGitHubEnrichmentForRequest, type GitHubEnrichment } from "../session/identity"; +import { resolveScmProviderFromEnv } from "../source-control/config"; +import { + GitHubAttributionUnavailableError, + resolveGitHubCredentialAuthority, +} from "../source-control/github-credential-authority"; import type { Env } from "../types"; import { error, GITHUB_USER_OR_SERVICE_ROUTE, requireSession } from "./shared"; import { parseJsonBody } from "./body"; import { dispatchSession, type SessionRouteContext } from "./session-route"; +// Optional attribution must leave time for minting before the browser proxy aborts. +const WS_GITHUB_ATTRIBUTION_TIMEOUT_MS = 5_000; + export async function handleSessionWsToken( request: Request, - _env: Env, + env: Env, params: { id: string }, ctx: SessionRouteContext ): Promise { @@ -32,6 +42,26 @@ export async function handleSessionWsToken( if (!authorization) return error("Authorization unavailable", 503); const userId = enforcement.enforced.participantUserId; const canonicalUserId = authorization.userId; + const isGitHub = resolveScmProviderFromEnv(env.SCM_PROVIDER) === "github"; + let enrichment: GitHubEnrichment | null = null; + if (isGitHub) { + let timeout: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), WS_GITHUB_ATTRIBUTION_TIMEOUT_MS); + }); + try { + enrichment = await Promise.race([ + resolveGitHubCredentialAuthority(ctx, request.headers).then((authority) => + resolveGitHubEnrichmentForRequest(new UserStore(ctx.db), canonicalUserId, authority) + ), + deadline, + ]); + } catch (retrievalError) { + if (!(retrievalError instanceof GitHubAttributionUnavailableError)) throw retrievalError; + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } + } return ctx.metrics.time("do_fetch", () => ctx.sessionRuntime.fetch(sessionId, SessionInternalPaths.wsToken, { @@ -40,9 +70,11 @@ export async function handleSessionWsToken( body: JSON.stringify({ userId, canonicalUserId, - scmLogin: body.scmLogin, - scmName: body.scmName, - scmEmail: body.scmEmail, + replaceScmIdentity: isGitHub, + scmUserId: enrichment?.scmUserId ?? null, + scmLogin: isGitHub ? (enrichment?.scmLogin ?? null) : body.scmLogin, + scmName: isGitHub ? (enrichment?.displayName ?? null) : body.scmName, + scmEmail: isGitHub ? (enrichment?.email ?? null) : body.scmEmail, }), }) ); diff --git a/packages/control-plane/src/routes/sessions.ts b/packages/control-plane/src/routes/sessions.ts index 4d8327bbea..c7823b15cc 100644 --- a/packages/control-plane/src/routes/sessions.ts +++ b/packages/control-plane/src/routes/sessions.ts @@ -14,11 +14,13 @@ import { sessionWsTokenRoutes } from "./session-ws-token"; import { sessionDiffRoutes } from "./session-diffs"; import { sessionSkillRoutes } from "./session-skills"; import { sessionExportRoutes } from "./session-export"; +import { sessionScopeRoutes } from "./session-scope"; /** Mount order is precedence order: static session paths precede `/sessions/:id`. */ export const sessionRoutes = new Hono(); for (const module of [ sessionCreateRoutes, + sessionScopeRoutes, sessionIndexRoutes, sessionExportRoutes, sessionRuntimeProxyRoutes, diff --git a/packages/control-plane/src/routes/settings-teams.ts b/packages/control-plane/src/routes/settings-teams.ts new file mode 100644 index 0000000000..04b37eaa58 --- /dev/null +++ b/packages/control-plane/src/routes/settings-teams.ts @@ -0,0 +1,38 @@ +import { Hono } from "hono"; +import { TeamSettingsStore, teamSettingsSchema } from "../db/team-settings"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import type { Env } from "../types"; +import { parseBody } from "./body"; +import { + error, + json, + requirePermission, + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + type RequestContext, +} from "./shared"; + +async function getSettings(_request: Request, _env: Env, _params: object, ctx: RequestContext) { + return json(await new TeamSettingsStore(ctx.db).get()); +} + +async function patchSettings(request: Request, _env: Env, _params: object, ctx: RequestContext) { + const body = await parseBody( + request, + teamSettingsSchema.partial().strict(), + "Invalid team settings" + ); + if (body instanceof Response) return body; + if (body.requireTeamOnCreate === undefined) return error("requireTeamOnCreate is required", 400); + const store = new TeamSettingsStore(ctx.db); + await store.set({ requireTeamOnCreate: body.requireTeamOnCreate }); + return json(await store.get()); +} + +export const teamSettingsRoutes = new Hono(); +const manage = admit({ + ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, + authorization: requirePermission("workspace.members.manage", { service: "deny" }), +}); +teamSettingsRoutes.get("/settings/teams", manage, (c) => dispatch(c, getSettings)); +teamSettingsRoutes.patch("/settings/teams", manage, (c) => dispatch(c, patchSettings)); diff --git a/packages/control-plane/src/routes/shared.test.ts b/packages/control-plane/src/routes/shared.test.ts new file mode 100644 index 0000000000..23873fe877 --- /dev/null +++ b/packages/control-plane/src/routes/shared.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { TeamCapabilities } from "@open-inspect/shared/types/team-access"; +import { permissionRequirement, requireAll, requireTeam } from "./shared"; + +describe("team audit defaults", () => { + it.each([ + "read", + "member", + "canEditMetadata", + "canManageMembers", + ])("classifies %s identically standalone and in a composition", (need) => { + const expected = need !== "read" && need !== "member"; + expect(requireTeam(need).auditAllowed).toBe(expected); + expect( + requireAll({ kind: "team", teamIdParam: "id", need }, permissionRequirement("sessions.read")) + .auditAllowed + ).toBe(expected); + }); + + it("still audits a composition with a write permission", () => { + expect( + requireAll( + { kind: "team", teamIdParam: "id", need: "member" }, + permissionRequirement("sessions.create") + ).auditAllowed + ).toBe(true); + }); + + it("preserves explicit audit overrides", () => { + expect(requireTeam("member", { auditAllowed: true }).auditAllowed).toBe(true); + expect(requireTeam("canManageRepositories", { auditAllowed: false }).auditAllowed).toBe(false); + }); +}); diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 7950b3f98b..5d43c33484 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -35,7 +35,8 @@ export interface ServiceActorProfileClaims { * identity, or assignment is written for a request the handler would refuse. */ export type ServiceActorClaimsResult = - { kind: "claims"; claims: ServiceActorProfileClaims } | { kind: "rejected"; response: Response }; + | { kind: "claims"; claims: ServiceActorProfileClaims } + | { kind: "rejected"; response: Response }; /** One permission or resource-admission requirement for an active user. */ export type RouteAuthorizationRequirement = @@ -45,8 +46,9 @@ export type RouteAuthorizationRequirement = operation: "manage" | "trigger"; automationIdParam: string; } - | { kind: "team"; teamIdParam: string; need: keyof TeamCapabilities | "read" } - | { kind: "session"; sessionIdParam: string; action: SessionAction }; + | { kind: "team"; teamIdParam: string; need: keyof TeamCapabilities | "read" | "member" } + | { kind: "team"; teamIdParam: string; need: "removeMember"; targetUserIdParam: string } + | { kind: "session"; sessionIdParam: string; action: SessionAction; enforceAlways?: boolean }; type BotServiceName = Exclude; const DEFAULT_AUDIT_ALLOWED = false; @@ -143,6 +145,9 @@ const AUDITED_ALLOWED_PERMISSIONS = new Set([ function auditsAllowedRequirement(requirement: RouteAuthorizationRequirement): boolean { if (requirement.kind === "session") return requirement.action !== "read"; + if (requirement.kind === "team") { + return requirement.need !== "read" && requirement.need !== "member"; + } if (requirement.kind === "permission") { return AUDITED_ALLOWED_PERMISSIONS.has(requirement.permission); } @@ -184,38 +189,56 @@ export function requireAutomation( } export function requireTeam( - need: keyof TeamCapabilities | "read", - teamIdParam = "id" + need: keyof TeamCapabilities | "read" | "member", + options?: { teamIdParam?: string; auditAllowed?: boolean } ): RouteAuthorization { + const requirement: RouteAuthorizationRequirement = { + kind: "team", + teamIdParam: options?.teamIdParam ?? "id", + need, + }; return { kind: "active-user", - allOf: [{ kind: "team", teamIdParam, need }], + allOf: [requirement], service: { kind: "deny" }, - auditAllowed: true, + auditAllowed: options?.auditAllowed ?? auditsAllowedRequirement(requirement), }; } export function sessionRequirement( action: SessionAction, sessionIdParam = "id" -): RouteAuthorizationRequirement { +): Extract { return { kind: "session", sessionIdParam, action }; } export function requireSession( action: SessionAction, - options?: { sessionIdParam?: string; actorlessGrants?: readonly ActorlessServiceGrant[] } -): RouteAuthorization { + options?: { + sessionIdParam?: string; + actorlessGrants?: readonly ActorlessServiceGrant[]; + enforceAlways?: boolean; + } +): Extract { return { kind: "active-user", - allOf: [sessionRequirement(action, options?.sessionIdParam)], - service: { kind: "actor", actorlessGrants: options?.actorlessGrants }, + allOf: [ + { + ...sessionRequirement(action, options?.sessionIdParam), + enforceAlways: options?.enforceAlways, + }, + ], + service: options?.enforceAlways + ? { kind: "deny" } + : { kind: "actor", actorlessGrants: options?.actorlessGrants }, auditAllowed: action !== "read", }; } /** Require an active user to satisfy every supplied authorization requirement. */ -export function requireAll(...allOf: readonly RouteAuthorizationRequirement[]): RouteAuthorization { +export function requireAll( + ...allOf: readonly RouteAuthorizationRequirement[] +): Extract { return { kind: "active-user", allOf, diff --git a/packages/control-plane/src/routes/team-secrets.test.ts b/packages/control-plane/src/routes/team-secrets.test.ts new file mode 100644 index 0000000000..a670e0de0a --- /dev/null +++ b/packages/control-plane/src/routes/team-secrets.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import type * as ImageBuildSaveHooksModule from "../image-builds/save-hooks"; +import { EnvironmentStore, type EnvironmentRow } from "../db/environments"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamSecretsStore } from "../db/team-secrets"; +import { TeamStore } from "../db/teams"; +import { + createTestEnv, + createTestRequestHandler, + ownerAuthorizationDatabase, + TEST_BACKGROUND_TASK_CONTEXT, +} from "../router.test-support"; +import { teamSecretsRoutes } from "./team-secrets"; + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + scheduleImageBuildOnSave: vi.fn(), +})); + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: mocks.authenticate, +})); + +vi.mock("../image-builds/save-hooks", async (importOriginal) => ({ + ...(await importOriginal()), + scheduleImageBuildOnSave: mocks.scheduleImageBuildOnSave, +})); + +const handleRequest = createTestRequestHandler([teamSecretsRoutes]); +const teamId = "team-1"; + +function environment( + id: string, + ownerTeamId: string | null, + prebuildEnabled: number +): EnvironmentRow { + return { + id, + owner_team_id: ownerTeamId, + prebuild_enabled: prebuildEnabled, + name: id, + description: null, + channel_associations: null, + created_at: 1, + updated_at: 1, + }; +} + +function request( + method: "PUT" | "DELETE", + secrets: Record = { TOKEN: "replacement" } +) { + return handleRequest( + new Request( + `https://test.local/teams/${teamId}/secrets${method === "DELETE" ? "/token" : ""}`, + { + method, + headers: { "Content-Type": "application/json" }, + ...(method === "PUT" ? { body: JSON.stringify({ secrets }) } : {}), + } + ), + createTestEnv({ + DB: ownerAuthorizationDatabase(), + SCM_PROVIDER: "github", + REPO_SECRETS_ENCRYPTION_KEY: "test-key", + }), + TEST_BACKGROUND_TASK_CONTEXT + ); +} + +describe("team secret rebuild scheduling", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.scheduleImageBuildOnSave.mockReset(); + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + })); + vi.spyOn(TeamStore.prototype, "getById").mockResolvedValue({ + id: teamId, + slug: "secrets", + name: "Secrets", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + }); + vi.spyOn(TeamMembershipStore.prototype, "countLeads").mockResolvedValue(1); + vi.spyOn(TeamSecretsStore.prototype, "setSecrets").mockResolvedValue({ + created: 0, + updated: 1, + keys: ["TOKEN"], + }); + vi.spyOn(TeamSecretsStore.prototype, "deleteSecret").mockResolvedValue(true); + vi.spyOn(EnvironmentStore.prototype, "list").mockResolvedValue({ + environments: [ + environment("owned-enabled", teamId, 1), + environment("owned-disabled", teamId, 0), + environment("other-enabled", "team-2", 1), + environment("workspace-enabled", null, 1), + environment("owned-enabled-2", teamId, 1), + ], + total: 5, + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it.each(["PUT", "DELETE"] as const)( + "%s schedules only enabled, owned environments after commit", + async (method) => { + const mutation = + method === "PUT" + ? vi.mocked(TeamSecretsStore.prototype.setSecrets) + : vi.mocked(TeamSecretsStore.prototype.deleteSecret); + mocks.scheduleImageBuildOnSave.mockImplementation(() => { + expect(mutation.mock.settledResults[0]?.type).toBe("fulfilled"); + }); + + expect((await request(method)).status).toBe(200); + expect(mocks.scheduleImageBuildOnSave.mock.calls.map((call) => call[1])).toEqual([ + { kind: "environment", id: "owned-enabled" }, + { kind: "environment", id: "owned-enabled-2" }, + ]); + } + ); + + it.each(["PUT", "DELETE"] as const)( + "%s stays successful when rebuild enumeration fails without logging bound values", + async (method) => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(EnvironmentStore.prototype.list).mockRejectedValue( + new Error("sensitive-bound-value") + ); + + const response = await request(method); + expect(response.status).toBe(200); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(1); + expect(JSON.stringify(warn.mock.calls)).not.toContain("sensitive-bound-value"); + expect(await response.text()).not.toContain("sensitive-bound-value"); + } + ); + + it("does not enumerate or rebuild for an empty write or a missing delete", async () => { + vi.mocked(TeamSecretsStore.prototype.setSecrets).mockResolvedValue({ + created: 0, + updated: 0, + keys: [], + }); + expect((await request("PUT", {})).status).toBe(200); + vi.mocked(TeamSecretsStore.prototype.deleteSecret).mockResolvedValue(false); + expect((await request("DELETE")).status).toBe(404); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it.each(["PUT", "DELETE"] as const)( + "%s does not rebuild after a failed mutation", + async (method) => { + vi.mocked(TeamSecretsStore.prototype.setSecrets).mockRejectedValue(new Error("write failed")); + vi.mocked(TeamSecretsStore.prototype.deleteSecret).mockRejectedValue( + new Error("write failed") + ); + expect((await request(method)).status).toBe(503); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/control-plane/src/routes/team-secrets.ts b/packages/control-plane/src/routes/team-secrets.ts new file mode 100644 index 0000000000..7afd447410 --- /dev/null +++ b/packages/control-plane/src/routes/team-secrets.ts @@ -0,0 +1,135 @@ +import { Hono } from "hono"; +import { EnvironmentStore } from "../db/environments"; +import { TeamSecretsStore } from "../db/team-secrets"; +import { SecretsValidationError, normalizeKey } from "../db/secrets-validation"; +import { scheduleImageBuildOnSave } from "../image-builds/save-hooks"; +import { createLogger } from "../logger"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import type { Env } from "../types"; +import { parseBody } from "./body"; +import { secretsRequestBodySchema } from "./secret-request-schemas"; +import { + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + error, + json, + requireTeam, + type UserRouteContext, +} from "./shared"; + +const logger = createLogger("router:team-secrets"); + +function secretsStore(env: Env, ctx: UserRouteContext): TeamSecretsStore | Response { + if (!env.REPO_SECRETS_ENCRYPTION_KEY) { + return error("REPO_SECRETS_ENCRYPTION_KEY not configured", 500); + } + return new TeamSecretsStore(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY); +} + +function secretsError(cause: unknown, teamId: string, ctx: UserRouteContext): Response { + if (cause instanceof SecretsValidationError) return error(cause.message, 400); + // Storage errors can contain bound values; never include their contents in logs or responses. + logger.error("Team secrets storage unavailable", { + team_id: teamId, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("Secrets storage unavailable", 503); +} + +async function scheduleTeamEnvironmentRebuilds( + env: Env, + teamId: string, + ctx: UserRouteContext +): Promise { + try { + const { environments } = await new EnvironmentStore(ctx.db).list(); + for (const environment of environments) { + if (environment.owner_team_id === teamId && environment.prebuild_enabled === 1) { + scheduleImageBuildOnSave(env, { kind: "environment", id: environment.id }, ctx); + } + } + } catch { + // The mutation and invalidation are committed; rebuild failures must not fail the secret write. + logger.warn("Team secrets image rebuild scheduling unavailable", { + team_id: teamId, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + } +} + +async function listSecrets( + _request: Request, + env: Env, + params: { id: string }, + ctx: UserRouteContext +): Promise { + const store = secretsStore(env, ctx); + if (store instanceof Response) return store; + try { + return json({ teamId: params.id, secrets: await store.listSecretKeys(params.id) }); + } catch (cause) { + return secretsError(cause, params.id, ctx); + } +} + +async function setSecrets( + request: Request, + env: Env, + params: { id: string }, + ctx: UserRouteContext +): Promise { + const store = secretsStore(env, ctx); + if (store instanceof Response) return store; + const body = await parseBody( + request, + secretsRequestBodySchema, + "Request body must include secrets object" + ); + if (body instanceof Response) return body; + try { + const result = await store.setSecrets(params.id, body.secrets, { + requestId: ctx.request_id, + actorUserId: ctx.principal.userId, + }); + if (result.keys.length > 0) await scheduleTeamEnvironmentRebuilds(env, params.id, ctx); + return json({ status: "updated", teamId: params.id, ...result }); + } catch (cause) { + return secretsError(cause, params.id, ctx); + } +} + +async function deleteSecret( + _request: Request, + env: Env, + params: { id: string; key: string }, + ctx: UserRouteContext +): Promise { + const store = secretsStore(env, ctx); + if (store instanceof Response) return store; + try { + const deleted = await store.deleteSecret(params.id, params.key, { + requestId: ctx.request_id, + actorUserId: ctx.principal.userId, + }); + if (!deleted) return error("Secret not found", 404); + await scheduleTeamEnvironmentRebuilds(env, params.id, ctx); + return json({ status: "deleted", teamId: params.id, key: normalizeKey(params.key) }); + } catch (cause) { + return secretsError(cause, params.id, ctx); + } +} + +const manageSecrets = admit({ + ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, + authorization: requireTeam("canManageSecrets"), + cacheControl: "private, no-store", +}); + +export const teamSecretsRoutes = new Hono(); +teamSecretsRoutes.get("/teams/:id/secrets", manageSecrets, (c) => dispatch(c, listSecrets)); +teamSecretsRoutes.put("/teams/:id/secrets", manageSecrets, (c) => dispatch(c, setSecrets)); +teamSecretsRoutes.delete("/teams/:id/secrets/:key", manageSecrets, (c) => + dispatch(c, deleteSecret) +); diff --git a/packages/control-plane/src/routes/teams.test.ts b/packages/control-plane/src/routes/teams.test.ts new file mode 100644 index 0000000000..ac7a285c5f --- /dev/null +++ b/packages/control-plane/src/routes/teams.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + sessionVisibilitySchema, + teamSessionsResponseSchema, +} from "@open-inspect/shared/types/teams"; +import { + SESSION_INBOX_CATEGORIES, + sessionInboxSessionSchema, +} from "@open-inspect/shared/types/session-inbox"; +import { sessionCapabilitiesSchema } from "@open-inspect/shared/types/sessions"; + +describe("team session response contract", () => { + it("matches the inbox session projection with required scope and capabilities", () => { + const row = + teamSessionsResponseSchema.options[0].options[0].shape.items.element.shape.rootSession; + expect(z.toJSONSchema(row)).toEqual( + z.toJSONSchema( + sessionInboxSessionSchema.extend({ + ownerTeamId: z.string().nullable(), + visibility: sessionVisibilitySchema, + capabilities: sessionCapabilitiesSchema, + }) + ) + ); + }); + + it("uses exactly the existing inbox categories", () => { + expect(teamSessionsResponseSchema.options[1].shape.categories.keyType.options).toEqual( + SESSION_INBOX_CATEGORIES + ); + }); +}); diff --git a/packages/control-plane/src/routes/teams.ts b/packages/control-plane/src/routes/teams.ts index c8eb27ee1e..912b643479 100644 --- a/packages/control-plane/src/routes/teams.ts +++ b/packages/control-plane/src/routes/teams.ts @@ -3,12 +3,27 @@ import { z } from "zod"; import { resolveTeamAccess } from "@open-inspect/shared/types/team-access"; import { createTeamRequestSchema, + teamMembershipSchema, teamRoleSchema, + teamSessionsResponseSchema, updateTeamRequestSchema, type Team, type TeamRole, } from "@open-inspect/shared/types/teams"; import { isSelfActingPrincipal } from "../auth/principal"; +import { + SESSION_INBOX_CATEGORIES, + sessionInboxCategorySchema, +} from "@open-inspect/shared/types/session-inbox"; +import { + effectiveSessionCapabilities, + teamsEnforcementMode, + viewerFromContext, +} from "../authorization/session-admission"; +import { SessionIndexStore } from "../db/session-index"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import { encodeSessionInboxCursor, parseSessionInboxCursor } from "../db/session-inbox-cursor"; +import type { ScopedInboxSession, ListSessionInboxResult } from "../db/session-inbox-store"; import { TeamAuditStore, type TeamAuditInput } from "../db/team-audit"; import { LastLeadError, @@ -16,18 +31,23 @@ import { TeamMembershipStore, } from "../db/team-memberships"; import { TeamSlugConflictError, TeamStore } from "../db/teams"; +import { TeamSettingsStore } from "../db/team-settings"; import type { RequestContext } from "../http/request-context"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import type { Env } from "../types"; import { parseBody } from "./body"; import { parseQuery } from "./query"; +import { SESSION_INBOX_LIMIT } from "./session-index"; import { SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, + SCM_AGNOSTIC_HUMAN_USER_ROUTE, error, json, requirePermission, requireTeam, + requireAll, + permissionRequirement, } from "./shared"; const PRIVATE = { cacheControl: "private, no-store" } as const; @@ -41,6 +61,10 @@ const querySchema = z.object({ search: z.string().optional(), includeArchived: z.enum(["true", "false"]).optional(), }); +const sessionsQuerySchema = z.object({ + bucket: sessionInboxCategorySchema.optional(), + cursor: z.string().min(1, { error: "Invalid cursor" }).optional(), +}); // A personal access token is its owner, so it sees that owner's teams. The // read-only method gate keeps it out of every mutating team route. @@ -110,7 +134,7 @@ async function listTeams(request: Request, _env: Env, _params: object, ctx: Requ const membershipStore = new TeamMembershipStore(ctx.db); const memberships = await membershipStore.listForUser(subject.userId); const teams = await new TeamStore(ctx.db).list({ - forUserId: query.membership === "all" && isAdmin ? undefined : subject.userId, + forUserId: query.membership === "all" ? undefined : subject.userId, includeArchived: query.includeArchived === "true", search: query.search, }); @@ -118,15 +142,17 @@ async function listTeams(request: Request, _env: Env, _params: object, ctx: Requ const memberCounts = await membershipStore.listMemberCounts(); return json({ teams: await Promise.all( - teams.map((team) => - responseTeam( - ctx, - team, - memberships, - leadCounts.get(team.id) ?? 0, - memberCounts.get(team.id) ?? 0 + teams + .filter((team) => team.archivedAt === null || isAdmin || memberships.has(team.id)) + .map((team) => + responseTeam( + ctx, + team, + memberships, + leadCounts.get(team.id) ?? 0, + memberCounts.get(team.id) ?? 0 + ) ) - ) ), }); } @@ -141,7 +167,9 @@ async function meTeams(_request: Request, _env: Env, _params: object, ctx: Reque }); const leadCounts = await membershipStore.listLeadCounts(); const memberCounts = await membershipStore.listMemberCounts(); + const { requireTeamOnCreate } = await new TeamSettingsStore(ctx.db).get(); return json({ + requireTeamOnCreate, teams: await Promise.all( teams.map(async (team) => ({ ...(await responseTeam( @@ -173,6 +201,86 @@ async function getTeam(_request: Request, _env: Env, _params: { id: string }, ct return json(await responseTeam(ctx, admittedTeam(ctx))); } +async function teamSessions( + request: Request, + env: Env, + _params: { id: string }, + ctx: RequestContext +) { + const query = parseQuery(request, sessionsQuerySchema); + if (query instanceof Response) return query; + if (query.cursor !== undefined && query.bucket === undefined) + return error("Bucket required for pagination", 400); + const cursor = parseSessionInboxCursor(query.cursor); + if (!cursor.ok) return error(cursor.error, 400); + const subject = viewer(ctx); + const sessionViewer = viewerFromContext(ctx, ctx.sessionMemberships ?? new Map()); + const mode = teamsEnforcementMode(ctx, env); + const options = { + teamIds: [admittedTeam(ctx).id], + readScope: sessionViewer, + mode, + viewerUserId: subject.userId, + limit: SESSION_INBOX_LIMIT, + }; + const store = new SessionIndexStore(ctx.db); + const pages = + query.bucket === undefined + ? await store.listInboxSnapshot(options) + : { + [query.bucket]: await store.listInbox({ + ...options, + category: query.bucket, + cursor: cursor.cursor, + }), + }; + const sessions = Object.values(pages).flatMap(({ items }) => + items.flatMap(({ rootSession, descendantSessions }) => [rootSession, ...descendantSessions]) + ); + const collaborators = await new SessionCollaboratorStore(ctx.db).listForSessions( + sessions.map((row) => row.id), + { privateOnly: true } + ); + const sessionIds = new Set(sessions.map((row) => row.id)); + const decorate = (row: ScopedInboxSession) => ({ + ...row, + parentSessionId: + row.parentSessionId !== null && sessionIds.has(row.parentSessionId) + ? row.parentSessionId + : null, + capabilities: effectiveSessionCapabilities( + sessionViewer, + { + id: row.id, + ownerUserId: row.userId, + ownerTeamId: row.ownerTeamId, + visibility: row.visibility, + collaboratorIds: collaborators.get(row.id) ?? [], + }, + mode + ), + }); + const encodePage = (page: ListSessionInboxResult) => ({ + items: page.items.map(({ rootSession, descendantSessions }) => ({ + rootSession: decorate(rootSession), + descendantSessions: descendantSessions.map(decorate), + })), + hasMore: page.hasMore, + nextCursor: page.nextCursor ? encodeSessionInboxCursor(page.nextCursor) : null, + }); + return json( + teamSessionsResponseSchema.parse( + query.bucket === undefined + ? { + categories: Object.fromEntries( + SESSION_INBOX_CATEGORIES.map((bucket) => [bucket, encodePage(pages[bucket])]) + ), + } + : encodePage(pages[query.bucket]) + ) + ); +} + async function updateTeam( request: Request, _env: Env, @@ -218,7 +326,9 @@ async function setArchived( async function members(_request: Request, _env: Env, _params: { id: string }, ctx: RequestContext) { return json({ - members: await new TeamMembershipStore(ctx.db).listMembersWithUsers(admittedTeam(ctx).id), + members: await new TeamMembershipStore(ctx.db).listMembersWithUsers(admittedTeam(ctx).id, { + includeEmail: ctx.authorization?.permissions.includes("workspace.members.read") ?? false, + }), }); } @@ -232,6 +342,7 @@ async function putMember( if (body instanceof Response) return body; const team = admittedTeam(ctx); const store = new TeamMembershipStore(ctx.db); + const includeEmail = ctx.authorization?.permissions.includes("workspace.members.read") ?? false; const user = await ctx.db .prepare("SELECT 1 AS ok FROM users WHERE id = ?") .bind(params.userId) @@ -241,7 +352,7 @@ async function putMember( (member) => member.userId === params.userId ); if (before?.role === body.role) { - const member = (await store.listMembersWithUsers(team.id)).find( + const member = (await store.listMembersWithUsers(team.id, { includeEmail })).find( (row) => row.userId === params.userId ); return json({ member }); @@ -251,7 +362,7 @@ async function putMember( else if (!(await store.add(team.id, params.userId, body.role))) { return json({ error: "Membership changed concurrently", code: "membership_conflict" }, 409); } - const after = (await store.listMembersWithUsers(team.id)).find( + const after = (await store.listMembersWithUsers(team.id, { includeEmail })).find( (member) => member.userId === params.userId )!; await auditTeamEvent({ @@ -260,7 +371,7 @@ async function putMember( targetUserId: params.userId, action: before ? "team.member_role_changed" : "team.member_added", before: before ?? {}, - after, + after: teamMembershipSchema.parse(after), }); return json({ member: after }); } catch (cause) { @@ -275,22 +386,11 @@ async function deleteMember( ctx: RequestContext ) { const team = admittedTeam(ctx); - const subject = viewer(ctx); const store = new TeamMembershipStore(ctx.db); const before = (await store.listMembers(team.id)).find( (member) => member.userId === params.userId ); if (!before) return error("Team membership not found", 404); - if (subject.userId !== params.userId && !ctx.teamAdmission?.access.canManageMembers) { - return json( - { - error: "Forbidden", - code: "team_capability_required", - reason_code: "team_capability_required", - }, - 403 - ); - } try { await store.remove(team.id, params.userId); await auditTeamEvent({ @@ -341,9 +441,7 @@ teamRoutes.get( admit({ ...SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, ...PRIVATE, authorization: ACTIVE_USER }), (c) => dispatch(c, meTeams) ); -teamRoutes.get("/teams", policy(requirePermission("sessions.read", { service: "deny" })), (c) => - dispatch(c, listTeams) -); +teamRoutes.get("/teams", policy(ACTIVE_USER), (c) => dispatch(c, listTeams)); teamRoutes.post( "/teams", policy(requirePermission("workspace.members.manage", { service: "deny" })), @@ -359,5 +457,32 @@ teamRoutes.post("/teams/:id/restore", archive, (c) => ); teamRoutes.get("/teams/:id/members", read, (c) => dispatch(c, members)); teamRoutes.put("/teams/:id/members/:userId", membersManage, (c) => dispatch(c, putMember)); -teamRoutes.delete("/teams/:id/members/:userId", read, (c) => dispatch(c, deleteMember)); +teamRoutes.delete( + "/teams/:id/members/:userId", + policy({ + ...requireAll({ + kind: "team", + teamIdParam: "id", + need: "removeMember", + targetUserIdParam: "userId", + }), + service: { kind: "deny" }, + }), + (c) => dispatch(c, deleteMember) +); teamRoutes.post("/teams/:id/join", policy(requireTeam("canJoin")), (c) => dispatch(c, joinTeam)); +teamRoutes.get( + "/teams/:id/sessions", + admit({ + ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, + ...PRIVATE, + authorization: { + ...requireAll( + { kind: "team", teamIdParam: "id", need: "member" }, + permissionRequirement("sessions.read") + ), + service: { kind: "deny" }, + }, + }), + (c) => dispatch(c, teamSessions) +); diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts index 00ac3f6a86..31f5868156 100644 --- a/packages/control-plane/src/routing/route-admission.ts +++ b/packages/control-plane/src/routing/route-admission.ts @@ -647,6 +647,15 @@ async function enforceTeamRequirement( (ctx.sessionMemberships ??= await memberships.listForUser(ctx.principal.userId)) ); if (viewer.kind !== "user") throw new Error("Missing team viewer"); + const isAdmin = viewer.roleKey === "owner" || viewer.roleKey === "administrator"; + const isMember = isAdmin || viewer.memberships.has(teamId); + if ( + !isMember && + (requirement.need === "member" || + requirement.need === "removeMember" || + (requirement.need === "read" && team.archivedAt !== null)) + ) + return { response: error("Team not found", 404) }; const access = resolveTeamAccess( { userId: viewer.userId, @@ -655,11 +664,23 @@ async function enforceTeamRequirement( }, { ...team, leadCount: await memberships.countLeads(teamId) } ); - const isAdmin = viewer.roleKey === "owner" || viewer.roleKey === "administrator"; - const visible = isAdmin || viewer.memberships.has(teamId); - if (!visible && requirement.need !== "canJoin") - return { response: error("Team not found", 404) }; - if (requirement.need !== "read" && !access[requirement.need]) { + let capabilityDenied: boolean; + if (requirement.need === "removeMember") { + const targetUserId = params[requirement.targetUserIdParam]; + if (!targetUserId) return { response: error("Invalid team member route", 400) }; + capabilityDenied = targetUserId !== viewer.userId && !access.canManageMembers; + // Preserve the existing 404 for an absent target membership. + if ( + capabilityDenied && + !(await memberships.listMembers(teamId)).some((member) => member.userId === targetUserId) + ) { + return { response: error("Team membership not found", 404) }; + } + } else { + capabilityDenied = + requirement.need !== "read" && requirement.need !== "member" && !access[requirement.need]; + } + if (capabilityDenied) { const reasonCode = requirement.need === "canJoin" ? team.archivedAt !== null @@ -699,7 +720,8 @@ async function enforceSessionRequirement( env, sessionId, requirement.action, - requirement.sessionIdParam === "childId" ? "child" : "session" + requirement.sessionIdParam === "childId" ? "child" : "session", + requirement.enforceAlways ); if (result.kind === "not_found") { return authorizationDenial( diff --git a/packages/control-plane/src/sandbox/client.test.ts b/packages/control-plane/src/sandbox/client.test.ts index 8ddcffc694..bf17806389 100644 --- a/packages/control-plane/src/sandbox/client.test.ts +++ b/packages/control-plane/src/sandbox/client.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MODAL_SANDBOX_START_REQUEST_DEADLINE_MS, MODAL_SNAPSHOT_REQUEST_DEADLINE_MS, + ModalApiError, buildModalSandboxDashboardUrl, buildModalWorkspaceSlug, createModalClient, @@ -218,6 +219,24 @@ describe("ModalClient", () => { ).rejects.toMatchObject({ name: "ModalVmStartupError", outcome: "window_closed" }); }); + it("keeps a VM create rejected before allocation as its HTTP error", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ detail: "docker_not_available" }, { status: 501 }) + ); + const created = createModalClient("secret", "acme").createSandbox({ + sessionId: "session", + sandboxId: "generation", + sandboxBackend: "modal-vm", + repoOwner: null, + repoName: null, + controlPlaneUrl: "https://control.test", + sandboxAuthToken: "token", + harness: "opencode", + }); + await expect(created).rejects.toBeInstanceOf(ModalApiError); + await expect(created).rejects.toMatchObject({ status: 501, detail: "docker_not_available" }); + }); + it("times out image-build creation when response headers stall", async () => { vi.useFakeTimers(); let markFetchStarted!: () => void; diff --git a/packages/control-plane/src/sandbox/client.ts b/packages/control-plane/src/sandbox/client.ts index b2d5cfec06..e450bbe83b 100644 --- a/packages/control-plane/src/sandbox/client.ts +++ b/packages/control-plane/src/sandbox/client.ts @@ -330,8 +330,20 @@ export class ModalApiError extends Error { } } +/** + * Whether a modal-vm launch HTTP error leaves the allocation unknown: any 5xx, except + * `docker_not_available`, which Modal returns before retiring or allocating a VM. + */ +export function isAmbiguousModalVmLaunchError(error: ModalApiError): boolean { + return error.status >= 500 && error.detail !== "docker_not_available"; +} + export type ModalVmStartupOutcome = - "unknown" | "not_visible" | "other_generation" | "window_closed" | "race_pending"; + | "unknown" + | "not_visible" + | "other_generation" + | "window_closed" + | "race_pending"; export class ModalVmStartupError extends Error { constructor( @@ -423,7 +435,7 @@ export class ModalClient { detail === "race_pending" ) throw new ModalVmStartupError(detail, error); - if (error.status < 500) throw error; + if (!isAmbiguousModalVmLaunchError(error)) throw error; } throw new ModalVmStartupError( "unknown", diff --git a/packages/control-plane/src/sandbox/daytona-rest-client.ts b/packages/control-plane/src/sandbox/daytona-rest-client.ts index 31a8f86c72..0b3aef74d0 100644 --- a/packages/control-plane/src/sandbox/daytona-rest-client.ts +++ b/packages/control-plane/src/sandbox/daytona-rest-client.ts @@ -759,7 +759,9 @@ function trimTrailingSlashes(url: string): string { /** Which of the three toolbox URL sources a refusal is about. */ type ToolboxUrlSource = - "configured toolbox URL" | "sandbox-reported toolbox proxy URL" | "toolbox proxy lookup"; + | "configured toolbox URL" + | "sandbox-reported toolbox proxy URL" + | "toolbox proxy lookup"; /** Hosts a toolbox URL may address over plain HTTP: a runner on this machine. */ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); diff --git a/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts b/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts index cf85f6f477..8b25ace946 100644 --- a/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts @@ -126,6 +126,57 @@ describe("boot budget alarm effects", () => { expect(stopSandbox).toHaveBeenCalledOnce(); }); + it("holds the termination guard only for provider stop, after publishing and detaching", async () => { + const sandbox = createMockSandbox({ + status: "connecting", + created_at: Date.now() - DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs, + }); + const observations: string[] = []; + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true }, + stopSandbox: vi.fn(async () => { + observations.push(`stop:${h.manager.isSpawning()}`); + return { success: true }; + }), + }) + ); + vi.spyOn(h.wsManager, "sendToSandbox").mockImplementation(() => { + observations.push(`send:${h.manager.isSpawning()}`); + return true; + }); + vi.spyOn(h.storage, "fenceSandboxGeneration").mockImplementation(() => { + observations.push(`fence:${h.manager.isSpawning()}`); + sandbox.fenced = 1; + }); + vi.spyOn(h.storage, "updateSandboxStatus").mockImplementation((status) => { + observations.push(`status:${h.manager.isSpawning()}`); + sandbox.status = status; + }); + vi.spyOn(h.broadcaster, "broadcast").mockImplementation(() => { + observations.push(`broadcast:${h.manager.isSpawning()}`); + }); + vi.spyOn(h.wsManager, "detachSandboxWebSocket").mockImplementation(() => { + observations.push(`detach:${h.manager.isSpawning()}`); + }); + + await expect(h.manager.handleAlarm()).resolves.toMatchObject({ + kind: "boot_budget_exceeded", + }); + expect(observations).toEqual([ + "send:false", + "fence:false", + "status:false", + "broadcast:false", + "broadcast:false", + "broadcast:false", + "detach:false", + "stop:true", + ]); + expect(h.manager.isSpawning()).toBe(false); + }); + it.each(["rejected", "unsuccessful"] as const)( "preserves the budget failure after a %s provider stop", async (failure) => { diff --git a/packages/control-plane/src/sandbox/lifecycle/allocation-cleanup.test.ts b/packages/control-plane/src/sandbox/lifecycle/allocation-cleanup.test.ts new file mode 100644 index 0000000000..697b8b9889 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/allocation-cleanup.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + attemptRejectedStartupCleanup, + destroyLateProviderResult, + rearmRejectedStartupCleanupAlarm, + type AllocationCleanupDependencies, +} from "./allocation-cleanup"; +import { createMockSandbox } from "./test-helpers"; + +function fixture() { + const row = createMockSandbox({ + status: "failed", + fenced: 1, + startup_rejected: 1, + modal_object_id: "rejected-handle", + }); + const generation = { sandboxId: row.modal_sandbox_id, createdAt: row.created_at }; + const logger = { warn: vi.fn() }; + const deps = { + storage: { + getSandbox: vi.fn(() => row), + updateSandboxModalObjectId: vi.fn((handle: string | null) => { + row.modal_object_id = handle; + }), + }, + alarmScheduler: { schedule: vi.fn(async (_deadline: number) => {}) }, + canStop: vi.fn(() => true), + stop: vi.fn(async () => "confirmed"), + getLogger: vi.fn(() => logger), + } satisfies AllocationCleanupDependencies; + return { row, generation, deps, logger }; +} + +afterEach(() => vi.useRealTimers()); + +describe("allocation cleanup mechanics", () => { + it.each([ + [1, "rejected-handle", true], + [0, "rejected-handle", false], + [1, null, false], + ] as const)( + "rearms only a rejected row with a handle (%s, %s)", + async (rejected, handle, armed) => { + vi.useFakeTimers(); + const { row, deps } = fixture(); + row.startup_rejected = rejected; + row.modal_object_id = handle; + await rearmRejectedStartupCleanupAlarm(deps); + expect(deps.alarmScheduler.schedule.mock.calls).toEqual(armed ? [[Date.now() + 30_000]] : []); + expect(deps.stop).not.toHaveBeenCalled(); + } + ); + + it("awaits retry persistence before stopping the explicit target, without retargeting", async () => { + const { row, generation, deps } = fixture(); + let scheduled!: () => void; + deps.alarmScheduler.schedule.mockImplementation( + () => new Promise((resolve) => (scheduled = resolve)) + ); + const cleaning = attemptRejectedStartupCleanup(deps, generation, "rejected-handle"); + expect(deps.stop).not.toHaveBeenCalled(); + row.modal_object_id = "replacement-handle"; + const replacement = structuredClone(row); + scheduled(); + await cleaning; + expect(deps.stop).toHaveBeenCalledExactlyOnceWith("rejected-handle", expect.any(AbortSignal)); + expect(row).toEqual(replacement); + expect(deps.storage.updateSandboxModalObjectId).not.toHaveBeenCalled(); + }); + + it.each([new Error("provider unavailable"), "provider unavailable"])( + "retains the handle and retry after stop failure (%s)", + async (error) => { + vi.useFakeTimers(); + const { row, generation, deps, logger } = fixture(); + deps.stop.mockRejectedValue(error); + await attemptRejectedStartupCleanup(deps, generation, "rejected-handle"); + expect(row.modal_object_id).toBe("rejected-handle"); + expect(deps.storage.updateSandboxModalObjectId).not.toHaveBeenCalled(); + expect(deps.alarmScheduler.schedule).toHaveBeenCalledExactlyOnceWith(Date.now() + 30_000); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + "Failed to destroy superseded provider sandbox", + { provider_object_id: "rejected-handle", error: "provider unavailable" } + ); + expect(vi.getTimerCount()).toBe(0); + } + ); + + it("retains the handle and retry when stop dispatch is skipped", async () => { + const { row, generation, deps } = fixture(); + deps.stop.mockResolvedValue("not_stopped"); + await attemptRejectedStartupCleanup(deps, generation, "rejected-handle"); + expect(row.modal_object_id).toBe("rejected-handle"); + expect(deps.storage.updateSandboxModalObjectId).not.toHaveBeenCalled(); + expect(deps.alarmScheduler.schedule).toHaveBeenCalledOnce(); + }); + + it("bounds local stop without treating abort or later success as retirement", async () => { + vi.useFakeTimers(); + const { row, generation, deps, logger } = fixture(); + const startedAt = Date.now(); + let finishStop!: () => void; + const stopping = new Promise<"confirmed">( + (resolve) => (finishStop = () => resolve("confirmed")) + ); + deps.stop.mockImplementation(() => stopping); + const cleaning = attemptRejectedStartupCleanup(deps, generation, "rejected-handle"); + await vi.advanceTimersByTimeAsync(9_999); + const signal = deps.stop.mock.calls[0][1]; + expect(signal.aborted).toBe(false); + expect(row.modal_object_id).toBe("rejected-handle"); + await vi.advanceTimersByTimeAsync(1); + await cleaning; + expect(signal.aborted).toBe(true); + expect(deps.alarmScheduler.schedule).toHaveBeenCalledExactlyOnceWith(startedAt + 30_000); + expect(logger.warn).toHaveBeenCalledWith("Failed to destroy superseded provider sandbox", { + provider_object_id: "rejected-handle", + error: "Late provider cleanup timed out", + }); + finishStop(); + await stopping; + expect(row.modal_object_id).toBe("rejected-handle"); + expect(deps.storage.updateSandboxModalObjectId).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("clears only the matching handle after confirmed retirement", async () => { + const { row, generation, deps } = fixture(); + const before = structuredClone(row); + await attemptRejectedStartupCleanup(deps, generation, "rejected-handle"); + expect(deps.storage.updateSandboxModalObjectId).toHaveBeenCalledExactlyOnceWith(null); + expect(row).toEqual({ ...before, modal_object_id: null }); + deps.alarmScheduler.schedule.mockClear(); + await rearmRejectedStartupCleanupAlarm(deps); + expect(deps.alarmScheduler.schedule).not.toHaveBeenCalled(); + }); + + it.each([undefined, "rejected-handle"])( + "does not stop an absent handle or unsupported provider (%s)", + async (handle) => { + const { deps } = fixture(); + deps.canStop.mockReturnValue(handle === undefined); + expect(await destroyLateProviderResult(deps, handle)).toBe(false); + expect(deps.stop).not.toHaveBeenCalled(); + expect(deps.getLogger).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/control-plane/src/sandbox/lifecycle/allocation-cleanup.ts b/packages/control-plane/src/sandbox/lifecycle/allocation-cleanup.ts new file mode 100644 index 0000000000..a468d4b10b --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/allocation-cleanup.ts @@ -0,0 +1,68 @@ +import type { Logger } from "../../logger"; +import type { AlarmScheduler } from "../../platform-ports"; +import type { SandboxRow } from "../../session/types"; +import type { SandboxGeneration } from "./ports"; +import { boundedProviderStop, type ProviderStopOutcome } from "./provider-stop"; + +const REJECTED_ALLOCATION_CLEANUP_RETRY_MS = 30_000; + +export interface AllocationCleanupStorage { + getSandbox(): SandboxRow | null; + updateSandboxModalObjectId(providerObjectId: string | null): void; +} + +export interface AllocationCleanupDependencies { + storage: AllocationCleanupStorage; + alarmScheduler: Pick; + canStop: () => boolean; + /** Confirms only provider retirement; skipped dispatch returns not_stopped. */ + stop: (providerObjectId: string, signal: AbortSignal) => Promise; + getLogger: () => Pick; +} + +export async function rearmRejectedStartupCleanupAlarm( + deps: Pick +): Promise { + const row = deps.storage.getSandbox(); + if (row?.startup_rejected && row.modal_object_id) { + await deps.alarmScheduler.schedule(Date.now() + REJECTED_ALLOCATION_CLEANUP_RETRY_MS); + } +} + +export async function attemptRejectedStartupCleanup( + deps: AllocationCleanupDependencies, + generation: SandboxGeneration, + providerObjectId: string +): Promise { + // Persist the next attempt before provider I/O so an eviction cannot lose cleanup. + await rearmRejectedStartupCleanupAlarm(deps); + if (!(await destroyLateProviderResult(deps, providerObjectId))) return; + const row = deps.storage.getSandbox(); + if ( + row?.modal_sandbox_id === generation.sandboxId && + row.created_at === generation.createdAt && + row.modal_object_id === providerObjectId + ) { + deps.storage.updateSandboxModalObjectId(null); + } +} + +export async function destroyLateProviderResult( + deps: Pick, + providerObjectId: string | undefined +): Promise { + if (!providerObjectId || !deps.canStop()) return false; + try { + const outcome = await boundedProviderStop( + (signal) => deps.stop(providerObjectId, signal), + "Late provider cleanup timed out" + ); + return outcome === "confirmed"; + } catch (error) { + deps.getLogger().warn("Failed to destroy superseded provider sandbox", { + provider_object_id: providerObjectId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} diff --git a/packages/control-plane/src/sandbox/lifecycle/image-selection.ts b/packages/control-plane/src/sandbox/lifecycle/image-selection.ts index e1423d2be3..1d38c60b15 100644 --- a/packages/control-plane/src/sandbox/lifecycle/image-selection.ts +++ b/packages/control-plane/src/sandbox/lifecycle/image-selection.ts @@ -12,8 +12,8 @@ * miss on any condition falls back to the base image; sessions are never * blocked on builds. * - * Pure decision logic in the decisions.ts style: the lifecycle manager owns - * the lookup call, logging, and fallback plumbing. + * Pure decision logic in the decisions.ts style: launch-context owns lookup/logging; + * the lifecycle manager owns provider fallback and retry decisions. */ import { DEFAULT_HARNESS, type HarnessId } from "@open-inspect/shared/harnesses"; @@ -71,7 +71,10 @@ export interface SelectedImageBuild { } type ImageBuildMissReason = - "no_ready_image" | "missing_artifact" | "runtime_below_floor" | "fingerprint_mismatch"; + | "no_ready_image" + | "missing_artifact" + | "runtime_below_floor" + | "fingerprint_mismatch"; export type ImageBuildSelectionResult = | { outcome: "selected"; image: SelectedImageBuild } diff --git a/packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts b/packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts new file mode 100644 index 0000000000..fedf7547fa --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts @@ -0,0 +1,711 @@ +import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_HARNESS } from "@open-inspect/shared/harnesses"; +import { DEFAULT_MODEL, extractProviderAndModel } from "@open-inspect/shared/models"; +import type { McpServerConfig } from "@open-inspect/shared/types/integrations"; +import { computeRepositoriesFingerprint } from "../../image-builds/fingerprint"; +import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; +import type { SessionRow } from "../../session/types"; +import { SandboxProviderError, type SessionRepositoryInfo } from "../provider"; +import type { ImageBuildLookup, ImageBuildSpawnRow, SelectedImageBuild } from "./image-selection"; +import { + SandboxLaunchContext, + resolveImageBuildScope, + type McpServerLookup, + type SandboxLaunchContextDependencies, + type SandboxLaunchContextReader, + type SlackAgentNotifyLookup, +} from "./launch-context"; +import { createMockSession } from "./test-helpers"; + +const CONFIGURED_MODEL = "openai/gpt-5.4"; +const PRIMARY: SessionRepositoryInfo = { + repoOwner: "testowner", + repoName: "testrepo", + baseBranch: "main", +}; +const SECONDARY: SessionRepositoryInfo = { + repoOwner: "Group/Subgroup", + repoName: "API", + baseBranch: "develop", +}; +const SELECTED_IMAGE: SelectedImageBuild = { + imageBuildId: "build-1", + providerImageId: "image-1", + primaryBaseSha: "baked-testrepo", + runtimeVersion: COMPATIBLE_RUNTIME_VERSION, +}; + +function fixture(overrides: Partial = {}) { + const sessionContext = { + getSessionRepositories: vi.fn(() => []), + getUserEnvVars: vi.fn(async () => undefined), + }; + const imageBuildLookup = { + getLatestReady: vi.fn(async () => null), + markRestoreFailed: vi.fn(async () => true), + }; + const logger = { info: vi.fn(), warn: vi.fn() }; + const getLogger = vi.fn(() => logger); + const context = new SandboxLaunchContext({ + sessionContext, + provider: { name: "modal", capabilities: { supportsSandboxTimeout: true } }, + config: { model: CONFIGURED_MODEL }, + imageBuildLookup, + getLogger, + ...overrides, + }); + return { context, sessionContext, imageBuildLookup, logger, getLogger }; +} + +async function readyImage(repositories: SessionRepositoryInfo[]): Promise { + return { + id: SELECTED_IMAGE.imageBuildId, + provider_image_id: SELECTED_IMAGE.providerImageId, + repositories_fingerprint: await computeRepositoriesFingerprint(repositories), + repository_shas: JSON.stringify( + repositories.map(({ repoOwner, repoName }) => ({ + repoOwner, + repoName, + baseSha: `baked-${repoName}`, + })) + ), + runtime_version: COMPATIBLE_RUNTIME_VERSION, + }; +} + +describe("resolveImageBuildScope", () => { + it.each([{ repositories: [PRIMARY, SECONDARY] }, { repositories: [] }])( + "keeps the environment scope with members $repositories", + ({ repositories }) => { + const session = createMockSession({ environment_id: "environment-1" }); + expect(resolveImageBuildScope(session, repositories)).toEqual({ + kind: "environment", + id: "environment-1", + }); + } + ); + + it("uses the single member's normalized nested identity for an ad-hoc repo image", () => { + expect(resolveImageBuildScope(createMockSession(), [SECONDARY])).toEqual({ + kind: "repo", + id: "group/subgroup/api", + }); + }); + + it("synchronously excludes ad-hoc multi-repo images", () => { + expect(resolveImageBuildScope(createMockSession(), [PRIMARY, SECONDARY])).toBeNull(); + }); + + it("synchronously excludes repo-less ad-hoc images", () => { + const session = createMockSession({ repo_owner: null, repo_name: null }); + expect(resolveImageBuildScope(session, [])).toBeNull(); + }); +}); + +describe("SandboxLaunchContext", () => { + it("does no dependency work at construction, even before a logger is available", () => { + const getLogger = vi.fn(() => { + throw new Error("session not initialized"); + }); + const mcpServerLookup = { + getDecryptedForSession: vi.fn(async () => []), + }; + const slackAgentNotifyLookup = { + isEnabledForRepo: vi.fn(async () => true), + }; + const f = fixture({ + getLogger, + config: { model: CONFIGURED_MODEL, mcpServerLookup, slackAgentNotifyLookup }, + }); + + for (const dependency of [ + getLogger, + ...Object.values(f.sessionContext), + ...Object.values(f.imageBuildLookup), + mcpServerLookup.getDecryptedForSession, + slackAgentNotifyLookup.isEnabledForRepo, + ]) { + expect(dependency).not.toHaveBeenCalled(); + } + }); + + it("resolves the current logger at each use rather than caching construction context", () => { + const f = fixture(); + const session = createMockSession({ sandbox_settings: "not-json" }); + f.context.resolveSandboxSettings(session); + const nextLogger = { info: vi.fn(), warn: vi.fn() }; + f.getLogger.mockReturnValue(nextLogger); + f.context.resolveSandboxSettings(session); + + expect(f.logger.warn).toHaveBeenCalledOnce(); + expect(nextLogger.warn).toHaveBeenCalledOnce(); + expect(f.getLogger).toHaveBeenCalledTimes(2); + }); + + describe("resolveAgent", () => { + it("uses the configured model and default harness for an absent legacy selection", () => { + const { context } = fixture(); + // Legacy stored rows can predate the current non-null harness schema. + const session = { + ...createMockSession({ model: "" }), + harness: undefined, + } as unknown as SessionRow; + + expect(context.resolveAgent(session)).toEqual({ + provider: "openai", + model: "gpt-5.4", + harness: DEFAULT_HARNESS, + }); + }); + + it("honors a session model and harness, normalizing a bare model ID", () => { + const { context } = fixture(); + + expect( + context.resolveAgent(createMockSession({ model: "claude-haiku-4-5", harness: "claude" })) + ).toEqual({ provider: "anthropic", model: "claude-haiku-4-5", harness: "claude" }); + }); + + it("falls back to catalog defaults for invalid stored model and harness values", () => { + const { context } = fixture(); + const session = { + ...createMockSession({ model: "unknown/model" }), + harness: "retired-harness", + } as unknown as SessionRow; + + expect(context.resolveAgent(session)).toEqual({ + ...extractProviderAndModel(DEFAULT_MODEL), + harness: DEFAULT_HARNESS, + }); + }); + + it.each(["openai/gpt-5.3-codex", "gpt-5.3-codex-spark"])( + "migrates retired model %s to its replacement", + (model) => { + const { context } = fixture(); + + expect(context.resolveAgent(createMockSession({ model }))).toEqual({ + provider: "openai", + model: "gpt-6-sol", + harness: DEFAULT_HARNESS, + }); + } + ); + }); + + describe("resolveRepositories", () => { + it("returns null scalar fields and no repository list for a repo-less session", () => { + const f = fixture(); + const session = createMockSession({ repo_owner: null, repo_name: null, base_branch: null }); + + expect(f.context.resolveRepositories(session)).toEqual({ + repositories: [], + fields: { repoOwner: null, repoName: null, branch: null }, + }); + expect(f.sessionContext.getSessionRepositories).toHaveBeenCalledOnce(); + }); + + it("keeps a single repository without a base SHA in scalar form", () => { + const f = fixture(); + f.sessionContext.getSessionRepositories.mockReturnValue([PRIMARY]); + + expect(f.context.resolveRepositories(createMockSession())).toEqual({ + repositories: [PRIMARY], + fields: { repoOwner: PRIMARY.repoOwner, repoName: PRIMARY.repoName, branch: "main" }, + }); + }); + + it("preserves member order and nested owners, reading members anew on each call", () => { + const f = fixture(); + const session = createMockSession({ + repo_owner: SECONDARY.repoOwner, + repo_name: SECONDARY.repoName, + base_branch: SECONDARY.baseBranch, + }); + f.sessionContext.getSessionRepositories.mockReturnValueOnce([SECONDARY, PRIMARY]); + const fields = { repoOwner: "Group/Subgroup", repoName: "API", branch: "develop" }; + + expect(f.context.resolveRepositories(session)).toEqual({ + repositories: [SECONDARY, PRIMARY], + fields: { ...fields, repositories: [SECONDARY, PRIMARY] }, + }); + f.sessionContext.getSessionRepositories.mockReturnValueOnce([SECONDARY]); + expect(f.context.resolveRepositories(session)).toEqual({ + repositories: [SECONDARY], + fields, + }); + expect(f.sessionContext.getSessionRepositories).toHaveBeenCalledTimes(2); + }); + + it("includes the list for a single repository with an immutable base SHA", () => { + const f = fixture(); + const repositories = [{ ...PRIMARY, baseSha: "session-start-sha" }]; + f.sessionContext.getSessionRepositories.mockReturnValue(repositories); + + expect(f.context.resolveRepositories(createMockSession())).toEqual({ + repositories, + fields: { + repoOwner: PRIMARY.repoOwner, + repoName: PRIMARY.repoName, + branch: PRIMARY.baseBranch, + repositories, + }, + }); + }); + }); + + describe("prebuilt images", () => { + it("selects only the environment image and uses its first baked SHA", async () => { + const f = fixture(); + const repositories = [{ ...SECONDARY, baseSha: "session-start-sha" }, PRIMARY]; + f.imageBuildLookup.getLatestReady.mockResolvedValue(await readyImage(repositories)); + + expect( + await f.context.lookupImageBuildForSpawn( + { kind: "environment", id: "environment-1" }, + repositories, + DEFAULT_HARNESS + ) + ).toEqual({ ...SELECTED_IMAGE, primaryBaseSha: "baked-API" }); + expect(f.imageBuildLookup.getLatestReady.mock.calls).toEqual([ + [{ kind: "environment", id: "environment-1" }], + ]); + expect(f.logger.info).toHaveBeenCalledWith("Using prebuilt image", { + event: "image_build.spawn_selected", + scope_kind: "environment", + scope_id: "environment-1", + image_build_id: "build-1", + runtime_version: COMPATIBLE_RUNTIME_VERSION, + }); + expect(f.imageBuildLookup.markRestoreFailed).not.toHaveBeenCalled(); + }); + + it("does not fall back to a repo image after a single-repo environment miss", async () => { + const f = fixture(); + + expect( + await f.context.lookupImageBuildForSpawn( + { kind: "environment", id: "environment-1" }, + [PRIMARY], + DEFAULT_HARNESS + ) + ).toBeNull(); + expect(f.imageBuildLookup.getLatestReady.mock.calls).toEqual([ + [{ kind: "environment", id: "environment-1" }], + ]); + expect(f.logger.info).toHaveBeenCalledWith( + "Prebuilt image miss, using base image", + expect.objectContaining({ reason: "no_ready_image", scope_kind: "environment" }) + ); + expect(f.imageBuildLookup.markRestoreFailed).not.toHaveBeenCalled(); + }); + + it("passes an explicitly selected repo scope through lookup", async () => { + const f = fixture(); + f.imageBuildLookup.getLatestReady.mockResolvedValue(await readyImage([SECONDARY])); + + expect( + await f.context.lookupImageBuildForSpawn( + { kind: "repo", id: "group/subgroup/api" }, + [SECONDARY], + DEFAULT_HARNESS + ) + ).toEqual({ + ...SELECTED_IMAGE, + primaryBaseSha: "baked-API", + }); + expect(f.imageBuildLookup.getLatestReady).toHaveBeenCalledExactlyOnceWith({ + kind: "repo", + id: "group/subgroup/api", + }); + }); + + it("retains the async boundary for an empty environment without invoking lookup", async () => { + const f = fixture(); + + const lookup = f.context.lookupImageBuildForSpawn( + { kind: "environment", id: "environment-1" }, + [], + DEFAULT_HARNESS + ); + expect(lookup).toBeInstanceOf(Promise); + expect(await lookup).toBeNull(); + expect(f.imageBuildLookup.getLatestReady).not.toHaveBeenCalled(); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("skips lookup and invalidation when no image lookup is configured", async () => { + const f = fixture({ imageBuildLookup: undefined }); + + const lookup = f.context.lookupImageBuildForSpawn( + { kind: "repo", id: "testowner/testrepo" }, + [PRIMARY], + DEFAULT_HARNESS + ); + expect(lookup).toBeInstanceOf(Promise); + expect(await lookup).toBeNull(); + await expect( + f.context.markImageBuildRestoreFailed(SELECTED_IMAGE, new Error("unavailable")) + ).resolves.toBeUndefined(); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("falls back on lookup failure without invalidating any image", async () => { + const f = fixture(); + f.imageBuildLookup.getLatestReady.mockRejectedValue(new Error("lookup unavailable")); + + expect( + await f.context.lookupImageBuildForSpawn( + { kind: "repo", id: "testowner/testrepo" }, + [PRIMARY], + DEFAULT_HARNESS + ) + ).toBeNull(); + expect(f.imageBuildLookup.markRestoreFailed).not.toHaveBeenCalled(); + expect(f.logger.warn).toHaveBeenCalledWith( + "Failed to look up prebuilt image, using base image", + { + event: "image_build.spawn_miss", + scope_kind: "repo", + scope_id: "testowner/testrepo", + reason: "lookup_failed", + error: "lookup unavailable", + } + ); + }); + + it.each([new Error("artifact unavailable"), "artifact unavailable"])( + "invalidates only on an explicit restore-failed call (%s)", + async (error) => { + const f = fixture(); + + await f.context.markImageBuildRestoreFailed(SELECTED_IMAGE, error); + + expect(f.imageBuildLookup.markRestoreFailed).toHaveBeenCalledExactlyOnceWith( + "build-1", + "restore failed at spawn: artifact unavailable" + ); + expect(f.imageBuildLookup.getLatestReady).not.toHaveBeenCalled(); + expect(f.getLogger).not.toHaveBeenCalled(); + } + ); + + it("keeps explicit invalidation best effort when the store refuses or throws", async () => { + const f = fixture(); + f.imageBuildLookup.markRestoreFailed + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error("store unavailable")); + + await expect( + f.context.markImageBuildRestoreFailed(SELECTED_IMAGE, "artifact unavailable") + ).resolves.toBeUndefined(); + await expect( + f.context.markImageBuildRestoreFailed(SELECTED_IMAGE, "artifact unavailable") + ).resolves.toBeUndefined(); + expect(f.logger.warn).toHaveBeenCalledExactlyOnceWith( + "Failed to mark prebuilt image restore-failed", + { image_build_id: "build-1", error: "store unavailable" } + ); + }); + }); + + describe("getUserEnvVars", () => { + it("forwards current user env values, including undefined, without copying or logging", async () => { + const f = fixture(); + const env = { API_KEY: "user-secret" }; + f.sessionContext.getUserEnvVars.mockResolvedValueOnce(env); + + expect(await f.context.getUserEnvVars()).toBe(env); + expect(await f.context.getUserEnvVars()).toBeUndefined(); + expect(f.sessionContext.getUserEnvVars.mock.calls).toEqual([[], []]); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("propagates user env failures unchanged to the caller", async () => { + const f = fixture(); + const error = new Error("decryption failed"); + f.sessionContext.getUserEnvVars.mockRejectedValue(error); + + await expect(f.context.getUserEnvVars()).rejects.toBe(error); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + }); + + describe("loadMcpServers", () => { + it("forwards ordered member identities and returns credentials without logging them", async () => { + const servers: McpServerConfig[] = [ + { + id: "local-1", + name: "local", + type: "local", + command: ["mcp"], + env: { TOKEN: "mcp-secret" }, + enabled: true, + }, + { + id: "remote-1", + name: "remote", + type: "remote", + url: "https://mcp.example", + headers: { Authorization: "mcp-secret" }, + enabled: true, + }, + ]; + const mcpServerLookup = { + getDecryptedForSession: vi.fn( + async () => servers + ), + }; + const f = fixture({ config: { model: CONFIGURED_MODEL, mcpServerLookup } }); + + expect( + await f.context.loadMcpServers([{ ...SECONDARY, baseSha: "session-sha" }, PRIMARY]) + ).toBe(servers); + expect(mcpServerLookup.getDecryptedForSession).toHaveBeenCalledExactlyOnceWith([ + { repoOwner: "Group/Subgroup", repoName: "API" }, + { repoOwner: PRIMARY.repoOwner, repoName: PRIMARY.repoName }, + ]); + expect(f.logger.info.mock.calls).toEqual([ + ["MCP servers loaded", { event: "mcp.loaded", count: 2, names: ["local", "remote"] }], + ]); + expect(JSON.stringify(f.logger.info.mock.calls)).not.toContain("mcp-secret"); + expect(f.logger.warn).not.toHaveBeenCalled(); + }); + + it("passes an empty scope for repo-less sessions and omits an empty server list", async () => { + const mcpServerLookup = { + getDecryptedForSession: vi.fn(async () => []), + }; + const f = fixture({ config: { model: CONFIGURED_MODEL, mcpServerLookup } }); + + expect(await f.context.loadMcpServers([])).toBeUndefined(); + expect(mcpServerLookup.getDecryptedForSession).toHaveBeenCalledExactlyOnceWith([]); + expect(f.logger.info).toHaveBeenCalledWith("MCP servers loaded", { + event: "mcp.loaded", + count: 0, + names: [], + }); + }); + + it("omits MCP servers without consulting the logger when no lookup is configured", async () => { + const f = fixture(); + + expect(await f.context.loadMcpServers([PRIMARY])).toBeUndefined(); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("omits servers on lookup failure and logs no private error metadata", async () => { + const error = Object.assign(new Error("credential lookup unavailable"), { + credentials: "mcp-secret", + }); + const mcpServerLookup = { + getDecryptedForSession: vi + .fn() + .mockRejectedValue(error), + }; + const f = fixture({ config: { model: CONFIGURED_MODEL, mcpServerLookup } }); + + expect(await f.context.loadMcpServers([PRIMARY])).toBeUndefined(); + expect(f.logger.warn.mock.calls).toEqual([ + [ + "Failed to load MCP servers", + { event: "mcp.load_failed", error: "Error: credential lookup unavailable" }, + ], + ]); + expect(JSON.stringify(f.logger.warn.mock.calls)).not.toContain("mcp-secret"); + expect(f.logger.info).not.toHaveBeenCalled(); + }); + }); + + describe("resolveAgentSlackNotifyEnabled", () => { + it.each([true, false])("forwards the repository-scoped gate result %s", async (enabled) => { + const slackAgentNotifyLookup = { + isEnabledForRepo: vi.fn(async () => enabled), + }; + const f = fixture({ config: { model: CONFIGURED_MODEL, slackAgentNotifyLookup } }); + + expect(await f.context.resolveAgentSlackNotifyEnabled(createMockSession())).toBe(enabled); + expect(slackAgentNotifyLookup.isEnabledForRepo).toHaveBeenCalledExactlyOnceWith( + PRIMARY.repoOwner, + PRIMARY.repoName + ); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("defaults to disabled when the lookup is missing", async () => { + const f = fixture(); + + expect(await f.context.resolveAgentSlackNotifyEnabled(createMockSession())).toBe(false); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("uses the global gate for repo-less sessions", async () => { + const slackAgentNotifyLookup = { + isEnabledForRepo: vi.fn(async () => true), + }; + const f = fixture({ config: { model: CONFIGURED_MODEL, slackAgentNotifyLookup } }); + + expect( + await f.context.resolveAgentSlackNotifyEnabled( + createMockSession({ repo_owner: null, repo_name: null }) + ) + ).toBe(true); + expect(slackAgentNotifyLookup.isEnabledForRepo).toHaveBeenCalledExactlyOnceWith(null, null); + }); + + it("disables notifications and warns when the lookup throws", async () => { + const slackAgentNotifyLookup = { + isEnabledForRepo: vi + .fn() + .mockRejectedValue(new Error("gate unavailable")), + }; + const f = fixture({ config: { model: CONFIGURED_MODEL, slackAgentNotifyLookup } }); + + expect(await f.context.resolveAgentSlackNotifyEnabled(createMockSession())).toBe(false); + expect(f.logger.warn).toHaveBeenCalledWith( + "Failed to resolve agent slack-notify gate; treating as disabled", + { event: "slack_notify.gate_resolve_failed", error: "gate unavailable" } + ); + }); + }); + + describe("resolveSandboxSettings", () => { + it("returns empty settings without logging when no settings are stored", () => { + const f = fixture(); + + expect(f.context.resolveSandboxSettings(createMockSession())).toEqual({ + sandboxSettings: {}, + timeoutSeconds: undefined, + }); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("parses valid settings, including an explicit provider-default resource", () => { + const f = fixture(); + const settings = { + sandboxTimeoutMs: 3_600_000, + cpuCores: 2, + memoryMib: null, + terminalEnabled: true, + tunnelPorts: [3000], + }; + + expect( + f.context.resolveSandboxSettings( + createMockSession({ sandbox_settings: JSON.stringify(settings) }) + ) + ).toEqual({ sandboxSettings: settings, timeoutSeconds: 3600 }); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("falls back to defaults and warns for invalid JSON without logging the stored blob", () => { + const f = fixture(); + + expect( + f.context.resolveSandboxSettings( + createMockSession({ sandbox_settings: "invalid-private-blob" }) + ) + ).toEqual({ sandboxSettings: {}, timeoutSeconds: undefined }); + expect(f.logger.warn.mock.calls).toEqual([ + ["Failed to parse sandbox_settings, using defaults"], + ]); + }); + + it("omits invalid individual settings while keeping valid values", () => { + const f = fixture(); + + expect( + f.context.resolveSandboxSettings( + createMockSession({ + sandbox_settings: JSON.stringify({ + sandboxTimeoutMs: 999, + cpuCores: -2, + memoryMib: 0, + terminalEnabled: "yes", + tunnelPorts: ["bad", 3000], + maxTotalChildSessions: 8, + }), + }) + ) + ).toEqual({ + sandboxSettings: { tunnelPorts: [3000], maxTotalChildSessions: 8 }, + timeoutSeconds: undefined, + }); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("drops unsupported provider settings before timeout conversion and warns with their names only", () => { + const f = fixture({ + provider: { name: "daytona", capabilities: { supportsSandboxTimeout: false } }, + }); + + expect( + f.context.resolveSandboxSettings( + createMockSession({ + sandbox_settings: JSON.stringify({ + cpuCores: 2, + memoryMib: 4096, + sandboxTimeoutMs: 3_600_000, + terminalEnabled: true, + buildTimeoutSeconds: 2400, + }), + }) + ) + ).toEqual({ + sandboxSettings: { terminalEnabled: true, buildTimeoutSeconds: 2400 }, + timeoutSeconds: undefined, + }); + expect(f.logger.warn).toHaveBeenCalledExactlyOnceWith( + "Ignoring persisted sandbox settings unsupported by the provider", + { + event: "sandbox.settings_unsupported", + provider: "daytona", + settings: ["cpuCores", "memoryMib", "sandboxTimeoutMs"], + } + ); + }); + + it.each([true, false])( + "leaves an absent timeout undefined with capability %s", + (supportsSandboxTimeout) => { + const f = fixture({ + provider: { name: "test-provider", capabilities: { supportsSandboxTimeout } }, + }); + + expect(f.context.resolveSandboxSettings(createMockSession())).toEqual({ + sandboxSettings: {}, + timeoutSeconds: undefined, + }); + expect(f.getLogger).not.toHaveBeenCalled(); + } + ); + + it("converts an explicit timeout from milliseconds to seconds", () => { + const { context } = fixture(); + + expect( + context.resolveSandboxSettings( + createMockSession({ sandbox_settings: '{"sandboxTimeoutMs":3661000}' }) + ) + ).toEqual({ sandboxSettings: { sandboxTimeoutMs: 3_661_000 }, timeoutSeconds: 3661 }); + }); + + it("raises a permanent provider error for an explicit unsupported timeout", () => { + const { context } = fixture({ + provider: { name: "test-provider", capabilities: { supportsSandboxTimeout: false } }, + }); + const resolve = () => + context.resolveSandboxSettings( + createMockSession({ sandbox_settings: '{"sandboxTimeoutMs":3600000}' }) + ); + + expect(resolve).toThrow(SandboxProviderError); + expect(resolve).toThrow( + expect.objectContaining({ + errorType: "permanent", + message: "test-provider does not support configurable sandbox timeouts", + }) + ); + }); + }); +}); diff --git a/packages/control-plane/src/sandbox/lifecycle/launch-context.ts b/packages/control-plane/src/sandbox/lifecycle/launch-context.ts new file mode 100644 index 0000000000..62fe7e6a24 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/launch-context.ts @@ -0,0 +1,268 @@ +import { getValidHarnessOrDefault, type HarnessId } from "@open-inspect/shared/harnesses"; +import { extractProviderAndModel, getValidModelOrDefault } from "@open-inspect/shared/models"; +import { + omitUnsupportedSandboxSettings, + unsupportedSandboxSettings, + type McpServerConfig, + type SandboxSettings, +} from "@open-inspect/shared/types/integrations"; +import { repoImageBuildScope, type ImageBuildScope } from "../../image-builds/model"; +import type { Logger } from "../../logger"; +import { sessionHasRepository, type SessionRow } from "../../session/types"; +import { + SandboxProviderError, + type CreateSandboxConfig, + type SandboxProviderCapabilities, + type SessionRepositoryInfo, +} from "../provider"; +import { parsePersistedSandboxSettings } from "../settings"; +import { + evaluateImageBuildForSpawn, + type ImageBuildLookup, + type SelectedImageBuild, +} from "./image-selection"; + +export interface SandboxLaunchContextReader { + /** Position-ordered members, including the scalar fallback for legacy sessions. */ + getSessionRepositories(): SessionRepositoryInfo[]; + getUserEnvVars(): Promise | undefined>; +} + +export interface McpServerLookup { + /** A scoped server applies when any member matches; empty for repo-less sessions. */ + getDecryptedForSession( + repositories: Array<{ repoOwner: string; repoName: string }> + ): Promise; +} + +export interface SlackAgentNotifyLookup { + /** False or throwing disables the tool, including in the global repo-less scope. */ + isEnabledForRepo(repoOwner: string | null, repoName: string | null): Promise; +} + +export interface SandboxLaunchConfig { + /** Default model when the session has no override. */ + model: string; + mcpServerLookup?: McpServerLookup; + slackAgentNotifyLookup?: SlackAgentNotifyLookup; +} + +export interface SandboxLaunchContextDependencies { + sessionContext: SandboxLaunchContextReader; + provider: { + name: string; + capabilities: Pick; + }; + config: SandboxLaunchConfig; + imageBuildLookup?: ImageBuildLookup; + /** Construction can precede the session row; resolve log context only at use. */ + getLogger: () => Pick; +} + +export type AgentLaunchFields = Pick; + +export interface RepositoryLaunchInputs { + repositories: SessionRepositoryInfo[]; + fields: Pick; +} + +export interface ResolvedSandboxSettings { + sandboxSettings: SandboxSettings; + timeoutSeconds: number | undefined; +} + +/** Synchronous eligibility keeps ineligible sessions outside the image-lookup await. */ +export function resolveImageBuildScope( + session: SessionRow, + repositories: SessionRepositoryInfo[] +): ImageBuildScope | null { + // Environment misses never fall back to a repo image, which bakes different setup/secrets. + if (session.environment_id) return { kind: "environment", id: session.environment_id }; + // Ad-hoc multi-repo sessions cannot use an image that bakes a single checkout. + return sessionHasRepository(session) && repositories.length === 1 + ? repoImageBuildScope(repositories[0].repoOwner, repositories[0].repoName) + : null; +} + +/** + * Stateless launch-input mechanics, with no reservation, admission, or provider I/O authority. + * The manager calls each operation at its existing await point: fresh and restore intentionally + * resolve integrations in different orders, while resume/bridge use only settings and timeouts. + */ +export class SandboxLaunchContext { + private readonly sessionContext: SandboxLaunchContextReader; + private readonly provider: SandboxLaunchContextDependencies["provider"]; + private readonly config: SandboxLaunchConfig; + private readonly imageBuildLookup: ImageBuildLookup | undefined; + private readonly getLogger: SandboxLaunchContextDependencies["getLogger"]; + + constructor({ + sessionContext, + provider, + config, + imageBuildLookup, + getLogger, + }: SandboxLaunchContextDependencies) { + this.sessionContext = sessionContext; + this.provider = provider; + this.config = config; + this.imageBuildLookup = imageBuildLookup; + this.getLogger = getLogger; + } + + getUserEnvVars(): Promise | undefined> { + return this.sessionContext.getUserEnvVars(); + } + + resolveAgent(session: SessionRow): AgentLaunchFields { + return { + ...extractProviderAndModel(getValidModelOrDefault(session.model || this.config.model)), + harness: getValidHarnessOrDefault(session.harness), + }; + } + + resolveRepositories(session: SessionRow): RepositoryLaunchInputs { + const repositories = this.sessionContext.getSessionRepositories(); + // Single-repo sessions keep the scalar wire form unless they carry a base SHA. + const multiRepoFields: Pick = + repositories.length > 1 || repositories.some((repository) => repository.baseSha) + ? { repositories } + : {}; + return { + repositories, + fields: { + repoOwner: session.repo_owner, + repoName: session.repo_name, + branch: session.base_branch, + ...multiRepoFields, + }, + }; + } + + async lookupImageBuildForSpawn( + scope: ImageBuildScope, + repositories: SessionRepositoryInfo[], + harness: HarnessId + ): Promise { + if (!this.imageBuildLookup || repositories.length === 0) return null; + try { + const image = await this.imageBuildLookup.getLatestReady(scope); + const result = await evaluateImageBuildForSpawn(image, repositories, harness); + if (result.outcome === "selected") { + this.getLogger().info("Using prebuilt image", { + event: "image_build.spawn_selected", + scope_kind: scope.kind, + scope_id: scope.id, + image_build_id: result.image.imageBuildId, + runtime_version: result.image.runtimeVersion, + }); + return result.image; + } + this.getLogger().info("Prebuilt image miss, using base image", { + event: "image_build.spawn_miss", + scope_kind: scope.kind, + scope_id: scope.id, + reason: result.reason, + image_build_id: result.imageBuildId, + }); + return null; + } catch (e) { + this.getLogger().warn("Failed to look up prebuilt image, using base image", { + event: "image_build.spawn_miss", + scope_kind: scope.kind, + scope_id: scope.id, + reason: "lookup_failed", + error: e instanceof Error ? e.message : String(e), + }); + return null; + } + } + + /** Called only by the manager's confirmed-unavailable branch; retry must survive D1 failure. */ + async markImageBuildRestoreFailed(image: SelectedImageBuild, error: unknown): Promise { + if (!this.imageBuildLookup) return; + try { + await this.imageBuildLookup.markRestoreFailed( + image.imageBuildId, + `restore failed at spawn: ${error instanceof Error ? error.message : String(error)}` + ); + } catch (e) { + this.getLogger().warn("Failed to mark prebuilt image restore-failed", { + image_build_id: image.imageBuildId, + error: e instanceof Error ? e.message : String(e), + }); + } + } + + async resolveAgentSlackNotifyEnabled(session: SessionRow): Promise { + if (!this.config.slackAgentNotifyLookup) return false; + try { + return await this.config.slackAgentNotifyLookup.isEnabledForRepo( + sessionHasRepository(session) ? session.repo_owner : null, + sessionHasRepository(session) ? session.repo_name : null + ); + } catch (err) { + this.getLogger().warn("Failed to resolve agent slack-notify gate; treating as disabled", { + event: "slack_notify.gate_resolve_failed", + error: err instanceof Error ? err.message : String(err), + }); + return false; + } + } + + async loadMcpServers( + repositories: SessionRepositoryInfo[] + ): Promise { + try { + if (!this.config.mcpServerLookup) return undefined; + const servers = await this.config.mcpServerLookup.getDecryptedForSession( + repositories.map(({ repoOwner, repoName }) => ({ repoOwner, repoName })) + ); + this.getLogger().info("MCP servers loaded", { + event: "mcp.loaded", + count: servers?.length ?? 0, + names: servers?.map((s) => s.name) ?? [], + }); + return servers?.length ? servers : undefined; + } catch (err) { + this.getLogger().warn("Failed to load MCP servers", { + event: "mcp.load_failed", + error: String(err), + }); + return undefined; + } + } + + resolveSandboxSettings(session: SessionRow): ResolvedSandboxSettings { + let sandboxSettings: SandboxSettings; + try { + const settings = parsePersistedSandboxSettings(session.sandbox_settings); + const unsupported = unsupportedSandboxSettings(settings, this.provider.name); + if (unsupported.length > 0) { + this.getLogger().warn("Ignoring persisted sandbox settings unsupported by the provider", { + event: "sandbox.settings_unsupported", + provider: this.provider.name, + settings: unsupported, + }); + } + sandboxSettings = omitUnsupportedSandboxSettings(settings, this.provider.name); + } catch { + this.getLogger().warn("Failed to parse sandbox_settings, using defaults"); + sandboxSettings = {}; + } + if (!this.provider.capabilities.supportsSandboxTimeout) { + if (sandboxSettings.sandboxTimeoutMs !== undefined) { + throw new SandboxProviderError( + `${this.provider.name} does not support configurable sandbox timeouts`, + "permanent" + ); + } + return { sandboxSettings, timeoutSeconds: undefined }; + } + const timeoutMs = sandboxSettings.sandboxTimeoutMs; + return { + sandboxSettings, + timeoutSeconds: timeoutMs === undefined ? undefined : timeoutMs / 1000, + }; + } +} diff --git a/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts b/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts new file mode 100644 index 0000000000..e8d1ab6e1b --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts @@ -0,0 +1,507 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { McpServerConfig } from "@open-inspect/shared/types/integrations"; +import { hashToken } from "../../auth/crypto"; +import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; +import type { PendingSandboxAllocation, SessionRepositoryInfo } from "../provider"; +import { + createMockAlarmScheduler, + createMockBroadcaster, + createMockIdGenerator, + createMockProvider, + createMockSandbox, + createMockSession, + createMockStorage, + createMockWebSocketManager, + createTestConfig, + createTestLifecycleManager, + createUnmanagedShutdown, + noLifetime, +} from "./test-helpers"; + +vi.mock("../../auth/crypto", () => ({ hashToken: vi.fn() })); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + +function createLaunchFixture() { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(2_000_000); + const effects: string[] = []; + const repositories: SessionRepositoryInfo[] = [ + { + repoOwner: "group/subgroup", + repoName: "api", + baseBranch: "release", + baseSha: "start-sha", + }, + ]; + const servers: McpServerConfig[] = [ + { + id: "mcp-1", + name: "tools", + type: "remote", + enabled: true, + url: "https://mcp.example", + headers: { Authorization: "private-mcp" }, + }, + ]; + const session = createMockSession({ + repo_owner: "group/subgroup", + repo_name: "api", + base_branch: "release", + environment_id: "environment-1", + model: "", + harness: "claude", + code_server_enabled: 1, + vnc_enabled: 1, + sandbox_settings: '{"sandboxTimeoutMs":3600000,"terminalEnabled":true}', + }); + const sandbox = createMockSandbox({ + status: "pending", + modal_object_id: null, + modal_sandbox_id: "prior-sandbox", + last_heartbeat: null, + }); + const storage = createMockStorage(session, sandbox); + const sessionContext = { + getSession: () => session, + getUserEnvVars: vi.fn(async () => { + effects.push("env"); + return { API_KEY: "private-env" }; + }), + getSessionRepositories: vi.fn(() => { + effects.push("repositories"); + return repositories; + }), + }; + const imageBuildLookup = { + getLatestReady: vi.fn(async () => { + effects.push("image"); + return null; + }), + markRestoreFailed: vi.fn(async () => true), + }; + const mcpServerLookup = { + getDecryptedForSession: vi.fn(async () => { + effects.push("mcp"); + return servers; + }), + }; + const slackAgentNotifyLookup = { + isEnabledForRepo: vi.fn(async () => { + effects.push("slack"); + return true; + }), + }; + const shutdown = createUnmanagedShutdown(); + shutdown.reserveStartup.mockImplementation((_createdAt, _policy, persist) => { + effects.push("reserve"); + persist(); + }); + shutdown.recordPendingProviderHandle.mockImplementation(async () => { + effects.push("pending_registration"); + return "registered"; + }); + const provider = createMockProvider(); + provider.pendingSandboxAllocation = vi.fn( + (): PendingSandboxAllocation => ({ + reference: "pending-provider", + lifetime: { + kind: "finite", + observedAtMs: Date.now(), + expiresAtMs: Date.now() + 3_600_000, + source: "conservative_start_bound", + }, + }) + ); + const manager = createTestLifecycleManager( + provider, + storage, + sessionContext, + createMockBroadcaster(), + createMockWebSocketManager(), + createMockAlarmScheduler(), + createMockIdGenerator(), + shutdown, + { + ...createTestConfig(), + model: "openai/gpt-5.4", + mcpServerLookup, + slackAgentNotifyLookup, + }, + imageBuildLookup + ); + return { + manager, + provider, + sandbox, + sessionContext, + shutdown, + imageBuildLookup, + mcpServerLookup, + slackAgentNotifyLookup, + effects, + repositories, + servers, + }; +} + +describe("launch input orchestration", () => { + afterEach(() => vi.useRealTimers()); + + it("fresh launch preserves the exact payload and env/image/MCP/Slack order", async () => { + const { + manager, + provider, + shutdown, + imageBuildLookup, + mcpServerLookup, + slackAgentNotifyLookup, + effects, + repositories, + servers, + } = createLaunchFixture(); + vi.mocked(hashToken).mockResolvedValueOnce("new-hash"); + const imageEntered = deferred(); + const image = deferred(); + imageBuildLookup.getLatestReady.mockImplementationOnce(() => { + effects.push("image"); + imageEntered.resolve(); + return image.promise; + }); + const mcpEntered = deferred(); + const mcp = deferred(); + mcpServerLookup.getDecryptedForSession.mockImplementationOnce(() => { + effects.push("mcp"); + mcpEntered.resolve(); + return mcp.promise; + }); + vi.mocked(provider.createSandbox).mockImplementation(async (config) => { + effects.push("create"); + return { sandboxId: config.sandboxId, createdAt: Date.now(), lifetime: noLifetime() }; + }); + expect(effects).toEqual([]); + + const launching = manager.spawnSandbox(); + await imageEntered.promise; + + expect(effects).toEqual(["reserve", "env", "repositories", "image"]); + expect(provider.createSandbox).not.toHaveBeenCalled(); + + image.resolve(null); + await mcpEntered.promise; + + expect(effects).toEqual(["reserve", "env", "repositories", "image", "mcp"]); + expect(slackAgentNotifyLookup.isEnabledForRepo).not.toHaveBeenCalled(); + expect(provider.createSandbox).not.toHaveBeenCalled(); + + mcp.resolve(servers); + await launching; + + expect(vi.mocked(provider.createSandbox).mock.calls).toStrictEqual([ + [ + { + sessionId: "test-session", + generationCreatedAtMs: 2_000_000, + retireSandboxId: "prior-sandbox", + sandboxId: "sandbox-group/subgroup-api-2000000", + sandboxAuthToken: "generated-id-1", + controlPlaneUrl: "https://test.workers.dev", + repoOwner: "group/subgroup", + repoName: "api", + branch: "release", + harness: "claude", + provider: "openai", + model: "gpt-5.4", + userEnvVars: { API_KEY: "private-env" }, + prebuiltImageId: null, + prebuiltImageSha: null, + timeoutSeconds: 3600, + codeServerEnabled: true, + vncEnabled: true, + agentSlackNotifyEnabled: true, + mcpServers: servers, + sandboxSettings: { sandboxTimeoutMs: 3_600_000, terminalEnabled: true }, + repositories, + }, + ], + ]); + expect(effects).toEqual([ + "reserve", + "env", + "repositories", + "image", + "mcp", + "slack", + "pending_registration", + "create", + ]); + expect(shutdown.markRecoveryInvoked).not.toHaveBeenCalled(); + expect(provider.restoreFromSnapshot).not.toHaveBeenCalled(); + }); + + it("restore preserves the exact payload and env/Slack/MCP order before receipt and provider", async () => { + const { + manager, + provider, + sandbox, + shutdown, + imageBuildLookup, + slackAgentNotifyLookup, + effects, + repositories, + servers, + } = createLaunchFixture(); + sandbox.status = "stopped"; + sandbox.snapshot_image_id = "saved-image"; + sandbox.snapshot_runtime_version = COMPATIBLE_RUNTIME_VERSION; + vi.mocked(hashToken).mockResolvedValueOnce("new-hash"); + const slackEntered = deferred(); + const slack = deferred(); + slackAgentNotifyLookup.isEnabledForRepo.mockImplementationOnce(() => { + effects.push("slack"); + slackEntered.resolve(); + return slack.promise; + }); + shutdown.markRecoveryInvoked.mockImplementation(() => { + effects.push("recovery_invoked"); + }); + vi.mocked(provider.restoreFromSnapshot!).mockImplementation(async (config) => { + effects.push("restore"); + return { success: true, sandboxId: config.sandboxId, lifetime: noLifetime() }; + }); + expect(effects).toEqual([]); + + const launching = manager.spawnSandbox(); + await slackEntered.promise; + + expect(effects).toEqual(["reserve", "env", "repositories", "slack"]); + expect(provider.restoreFromSnapshot).not.toHaveBeenCalled(); + expect(shutdown.markRecoveryInvoked).not.toHaveBeenCalled(); + + slack.resolve(true); + await launching; + + expect(vi.mocked(provider.restoreFromSnapshot!).mock.calls).toStrictEqual([ + [ + { + snapshotImageId: "saved-image", + sessionId: "test-session", + generationCreatedAtMs: 2_000_000, + retireSandboxId: "prior-sandbox", + sandboxId: "sandbox-group/subgroup-api-2000000", + sandboxAuthToken: "generated-id-1", + controlPlaneUrl: "https://test.workers.dev", + repoOwner: "group/subgroup", + repoName: "api", + branch: "release", + harness: "claude", + provider: "openai", + model: "gpt-5.4", + userEnvVars: { API_KEY: "private-env" }, + timeoutSeconds: 3600, + codeServerEnabled: true, + vncEnabled: true, + agentSlackNotifyEnabled: true, + mcpServers: servers, + sandboxSettings: { sandboxTimeoutMs: 3_600_000, terminalEnabled: true }, + repositories, + }, + ], + ]); + expect(effects).toEqual([ + "reserve", + "env", + "repositories", + "slack", + "mcp", + "pending_registration", + "recovery_invoked", + "restore", + ]); + expect(imageBuildLookup.getLatestReady).not.toHaveBeenCalled(); + expect(provider.createSandbox).not.toHaveBeenCalled(); + }); + + it.each(["expired", "superseded"] as const)( + "does not mark restore invoked or call the provider after %s pending registration", + async (outcome) => { + const { manager, provider, sandbox, shutdown } = createLaunchFixture(); + sandbox.status = "stopped"; + sandbox.snapshot_image_id = "saved-image"; + sandbox.snapshot_runtime_version = COMPATIBLE_RUNTIME_VERSION; + vi.mocked(hashToken).mockResolvedValueOnce("new-hash"); + shutdown.recordPendingProviderHandle.mockResolvedValueOnce(outcome); + await manager.spawnSandbox(); + expect(shutdown.markRecoveryInvoked).not.toHaveBeenCalled(); + expect(provider.restoreFromSnapshot).not.toHaveBeenCalled(); + expect(provider.createSandbox).not.toHaveBeenCalled(); + } + ); + + it.each(["fresh", "restore"] as const)( + "%s reserves the identity before deferred hash and env reads", + async (mode) => { + const { manager, provider, sandbox, sessionContext, effects } = createLaunchFixture(); + if (mode === "restore") { + sandbox.status = "stopped"; + sandbox.snapshot_image_id = "saved-image"; + sandbox.snapshot_runtime_version = COMPATIBLE_RUNTIME_VERSION; + } + const hashEntered = deferred(); + const hash = deferred(); + vi.mocked(hashToken).mockImplementationOnce(() => { + effects.push("hash"); + hashEntered.resolve(); + return hash.promise; + }); + const envEntered = deferred(); + const env = deferred<{ API_KEY: string }>(); + sessionContext.getUserEnvVars.mockImplementationOnce(() => { + effects.push("env"); + envEntered.resolve(); + return env.promise; + }); + + const launching = manager.spawnSandbox(); + await hashEntered.promise; + + expect(effects).toEqual(["reserve", "hash"]); + expect(sandbox.status).toBe("spawning"); + expect(sandbox.created_at).toBe(2_000_000); + expect(sandbox.modal_sandbox_id).toBe("sandbox-group/subgroup-api-2000000"); + expect(sandbox.auth_token_hash).toBe(""); + expect(sessionContext.getUserEnvVars).not.toHaveBeenCalled(); + + hash.resolve("new-hash"); + await envEntered.promise; + + expect(effects).toEqual(["reserve", "hash", "env"]); + expect(sandbox.auth_token_hash).toBe("new-hash"); + expect(sandbox.runtime_version).toBe(mode === "restore" ? COMPATIBLE_RUNTIME_VERSION : null); + expect(provider.createSandbox).not.toHaveBeenCalled(); + expect(provider.restoreFromSnapshot).not.toHaveBeenCalled(); + + env.resolve({ API_KEY: "private-env" }); + await launching; + + expect( + mode === "fresh" ? provider.createSandbox : provider.restoreFromSnapshot + ).toHaveBeenCalledOnce(); + } + ); + + it.each(["repo-less", "multi-repo"] as const)( + "%s fresh launch skips the image await before MCP resolution", + async (mode) => { + vi.mocked(hashToken).mockResolvedValueOnce("new-hash"); + const session = createMockSession( + mode === "repo-less" ? { repo_owner: null, repo_name: null } : {} + ); + const repositories: SessionRepositoryInfo[] = + mode === "repo-less" + ? [] + : [ + { repoOwner: "testowner", repoName: "testrepo", baseBranch: "main" }, + { repoOwner: "group/subgroup", repoName: "api", baseBranch: "release" }, + ]; + const storage = createMockStorage( + session, + createMockSandbox({ status: "pending", modal_object_id: null, last_heartbeat: null }) + ); + let repositoryMicrotaskPending = false; + let mcpStartedBeforeRepositoryMicrotask = false; + vi.mocked(storage.getSessionRepositories).mockImplementation(() => { + repositoryMicrotaskPending = true; + queueMicrotask(() => { + repositoryMicrotaskPending = false; + }); + return repositories; + }); + const manager = createTestLifecycleManager( + createMockProvider(), + storage, + storage, + createMockBroadcaster(), + createMockWebSocketManager(), + createMockAlarmScheduler(), + createMockIdGenerator(), + createUnmanagedShutdown(), + { + ...createTestConfig(), + mcpServerLookup: { + getDecryptedForSession: async () => { + mcpStartedBeforeRepositoryMicrotask = repositoryMicrotaskPending; + return []; + }, + }, + } + ); + + await manager.spawnSandbox(); + + expect(mcpStartedBeforeRepositoryMicrotask).toBe(true); + } + ); + + it("resume retains its smaller exact payload without env, repositories, integrations, images, or hash work", async () => { + vi.mocked(hashToken).mockClear(); + const session = createMockSession({ sandbox_settings: '{"sandboxTimeoutMs":3600000}' }); + const sandbox = createMockSandbox({ status: "stopped", snapshot_image_id: null }); + const storage = createMockStorage(session, sandbox); + const provider = createMockProvider({ + capabilities: { supportsPersistentResume: true }, + resumeSandbox: vi.fn(async () => ({ success: true as const, lifetime: noLifetime() })), + }); + const mcpServerLookup = { getDecryptedForSession: vi.fn(async () => []) }; + const slackAgentNotifyLookup = { isEnabledForRepo: vi.fn(async () => true) }; + const imageBuildLookup = { + getLatestReady: vi.fn(async () => null), + markRestoreFailed: vi.fn(async () => true), + }; + const manager = createTestLifecycleManager( + provider, + storage, + storage, + createMockBroadcaster(), + createMockWebSocketManager(), + createMockAlarmScheduler(), + createMockIdGenerator(), + createUnmanagedShutdown(), + { ...createTestConfig(), mcpServerLookup, slackAgentNotifyLookup }, + imageBuildLookup + ); + + await manager.spawnSandbox(); + + expect(vi.mocked(provider.resumeSandbox!).mock.calls).toStrictEqual([ + [ + { + providerObjectId: "modal-obj-123", + sessionId: "test-session", + sandboxId: "sandbox-testowner-testrepo-123", + timeoutSeconds: 3600, + codeServerEnabled: false, + vncEnabled: false, + sandboxSettings: { sandboxTimeoutMs: 3_600_000 }, + }, + ], + ]); + for (const dependency of [ + hashToken, + storage.getUserEnvVars, + storage.getSessionRepositories, + mcpServerLookup.getDecryptedForSession, + slackAgentNotifyLookup.isEnabledForRepo, + imageBuildLookup.getLatestReady, + imageBuildLookup.markRestoreFailed, + provider.createSandbox, + provider.restoreFromSnapshot, + ]) { + expect(dependency).not.toHaveBeenCalled(); + } + }); +}); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts new file mode 100644 index 0000000000..2054cc8ce7 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts @@ -0,0 +1,846 @@ +import { describe, it, expect, vi } from "vitest"; +import { DEFAULT_LIFECYCLE_CONFIG, type SandboxShutdownLifecycle } from "./manager"; +import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; +import { MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION } from "../runtime-manifest"; +import type { SandboxProvider, ResumeResult } from "../provider"; +import { mintJwt } from "../../auth/jwt"; +import { + createMockSession, + createMockSandbox, + createMockStorage, + createMockBroadcaster, + createMockWebSocketManager, + createMockAlarmScheduler, + createMockIdGenerator, + createMockProvider, + createTestConfig, + createTestLifecycleManager, + createUnmanagedShutdown, + noLifetime, +} from "./test-helpers"; +import { SandboxShutdownCoordinator } from "../../session/sandbox-shutdown"; +import type { ShutdownRecord } from "../../session/sandbox-shutdown-repository"; + +function parseStructuredLogs(spy: ReturnType): Array> { + return spy.mock.calls.map( + (call: unknown[]) => JSON.parse(String(call[0])) as Record + ); +} + +describe("final graceful shutdown lifecycle integration", () => { + function fixture( + provider = createMockProvider(), + sandbox = createMockSandbox({ status: "stopped" }), + session = createMockSession() + ) { + const storage = createMockStorage(session, sandbox); + const sockets = createMockWebSocketManager(); + const broadcaster = createMockBroadcaster(); + const shutdown = { + ...createUnmanagedShutdown(), + requestShutdown: vi.fn(async () => "owned"), + }; + const manager = createTestLifecycleManager( + provider, + storage, + storage, + broadcaster, + sockets, + createMockAlarmScheduler(), + createMockIdGenerator(), + shutdown, + createTestConfig() + ); + return { manager, shutdown, storage, provider, sockets, broadcaster }; + } + + function withSavedState(f: ReturnType, kind: "snapshot" | "retained") { + const row = f.storage.getSandbox()!; + let state: ShutdownRecord = { + phase: "saved", + generation: { sandboxId: row.modal_sandbox_id!, createdAt: row.created_at! }, + provider: f.provider.name, + providerObjectId: row.modal_object_id, + lifetimeKind: "none", + expiresAtMs: null, + drainAtMs: null, + generationReady: true, + receipt: { + kind, + provider: f.provider.name, + artifactId: kind === "snapshot" ? "saved-image" : row.modal_object_id!, + runtimeVersion: COMPATIBLE_RUNTIME_VERSION, + savedAtMs: Date.now(), + }, + }; + const shutdown = new SandboxShutdownCoordinator({ + store: { + read: () => structuredClone(state), + write: (next: ShutdownRecord) => { + state = structuredClone(next); + }, + }, + provider: f.provider, + sandbox: f.storage, + session: { + getSession: () => f.storage.getSession(), + transaction: (operation: () => T): T => operation(), + }, + messenger: f.broadcaster, + sockets: { getSandboxSocket: () => null }, + alarm: createMockAlarmScheduler(), + background: { submit: vi.fn() }, + retireAccess: vi.fn(), + } as never); + f.manager = createTestLifecycleManager( + f.provider, + f.storage, + f.storage, + f.broadcaster, + f.sockets, + createMockAlarmScheduler(), + createMockIdGenerator(), + shutdown, + createTestConfig() + ); + return { shutdown, read: () => state }; + } + + describe.each(["restore", "resume"] as const)("committed saved %s", (kind) => { + it.each([ + ["access", "connecting"], + ["access", "ready"], + ["publication", "connecting"], + ["publication", "ready"], + ] as const)( + "retains %s failures on a %s sandbox without a recovery hold", + async (failure, status) => { + const sandbox = createMockSandbox({ + status: "stopped", + ttyd_token: await mintJwt({ exp: Math.floor(Date.now() / 1000) + 60 }, "saved-auth-key"), + }); + const result = { + providerObjectId: "committed-source", + lifetime: noLifetime(), + codeServerUrl: "https://code.test/committed", + codeServerPassword: "committed-code-secret", + vncAccess: { url: "https://vnc.test/committed", password: "committed-vnc-secret" }, + tunnelUrls: { "3000": "https://preview.test/committed" }, + ttydUrl: "https://terminal.test/committed", + }; + const provider = createMockProvider({ + capabilities: { supportsPersistentResume: kind === "resume" }, + restoreFromSnapshot: vi.fn(async (config) => { + if (status === "ready") sandbox.status = "ready"; + return { success: true as const, sandboxId: config.sandboxId, ...result }; + }), + resumeSandbox: vi.fn(async () => { + if (status === "ready") sandbox.status = "ready"; + return { success: true as const, ...result }; + }), + stopSandbox: vi.fn(async () => ({ success: true })), + }); + const f = fixture( + provider, + sandbox, + createMockSession({ + code_server_enabled: 1, + vnc_enabled: 1, + sandbox_settings: JSON.stringify({ terminalEnabled: true }), + }) + ); + vi.mocked(f.sockets.getSandboxWebSocket).mockImplementation(() => + sandbox.status === "ready" ? ({} as WebSocket) : null + ); + const saved = withSavedState(f, kind === "restore" ? "snapshot" : "retained"); + const holdFailedRecovery = vi.spyOn(saved.shutdown, "holdFailedRecovery"); + const recordProviderStartup = vi.spyOn(saved.shutdown, "recordProviderStartup"); + const error = new Error(`committed ${failure} failed`); + let rejectAccess: ((error: Error) => void) | undefined; + if (failure === "access") { + const write = new Promise((_resolve, reject) => { + rejectAccess = reject; + }); + if (kind === "restore") { + const persist = vi.mocked(f.storage.updateSandboxAccess).getMockImplementation()!; + vi.mocked(f.storage.updateSandboxAccess).mockImplementation( + async (artifact, url, secret) => { + if (artifact === "ttyd") return write; + await persist(artifact, url, secret); + } + ); + } else { + vi.mocked(f.storage.completeProviderResume).mockReturnValueOnce(write); + } + } else { + const broadcast = vi.mocked(f.broadcaster.broadcast).getMockImplementation()!; + vi.mocked(f.broadcaster.broadcast).mockImplementation((message) => { + if (message.type === "sandbox_access_changed" && saved.read().phase === "running") + throw error; + broadcast(message); + }); + } + const info = vi.spyOn(console, "log").mockImplementation(() => {}); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const spawning = f.manager.spawnSandbox(); + try { + if (failure === "access") { + await vi.waitFor(() => { + if (kind === "restore") + expect(f.storage.updateSandboxAccess).toHaveBeenCalledWith( + "ttyd", + result.ttydUrl, + expect.any(String) + ); + else expect(f.storage.completeProviderResume).toHaveBeenCalledOnce(); + }); + expect(f.manager.isSpawning()).toBe(true); + expect(sandbox.status).toBe(status); + expect(saved.read().phase).toBe(kind === "restore" ? "running" : "restoring"); + expect(holdFailedRecovery).not.toHaveBeenCalled(); + rejectAccess!(error); + } + await expect(spawning).resolves.toBeUndefined(); + + expect(sandbox.status).toBe(status); + expect(sandbox.modal_object_id).toBe(result.providerObjectId); + expect(saved.read()).toMatchObject({ + phase: "running", + sourceRetired: false, + providerObjectId: result.providerObjectId, + generation: { sandboxId: sandbox.modal_sandbox_id, createdAt: sandbox.created_at }, + receipt: { kind: kind === "restore" ? "snapshot" : "retained" }, + }); + expect(saved.shutdown.isHolding()).toBe(false); + expect(recordProviderStartup).toHaveBeenCalledExactlyOnceWith( + { sandboxId: sandbox.modal_sandbox_id, createdAt: sandbox.created_at }, + result.lifetime + ); + expect(holdFailedRecovery).not.toHaveBeenCalled(); + expect(f.storage.incrementCircuitBreakerFailure).not.toHaveBeenCalled(); + expect(f.storage.setLastSpawnError).toHaveBeenCalledExactlyOnceWith(null, null); + expect(f.storage.transitionSandboxStatus).not.toHaveBeenCalled(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + expect(f.provider.stopSandbox).not.toHaveBeenCalled(); + expect(f.broadcaster.messages).not.toContainEqual({ + type: "sandbox_status", + status: "failed", + }); + expect(f.broadcaster.messages).not.toContainEqual({ + type: "sandbox_error", + error: error.message, + }); + expect(f.broadcaster.messages).not.toContainEqual({ + type: "sandbox_restored", + message: "Session restored from snapshot", + }); + expect(parseStructuredLogs(info)).not.toContainEqual( + expect.objectContaining({ event: "sandbox.restore" }) + ); + expect(errors).not.toHaveBeenCalled(); + expect(parseStructuredLogs(warn)).toContainEqual( + expect.objectContaining({ + event: "sandbox.recovery_access_failed", + msg: + kind === "restore" + ? "Restored sandbox access/publication failed" + : "Resumed sandbox access/publication failed", + }) + ); + expect(f.manager.isSpawning()).toBe(false); + expect(f.manager.isProviderStartupPending()).toBe(false); + } finally { + rejectAccess?.(error); + await spawning; + info.mockRestore(); + errors.mockRestore(); + warn.mockRestore(); + holdFailedRecovery.mockRestore(); + recordProviderStartup.mockRestore(); + } + } + ); + }); + + it("holds saved resume when its terminal secret read fails before startup is committed", async () => { + const f = fixture( + createMockProvider({ + capabilities: { supportsPersistentResume: true }, + resumeSandbox: vi.fn(async () => ({ + success: true as const, + lifetime: noLifetime(), + ttydUrl: "https://terminal.test/resumed", + })), + }), + createMockSandbox({ status: "stopped", modal_object_id: "retained-source" }), + createMockSession({ sandbox_settings: JSON.stringify({ terminalEnabled: true }) }) + ); + const saved = withSavedState(f, "retained"); + const holdFailedRecovery = vi.spyOn(saved.shutdown, "holdFailedRecovery"); + const recordProviderStartup = vi.spyOn(saved.shutdown, "recordProviderStartup"); + vi.mocked(f.storage.getSandboxAccessSecret).mockRejectedValue(new Error("secret read failed")); + try { + await expect(f.manager.spawnSandbox()).resolves.toBeUndefined(); + + expect(f.provider.resumeSandbox).toHaveBeenCalledOnce(); + expect(f.storage.completeProviderResume).not.toHaveBeenCalled(); + expect(recordProviderStartup).not.toHaveBeenCalled(); + expect(holdFailedRecovery).toHaveBeenCalledExactlyOnceWith("secret read failed", { + sandboxId: f.storage.getSandbox()!.modal_sandbox_id, + createdAt: f.storage.getSandbox()!.created_at, + }); + expect(f.storage.getSandbox()).toMatchObject({ + status: "failed", + modal_object_id: "retained-source", + last_spawn_error: "secret read failed", + }); + expect(saved.read()).toMatchObject({ + phase: "unknown", + sourceRetired: false, + receipt: { kind: "retained", artifactId: "retained-source" }, + }); + expect(saved.shutdown.isHolding()).toBe(true); + expect(f.storage.incrementCircuitBreakerFailure).not.toHaveBeenCalled(); + await f.manager.spawnSandbox(); + expect(f.provider.resumeSandbox).toHaveBeenCalledOnce(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + } finally { + holdFailedRecovery.mockRestore(); + recordProviderStartup.mockRestore(); + } + }); + + it("allows explicit saved-state retry after restore preflight fails without provider I/O", async () => { + const f = fixture(); + const saved = withSavedState(f, "snapshot"); + vi.mocked(f.storage.getUserEnvVars).mockRejectedValueOnce( + new Error("temporary secrets failure") + ); + + await f.manager.spawnSandbox(); + + expect(f.provider.restoreFromSnapshot).not.toHaveBeenCalled(); + expect(saved.read()).toMatchObject({ phase: "unknown", sourceRetired: true }); + await f.manager.spawnSandbox(); + expect(f.provider.restoreFromSnapshot).not.toHaveBeenCalled(); + + await saved.shutdown.recover("restore_saved"); + expect(saved.read().phase).toBe("saved"); + await f.manager.spawnSandbox(); + expect(f.provider.restoreFromSnapshot).toHaveBeenCalledOnce(); + expect(saved.read()).toMatchObject({ phase: "running", sourceRetired: false }); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it("retires an ambiguously resumed retained object before explicitly retrying it", async () => { + const resumeSandbox = vi + .fn>() + .mockRejectedValueOnce(new Error("provider response lost")) + .mockResolvedValue({ success: true, lifetime: { kind: "none", observedAtMs: Date.now() } }); + const stopSandbox = vi.fn(async () => ({ success: true })); + const f = fixture( + createMockProvider({ + resumeSandbox, + stopSandbox, + capabilities: { supportsPersistentResume: true, supportsExplicitStop: true }, + }) + ); + const saved = withSavedState(f, "retained"); + + await f.manager.spawnSandbox(); + expect(saved.read()).toMatchObject({ + phase: "unknown", + sourceRetired: false, + providerObjectId: "modal-obj-123", + }); + await f.manager.spawnSandbox(); + expect(resumeSandbox).toHaveBeenCalledOnce(); + + await saved.shutdown.recover("restore_saved"); + expect(stopSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + providerObjectId: "modal-obj-123", + intent: "preserve", + }) + ); + expect(saved.read()).toMatchObject({ phase: "saved", sourceRetired: true }); + await f.manager.spawnSandbox(); + expect(resumeSandbox).toHaveBeenCalledTimes(2); + expect(saved.read()).toMatchObject({ phase: "running", sourceRetired: false }); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it.each(["connect timeout", "fatal runtime error", "boot budget"] as const)( + "holds a resumed retained source after a %s instead of deleting it", + async (failure) => { + vi.useFakeTimers(); + try { + const resumeSandbox = vi.fn(async () => ({ + success: true as const, + providerObjectId: "retained-source", + lifetime: noLifetime(), + ttydUrl: "https://terminal.test/resumed", + })); + const stopSandbox = vi.fn(async () => ({ success: true })); + const f = fixture( + createMockProvider({ + resumeSandbox, + stopSandbox, + capabilities: { supportsPersistentResume: true, supportsExplicitStop: true }, + }), + createMockSandbox({ + status: "stopped", + modal_object_id: "retained-source", + // The repository clears this on resume; the mock does not. + last_heartbeat: null, + ttyd_token: await mintJwt( + { exp: Math.floor(Date.now() / 1000) - 1 }, + "sandbox-auth-token" + ), + }), + createMockSession({ sandbox_settings: JSON.stringify({ terminalEnabled: true }) }) + ); + const saved = withSavedState(f, "retained"); + + await f.manager.spawnSandbox(); + expect(saved.read().phase).toBe("running"); + const row = f.storage.getSandbox()!; + if (failure === "fatal runtime error") { + row.last_heartbeat = Date.now(); + expect(await f.manager.terminateFailedSandbox("runtime failed")).toBe(false); + } else { + vi.advanceTimersByTime( + failure === "connect timeout" + ? DEFAULT_LIFECYCLE_CONFIG.connectingTimeout.timeoutMs + 1 + : DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs + 1 + ); + if (failure === "boot budget") row.last_heartbeat = Date.now(); + expect(await f.manager.handleShutdownAlarm()).toBe("continue"); + await f.manager.handleAlarm(); + } + + // Neither deleted nor fenced: the source is the only copy of the workspace. + expect(stopSandbox).not.toHaveBeenCalled(); + expect(row).toMatchObject({ + status: "failed", + modal_object_id: "retained-source", + fenced: 0, + }); + expect(saved.read()).toMatchObject({ + phase: "unknown", + receipt: { kind: "retained", artifactId: "retained-source" }, + }); + await f.manager.spawnSandbox(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + + await saved.shutdown.recover("restore_saved"); + expect(stopSandbox).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ providerObjectId: "retained-source", intent: "preserve" }) + ); + await f.manager.spawnSandbox(); + expect(resumeSandbox).toHaveBeenCalledTimes(2); + expect(saved.read().phase).toBe("running"); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + } + ); + + it("keeps restore available after a later ordinary resume of the retained source fails mid-resume", async () => { + let finishResume!: (result: ResumeResult) => void; + const resumed = { + success: true as const, + providerObjectId: "retained-source", + lifetime: noLifetime(), + }; + const resumeSandbox = vi + .fn>() + .mockResolvedValueOnce(resumed) + .mockReturnValueOnce(new Promise((resolve) => (finishResume = resolve))) + .mockResolvedValue(resumed); + const stopSandbox = vi.fn(async () => ({ success: true })); + const f = fixture( + createMockProvider({ + resumeSandbox, + stopSandbox, + capabilities: { supportsPersistentResume: true, supportsExplicitStop: true }, + }), + createMockSandbox({ status: "stopped", modal_object_id: "retained-source" }) + ); + const saved = withSavedState(f, "retained"); + await f.manager.spawnSandbox(); + // A heartbeat timeout preserve-stops it; the shutdown record stays running. + const row = f.storage.getSandbox()!; + row.status = "stopped"; + + const ordinaryResume = f.manager.spawnSandbox(); + await vi.waitFor(() => expect(resumeSandbox).toHaveBeenCalledTimes(2)); + row.last_heartbeat = Date.now(); + expect(await f.manager.terminateFailedSandbox("runtime failed")).toBe(false); + finishResume(resumed); + await ordinaryResume; + + expect(stopSandbox).not.toHaveBeenCalled(); + expect(saved.read()).toMatchObject({ phase: "unknown", providerObjectId: "retained-source" }); + expect(saved.shutdown.snapshot()?.availableRecoveryActions).toContain("restore_saved"); + await saved.shutdown.recover("restore_saved"); + expect(stopSandbox).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ providerObjectId: "retained-source", intent: "preserve" }) + ); + await f.manager.spawnSandbox(); + expect(resumeSandbox).toHaveBeenCalledTimes(3); + expect(saved.read().phase).toBe("running"); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it("allows only explicit retry of an ambiguous snapshot restore from a retired source", async () => { + const restoreFromSnapshot = vi + .fn>() + .mockRejectedValueOnce(new Error("provider response lost")) + .mockResolvedValue({ + success: true, + sandboxId: "restored-sandbox", + providerObjectId: "restored-source", + lifetime: { kind: "none", observedAtMs: Date.now() }, + }); + const f = fixture(createMockProvider({ restoreFromSnapshot })); + const saved = withSavedState(f, "snapshot"); + + await f.manager.spawnSandbox(); + expect(saved.read()).toMatchObject({ phase: "unknown", sourceRetired: true }); + await f.manager.spawnSandbox(); + expect(saved.read()).toMatchObject({ + phase: "unknown", + receipt: { artifactId: "saved-image" }, + }); + expect(restoreFromSnapshot).toHaveBeenCalledOnce(); + expect(saved.shutdown.snapshot()).toMatchObject({ + availableRecoveryActions: ["restore_saved"], + discardAvailable: true, + }); + await saved.shutdown.recover("restore_saved"); + await f.manager.spawnSandbox(); + expect(restoreFromSnapshot).toHaveBeenCalledTimes(2); + expect(saved.read()).toMatchObject({ phase: "running", sourceRetired: false }); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it("does not run generic termination or replacement while shutdown owns the source", async () => { + const f = fixture( + createMockProvider({ + stopSandbox: vi.fn(async () => ({ success: true })), + capabilities: { supportsExplicitStop: true }, + }), + createMockSandbox() + ); + f.shutdown.isHolding.mockReturnValue(true); + f.shutdown.startupDecision.mockReturnValue({ + kind: "hold", + reason: "shutdown in progress", + }); + await f.manager.terminateUnresponsiveSandbox("stop_confirmation_timeout"); + expect(await f.manager.terminateFailedSandbox("runtime failed")).toBe(false); + expect(await f.manager.handleAlarm()).toBe("no_action"); + await f.manager.spawnSandbox(); + expect(f.provider.stopSandbox).not.toHaveBeenCalled(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it("saves an archived session's sandbox through graceful shutdown", async () => { + const f = fixture( + createMockProvider({ capabilities: { snapshotRequiresShutdown: true } }), + createMockSandbox({ status: "ready" }) + ); + await f.manager.preserveForArchive(); + expect(f.shutdown.requestShutdown).toHaveBeenCalledExactlyOnceWith("session_archived"); + }); + + it("keeps a sandbox whose snapshots stop it running after a finished turn", async () => { + const sandbox = createMockSandbox({ status: "ready" }); + const f = fixture( + createMockProvider({ capabilities: { snapshotRequiresShutdown: true } }), + sandbox + ); + await f.manager.triggerSnapshot("execution_complete"); + expect(f.shutdown.requestShutdown).not.toHaveBeenCalled(); + expect(f.shutdown.captureCheckpoint).not.toHaveBeenCalled(); + expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); + expect(sandbox.status).toBe("ready"); + }); + + it("routes other destructive snapshots through confirmed graceful shutdown", async () => { + const f = fixture(createMockProvider({ capabilities: { snapshotRequiresShutdown: true } })); + await f.manager.triggerSnapshot("inactivity_timeout"); + expect(f.shutdown.requestShutdown).toHaveBeenCalledWith("inactivity_timeout"); + expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); + }); + + it("does not fall through to a destructive checkpoint when shutdown declines it", async () => { + const sandbox = createMockSandbox({ status: "ready" }); + const f = fixture( + createMockProvider({ capabilities: { snapshotRequiresShutdown: true } }), + sandbox + ); + f.shutdown.requestShutdown.mockResolvedValue("held"); + + await f.manager.triggerSnapshot("inactivity_timeout"); + + expect(f.shutdown.requestShutdown).toHaveBeenCalledWith("inactivity_timeout"); + expect(f.shutdown.captureCheckpoint).not.toHaveBeenCalled(); + expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); + expect(sandbox.status).toBe("ready"); + }); + + it("restores an independent final receipt instead of preferring an expired persistent source", async () => { + const f = fixture( + createMockProvider({ + resumeSandbox: vi.fn(), + capabilities: { supportsPersistentResume: true }, + }) + ); + f.shutdown.startupDecision.mockReturnValue({ + kind: "restore_snapshot", + snapshotId: "final-image", + runtimeVersion: "v62-compatible", + }); + await f.manager.spawnSandbox(); + expect(f.provider.restoreFromSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ snapshotImageId: "final-image" }) + ); + expect(f.provider.resumeSandbox).not.toHaveBeenCalled(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( + expect.any(Number), + "legacy", + expect.any(Function) + ); + }); + + it.each([ + ["v62-compatible", "legacy"], + ["v70-before-shutdown", "legacy"], + [`v${MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION}-confirmed`, "confirmed"], + ] as const)("restores snapshot runtime %s with %s policy", async (runtimeVersion, policy) => { + const f = fixture( + createMockProvider(), + createMockSandbox({ + status: "stopped", + snapshot_image_id: "existing-image", + snapshot_runtime_version: runtimeVersion, + }) + ); + + await f.manager.spawnSandbox(); + + expect(f.provider.restoreFromSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ snapshotImageId: "existing-image" }) + ); + expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( + expect.any(Number), + policy, + expect.any(Function) + ); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it.each([ + [null, "legacy"], + ["v70-before-shutdown", "legacy"], + [`v${MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION}-confirmed`, "confirmed"], + ] as const)("resumes retained runtime %s with %s policy", async (runtimeVersion, policy) => { + const f = fixture( + createMockProvider({ + capabilities: { supportsPersistentResume: true }, + resumeSandbox: vi.fn(async () => ({ success: true as const, lifetime: noLifetime() })), + }), + createMockSandbox({ + status: "stopped", + modal_object_id: "retained-source", + runtime_version: runtimeVersion, + }) + ); + + await f.manager.spawnSandbox(); + + expect(f.provider.resumeSandbox).toHaveBeenCalledWith( + expect.objectContaining({ providerObjectId: "retained-source" }) + ); + expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( + expect.any(Number), + policy, + expect.any(Function) + ); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it("does not silently create a fresh sandbox after retained final resume fails", async () => { + const f = fixture( + createMockProvider({ + resumeSandbox: vi.fn(async () => ({ + success: false as const, + shouldSpawnFresh: true, + error: "missing", + })), + capabilities: { supportsPersistentResume: true }, + }) + ); + f.shutdown.startupDecision.mockReturnValue({ + kind: "resume_retained", + providerObjectId: "retained-source", + runtimeVersion: COMPATIBLE_RUNTIME_VERSION, + }); + await f.manager.spawnSandbox(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledWith( + "missing", + expect.objectContaining({ + createdAt: f.shutdown.reserveStartup.mock.calls[0][0], + }) + ); + }); + + it("does not silently create a fresh sandbox when retained final resume is unsupported", async () => { + const f = fixture(createMockProvider({ capabilities: { supportsPersistentResume: true } })); + f.shutdown.startupDecision.mockReturnValue({ + kind: "resume_retained", + providerObjectId: "retained-source", + runtimeVersion: COMPATIBLE_RUNTIME_VERSION, + }); + + await f.manager.spawnSandbox(); + + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledWith( + expect.stringContaining("cannot resume") + ); + }); + + it("retains incompatible and foreign-provider receipts without fresh fallback", async () => { + const f = fixture(); + f.shutdown.startupDecision.mockReturnValue({ + kind: "hold", + reason: "provider mismatch", + }); + await f.manager.spawnSandbox(); + f.shutdown.startupDecision.mockReturnValue({ + kind: "restore_snapshot", + snapshotId: "final-image", + runtimeVersion: "0.0.0", + }); + await f.manager.spawnSandbox(); + expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledOnce(); + expect(f.provider.restoreFromSnapshot).not.toHaveBeenCalled(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it("publishes the actual provider lifetime for the reserved generation", async () => { + const lifetime = { + kind: "finite" as const, + expiresAtMs: Date.now() + 900_000, + observedAtMs: Date.now(), + source: "provider" as const, + }; + const f = fixture( + createMockProvider({ + createSandbox: vi.fn(async (config) => ({ + sandboxId: config.sandboxId, + providerObjectId: "provider-new", + status: "connecting", + createdAt: Date.now(), + lifetime, + })), + }), + createMockSandbox({ status: "pending" }) + ); + await f.manager.spawnSandbox(); + const generation = f.shutdown.recordProviderStartup.mock.calls[0]?.[0]; + expect(generation).toEqual( + expect.objectContaining({ sandboxId: expect.any(String), createdAt: expect.any(Number) }) + ); + expect(f.shutdown.recordProviderStartup).toHaveBeenCalledWith(generation, lifetime); + }); + + it.each([null, "0.0.0"])( + "resumes retained legacy runtime %s without fresh fallback", + async (runtimeVersion) => { + const f = fixture( + createMockProvider({ + resumeSandbox: vi.fn(async () => ({ + success: true as const, + lifetime: noLifetime(), + })), + }) + ); + f.storage.getSandbox()!.runtime_version = `v${MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION}-different-row`; + f.shutdown.startupDecision.mockReturnValue({ + kind: "resume_retained", + providerObjectId: "retained-source", + runtimeVersion, + }); + await f.manager.spawnSandbox(); + expect(f.provider.resumeSandbox).toHaveBeenCalledWith( + expect.objectContaining({ providerObjectId: "retained-source" }) + ); + expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( + expect.any(Number), + "legacy", + expect.any(Function) + ); + expect(f.storage.getSandbox()!.runtime_version).toBe(runtimeVersion); + expect(f.shutdown.holdFailedRecovery).not.toHaveBeenCalled(); + expect(f.provider.createSandbox).not.toHaveBeenCalled(); + } + ); + + it.each(["returned", "thrown"] as const)( + "holds an ordinary snapshot restore failure when %s instead of retrying ambiguously", + async (failureKind) => { + const restoreFromSnapshot = + failureKind === "returned" + ? vi.fn(async () => ({ success: false as const, error: "ordinary restore failed" })) + : vi.fn(async () => { + throw new Error("ordinary restore failed"); + }); + const f = fixture( + createMockProvider({ restoreFromSnapshot }), + createMockSandbox({ + status: "stopped", + snapshot_image_id: "ordinary-image", + snapshot_runtime_version: COMPATIBLE_RUNTIME_VERSION, + }) + ); + + await f.manager.spawnSandbox(); + + expect(restoreFromSnapshot).toHaveBeenCalled(); + expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledWith( + "ordinary restore failed", + expect.objectContaining({ sandboxId: expect.any(String) }) + ); + } + ); + + it("does not report an ordinary retained resume failure as final graceful shutdown", async () => { + const resumeSandbox = vi.fn(async () => ({ + success: false as const, + error: "ordinary resume failed", + })); + const f = fixture( + createMockProvider({ + resumeSandbox, + capabilities: { supportsPersistentResume: true }, + }), + createMockSandbox({ + status: "stopped", + modal_object_id: "ordinary-retained-source", + }) + ); + + await f.manager.spawnSandbox(); + + expect(resumeSandbox).toHaveBeenCalled(); + expect(f.shutdown.holdFailedRecovery).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts index 4efa2eabc0..7b7dd9acba 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts @@ -10,11 +10,11 @@ import { DEFAULT_LIFECYCLE_CONFIG, type AlarmScheduler, type SandboxShutdownLifecycle, - type McpServerLookup, - type ImageBuildLookup, - type SlackAgentNotifyLookup, } from "./manager"; -import type { ImageBuildSpawnRow } from "./image-selection"; +import type { McpServerLookup, SlackAgentNotifyLookup } from "./launch-context"; +import type { ImageBuildLookup, ImageBuildSpawnRow } from "./image-selection"; +import { SandboxAccess, TERMINAL_TOKEN_TTL_SECONDS } from "./sandbox-access"; +import { createLogger } from "../../logger"; import { computeRepositoriesFingerprint } from "../../image-builds/fingerprint"; import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; import { MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION } from "../runtime-manifest"; @@ -47,12 +47,11 @@ import { createMockIdGenerator, createMockProvider, createTestConfig, + createTestLifecycleManager, createUnmanagedShutdown, createCheckpointShutdown, noLifetime, } from "./test-helpers"; -import { SandboxShutdownCoordinator } from "../../session/sandbox-shutdown"; -import type { ShutdownRecord } from "../../session/sandbox-shutdown-repository"; // Gate for the #1589 admission-race suite: hashToken passes through to the // real implementation, but a test can hold the next call open to keep the @@ -153,7 +152,7 @@ async function expectEarlyBridgeStartup(kind: ProviderStartupKind): Promise { - function fixture( - provider = createMockProvider(), - sandbox = createMockSandbox({ status: "stopped" }), - session = createMockSession() - ) { - const storage = createMockStorage(session, sandbox); - const sockets = createMockWebSocketManager(); - const shutdown = { - ...createUnmanagedShutdown(), - requestShutdown: vi.fn(async () => "owned"), - }; - const manager = new SandboxLifecycleManager( - provider, - storage, - storage, - createMockBroadcaster(), - sockets, - createMockAlarmScheduler(), - createMockIdGenerator(), - shutdown, - createTestConfig() - ); - return { manager, shutdown, storage, provider, sockets }; - } - - function withSavedState(f: ReturnType, kind: "snapshot" | "retained") { - const row = f.storage.getSandbox()!; - let state: ShutdownRecord = { - phase: "saved", - generation: { sandboxId: row.modal_sandbox_id!, createdAt: row.created_at! }, - provider: f.provider.name, - providerObjectId: row.modal_object_id, - lifetimeKind: "none", - expiresAtMs: null, - drainAtMs: null, - generationReady: true, - receipt: { - kind, - provider: f.provider.name, - artifactId: kind === "snapshot" ? "saved-image" : row.modal_object_id!, - runtimeVersion: COMPATIBLE_RUNTIME_VERSION, - savedAtMs: Date.now(), - }, - }; - const shutdown = new SandboxShutdownCoordinator({ - store: { - read: () => structuredClone(state), - write: (next: ShutdownRecord) => { - state = structuredClone(next); - }, - }, - provider: f.provider, - sandbox: f.storage, - session: { - getSession: () => f.storage.getSession(), - transaction: (operation: () => T): T => operation(), - }, - messenger: createMockBroadcaster(), - sockets: { getSandboxSocket: () => null }, - alarm: createMockAlarmScheduler(), - background: { submit: vi.fn() }, - retireAccess: vi.fn(), - } as never); - f.manager = new SandboxLifecycleManager( - f.provider, - f.storage, - f.storage, - createMockBroadcaster(), - f.sockets, - createMockAlarmScheduler(), - createMockIdGenerator(), - shutdown, - createTestConfig() - ); - return { shutdown, read: () => state }; - } - - it("allows explicit saved-state retry after restore preflight fails without provider I/O", async () => { - const f = fixture(); - const saved = withSavedState(f, "snapshot"); - vi.mocked(f.storage.getUserEnvVars).mockRejectedValueOnce( - new Error("temporary secrets failure") - ); - - await f.manager.spawnSandbox(); - - expect(f.provider.restoreFromSnapshot).not.toHaveBeenCalled(); - expect(saved.read()).toMatchObject({ phase: "unknown", sourceRetired: true }); - await f.manager.spawnSandbox(); - expect(f.provider.restoreFromSnapshot).not.toHaveBeenCalled(); - - await saved.shutdown.recover("restore_saved"); - expect(saved.read().phase).toBe("saved"); - await f.manager.spawnSandbox(); - expect(f.provider.restoreFromSnapshot).toHaveBeenCalledOnce(); - expect(saved.read()).toMatchObject({ phase: "running", sourceRetired: false }); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it("retires an ambiguously resumed retained object before explicitly retrying it", async () => { - const resumeSandbox = vi - .fn>() - .mockRejectedValueOnce(new Error("provider response lost")) - .mockResolvedValue({ success: true, lifetime: { kind: "none", observedAtMs: Date.now() } }); - const stopSandbox = vi.fn(async () => ({ success: true })); - const f = fixture( - createMockProvider({ - resumeSandbox, - stopSandbox, - capabilities: { supportsPersistentResume: true, supportsExplicitStop: true }, - }) - ); - const saved = withSavedState(f, "retained"); - - await f.manager.spawnSandbox(); - expect(saved.read()).toMatchObject({ - phase: "unknown", - sourceRetired: false, - providerObjectId: "modal-obj-123", - }); - await f.manager.spawnSandbox(); - expect(resumeSandbox).toHaveBeenCalledOnce(); - - await saved.shutdown.recover("restore_saved"); - expect(stopSandbox).toHaveBeenCalledWith( - expect.objectContaining({ - providerObjectId: "modal-obj-123", - intent: "preserve", - }) - ); - expect(saved.read()).toMatchObject({ phase: "saved", sourceRetired: true }); - await f.manager.spawnSandbox(); - expect(resumeSandbox).toHaveBeenCalledTimes(2); - expect(saved.read()).toMatchObject({ phase: "running", sourceRetired: false }); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it.each(["connect timeout", "fatal runtime error", "boot budget"] as const)( - "holds a resumed retained source after a %s instead of deleting it", - async (failure) => { - vi.useFakeTimers(); - try { - const resumeSandbox = vi.fn(async () => ({ - success: true as const, - providerObjectId: "retained-source", - lifetime: noLifetime(), - ttydUrl: "https://terminal.test/resumed", - })); - const stopSandbox = vi.fn(async () => ({ success: true })); - const f = fixture( - createMockProvider({ - resumeSandbox, - stopSandbox, - capabilities: { supportsPersistentResume: true, supportsExplicitStop: true }, - }), - createMockSandbox({ - status: "stopped", - modal_object_id: "retained-source", - // The repository clears this on resume; the mock does not. - last_heartbeat: null, - ttyd_token: await mintJwt( - { exp: Math.floor(Date.now() / 1000) - 1 }, - "sandbox-auth-token" - ), - }), - createMockSession({ sandbox_settings: JSON.stringify({ terminalEnabled: true }) }) - ); - const saved = withSavedState(f, "retained"); - - await f.manager.spawnSandbox(); - expect(saved.read().phase).toBe("running"); - const row = f.storage.getSandbox()!; - if (failure === "fatal runtime error") { - row.last_heartbeat = Date.now(); - expect(await f.manager.terminateFailedSandbox("runtime failed")).toBe(false); - } else { - vi.advanceTimersByTime( - failure === "connect timeout" - ? DEFAULT_LIFECYCLE_CONFIG.connectingTimeout.timeoutMs + 1 - : DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs + 1 - ); - if (failure === "boot budget") row.last_heartbeat = Date.now(); - expect(await f.manager.handleShutdownAlarm()).toBe("continue"); - await f.manager.handleAlarm(); - } - - // Neither deleted nor fenced: the source is the only copy of the workspace. - expect(stopSandbox).not.toHaveBeenCalled(); - expect(row).toMatchObject({ - status: "failed", - modal_object_id: "retained-source", - fenced: 0, - }); - expect(saved.read()).toMatchObject({ - phase: "unknown", - receipt: { kind: "retained", artifactId: "retained-source" }, - }); - await f.manager.spawnSandbox(); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - - await saved.shutdown.recover("restore_saved"); - expect(stopSandbox).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ providerObjectId: "retained-source", intent: "preserve" }) - ); - await f.manager.spawnSandbox(); - expect(resumeSandbox).toHaveBeenCalledTimes(2); - expect(saved.read().phase).toBe("running"); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - } - ); - - it("keeps restore available after a later ordinary resume of the retained source fails mid-resume", async () => { - let finishResume!: (result: ResumeResult) => void; - const resumed = { - success: true as const, - providerObjectId: "retained-source", - lifetime: noLifetime(), - }; - const resumeSandbox = vi - .fn>() - .mockResolvedValueOnce(resumed) - .mockReturnValueOnce(new Promise((resolve) => (finishResume = resolve))) - .mockResolvedValue(resumed); - const stopSandbox = vi.fn(async () => ({ success: true })); - const f = fixture( - createMockProvider({ - resumeSandbox, - stopSandbox, - capabilities: { supportsPersistentResume: true, supportsExplicitStop: true }, - }), - createMockSandbox({ status: "stopped", modal_object_id: "retained-source" }) - ); - const saved = withSavedState(f, "retained"); - await f.manager.spawnSandbox(); - // A heartbeat timeout preserve-stops it; the shutdown record stays running. - const row = f.storage.getSandbox()!; - row.status = "stopped"; - - const ordinaryResume = f.manager.spawnSandbox(); - await vi.waitFor(() => expect(resumeSandbox).toHaveBeenCalledTimes(2)); - row.last_heartbeat = Date.now(); - expect(await f.manager.terminateFailedSandbox("runtime failed")).toBe(false); - finishResume(resumed); - await ordinaryResume; - - expect(stopSandbox).not.toHaveBeenCalled(); - expect(saved.read()).toMatchObject({ phase: "unknown", providerObjectId: "retained-source" }); - expect(saved.shutdown.snapshot()?.availableRecoveryActions).toContain("restore_saved"); - await saved.shutdown.recover("restore_saved"); - expect(stopSandbox).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ providerObjectId: "retained-source", intent: "preserve" }) - ); - await f.manager.spawnSandbox(); - expect(resumeSandbox).toHaveBeenCalledTimes(3); - expect(saved.read().phase).toBe("running"); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it("allows only explicit retry of an ambiguous snapshot restore from a retired source", async () => { - const restoreFromSnapshot = vi - .fn>() - .mockRejectedValueOnce(new Error("provider response lost")) - .mockResolvedValue({ - success: true, - sandboxId: "restored-sandbox", - providerObjectId: "restored-source", - lifetime: { kind: "none", observedAtMs: Date.now() }, - }); - const f = fixture(createMockProvider({ restoreFromSnapshot })); - const saved = withSavedState(f, "snapshot"); - - await f.manager.spawnSandbox(); - expect(saved.read()).toMatchObject({ phase: "unknown", sourceRetired: true }); - await f.manager.spawnSandbox(); - expect(saved.read()).toMatchObject({ - phase: "unknown", - receipt: { artifactId: "saved-image" }, - }); - expect(restoreFromSnapshot).toHaveBeenCalledOnce(); - expect(saved.shutdown.snapshot()).toMatchObject({ - availableRecoveryActions: ["restore_saved"], - discardAvailable: true, - }); - await saved.shutdown.recover("restore_saved"); - await f.manager.spawnSandbox(); - expect(restoreFromSnapshot).toHaveBeenCalledTimes(2); - expect(saved.read()).toMatchObject({ phase: "running", sourceRetired: false }); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it("does not run generic termination or replacement while shutdown owns the source", async () => { - const f = fixture( - createMockProvider({ - stopSandbox: vi.fn(async () => ({ success: true })), - capabilities: { supportsExplicitStop: true }, - }), - createMockSandbox() - ); - f.shutdown.isHolding.mockReturnValue(true); - f.shutdown.startupDecision.mockReturnValue({ - kind: "hold", - reason: "shutdown in progress", - }); - await f.manager.terminateUnresponsiveSandbox("stop_confirmation_timeout"); - expect(await f.manager.terminateFailedSandbox("runtime failed")).toBe(false); - expect(await f.manager.handleAlarm()).toBe("no_action"); - await f.manager.spawnSandbox(); - expect(f.provider.stopSandbox).not.toHaveBeenCalled(); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it("saves an archived session's sandbox through graceful shutdown", async () => { - const f = fixture( - createMockProvider({ capabilities: { snapshotRequiresShutdown: true } }), - createMockSandbox({ status: "ready" }) - ); - await f.manager.preserveForArchive(); - expect(f.shutdown.requestShutdown).toHaveBeenCalledExactlyOnceWith("session_archived"); - }); - - it("keeps a sandbox whose snapshots stop it running after a finished turn", async () => { - const sandbox = createMockSandbox({ status: "ready" }); - const f = fixture( - createMockProvider({ capabilities: { snapshotRequiresShutdown: true } }), - sandbox - ); - await f.manager.triggerSnapshot("execution_complete"); - expect(f.shutdown.requestShutdown).not.toHaveBeenCalled(); - expect(f.shutdown.captureCheckpoint).not.toHaveBeenCalled(); - expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); - expect(sandbox.status).toBe("ready"); - }); - - it("routes other destructive snapshots through confirmed graceful shutdown", async () => { - const f = fixture(createMockProvider({ capabilities: { snapshotRequiresShutdown: true } })); - await f.manager.triggerSnapshot("inactivity_timeout"); - expect(f.shutdown.requestShutdown).toHaveBeenCalledWith("inactivity_timeout"); - expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); - }); - - it("does not fall through to a destructive checkpoint when shutdown declines it", async () => { - const sandbox = createMockSandbox({ status: "ready" }); - const f = fixture( - createMockProvider({ capabilities: { snapshotRequiresShutdown: true } }), - sandbox - ); - f.shutdown.requestShutdown.mockResolvedValue("held"); - - await f.manager.triggerSnapshot("inactivity_timeout"); - - expect(f.shutdown.requestShutdown).toHaveBeenCalledWith("inactivity_timeout"); - expect(f.shutdown.captureCheckpoint).not.toHaveBeenCalled(); - expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); - expect(sandbox.status).toBe("ready"); - }); - - it("restores an independent final receipt instead of preferring an expired persistent source", async () => { - const f = fixture( - createMockProvider({ - resumeSandbox: vi.fn(), - capabilities: { supportsPersistentResume: true }, - }) - ); - f.shutdown.startupDecision.mockReturnValue({ - kind: "restore_snapshot", - snapshotId: "final-image", - runtimeVersion: "v62-compatible", - }); - await f.manager.spawnSandbox(); - expect(f.provider.restoreFromSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ snapshotImageId: "final-image" }) - ); - expect(f.provider.resumeSandbox).not.toHaveBeenCalled(); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( - expect.any(Number), - "legacy", - expect.any(Function) - ); - }); - - it.each([ - ["v62-compatible", "legacy"], - ["v70-before-shutdown", "legacy"], - [`v${MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION}-confirmed`, "confirmed"], - ] as const)("restores snapshot runtime %s with %s policy", async (runtimeVersion, policy) => { - const f = fixture( - createMockProvider(), - createMockSandbox({ - status: "stopped", - snapshot_image_id: "existing-image", - snapshot_runtime_version: runtimeVersion, - }) - ); - - await f.manager.spawnSandbox(); - - expect(f.provider.restoreFromSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ snapshotImageId: "existing-image" }) - ); - expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( - expect.any(Number), - policy, - expect.any(Function) - ); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it.each([ - [null, "legacy"], - ["v70-before-shutdown", "legacy"], - [`v${MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION}-confirmed`, "confirmed"], - ] as const)("resumes retained runtime %s with %s policy", async (runtimeVersion, policy) => { - const f = fixture( - createMockProvider({ - capabilities: { supportsPersistentResume: true }, - resumeSandbox: vi.fn(async () => ({ success: true as const, lifetime: noLifetime() })), - }), - createMockSandbox({ - status: "stopped", - modal_object_id: "retained-source", - runtime_version: runtimeVersion, - }) - ); - - await f.manager.spawnSandbox(); - - expect(f.provider.resumeSandbox).toHaveBeenCalledWith( - expect.objectContaining({ providerObjectId: "retained-source" }) - ); - expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( - expect.any(Number), - policy, - expect.any(Function) - ); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it("does not silently create a fresh sandbox after retained final resume fails", async () => { - const f = fixture( - createMockProvider({ - resumeSandbox: vi.fn(async () => ({ - success: false as const, - shouldSpawnFresh: true, - error: "missing", - })), - capabilities: { supportsPersistentResume: true }, - }) - ); - f.shutdown.startupDecision.mockReturnValue({ - kind: "resume_retained", - providerObjectId: "retained-source", - runtimeVersion: COMPATIBLE_RUNTIME_VERSION, - }); - await f.manager.spawnSandbox(); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledWith( - "missing", - expect.objectContaining({ - createdAt: f.shutdown.reserveStartup.mock.calls[0][0], - }) - ); - }); - - it("does not silently create a fresh sandbox when retained final resume is unsupported", async () => { - const f = fixture(createMockProvider({ capabilities: { supportsPersistentResume: true } })); - f.shutdown.startupDecision.mockReturnValue({ - kind: "resume_retained", - providerObjectId: "retained-source", - runtimeVersion: COMPATIBLE_RUNTIME_VERSION, - }); - - await f.manager.spawnSandbox(); - - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledWith( - expect.stringContaining("cannot resume") - ); - }); - - it("retains incompatible and foreign-provider receipts without fresh fallback", async () => { - const f = fixture(); - f.shutdown.startupDecision.mockReturnValue({ - kind: "hold", - reason: "provider mismatch", - }); - await f.manager.spawnSandbox(); - f.shutdown.startupDecision.mockReturnValue({ - kind: "restore_snapshot", - snapshotId: "final-image", - runtimeVersion: "0.0.0", - }); - await f.manager.spawnSandbox(); - expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledOnce(); - expect(f.provider.restoreFromSnapshot).not.toHaveBeenCalled(); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - }); - - it("publishes the actual provider lifetime for the reserved generation", async () => { - const lifetime = { - kind: "finite" as const, - expiresAtMs: Date.now() + 900_000, - observedAtMs: Date.now(), - source: "provider" as const, - }; - const f = fixture( - createMockProvider({ - createSandbox: vi.fn(async (config) => ({ - sandboxId: config.sandboxId, - providerObjectId: "provider-new", - status: "connecting", - createdAt: Date.now(), - lifetime, - })), - }), - createMockSandbox({ status: "pending" }) - ); - await f.manager.spawnSandbox(); - const generation = f.shutdown.recordProviderStartup.mock.calls[0]?.[0]; - expect(generation).toEqual( - expect.objectContaining({ sandboxId: expect.any(String), createdAt: expect.any(Number) }) - ); - expect(f.shutdown.recordProviderStartup).toHaveBeenCalledWith(generation, lifetime); - }); - - it.each([null, "0.0.0"])( - "resumes retained legacy runtime %s without fresh fallback", - async (runtimeVersion) => { - const f = fixture( - createMockProvider({ - resumeSandbox: vi.fn(async () => ({ - success: true as const, - lifetime: noLifetime(), - })), - }) - ); - f.storage.getSandbox()!.runtime_version = `v${MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION}-different-row`; - f.shutdown.startupDecision.mockReturnValue({ - kind: "resume_retained", - providerObjectId: "retained-source", - runtimeVersion, - }); - await f.manager.spawnSandbox(); - expect(f.provider.resumeSandbox).toHaveBeenCalledWith( - expect.objectContaining({ providerObjectId: "retained-source" }) - ); - expect(f.shutdown.reserveStartup).toHaveBeenCalledWith( - expect.any(Number), - "legacy", - expect.any(Function) - ); - expect(f.storage.getSandbox()!.runtime_version).toBe(runtimeVersion); - expect(f.shutdown.holdFailedRecovery).not.toHaveBeenCalled(); - expect(f.provider.createSandbox).not.toHaveBeenCalled(); - } - ); - - it.each(["returned", "thrown"] as const)( - "holds an ordinary snapshot restore failure when %s instead of retrying ambiguously", - async (failureKind) => { - const restoreFromSnapshot = - failureKind === "returned" - ? vi.fn(async () => ({ success: false as const, error: "ordinary restore failed" })) - : vi.fn(async () => { - throw new Error("ordinary restore failed"); - }); - const f = fixture( - createMockProvider({ restoreFromSnapshot }), - createMockSandbox({ - status: "stopped", - snapshot_image_id: "ordinary-image", - snapshot_runtime_version: COMPATIBLE_RUNTIME_VERSION, - }) - ); - - await f.manager.spawnSandbox(); - - expect(restoreFromSnapshot).toHaveBeenCalled(); - expect(f.shutdown.holdFailedRecovery).toHaveBeenCalledWith( - "ordinary restore failed", - expect.objectContaining({ sandboxId: expect.any(String) }) - ); - } - ); - - it("does not report an ordinary retained resume failure as final graceful shutdown", async () => { - const resumeSandbox = vi.fn(async () => ({ - success: false as const, - error: "ordinary resume failed", - })); - const f = fixture( - createMockProvider({ - resumeSandbox, - capabilities: { supportsPersistentResume: true }, - }), - createMockSandbox({ - status: "stopped", - modal_object_id: "ordinary-retained-source", - }) - ); - - await f.manager.spawnSandbox(); - - expect(resumeSandbox).toHaveBeenCalled(); - expect(f.shutdown.holdFailedRecovery).not.toHaveBeenCalled(); - }); -}); - describe("lifecycle-owned runtime readiness and cancellation", () => { function harness( status: SandboxStatus | null, @@ -830,7 +217,7 @@ describe("lifecycle-owned runtime readiness and cancellation", () => { }, }); const alarms = createMockAlarmScheduler(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -973,19 +360,26 @@ describe("SandboxLifecycleManager", () => { broadcast: vi.fn(), schedule: vi.fn(async () => undefined), }; + const wsManager = createMockWebSocketManager(false); + const idGenerator = createMockIdGenerator(); + const getSessionId = vi.fn(() => "construction-session"); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, { broadcast: callbacks.broadcast }, - createMockWebSocketManager(false), + wsManager, { ...createMockAlarmScheduler(), schedule: callbacks.schedule }, - createMockIdGenerator(), + idGenerator, shutdown, - createTestConfig() + { ...createTestConfig(), getSessionId } ); + expect(storage.calls).toEqual([]); + expect(getSessionId).not.toHaveBeenCalled(); + expect(wsManager.getSandboxWebSocket).not.toHaveBeenCalled(); + expect(idGenerator.generateId).not.toHaveBeenCalled(); expect(shutdown.startupDecision).not.toHaveBeenCalled(); expect(callbacks.broadcast).not.toHaveBeenCalled(); expect(callbacks.schedule).not.toHaveBeenCalled(); @@ -993,6 +387,8 @@ describe("SandboxLifecycleManager", () => { await manager.spawnSandbox(); expect(shutdown.startupDecision).toHaveBeenCalledOnce(); + expect(storage.calls).toEqual([]); + expect(getSessionId).not.toHaveBeenCalled(); expect(provider.createSandbox).not.toHaveBeenCalled(); expect(callbacks.broadcast).not.toHaveBeenCalled(); expect(callbacks.schedule).not.toHaveBeenCalled(); @@ -1008,7 +404,7 @@ describe("SandboxLifecycleManager", () => { const idGenerator = createMockIdGenerator(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1065,7 +461,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsExplicitStop: true }, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1127,7 +523,7 @@ describe("SandboxLifecycleManager", () => { throw new Error("provider unavailable"); }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1182,7 +578,7 @@ describe("SandboxLifecycleManager", () => { }), stopSandbox: vi.fn(() => new Promise(() => {})), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1200,6 +596,7 @@ describe("SandboxLifecycleManager", () => { await vi.advanceTimersByTimeAsync(10_000); await spawning; + expect(vi.mocked(provider.stopSandbox!).mock.calls[0][0].signal?.aborted).toBe(true); expect(provider.createSandbox).toHaveBeenCalledOnce(); expect(providerHandleAtCreate).toBeNull(); expect(storage.commitProviderStartup).toHaveBeenCalledWith( @@ -1220,6 +617,44 @@ describe("SandboxLifecycleManager", () => { } }); + it.each([false, true])( + "requires a dispatched stop only for confirmation-required replacement (fenced=%s)", + async (fenced) => { + const session = createMockSession(); + const sandbox = createMockSandbox({ status: "failed", fenced: fenced ? 1 : 0 }); + const storage = createMockStorage(session, sandbox); + vi.mocked(storage.getSession).mockReturnValueOnce(session).mockReturnValue(null); + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: true }, + stopSandbox: vi.fn(async () => ({ success: true })), + }); + const manager = createTestLifecycleManager( + provider, + storage, + storage, + createMockBroadcaster(), + createMockWebSocketManager(), + createMockAlarmScheduler(), + createMockIdGenerator(), + createUnmanagedShutdown(), + createTestConfig() + ); + await manager.spawnSandbox(); + expect(provider.stopSandbox).not.toHaveBeenCalled(); + if (fenced) { + expect(provider.createSandbox).not.toHaveBeenCalled(); + expect(storage.updateSandboxForSpawn).not.toHaveBeenCalled(); + expect(sandbox.modal_object_id).toBe("modal-obj-123"); + expect(sandbox.last_spawn_error).toBe( + "Provider stop could not be dispatched before sandbox replacement" + ); + } else { + expect(provider.createSandbox).toHaveBeenCalledOnce(); + expect(storage.updateSandboxModalObjectId).toHaveBeenCalledWith(null); + } + } + ); + it("stores VNC access without publishing it before the sandbox is ready", async () => { const sandbox = createMockSandbox({ status: "pending", created_at: Date.now() - 60000 }); const storage = createMockStorage(createMockSession({ vnc_enabled: 1 }), sandbox); @@ -1233,7 +668,7 @@ describe("SandboxLifecycleManager", () => { vncAccess: { url: "https://vnc.test", password: "secret" }, })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1260,10 +695,98 @@ describe("SandboxLifecycleManager", () => { expectEarlyBridgeStartup ); + it.each([ + ["spawn", "public-session-name"], + ["restore", null], + ] as const)("signs %s terminal claims with the launch auth key", async (kind, sessionName) => { + const nowMs = 1_700_000_000_123; + const clock = vi.spyOn(Date, "now").mockReturnValue(nowMs); + try { + const session = createMockSession({ + session_name: sessionName, + sandbox_settings: JSON.stringify({ terminalEnabled: true }), + }); + const sandbox = createMockSandbox({ + status: kind === "spawn" ? "pending" : "stopped", + snapshot_image_id: kind === "restore" ? "saved-image" : null, + snapshot_runtime_version: kind === "restore" ? COMPATIBLE_RUNTIME_VERSION : null, + }); + const storage = createMockStorage(session, sandbox); + const ttydUrl = `https://terminal.test/${kind}`; + const provider = createMockProvider({ + createSandbox: vi.fn(async (config) => ({ + sandboxId: config.sandboxId, + providerObjectId: "fresh-source", + createdAt: nowMs, + lifetime: noLifetime(), + ttydUrl, + })), + restoreFromSnapshot: vi.fn(async (config) => ({ + success: true as const, + sandboxId: config.sandboxId, + providerObjectId: "restored-source", + lifetime: noLifetime(), + ttydUrl, + })), + }); + const manager = createTestLifecycleManager( + provider, + storage, + storage, + createMockBroadcaster(), + createMockWebSocketManager(false), + createMockAlarmScheduler(), + createMockIdGenerator(), + createUnmanagedShutdown(), + createTestConfig() + ); + + await manager.spawnSandbox(); + + const launch = + kind === "spawn" + ? vi.mocked(provider.createSandbox).mock.calls[0][0] + : vi.mocked(provider.restoreFromSnapshot!).mock.calls[0][0]; + const token = sandbox.ttyd_token; + expect(token).toEqual(expect.any(String)); + const [header, body, signature] = token!.split("."); + expect(JSON.parse(atob(header))).toEqual({ alg: "HS256", typ: "JWT" }); + expect(JSON.parse(atob(body.replace(/-/g, "+").replace(/_/g, "/")))).toEqual({ + sub: session.session_name || session.id, + sid: launch.sandboxId, + iat: Math.floor(nowMs / 1000), + exp: Math.floor(nowMs / 1000) + TERMINAL_TOKEN_TTL_SECONDS, + }); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(launch.sandboxAuthToken), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"] + ); + expect( + await crypto.subtle.verify( + "HMAC", + key, + Uint8Array.from(atob(signature.replace(/-/g, "+").replace(/_/g, "/")), (character) => + character.charCodeAt(0) + ), + new TextEncoder().encode(`${header}.${body}`) + ) + ).toBe(true); + expect(launch.sessionId).toBe(session.session_name || session.id); + expect(storage.updateSandboxAccess).toHaveBeenCalledExactlyOnceWith("ttyd", ttydUrl, token); + expect(storage.completeProviderResume).not.toHaveBeenCalled(); + expect(sandbox.ttyd_url).toBe(ttydUrl); + } finally { + clock.mockRestore(); + } + }); + it("logs one terminal sandbox.spawn event for success", async () => { const sandbox = createMockSandbox({ status: "pending", created_at: Date.now() - 60000 }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1303,7 +826,7 @@ describe("SandboxLifecycleManager", () => { throw new Error("spawn exploded"); }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1338,7 +861,7 @@ describe("SandboxLifecycleManager", () => { vi.mocked(storage.updateSandboxModalObjectId).mockImplementation(() => { throw new Error("storage unavailable"); }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1374,7 +897,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1401,7 +924,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1427,7 +950,7 @@ describe("SandboxLifecycleManager", () => { const alarmScheduler = createMockAlarmScheduler(); const config = createTestConfig(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1456,7 +979,7 @@ describe("SandboxLifecycleManager", () => { const idGenerator = createMockIdGenerator(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1497,7 +1020,7 @@ describe("SandboxLifecycleManager", () => { markRestoreFailed: vi.fn(async () => true), }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1544,7 +1067,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1571,7 +1094,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: now - 60000, }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1608,7 +1131,7 @@ describe("SandboxLifecycleManager", () => { throw new Error("storage unavailable"); }); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1641,7 +1164,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1670,7 +1193,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1700,7 +1223,7 @@ describe("SandboxLifecycleManager", () => { }; const storage = createMockStorage(createMockSession(), sandbox, userEnvVars); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1727,7 +1250,7 @@ describe("SandboxLifecycleManager", () => { snapshot_runtime_version: COMPATIBLE_RUNTIME_VERSION, }); const mockStorage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), mockStorage, mockStorage, @@ -1764,13 +1287,15 @@ describe("SandboxLifecycleManager", () => { snapshot_runtime_version: COMPATIBLE_RUNTIME_VERSION, }); const provider = createMockProvider({ - restoreFromSnapshot: vi.fn(async (): Promise => ({ - success: false, - error: "Snapshot not found", - })), + restoreFromSnapshot: vi.fn( + async (): Promise => ({ + success: false, + error: "Snapshot not found", + }) + ), }); const mockStorage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -1819,7 +1344,7 @@ describe("SandboxLifecycleManager", () => { lifetime: noLifetime(), })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1864,7 +1389,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1903,7 +1428,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1946,7 +1471,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1991,7 +1516,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2028,9 +1553,19 @@ describe("SandboxLifecycleManager", () => { ttyd_token: ttydToken, }); const storage = createMockStorage( - createMockSession({ sandbox_settings: JSON.stringify({ terminalEnabled: true }) }), + createMockSession({ + code_server_enabled: 1, + vnc_enabled: 1, + sandbox_settings: JSON.stringify({ terminalEnabled: true }), + }), sandbox ); + const access = { + codeServerUrl: "https://code.test/resumed", + codeServerPassword: "resumed-code-secret", + vncAccess: { url: "https://vnc.test/resumed", password: "resumed-vnc-secret" }, + tunnelUrls: { "3000": "https://preview.test/resumed" }, + }; const provider = createMockProvider({ capabilities: { supportsPersistentResume: true }, resumeSandbox: vi.fn(async () => ({ @@ -2038,9 +1573,10 @@ describe("SandboxLifecycleManager", () => { providerObjectId: "same-provider-obj", lifetime: noLifetime(), ttydUrl: "https://terminal.test/refreshed", + ...access, })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2056,7 +1592,27 @@ describe("SandboxLifecycleManager", () => { expect(sandbox.ttyd_url).toBe("https://terminal.test/refreshed"); expect(sandbox.ttyd_token).toBe(ttydToken); - expect(storage.calls).toContain("completeProviderResume"); + expect(storage.completeProviderResume).toHaveBeenCalledExactlyOnceWith( + { sandboxId: sandbox.modal_sandbox_id, createdAt: sandbox.created_at }, + { + providerObjectId: "same-provider-obj", + codeServer: { url: access.codeServerUrl, password: access.codeServerPassword }, + vnc: access.vncAccess, + ttyd: { url: "https://terminal.test/refreshed", token: ttydToken }, + tunnelUrls: access.tunnelUrls, + } + ); + expect(vi.mocked(storage.completeProviderResume).mock.calls[0]).toHaveLength(2); + expect(storage.getSandboxAccessSecret).toHaveBeenCalledExactlyOnceWith("ttyd"); + expect(storage.updateSandboxAccess).not.toHaveBeenCalled(); + expect(storage.updateSandboxTunnelUrls).not.toHaveBeenCalled(); + expect(sandbox).toMatchObject({ + code_server_url: access.codeServerUrl, + code_server_password: access.codeServerPassword, + vnc_url: access.vncAccess.url, + vnc_password: access.vncAccess.password, + tunnel_urls: JSON.stringify(access.tunnelUrls), + }); }); it("does not publish access when a resume is cancelled during the provider request", async () => { @@ -2075,7 +1631,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsPersistentResume: true }, resumeSandbox: vi.fn(async () => resumeResult), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2140,7 +1696,7 @@ describe("SandboxLifecycleManager", () => { resumeSandbox, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2197,7 +1753,7 @@ describe("SandboxLifecycleManager", () => { resumeSandbox, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2247,7 +1803,7 @@ describe("SandboxLifecycleManager", () => { providerObjectId: "retained-source", runtimeVersion: COMPATIBLE_RUNTIME_VERSION, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2279,7 +1835,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2311,7 +1867,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2338,7 +1894,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2365,7 +1921,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2399,7 +1955,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2431,13 +1987,15 @@ describe("SandboxLifecycleManager", () => { const broadcaster = createMockBroadcaster(); const wsManager = createMockWebSocketManager(false); const provider = createMockProvider({ - restoreFromSnapshot: vi.fn(async (): Promise => ({ - success: false, - error: "Snapshot not found", - })), + restoreFromSnapshot: vi.fn( + async (): Promise => ({ + success: false, + error: "Snapshot not found", + }) + ), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2471,7 +2029,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2501,7 +2059,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2533,7 +2091,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: now - 60000, }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2561,7 +2119,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: now - 60000, }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2588,7 +2146,7 @@ describe("SandboxLifecycleManager", () => { alarmScheduler.schedule = vi.fn(async () => { throw new Error("alarm storage unavailable"); }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2622,7 +2180,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2651,7 +2209,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2679,7 +2237,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2705,7 +2263,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2728,7 +2286,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "spawning", created_at: 7000 }); const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2753,7 +2311,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "failed", fenced: 0, created_at: 7000 }); const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2775,7 +2333,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "failed", fenced: 1, created_at: 7000 }); const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2799,7 +2357,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status, created_at: 7000 }); const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2821,7 +2379,7 @@ describe("SandboxLifecycleManager", () => { it("does not move a row a newer reservation owns", () => { const sandbox = createMockSandbox({ status: "spawning", created_at: 9000 }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2850,7 +2408,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "connecting", spawn_failure_count: 0 }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2888,7 +2446,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: Date.now(), }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2920,7 +2478,7 @@ describe("SandboxLifecycleManager", () => { const broadcaster = createMockBroadcaster(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2962,7 +2520,7 @@ describe("SandboxLifecycleManager", () => { // No takeSnapshot method }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2991,7 +2549,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3023,7 +2581,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3058,7 +2616,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3090,7 +2648,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3117,7 +2675,7 @@ describe("SandboxLifecycleManager", () => { takeSnapshot: vi.fn(async () => ({ success: false, error: "capture failed" })), }); const shutdown = createCheckpointShutdown(provider, storage, broadcaster); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3155,7 +2713,7 @@ describe("SandboxLifecycleManager", () => { sourceStopped: false, })), } satisfies SandboxShutdownLifecycle; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3194,7 +2752,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsExplicitStop: true }, stopSandbox: vi.fn(async () => ({ success: true })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3229,7 +2787,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsExplicitStop: true }, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3260,7 +2818,7 @@ describe("SandboxLifecycleManager", () => { createMockSession(), createMockSandbox({ status: "connecting" }) ); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider({ capabilities: { supportsExplicitStop: true }, stopSandbox: vi.fn(() => providerStop), @@ -3305,22 +2863,32 @@ describe("SandboxLifecycleManager", () => { const createSandbox = vi.fn(); const storage = createMockStorage(); const wsManager = createMockWebSocketManager(true); + const broadcaster = createMockBroadcaster(); const provider = createMockProvider({ capabilities: { supportsExplicitStop: true }, stopSandbox, createSandbox, }); + const access = new SandboxAccess({ + storage, + broadcaster, + sockets: wsManager, + canResumeAfterStop: () => false, + getLogger: () => createLogger("lifecycle-manager"), + }); + const shutdown = createCheckpointShutdown(provider, storage, broadcaster, undefined, () => + access.retireShutdownAccess() + ); const manager = new SandboxLifecycleManager( provider, storage, storage, - createMockBroadcaster(), + broadcaster, wsManager, createMockAlarmScheduler(), createMockIdGenerator(), - createCheckpointShutdown(provider, storage, createMockBroadcaster(), undefined, () => - manager.retireShutdownAccess() - ), + shutdown, + access, createTestConfig() ); @@ -3349,7 +2917,7 @@ describe("SandboxLifecycleManager", () => { const now = Date.now(); const sandbox = createMockSandbox({ status: "ready" as SandboxStatus }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3378,7 +2946,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3415,7 +2983,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "failed" as SandboxStatus }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3450,7 +3018,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "failed" as SandboxStatus }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3481,7 +3049,7 @@ describe("SandboxLifecycleManager", () => { async (status) => { const storage = createMockStorage(createMockSession(), createMockSandbox({ status })); const wsManager = createMockWebSocketManager(true); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3513,7 +3081,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const wsManager = createMockWebSocketManager(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider({ capabilities: { supportsExplicitStop: false } }), storage, storage, @@ -3546,7 +3114,7 @@ describe("SandboxLifecycleManager", () => { const alarmScheduler = createMockAlarmScheduler(); const config = createTestConfig(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3578,7 +3146,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(true); // Has WebSocket const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3602,7 +3170,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3626,7 +3194,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3652,7 +3220,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox(); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3678,7 +3246,7 @@ describe("SandboxLifecycleManager", () => { const alarmScheduler = createMockAlarmScheduler(); const config = createTestConfig(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3735,7 +3303,7 @@ describe("SandboxLifecycleManager", () => { overrides?.sessionRepositories ?? REPO_MEMBER ); const provider = overrides?.provider ?? createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4046,7 +3614,7 @@ describe("SandboxLifecycleManager", () => { overrides?.sessionRepositories ?? ENV_MEMBERS ); const provider = overrides?.provider ?? createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4285,7 +3853,7 @@ describe("SandboxLifecycleManager", () => { overrides?.sessionRepositories ?? MULTI_REPO_MEMBERS ); const provider = overrides?.provider ?? createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4406,7 +3974,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "pending", created_at: Date.now() - 60000 }); const provider = createMockProvider(); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4436,7 +4004,7 @@ describe("SandboxLifecycleManager", () => { }); const provider = createMockProvider(); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4469,7 +4037,7 @@ describe("SandboxLifecycleManager", () => { resumeSandbox: vi.fn(async () => ({ success: true as const, lifetime: noLifetime() })), }); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4496,7 +4064,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "pending", created_at: Date.now() - 60000 }); const provider = createMockProvider(); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4520,7 +4088,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox({ status: "pending", created_at: Date.now() - 60000 }); const provider = createMockProvider(); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4550,7 +4118,7 @@ describe("SandboxLifecycleManager", () => { }); const broadcaster = createMockBroadcaster(); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4583,7 +4151,7 @@ describe("SandboxLifecycleManager", () => { name: "daytona", }; const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4612,7 +4180,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsSandboxTimeout: false }, }); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4639,7 +4207,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4666,7 +4234,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4695,7 +4263,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4724,7 +4292,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4753,7 +4321,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4792,7 +4360,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4824,7 +4392,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4856,7 +4424,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4887,7 +4455,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4928,7 +4496,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4961,7 +4529,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(opts.session ?? createMockSession(), sandbox); const provider = opts.provider ?? createMockProvider(); const config = { ...createTestConfig(), slackAgentNotifyLookup: opts.lookup }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -5142,7 +4710,7 @@ describe("status writes after a provider await (COL-99)", () => { ) { const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider({ createSandbox }), storage, storage, @@ -5205,7 +4773,7 @@ describe("status writes after a provider await (COL-99)", () => { reserveStartup: vi.fn((_createdAt, _policy, persist) => persist()), requestShutdown: vi.fn(async () => "owned" as const), } satisfies SandboxShutdownLifecycle; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider({ capabilities: { supportsExplicitStop: true }, createSandbox, @@ -5301,7 +4869,7 @@ describe("status writes after a provider await (COL-99)", () => { const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); const wsManager = createMockWebSocketManager(false); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider({ createSandbox: vi.fn(async () => { sandbox.status = "connecting"; @@ -5360,7 +4928,7 @@ describe("status writes after a provider await (COL-99)", () => { // stops the sandbox without changing its reserved identity. sandbox.status = "stopped"; }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -5407,7 +4975,7 @@ describe("status writes after a provider await (COL-99)", () => { }; }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -5451,7 +5019,7 @@ describe("status writes after a provider await (COL-99)", () => { return { success: true, imageId: "snapshot-of-A" }; }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -5489,7 +5057,8 @@ describe("SandboxLifecycleManager log context", () => { it("derives session_id from getSessionId per use, upgrading once the id changes", async () => { let currentId = "do-fallback-id"; const mockStorage = createMockStorage(null); - const manager = new SandboxLifecycleManager( + const getSessionId = vi.fn(() => currentId); + const manager = createTestLifecycleManager( createMockProvider(), mockStorage, mockStorage, @@ -5498,8 +5067,9 @@ describe("SandboxLifecycleManager log context", () => { createMockAlarmScheduler(), createMockIdGenerator(), createUnmanagedShutdown(), - { ...createTestConfig(), getSessionId: () => currentId } + { ...createTestConfig(), getSessionId } ); + expect(getSessionId).not.toHaveBeenCalled(); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); // The no-session spawn guard is the cheapest this.log-emitting operation. @@ -5519,9 +5089,54 @@ describe("SandboxLifecycleManager log context", () => { expect(contexts).toEqual(["do-fallback-id", "public-session-name"]); }); + it("resolves the extracted access logger's session_id only when access is used", async () => { + let currentId = "do-fallback-id"; + const getSessionId = vi.fn(() => currentId); + const sandbox = createMockSandbox({ status: "pending" }); + const storage = createMockStorage(createMockSession({ code_server_enabled: 1 }), sandbox); + const provider = createMockProvider({ + createSandbox: vi.fn(async (config) => ({ + sandboxId: config.sandboxId, + providerObjectId: "logged-source", + createdAt: Date.now(), + lifetime: noLifetime(), + codeServerUrl: "https://code.test/logged", + codeServerPassword: "logged-secret", + })), + }); + const manager = createTestLifecycleManager( + provider, + storage, + storage, + createMockBroadcaster(), + createMockWebSocketManager(false), + createMockAlarmScheduler(), + createMockIdGenerator(), + createUnmanagedShutdown(), + { ...createTestConfig(), getSessionId } + ); + expect(getSessionId).not.toHaveBeenCalled(); + const info = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await manager.spawnSandbox(); + currentId = "public-session-name"; + sandbox.status = "failed"; + sandbox.last_heartbeat = null; + await manager.spawnSandbox(); + + expect( + parseStructuredLogs(info) + .filter((entry) => entry.msg === "Storing code-server info") + .map((entry) => entry.session_id) + ).toEqual(["do-fallback-id", "public-session-name"]); + } finally { + info.mockRestore(); + } + }); + it("omits session_id entirely when no getSessionId is configured", async () => { const mockStorage = createMockStorage(null); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), mockStorage, mockStorage, @@ -5552,7 +5167,7 @@ describe("spawn admission race (#1589)", () => { // identity, with credentials invalidated, must already be persisted. function raceHarness(sandbox: ReturnType) { const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index 285f7c30b7..38db24e5f7 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -10,14 +10,6 @@ * spawn attempts within the same request. */ -import { getValidHarnessOrDefault, type HarnessId } from "@open-inspect/shared/harnesses"; -import { - omitUnsupportedSandboxSettings, - unsupportedSandboxSettings, - type McpServerConfig, - type SandboxSettings, -} from "@open-inspect/shared/types/integrations"; -import { extractProviderAndModel, getValidModelOrDefault } from "@open-inspect/shared/models"; import type { ServerMessage } from "@open-inspect/shared/types/server-messages"; import type { SandboxStatus } from "@open-inspect/shared/types/sessions"; import type { SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; @@ -35,10 +27,10 @@ import { PrebuiltImageUnavailableError, SandboxProviderError, SandboxLaunchRejectedError, + providerResumesAfterStop, type SandboxProvider, type CreateSandboxConfig, type CreateSandboxResult, - type SessionRepositoryInfo, type SandboxLifetime, type StopConfig, } from "../provider"; @@ -55,7 +47,6 @@ import { DEFAULT_HEARTBEAT_CONFIG, DEFAULT_CONNECTING_TIMEOUT_CONFIG, DEFAULT_BOOT_BUDGET_CONFIG, - PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS, type CircuitBreakerConfig, type SpawnConfig, } from "./decisions"; @@ -63,15 +54,14 @@ import { evaluateAlarmPolicy, type AlarmPolicyConfig } from "./alarm-policy"; import { formatBootBudgetFailure } from "./boot-failure-message"; import { createLogger, type Logger } from "../../logger"; import { hashToken } from "../../auth/crypto"; -import { isJwtUnexpired, mintJwt } from "../../auth/jwt"; -import { repoImageBuildScope, type ImageBuildScope } from "../../image-builds/model"; -import { parsePersistedSandboxSettings } from "../settings"; import { parseStoredSandboxBootPhase, sandboxBootPhaseLogFields } from "../boot-phase"; +import type { ImageBuildLookup } from "./image-selection"; import { - evaluateImageBuildForSpawn, - type ImageBuildLookup, - type SelectedImageBuild, -} from "./image-selection"; + SandboxLaunchContext, + resolveImageBuildScope, + type SandboxLaunchConfig, + type SandboxLaunchContextReader, +} from "./launch-context"; import type { AlarmScheduler, BackgroundTasks, SessionWebSocket } from "../../platform-ports"; import { DEFAULT_SANDBOX_STATUS } from "../sandbox-status"; import type { @@ -87,33 +77,31 @@ import type { SandboxPushAdmission, } from "./ports"; import { shutdownPolicyForLaunch, type ShutdownLifecyclePolicy } from "./shutdown-policy"; -import { parsePendingVmReference } from "../providers/pending-vm-reference"; -import { ModalApiError, ModalVmStartupError } from "../client"; -import type { ResolveSandboxResult } from "../provider"; +import type { SandboxAccess } from "./sandbox-access"; +import { SandboxLaunchExpiredError, SpawnSupersededError } from "./startup-errors"; +import { + VmStartupReconciliation, + type VmStartupReconciliationStorage, + type VmStartupReconciliationShutdown, +} from "./vm-startup-reconciliation"; +import { + attemptRejectedStartupCleanup, + destroyLateProviderResult, + rearmRejectedStartupCleanupAlarm, + type AllocationCleanupDependencies, + type AllocationCleanupStorage, +} from "./allocation-cleanup"; +import { boundedProviderStop, type ProviderStopOutcome } from "./provider-stop"; export type { SandboxGeneration, SandboxAlarmResult } from "./ports"; -export type { ImageBuildLookup } from "./image-selection"; export type { AlarmScheduler } from "../../platform-ports"; const log = createLogger("lifecycle-manager"); -/** TTL for terminal auth JWTs (24 hours, matching typical sandbox lifetime). */ -const TERMINAL_TOKEN_TTL_SECONDS = 86400; -const PROVIDER_REPLACEMENT_STOP_TIMEOUT_MS = 10_000; -const REJECTED_ALLOCATION_CLEANUP_RETRY_MS = 30_000; -const VM_RESOLVE_RETRY_MS = 10_000; - -function vmAllocationDetail(error: unknown): string | undefined { - const cause = error instanceof SandboxProviderError ? error.cause : error; - if (cause instanceof ModalVmStartupError) return cause.outcome; - if (cause instanceof ModalApiError) return cause.detail; - return undefined; -} - // ==================== Dependency Interfaces ==================== /** Internal shutdown collaborator; callers outside this subsystem use the manager's policies. */ -export interface SandboxShutdownLifecycle { +export interface SandboxShutdownLifecycle extends VmStartupReconciliationShutdown { /** Atomically reserves the sandbox row and shutdown ownership, then announces after commit. */ reserveStartup( createdAt: number, @@ -122,20 +110,8 @@ export interface SandboxShutdownLifecycle { ): void; /** Durably marks the provider-I/O boundary so restart recovery cannot repeat it blindly. */ markRecoveryInvoked(generation: SandboxGeneration, providerObjectId?: string): void; - /** Records a generation-scoped pending provider handle and its conservative expiry. */ - recordPendingProviderHandle( - generation: SandboxGeneration, - reference: string, - lifetime: Extract - ): Promise<"registered" | "expired" | "superseded">; /** Records the provider-confirmed handle and scheduling lifetime after startup. */ recordProviderStartup(generation: SandboxGeneration, lifetime: SandboxLifetime): Promise; - /** Swap a pending handle after bridge recovery without changing shutdown policy or lifetime. */ - recordResolvedProviderHandle?( - generation: SandboxGeneration, - expectedReference: string, - providerObjectId: string - ): void; /** Blocks generic destructive lifecycle work while shutdown or capture ownership is unresolved. */ isHolding(): boolean; /** Tells a runtime refused at reconnect to retry while a capture needs its sandbox. */ @@ -165,7 +141,7 @@ export interface SandboxShutdownLifecycle { /** Supplies internal admission facts; the manager applies distinct queue and live-push policies. */ admissionDecision(): SandboxWorkAdmission; /** Advances shutdown and prevents generic watchdogs from competing with unresolved work. */ - handleAlarm(): Promise<"continue" | "hold_watchdogs">; + handleAlarm(allowCaptureRetry?: boolean): Promise<"continue" | "hold_watchdogs">; /** Applies an already-authorized recovery choice; only explicit restore releases a saved pause. */ recover(action: ShutdownRecoveryAction): Promise; /** Returns the safe public projection, excluding private provider handles and recovery receipts. */ @@ -194,27 +170,16 @@ interface SandboxCircuitBreakerInfo { * contract, these reads belong to others, and conflating them forced every * implementer to bridge unrelated objects. */ -export interface SessionContextReader { +export interface SessionContextReader extends SandboxLaunchContextReader { /** Get current session */ getSession(): SessionRow | null; - /** - * Get the session's member repositories in position order. Pre-list - * sessions get a one-entry list synthesized from the scalar columns - * (buildSessionRepositories owns the rule); empty only for repo-less - * sessions. - */ - getSessionRepositories(): SessionRepositoryInfo[]; - /** Get user env vars for sandbox injection */ - getUserEnvVars(): Promise | undefined>; } /** * Storage adapter for sandbox data operations — the sandbox repository's * contract, satisfied by it structurally. */ -export interface SandboxStorage { - /** Get current sandbox state */ - getSandbox(): SandboxRow | null; +export interface SandboxStorage extends VmStartupReconciliationStorage, AllocationCleanupStorage { /** Get sandbox with circuit breaker state (subset of fields) */ getSandboxWithCircuitBreaker(): SandboxCircuitBreakerInfo | null; /** Update sandbox status */ @@ -281,20 +246,6 @@ export interface SandboxStorage { updateSandboxAuthTokenHash(modalSandboxId: string, authTokenHash: string): boolean; /** Update sandbox state for in-place resume without rotating auth/token identity */ updateSandboxForResume(data: { status: SandboxStatus; createdAt: number }): void; - /** Atomically commit access returned for the named resume generation. */ - completeProviderResume( - generation: SandboxGeneration, - access: { - providerObjectId: string; - codeServer: { url: string; password: string } | null; - vnc: { url: string; password: string } | null; - ttyd: { url: string | null; token: string } | null; - tunnelUrls: Record | null; - }, - expectedProviderObjectId?: string - ): Promise; - /** Update sandbox Modal object ID (for snapshot API) */ - updateSandboxModalObjectId(modalObjectId: string | null): void; /** Set the runtime version describing the sandbox's current filesystem. */ updateSandboxRuntimeVersion(runtimeVersion: string | null): void; /** @@ -318,18 +269,8 @@ export interface SandboxStorage { resetCircuitBreaker(): void; /** Persist last spawn error */ setLastSpawnError(error: string | null, timestamp: number | null): void; - /** Set one access artifact's URL and (encrypted) secret on the sandbox row */ - updateSandboxAccess(kind: SandboxAccessKind, url: string, secret: string): void | Promise; /** Read and decrypt one access artifact's stored secret */ getSandboxAccessSecret(kind: SandboxAccessKind): Promise; - /** Clear one access artifact's URL and secret (e.g. on sandbox teardown) */ - clearSandboxAccess(kind: SandboxAccessKind): void; - /** Clear one access artifact's URL while preserving its stored secret */ - clearSandboxAccessUrl?(kind: SandboxAccessKind): void; - /** Update tunnel URLs for extra ports on the sandbox row */ - updateSandboxTunnelUrls(urls: Record): void | Promise; - /** Clear stale tunnel URLs (e.g. on sandbox teardown) */ - clearSandboxTunnelUrls(): void; } /** @@ -386,14 +327,12 @@ interface AlarmContext { /** * Complete lifecycle configuration. */ -export interface SandboxLifecycleConfig extends AlarmPolicyConfig { +export interface SandboxLifecycleConfig extends AlarmPolicyConfig, SandboxLaunchConfig { /** Persist a user-visible lifecycle warning in the session event stream. */ recordWarning?: (message: string, eventId: string) => void; circuitBreaker: CircuitBreakerConfig; spawn: SpawnConfig; controlPlaneUrl: string; - /** Default model ID used when the session has no model override. */ - model: string; /** * Session ID for log correlation, resolved per use. Optional — logs will * omit sessionId if not provided. A thunk rather than a value because the @@ -401,12 +340,6 @@ export interface SandboxLifecycleConfig extends AlarmPolicyConfig { * row (and its public id) exists. */ getSessionId?: () => string; - /** MCP server lookup for injecting servers into sandboxes. */ - mcpServerLookup?: McpServerLookup; - /** Resolves the spawn-time agent-slack-notify gate. */ - slackAgentNotifyLookup?: SlackAgentNotifyLookup; - /** Builds a provider dashboard URL for a persisted provider object ID. */ - sandboxDashboardUrlBuilder?: (providerObjectId: string) => string | null; } /** @@ -428,47 +361,6 @@ function buildSandboxIdForSession(session: SessionRow, now: number): string { return `sandbox-${sandboxName}-${now}`; } -/** - * Multi-repo additions to a spawn/restore config. Single-repo sessions keep - * the scalar wire form untouched (the runtime synthesizes its one-entry - * list from repo_owner/repo_name/branch), so nothing changes for them. - * Working-branch names stay lazily derived at PR-creation time - * (pull-request-service) and reach the sandbox via per-repo push specs, - * never via spawn config. - */ -function multiRepoSpawnFields( - repositories: SessionRepositoryInfo[] -): Pick { - return repositories.length > 1 || repositories.some((repository) => repository.baseSha) - ? { repositories } - : {}; -} - -// ==================== MCP Server Lookup ==================== - -/** - * Lookup interface for MCP servers applicable to a session. - * Keeps the lifecycle manager free of direct D1Database dependencies. - * Receives the session's member repositories (empty for repo-less sessions); - * a scoped server applies when any member matches one of its scopes. - */ -export interface McpServerLookup { - getDecryptedForSession( - repositories: Array<{ repoOwner: string; repoName: string }> - ): Promise; -} - -// ==================== Slack Agent-Notify Lookup ==================== - -/** - * Resolves the spawn-time agent-slack-notify gate for a repository or the - * global no-repository scope. - * False (or throwing) means do not install the tool in this sandbox. - */ -export interface SlackAgentNotifyLookup { - isEnabledForRepo(repoOwner: string | null, repoName: string | null): Promise; -} - // ==================== Manager ==================== /** @@ -497,29 +389,6 @@ export type UnresponsiveSandboxTrigger = * Uses dependency injection for all external interactions, enabling unit testing * with mocked dependencies. */ -/** - * A spawn attempt discovered at hash publication that a newer reservation - * had replaced its identity. The attempt must abandon without failure - * writes: the sandbox row and circuit breaker now describe the newer - * attempt, and marking them failed would clobber it. - */ -class SpawnSupersededError extends Error { - constructor() { - super("Spawn reservation superseded before its auth hash was published"); - this.name = "SpawnSupersededError"; - } -} - -class SandboxLaunchExpiredError extends SandboxProviderError { - constructor() { - super( - "The sandbox timeout leaves no time before the final save begins. Increase the sandbox timeout or reduce the final snapshot buffer in the sandbox settings.", - "transient" - ); - this.name = "SandboxLaunchExpiredError"; - } -} - export class SandboxLifecycleManager implements SandboxLifecycle, @@ -536,25 +405,12 @@ export class SandboxLifecycleManager private isSpawningSandbox = false; private isTerminatingSandbox = false; private providerStartupPending = false; - private bridgeResolution: SandboxGeneration | null = null; - private bridgeRetryGeneration: SandboxGeneration | null = null; - private bridgeStartupClaim: SandboxGeneration | null = null; - private bridgeResolvedStartup: { - generation: SandboxGeneration; - result: ResolveSandboxResult; - } | null = null; - private vmStartupAuth: { - generation: SandboxGeneration; - sessionId: string; - token: string; - } | null = null; - retireShutdownAccess(): void { - this.clearSandboxAccessState(); - this.wsManager.detachSandboxWebSocket(1000, "Sandbox state preserved"); - } /** Memoized session-scoped logger, keyed by the resolved session id. */ private logMemo?: { sessionId: string | undefined; logger: Logger }; + private readonly launchContext: SandboxLaunchContext; + private readonly vmStartup: VmStartupReconciliation; + private readonly allocationCleanup: AllocationCleanupDependencies; /** * Session-scoped logger. Falls back to the module-level logger if no @@ -583,10 +439,46 @@ export class SandboxLifecycleManager private readonly alarmScheduler: AlarmScheduler, private readonly idGenerator: IdGenerator, private readonly shutdown: SandboxShutdownLifecycle, + private readonly access: SandboxAccess, private readonly config: SandboxLifecycleConfig, - private readonly imageBuildLookup?: ImageBuildLookup, - private readonly backgroundTasks?: BackgroundTasks - ) {} + imageBuildLookup?: ImageBuildLookup, + backgroundTasks?: BackgroundTasks + ) { + this.launchContext = new SandboxLaunchContext({ + sessionContext, + provider: { + name: provider.name, + capabilities: { supportsSandboxTimeout: provider.capabilities.supportsSandboxTimeout }, + }, + config: { + model: config.model, + mcpServerLookup: config.mcpServerLookup, + slackAgentNotifyLookup: config.slackAgentNotifyLookup, + }, + imageBuildLookup, + getLogger: () => this.log, + }); + this.vmStartup = new VmStartupReconciliation({ + provider, + storage, + sessionContext, + shutdown, + access, + launchContext: this.launchContext, + acceptResolvedStartup: (generation, providerObjectId, lifetime) => + this.claimProviderStartup(generation, providerObjectId, lifetime), + getLogger: () => this.log, + backgroundTasks, + }); + this.allocationCleanup = { + storage, + alarmScheduler, + canStop: () => this.canStopProviderSandbox(), + stop: (providerObjectId, signal) => + this.stopProviderSandbox("startup_superseded", "destroy", signal, providerObjectId), + getLogger: () => this.log, + }; + } /** * Spawn a sandbox (fresh or from snapshot). @@ -805,7 +697,6 @@ export class SandboxLifecycleManager } } - const hasRepository = sessionHasRepository(session); const priorSandbox = this.storage.getSandbox(); const priorSandboxId = priorSandbox?.modal_sandbox_id ?? null; // A fenced allocation must be retired before its durable identity is replaced. @@ -820,77 +711,54 @@ export class SandboxLifecycleManager }); await this.stopPriorProviderSandbox(); - const userEnvVars = await this.sessionContext.getUserEnvVars(); - const { provider, model: modelId } = this.resolveProviderAndModel(session); - const repositories = this.sessionContext.getSessionRepositories(); - const multiRepoFields = multiRepoSpawnFields(repositories); - - // Prebuilt-image selection: an environment session matches its - // environment's image against the session's own repository snapshot - // (design §7.3); a single-repo ad-hoc session matches its repo scope's - // image the same way, where the one-element fingerprint reproduces the - // old base_branch filter (non-default-branch sessions miss to base). - // Environment sessions never fall back to a repo image — it bakes that - // repository's setup and secrets, not the environment's — and - // multi-repo ad-hoc sessions never use prebuilt images (a repo image - // bakes a single checkout), so both miss straight to the base image. - let selectedImage: SelectedImageBuild | null = null; - if (session.environment_id) { - selectedImage = await this.lookupImageBuildForSpawn( - { kind: "environment", id: session.environment_id }, - repositories, - getValidHarnessOrDefault(session.harness) - ); - } else if (hasRepository && repositories.length === 1) { - selectedImage = await this.lookupImageBuildForSpawn( - repoImageBuildScope(repositories[0].repoOwner, repositories[0].repoName), - repositories, - getValidHarnessOrDefault(session.harness) - ); - } + const userEnvVars = await this.launchContext.getUserEnvVars(); + const agent = this.launchContext.resolveAgent(session); + const { repositories, fields: repositoryFields } = + this.launchContext.resolveRepositories(session); + const imageScope = resolveImageBuildScope(session, repositories); + const selectedImage = imageScope + ? await this.launchContext.lookupImageBuildForSpawn(imageScope, repositories, agent.harness) + : null; const prebuiltImageId: string | null = selectedImage?.providerImageId ?? null; const prebuiltImageSha: string | null = selectedImage?.primaryBaseSha ?? null; - const mcpServers = await this.loadMcpServers(repositories); + const mcpServers = await this.launchContext.loadMcpServers(repositories); const codeServerEnabled = session.code_server_enabled === 1; const vncEnabled = session.vnc_enabled === 1; - const agentSlackNotifyEnabled = await this.resolveAgentSlackNotifyEnabled(session); - const sandboxSettings = this.parseSandboxSettings(session); - const timeoutSeconds = this.resolveSandboxTimeoutSeconds(sandboxSettings); + const agentSlackNotifyEnabled = + await this.launchContext.resolveAgentSlackNotifyEnabled(session); + const { sandboxSettings, timeoutSeconds } = + this.launchContext.resolveSandboxSettings(session); const createConfig: CreateSandboxConfig = { sessionId, generationCreatedAtMs: generation.createdAt, retireSandboxId: priorSandboxId, sandboxId: expectedSandboxId, - repoOwner: session.repo_owner, - repoName: session.repo_name, controlPlaneUrl: this.config.controlPlaneUrl, sandboxAuthToken, - harness: getValidHarnessOrDefault(session.harness), - provider, - model: modelId, + harness: agent.harness, + provider: agent.provider, + model: agent.model, userEnvVars, prebuiltImageId, prebuiltImageSha, timeoutSeconds, - branch: session.base_branch, codeServerEnabled, vncEnabled, agentSlackNotifyEnabled, mcpServers, sandboxSettings, - ...multiRepoFields, + ...repositoryFields, }; - if (this.provider.name === "modal-vm") - this.vmStartupAuth = { generation, sessionId, token: sandboxAuthToken }; + this.vmStartup.registerForegroundAuth(generation, sessionId, sandboxAuthToken); let result: CreateSandboxResult; try { - await this.recordPendingProviderReference(generation, createConfig); - const created = await this.createWithVmRecovery(createConfig, generation); + await this.vmStartup.recordPendingProviderReference(generation, createConfig); + const created = await this.vmStartup.createWithVmRecovery(createConfig, generation); if (!created) return; result = created; } catch (error) { @@ -914,7 +782,7 @@ export class SandboxLifecycleManager error_type: error.errorType, error: error.message, }); - await this.markImageBuildRestoreFailed(selectedImage, error); + await this.launchContext.markImageBuildRestoreFailed(selectedImage, error); // The retry gets a fresh spawn identity: the failed attempt may have // actually created a sandbox provider-side (post-create errors are // indistinguishable here), and rotating the token hash and sandbox id @@ -923,13 +791,12 @@ export class SandboxLifecycleManager const retryNow = Math.max(Date.now(), now + 1); const retry = this.spawnGeneration(session, retryNow); generation = retry; - this.vmStartupAuth = null; + this.vmStartup.beginForegroundRetry(); ({ sandboxAuthToken, expectedSandboxId } = await this.reserveSpawnIdentity(retry, { preserveProviderObjectId: false, shutdownPolicy: shutdownPolicyForLaunch("new", null), })); - if (this.provider.name === "modal-vm") - this.vmStartupAuth = { generation, sessionId, token: sandboxAuthToken }; + this.vmStartup.registerForegroundAuth(generation, sessionId, sandboxAuthToken); const retryConfig: CreateSandboxConfig = { ...createConfig, sandboxId: expectedSandboxId, @@ -938,8 +805,8 @@ export class SandboxLifecycleManager prebuiltImageId: null, prebuiltImageSha: null, }; - await this.recordPendingProviderReference(generation, retryConfig); - const created = await this.createWithVmRecovery(retryConfig, generation); + await this.vmStartup.recordPendingProviderReference(generation, retryConfig); + const created = await this.vmStartup.createWithVmRecovery(retryConfig, generation); if (!created) return; result = created; } @@ -947,17 +814,17 @@ export class SandboxLifecycleManager if (!(await this.claimProviderStartup(generation, result.providerObjectId, result.lifetime))) return; if (result.codeServerUrl && result.codeServerPassword) { - await this.storeCodeServer(result.codeServerUrl, result.codeServerPassword); + await this.access.storeCodeServer(result.codeServerUrl, result.codeServerPassword); } if (result.vncAccess) { - await this.storeVnc(result.vncAccess.url, result.vncAccess.password); + await this.access.storeVnc(result.vncAccess.url, result.vncAccess.password); } - await this.storeAndBroadcastTunnelUrls(result.tunnelUrls); + await this.access.storeAndBroadcastTunnelUrls(result.tunnelUrls); if (result.ttydUrl) { - await this.storeTtyd(result.ttydUrl, sandboxAuthToken, sessionId, expectedSandboxId); + await this.access.storeTtyd(result.ttydUrl, sandboxAuthToken, sessionId, expectedSandboxId); } - this.broadcastProviderAccessIfConnected(); + this.access.broadcastProviderAccessIfConnected(); this.log.info("Sandbox spawn completed", { event: "sandbox.spawn", @@ -1016,119 +883,7 @@ export class SandboxLifecycleManager } finally { this.isSpawningSandbox = false; this.providerStartupPending = false; - if (this.vmStartupAuth?.generation === generation && this.bridgeStartupClaim !== generation) - this.vmStartupAuth = null; - } - } - - /** - * Resolve the scope's prebuilt image for a fresh spawn. Returns null on any - * miss or lookup failure — the session boots from base (never blocked, - * design §7.3) — logging the reason either way; miss-reason counts are the - * numbers that justify (or kill) the prebuild fast-follows. - */ - private async lookupImageBuildForSpawn( - scope: ImageBuildScope, - repositories: SessionRepositoryInfo[], - harness: HarnessId - ): Promise { - if (!this.imageBuildLookup || repositories.length === 0) return null; - try { - const image = await this.imageBuildLookup.getLatestReady(scope); - const result = await evaluateImageBuildForSpawn(image, repositories, harness); - if (result.outcome === "selected") { - this.log.info("Using prebuilt image", { - event: "image_build.spawn_selected", - scope_kind: scope.kind, - scope_id: scope.id, - image_build_id: result.image.imageBuildId, - runtime_version: result.image.runtimeVersion, - }); - return result.image; - } - this.log.info("Prebuilt image miss, using base image", { - event: "image_build.spawn_miss", - scope_kind: scope.kind, - scope_id: scope.id, - reason: result.reason, - image_build_id: result.imageBuildId, - }); - return null; - } catch (e) { - this.log.warn("Failed to look up prebuilt image, using base image", { - event: "image_build.spawn_miss", - scope_kind: scope.kind, - scope_id: scope.id, - reason: "lookup_failed", - error: e instanceof Error ? e.message : String(e), - }); - return null; - } - } - - /** - * Best-effort: the base-image retry must proceed even when D1 is the thing - * that is down. An unmarked row costs one more failed image boot on the - * next spawn, not a broken session. - */ - private async markImageBuildRestoreFailed( - image: SelectedImageBuild, - error: unknown - ): Promise { - if (!this.imageBuildLookup) return; - try { - await this.imageBuildLookup.markRestoreFailed( - image.imageBuildId, - `restore failed at spawn: ${error instanceof Error ? error.message : String(error)}` - ); - } catch (e) { - this.log.warn("Failed to mark prebuilt image restore-failed", { - image_build_id: image.imageBuildId, - error: e instanceof Error ? e.message : String(e), - }); - } - } - - private async resolveAgentSlackNotifyEnabled(session: SessionRow): Promise { - if (!this.config.slackAgentNotifyLookup) return false; - try { - return await this.config.slackAgentNotifyLookup.isEnabledForRepo( - sessionHasRepository(session) ? session.repo_owner : null, - sessionHasRepository(session) ? session.repo_name : null - ); - } catch (err) { - this.log.warn("Failed to resolve agent slack-notify gate; treating as disabled", { - event: "slack_notify.gate_resolve_failed", - error: err instanceof Error ? err.message : String(err), - }); - return false; - } - } - - /** - * Load MCP servers applicable to the current session's repository. - * Returns undefined if none are found or DB is not configured. - */ - private async loadMcpServers( - repositories: SessionRepositoryInfo[] - ): Promise { - try { - if (!this.config.mcpServerLookup) return undefined; - const servers = await this.config.mcpServerLookup.getDecryptedForSession( - repositories.map(({ repoOwner, repoName }) => ({ repoOwner, repoName })) - ); - this.log.info("MCP servers loaded", { - event: "mcp.loaded", - count: servers?.length ?? 0, - names: servers?.map((s) => s.name) ?? [], - }); - return servers?.length ? servers : undefined; - } catch (err) { - this.log.warn("Failed to load MCP servers", { - event: "mcp.load_failed", - error: String(err), - }); - return undefined; + this.vmStartup.finalizeForeground(generation); } } @@ -1274,16 +1029,18 @@ export class SandboxLifecycleManager await this.stopPriorProviderSandbox(); - const userEnvVars = await this.sessionContext.getUserEnvVars(); - const { provider, model: modelId } = this.resolveProviderAndModel(session); + const userEnvVars = await this.launchContext.getUserEnvVars(); + const agent = this.launchContext.resolveAgent(session); - const repositories = this.sessionContext.getSessionRepositories(); + const { repositories, fields: repositoryFields } = + this.launchContext.resolveRepositories(session); const codeServerEnabled = session.code_server_enabled === 1; const vncEnabled = session.vnc_enabled === 1; - const agentSlackNotifyEnabled = await this.resolveAgentSlackNotifyEnabled(session); - const mcpServers = await this.loadMcpServers(repositories); - const sandboxSettings = this.parseSandboxSettings(session); - const timeoutSeconds = this.resolveSandboxTimeoutSeconds(sandboxSettings); + const agentSlackNotifyEnabled = + await this.launchContext.resolveAgentSlackNotifyEnabled(session); + const mcpServers = await this.launchContext.loadMcpServers(repositories); + const { sandboxSettings, timeoutSeconds } = + this.launchContext.resolveSandboxSettings(session); const restoreConfig = { snapshotImageId, generationCreatedAtMs: generation.createdAt, @@ -1292,35 +1049,27 @@ export class SandboxLifecycleManager sandboxId: expectedSandboxId, sandboxAuthToken, controlPlaneUrl: this.config.controlPlaneUrl, - repoOwner: session.repo_owner, - repoName: session.repo_name, - harness: getValidHarnessOrDefault(session.harness), - provider, - model: modelId, + harness: agent.harness, + provider: agent.provider, + model: agent.model, userEnvVars, timeoutSeconds, - branch: session.base_branch, codeServerEnabled, vncEnabled, agentSlackNotifyEnabled, mcpServers, sandboxSettings, - ...multiRepoSpawnFields(repositories), + ...repositoryFields, }; - if (this.provider.name === "modal-vm") - this.vmStartupAuth = { - generation, - sessionId: restoreConfig.sessionId, - token: sandboxAuthToken, - }; - await this.recordPendingProviderReference(generation, restoreConfig); + this.vmStartup.registerForegroundAuth(generation, restoreConfig.sessionId, sandboxAuthToken); + await this.vmStartup.recordPendingProviderReference(generation, restoreConfig); this.shutdown.markRecoveryInvoked(generation); let result; try { result = await this.provider.restoreFromSnapshot(restoreConfig); } catch (error) { if (!this.provider.isUnknownStartupError?.(error)) throw error; - const recovered = await this.resolveUnknownVmStartup(generation, restoreConfig); + const recovered = await this.vmStartup.resolveUnknownVmStartup(generation, restoreConfig); if (!recovered) return; result = { ...recovered, success: true as const }; } @@ -1332,14 +1081,14 @@ export class SandboxLifecycleManager return; startupClaimed = true; if (result.codeServerUrl && result.codeServerPassword) { - await this.storeCodeServer(result.codeServerUrl, result.codeServerPassword); + await this.access.storeCodeServer(result.codeServerUrl, result.codeServerPassword); } if (result.vncAccess) { - await this.storeVnc(result.vncAccess.url, result.vncAccess.password); + await this.access.storeVnc(result.vncAccess.url, result.vncAccess.password); } - await this.storeAndBroadcastTunnelUrls(result.tunnelUrls); + await this.access.storeAndBroadcastTunnelUrls(result.tunnelUrls); if (result.ttydUrl) { - await this.storeTtyd( + await this.access.storeTtyd( result.ttydUrl, sandboxAuthToken, session.session_name || session.id, @@ -1347,7 +1096,7 @@ export class SandboxLifecycleManager ); } - this.broadcastProviderAccessIfConnected(); + this.access.broadcastProviderAccessIfConnected(); this.broadcaster.broadcast({ type: "sandbox_restored", @@ -1412,8 +1161,7 @@ export class SandboxLifecycleManager } finally { this.isSpawningSandbox = false; this.providerStartupPending = false; - if (this.vmStartupAuth?.generation === generation && this.bridgeStartupClaim !== generation) - this.vmStartupAuth = null; + this.vmStartup.finalizeForeground(generation); } } @@ -1463,8 +1211,8 @@ export class SandboxLifecycleManager this.storage.updateSandboxRuntimeVersion(sourceRuntimeVersion); }); - const sandboxSettings = this.parseSandboxSettings(session); - const timeoutSeconds = this.resolveSandboxTimeoutSeconds(sandboxSettings); + const { sandboxSettings, timeoutSeconds } = + this.launchContext.resolveSandboxSettings(session); if (restoringSavedState) this.shutdown.markRecoveryInvoked(generation, providerObjectId); const result = await this.provider.resumeSandbox({ @@ -1494,17 +1242,11 @@ export class SandboxLifecycleManager const ttydToken = sandboxSettings.terminalEnabled ? await this.storage.getSandboxAccessSecret("ttyd") : null; - const validTtydToken = ttydToken && isJwtUnexpired(ttydToken) ? ttydToken : null; - if (result.ttydUrl && !validTtydToken) { - // Terminal tokens are signed with the sandbox auth token, which is kept - // only as a hash, so an expired or missing one cannot be renewed. The - // resumed sandbox holds the workspace; keep it without terminal access. - this.log.warn("Terminal credential unavailable; resuming without terminal access", { - event: "sandbox.resume_terminal_credential_unavailable", - provider_object_id: finalProviderObjectId, - reason: ttydToken ? "invalid_or_expired" : "missing", - }); - } + const validTtydToken = this.access.reusableTtydToken( + ttydToken, + result.ttydUrl, + finalProviderObjectId + ); let completed: boolean; try { completed = await this.storage.completeProviderResume(generation, { @@ -1538,7 +1280,7 @@ export class SandboxLifecycleManager await this.shutdown.recordProviderStartup(generation, result.lifetime); startupClaimed = true; - if (!this.broadcastSandboxDashboardUrl(finalProviderObjectId)) { + if (!this.access.broadcastSandboxDashboardUrl(finalProviderObjectId)) { this.broadcaster.broadcast({ type: "sandbox_access_changed" }); } } catch (error) { @@ -1620,7 +1362,7 @@ export class SandboxLifecycleManager * Whether stopping should preserve provider-owned state for in-place resume. */ private usesProviderManagedStop(): boolean { - return this.canStopProviderSandbox() && !!this.provider.capabilities.supportsPersistentResume; + return providerResumesAfterStop(this.provider); } /** @@ -1638,25 +1380,20 @@ export class SandboxLifecycleManager return; } - const controller = new AbortController(); - let timeoutId: ReturnType | undefined; try { - const stopTimeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - controller.abort(); - reject(new Error("Provider stop timed out before sandbox replacement")); - }, PROVIDER_REPLACEMENT_STOP_TIMEOUT_MS); - }); - await Promise.race([ - this.stopProviderSandbox( - "respawn", - "destroy", - controller.signal, - providerObjectId, - prior?.created_at - ), - stopTimeoutPromise, - ]); + const outcome = await boundedProviderStop( + (signal) => + this.stopProviderSandbox( + "respawn", + "destroy", + signal, + providerObjectId, + prior?.created_at + ), + "Provider stop timed out before sandbox replacement" + ); + if (requireConfirmation && outcome !== "confirmed") + throw new Error("Provider stop could not be dispatched before sandbox replacement"); this.storage.updateSandboxModalObjectId(null); } catch (error) { if (requireConfirmation) throw error; @@ -1665,30 +1402,7 @@ export class SandboxLifecycleManager provider_object_id: providerObjectId, error: error instanceof Error ? error.message : String(error), }); - } finally { - if (timeoutId !== undefined) clearTimeout(timeoutId); - } - } - - /** - * Clear preview URLs after a sandbox is no longer reachable. - * - * Persistent resumes preserve code-server and VNC passwords plus the ttyd - * token, so only their URLs are cleared. Snapshot restores rotate access - * secrets, so both values are removed. - */ - private clearSandboxAccessState(): void { - if (this.usesProviderManagedStop() && this.storage.clearSandboxAccessUrl) { - this.storage.clearSandboxAccessUrl("codeServer"); - this.storage.clearSandboxAccessUrl("vnc"); - this.storage.clearSandboxAccessUrl("ttyd"); - } else { - this.storage.clearSandboxAccess("codeServer"); - this.storage.clearSandboxAccess("vnc"); - this.storage.clearSandboxAccess("ttyd"); } - this.storage.clearSandboxTunnelUrls(); - this.broadcaster.broadcast({ type: "sandbox_access_changed" }); } /** @@ -1700,16 +1414,16 @@ export class SandboxLifecycleManager signal?: AbortSignal, providerObjectId?: string, generationCreatedAtMs?: number - ): Promise { + ): Promise { if (!this.provider.stopSandbox) { - return; + return "not_stopped"; } const sandbox = providerObjectId ? null : this.storage.getSandbox(); const session = this.sessionContext.getSession(); const objectId = providerObjectId ?? sandbox?.modal_object_id; if (!objectId || !session) { - return; + return "not_stopped"; } const result = await this.provider.stopSandbox({ @@ -1724,6 +1438,7 @@ export class SandboxLifecycleManager if (!result.success) { throw new Error(result.error || "Failed to stop provider sandbox"); } + return "confirmed"; } /** @@ -1867,7 +1582,7 @@ export class SandboxLifecycleManager }); this.storage.updateSandboxStatus("failed"); this.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); - this.clearSandboxAccessState(); + this.access.clearAccess(); const held = this.holdFailedRetainedBoot( ctx.sandbox, "Sandbox failed to connect within the allowed time" @@ -1925,7 +1640,7 @@ export class SandboxLifecycleManager // like any other; the termination re-drives the queue, and the breaker // is what bounds a boot that dies the same way every time. if (isBooting) this.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); - this.clearSandboxAccessState(); + this.access.clearAccess(); this.broadcaster.broadcast({ type: "sandbox_status", status: "stale" }); const preservesProviderState = this.usesProviderManagedStop(); @@ -2024,7 +1739,7 @@ export class SandboxLifecycleManager } this.storage.updateSandboxStatus("failed"); this.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); - this.clearSandboxAccessState(); + this.access.clearAccess(); this.broadcaster.broadcast({ type: "sandbox_status", status: "failed" }); this.reportSandboxError(reason); if (held) return { kind: "boot_budget_exceeded", reason }; @@ -2062,7 +1777,7 @@ export class SandboxLifecycleManager }); // Set status to stopped FIRST to block reconnection attempts this.storage.updateSandboxStatus("stopped"); - this.clearSandboxAccessState(); + this.access.clearAccess(); this.broadcaster.broadcast({ type: "sandbox_status", status: "stopped" }); const preservesProviderState = this.usesProviderManagedStop(); @@ -2124,7 +1839,7 @@ export class SandboxLifecycleManager const canStopProvider = this.canStopProviderSandbox(); if (!canStopProvider) this.wsManager.sendToSandbox({ type: "shutdown" }); this.storage.updateSandboxStatus("stale"); - this.clearSandboxAccessState(); + this.access.clearAccess(); this.broadcaster.broadcast({ type: "sandbox_status", status: "stale" }); const closeReason = { prompt_dispatch_send_failed: "Prompt dispatch send failed", @@ -2182,7 +1897,7 @@ export class SandboxLifecycleManager this.recordSpawnFailure(Date.now(), sandbox.created_at); this.broadcaster.broadcast({ type: "sandbox_status", status: "failed" }); this.reportSandboxError(reason); - this.clearSandboxAccessState(); + this.access.clearAccess(); if (held) return false; const canStopProvider = this.canStopProviderSandbox(); @@ -2241,7 +1956,7 @@ export class SandboxLifecycleManager if (!row) return false; const generation = { sandboxId: row.modal_sandbox_id, createdAt: row.created_at }; if (!this.storage.markSandboxReady(generation)) return false; - this.resolvePendingBridge(generation); + this.vmStartup.resolvePendingBridge(generation); this.log.info("sandbox.ready", { event: "sandbox.ready", harness: harness ?? null }); this.updateLastActivity(timestamp); this.broadcaster.broadcast({ type: "sandbox_status", status: "ready" }); @@ -2286,16 +2001,17 @@ export class SandboxLifecycleManager } } - async handleShutdownAlarm(): Promise<"continue" | "hold_watchdogs"> { + async handleShutdownAlarm(allowCaptureRetry = true): Promise<"continue" | "hold_watchdogs"> { const rejected = this.storage.getSandbox(); if (rejected?.startup_rejected && rejected.modal_object_id) { - await this.attemptRejectedStartupCleanup( + await attemptRejectedStartupCleanup( + this.allocationCleanup, { sandboxId: rejected.modal_sandbox_id, createdAt: rejected.created_at }, rejected.modal_object_id ); return "hold_watchdogs"; } - return this.shutdown.handleAlarm(); + return this.shutdown.handleAlarm(allowCaptureRetry); } recoverShutdown(action: ShutdownRecoveryAction): Promise { @@ -2367,14 +2083,6 @@ export class SandboxLifecycleManager await this.alarmScheduler.schedule(alarmTime); } - /** - * Resolve the provider and model ID from the session or config default. - * e.g., "openai/gpt-6-sol" -> { provider: "openai", model: "gpt-6-sol" } - */ - private resolveProviderAndModel(session: SessionRow): { provider: string; model: string } { - return extractProviderAndModel(getValidModelOrDefault(session.model || this.config.model)); - } - /** * Get the count of connected client WebSockets. */ @@ -2382,390 +2090,6 @@ export class SandboxLifecycleManager return this.wsManager.getConnectedClientCount(); } - private broadcastSandboxDashboardUrl(providerObjectId: string): boolean { - const url = this.config.sandboxDashboardUrlBuilder?.(providerObjectId); - if (url) { - this.log.debug("Broadcasting sandbox dashboard URL", { - provider_object_id: providerObjectId, - }); - this.broadcaster.broadcast({ type: "sandbox_access_changed" }); - return true; - } - return false; - } - - private broadcastProviderAccessIfConnected(): void { - if (this.wsManager.getSandboxWebSocket()) { - this.broadcaster.broadcast({ type: "sandbox_access_changed" }); - } - } - - private async storeCodeServer(url: string, password: string): Promise { - this.log.info("Storing code-server info", { url }); - await this.storage.updateSandboxAccess("codeServer", url, password); - } - - private async storeVnc(url: string, password: string): Promise { - this.log.info("Storing VNC info", { url }); - await this.storage.updateSandboxAccess("vnc", url, password); - } - - private parseSandboxSettings(session: SessionRow): SandboxSettings { - try { - const settings = parsePersistedSandboxSettings(session.sandbox_settings); - const unsupported = unsupportedSandboxSettings(settings, this.provider.name); - if (unsupported.length > 0) { - this.log.warn("Ignoring persisted sandbox settings unsupported by the provider", { - event: "sandbox.settings_unsupported", - provider: this.provider.name, - settings: unsupported, - }); - } - return omitUnsupportedSandboxSettings(settings, this.provider.name); - } catch { - this.log.warn("Failed to parse sandbox_settings, using defaults"); - return {}; - } - } - - private resolveSandboxTimeoutSeconds(sandboxSettings: SandboxSettings): number | undefined { - if (!this.provider.capabilities.supportsSandboxTimeout) { - if (sandboxSettings.sandboxTimeoutMs !== undefined) { - throw new SandboxProviderError( - `${this.provider.name} does not support configurable sandbox timeouts`, - "permanent" - ); - } - return undefined; - } - const timeoutMs = sandboxSettings.sandboxTimeoutMs; - return timeoutMs === undefined ? undefined : timeoutMs / 1000; - } - - private async storeAndBroadcastTunnelUrls( - urls: Record | undefined - ): Promise { - if (!urls || Object.keys(urls).length === 0) return; - this.log.info("Storing and broadcasting tunnel URLs", { ports: Object.keys(urls) }); - await this.storage.updateSandboxTunnelUrls(urls); - this.broadcaster.broadcast({ type: "sandbox_access_changed" }); - } - - /** Mint and persist terminal access. */ - private async storeTtyd( - url: string, - sandboxAuthToken: string, - sessionId: string, - sandboxId: string - ): Promise { - const token = await this.mintTtydToken(sandboxAuthToken, sessionId, sandboxId); - - this.log.info("Storing ttyd info", { url }); - await this.storage.updateSandboxAccess("ttyd", url, token); - } - - private mintTtydToken( - sandboxAuthToken: string, - sessionId: string, - sandboxId: string - ): Promise { - return mintJwt( - { - sub: sessionId, - sid: sandboxId, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + TERMINAL_TOKEN_TTL_SECONDS, - }, - sandboxAuthToken - ); - } - - private async recordPendingProviderReference( - generation: SandboxGeneration, - config: Pick< - CreateSandboxConfig, - "sessionId" | "sandboxId" | "generationCreatedAtMs" | "timeoutSeconds" - > - ): Promise { - if (!generation.sandboxId || config.sandboxId !== generation.sandboxId) - throw new SpawnSupersededError(); - const pending = this.provider.pendingSandboxAllocation?.(config); - if (!pending) return; - const row = this.storage.getSandbox(); - if ( - row?.modal_sandbox_id !== generation.sandboxId || - row.created_at !== generation.createdAt || - row.fenced - ) { - throw new SpawnSupersededError(); - } - const previousProviderObjectId = row.modal_object_id; - this.storage.updateSandboxModalObjectId(pending.reference); - const registered = await this.shutdown.recordPendingProviderHandle( - generation, - pending.reference, - pending.lifetime - ); - if (registered === "superseded") throw new SpawnSupersededError(); - if (registered === "expired") { - const current = this.storage.getSandbox(); - if ( - current?.modal_sandbox_id === generation.sandboxId && - current.created_at === generation.createdAt && - current.modal_object_id === pending.reference && - !current.fenced - ) { - this.storage.updateSandboxModalObjectId(previousProviderObjectId); - } - throw new SandboxLaunchExpiredError(); - } - } - - private async createWithVmRecovery( - config: CreateSandboxConfig, - generation: SandboxGeneration - ): Promise { - try { - return await this.provider.createSandbox(config); - } catch (error) { - if (!this.provider.isUnknownStartupError?.(error)) throw error; - const recovered = await this.resolveUnknownVmStartup(generation, config); - return recovered ? { ...recovered, createdAt: generation.createdAt } : null; - } - } - - private knownBridgeStartup( - generation: SandboxGeneration, - row: SandboxRow | null - ): ResolveSandboxResult | null { - const known = this.bridgeResolvedStartup; - if ( - !known || - row?.modal_sandbox_id !== generation.sandboxId || - row.created_at !== generation.createdAt || - row.fenced || - !["spawning", "connecting", "ready"].includes(row.status) || - row.modal_object_id !== known.result.providerObjectId || - known.generation.sandboxId !== generation.sandboxId || - known.generation.createdAt !== generation.createdAt - ) - return null; - return known.result; - } - - private async resolveUnknownVmStartup( - generation: SandboxGeneration, - config: Pick< - CreateSandboxConfig, - "sessionId" | "sandboxId" | "generationCreatedAtMs" | "timeoutSeconds" - > - ): Promise { - if (!this.provider.resolveSandbox) return null; - const reference = this.provider.pendingSandboxAllocation?.(config)?.reference; - while (true) { - const row = this.storage.getSandbox(); - const bridged = this.knownBridgeStartup(generation, row); - if (bridged) return bridged; - const resolvedByBridge = - !!row?.modal_object_id && - row.modal_object_id !== reference && - parsePendingVmReference(row.modal_object_id) === null; - if ( - row?.modal_sandbox_id !== generation.sandboxId || - row.created_at !== generation.createdAt || - row.fenced || - !["spawning", "connecting", "ready"].includes(row.status) || - (row.modal_object_id !== reference && !resolvedByBridge) - ) - return null; - try { - return await this.provider.resolveSandbox({ - ...config, - generationCreatedAtMs: generation.createdAt, - }); - } catch (error) { - const detail = vmAllocationDetail(error); - if (detail === "other_generation") throw error; - if (detail !== "not_visible" && !this.provider.isUnknownStartupError?.(error)) throw error; - if (Date.now() - generation.createdAt >= PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS) { - const current = this.storage.getSandbox(); - const bridgedAfterLookup = this.knownBridgeStartup(generation, current); - if (bridgedAfterLookup) return bridgedAfterLookup; - if ( - current?.modal_sandbox_id === generation.sandboxId && - current.created_at === generation.createdAt && - !current.fenced && - current.modal_object_id && - parsePendingVmReference(current.modal_object_id) === null - ) { - const lifetime = this.provider.pendingSandboxAllocation?.(config)?.lifetime; - if (lifetime) - return { - sandboxId: config.sandboxId, - providerObjectId: current.modal_object_id, - lifetime, - }; - } - if (detail === "not_visible") - throw new SandboxProviderError( - "The VM allocation did not appear for this attempt. Please retry.", - "transient", - error instanceof Error ? error : undefined - ); - if ( - current?.modal_sandbox_id === generation.sandboxId && - current.created_at === generation.createdAt && - !current.fenced && - current.modal_object_id === reference - ) - this.bridgeStartupClaim = generation; - return null; - } - } - await new Promise((resolve) => setTimeout(resolve, VM_RESOLVE_RETRY_MS)); - } - } - - /** Bridge-triggered reconciliation is lookup-only and never holds readiness. */ - private resolvePendingBridge(generation: SandboxGeneration): void { - if (this.bridgeResolution) { - if ( - this.bridgeResolution.sandboxId !== generation.sandboxId || - this.bridgeResolution.createdAt !== generation.createdAt - ) - this.bridgeRetryGeneration = generation; - return; - } - if (!this.provider.resolveSandbox) return; - const row = this.storage.getSandbox(); - const reference = row?.modal_object_id; - const pending = reference ? parsePendingVmReference(reference) : null; - const session = this.sessionContext.getSession(); - if ( - !row || - row.fenced || - !["spawning", "connecting", "ready"].includes(row.status) || - row.created_at !== generation.createdAt || - row.modal_sandbox_id !== generation.sandboxId || - !reference || - !pending || - !session || - pending.sandboxId !== row.modal_sandbox_id || - pending.sessionId !== (session.session_name || session.id) - ) - return; - const config = { - sessionId: pending.sessionId, - sandboxId: pending.sandboxId, - generationCreatedAtMs: generation.createdAt, - timeoutSeconds: this.resolveSandboxTimeoutSeconds(this.parseSandboxSettings(session)), - }; - const retryDeadlineAtMs = Date.now() + PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS; - this.bridgeResolution = generation; - const work = () => - (async () => { - let result: ResolveSandboxResult; - while (true) { - const current = this.storage.getSandbox(); - if ( - current?.modal_sandbox_id !== generation.sandboxId || - current.created_at !== generation.createdAt || - current.fenced || - !["spawning", "connecting", "ready"].includes(current.status) || - current.modal_object_id !== reference - ) - return; - try { - result = await this.provider.resolveSandbox!(config); - break; - } catch (error) { - const detail = vmAllocationDetail(error); - if (detail !== "not_visible" && !this.provider.isUnknownStartupError?.(error)) - throw error; - if ( - Date.now() >= retryDeadlineAtMs || - (detail === "not_visible" && - Date.now() - generation.createdAt >= PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS) || - this.bridgeRetryGeneration - ) - return; - await new Promise((resolve) => setTimeout(resolve, VM_RESOLVE_RETRY_MS)); - } - } - if (!result.providerObjectId) return; - const auth = this.vmStartupAuth; - const terminalToken = - result.ttydUrl && - auth && - auth.generation.sandboxId === generation.sandboxId && - auth.generation.createdAt === generation.createdAt - ? await this.mintTtydToken(auth.token, auth.sessionId, generation.sandboxId!) - : null; - const committed = await this.storage.completeProviderResume( - generation, - { - providerObjectId: result.providerObjectId, - codeServer: - result.codeServerUrl && result.codeServerPassword - ? { url: result.codeServerUrl, password: result.codeServerPassword } - : null, - vnc: result.vncAccess ?? null, - ttyd: - result.ttydUrl && terminalToken - ? { url: result.ttydUrl, token: terminalToken } - : null, - tunnelUrls: result.tunnelUrls ?? null, - }, - reference - ); - if (!committed) return; - this.bridgeResolvedStartup = { - generation, - result: { - sandboxId: result.sandboxId, - providerObjectId: result.providerObjectId, - lifetime: result.lifetime, - }, - }; - if ( - this.bridgeStartupClaim?.sandboxId === generation.sandboxId && - this.bridgeStartupClaim.createdAt === generation.createdAt - ) { - this.bridgeStartupClaim = null; - try { - await this.claimProviderStartup(generation, result.providerObjectId, result.lifetime); - } finally { - if ( - this.vmStartupAuth?.generation.sandboxId === generation.sandboxId && - this.vmStartupAuth.generation.createdAt === generation.createdAt - ) - this.vmStartupAuth = null; - } - } else { - this.shutdown.recordResolvedProviderHandle?.( - generation, - reference, - result.providerObjectId - ); - } - this.broadcastProviderAccessIfConnected(); - })() - .catch((error) => { - this.log.warn("Bridge VM resolution failed", { - event: "sandbox.vm_resolve_failed", - error: error instanceof Error ? error.message : String(error), - }); - }) - .finally(() => { - this.bridgeResolution = null; - const queued = this.bridgeRetryGeneration; - this.bridgeRetryGeneration = null; - if (queued) this.resolvePendingBridge(queued); - }); - if (this.backgroundTasks) this.backgroundTasks.submit(work, { name: "sandbox.vm_resolve" }); - else void work(); - } - private async handleRejectedStartupAllocation( error: unknown, generation: SandboxGeneration | null @@ -2776,42 +2100,26 @@ export class SandboxLifecycleManager // accept the allocation or lose the cleanup obligation. const rejection = this.storage.rejectProviderStartup(generation, error.providerObjectId); if (rejection === "superseded") { - await this.destroyLateProviderResult(error.providerObjectId ?? undefined); + await destroyLateProviderResult(this.allocationCleanup, error.providerObjectId ?? undefined); return; } this.wsManager.detachSandboxWebSocket(1008, "Provider allocation rejected"); - this.clearSandboxAccessState(); + this.access.clearAccess(); if (rejection === "failed") { this.broadcaster.broadcast({ type: "sandbox_status", status: "failed" }); this.reportSandboxError(error.message); this.recordSpawnFailure(Date.now(), generation.createdAt); } if (error.providerObjectId) - await this.attemptRejectedStartupCleanup(generation, error.providerObjectId); - } - - async rearmRejectedStartupCleanupAlarm(): Promise { - const row = this.storage.getSandbox(); - if (row?.startup_rejected && row.modal_object_id) { - await this.alarmScheduler.schedule(Date.now() + REJECTED_ALLOCATION_CLEANUP_RETRY_MS); - } + await attemptRejectedStartupCleanup( + this.allocationCleanup, + generation, + error.providerObjectId + ); } - private async attemptRejectedStartupCleanup( - generation: SandboxGeneration, - providerObjectId: string - ): Promise { - // Persist the next attempt before provider I/O so an eviction cannot lose cleanup. - await this.rearmRejectedStartupCleanupAlarm(); - if (!(await this.destroyLateProviderResult(providerObjectId))) return; - const row = this.storage.getSandbox(); - if ( - row?.modal_sandbox_id === generation.sandboxId && - row.created_at === generation.createdAt && - row.modal_object_id === providerObjectId - ) { - this.storage.updateSandboxModalObjectId(null); - } + rearmRejectedStartupCleanupAlarm(): Promise { + return rearmRejectedStartupCleanupAlarm(this.allocationCleanup); } private async claimProviderStartup( @@ -2840,14 +2148,14 @@ export class SandboxLifecycleManager !this.canStopProviderSandbox() ); if (status === null) { - await this.destroyLateProviderResult(providerObjectId); + await destroyLateProviderResult(this.allocationCleanup, providerObjectId); return false; } await this.shutdown.recordProviderStartup(generation, lifetime); if (announce) { try { - if (providerObjectId) this.broadcastSandboxDashboardUrl(providerObjectId); + if (providerObjectId) this.access.broadcastSandboxDashboardUrl(providerObjectId); if (!this.wsManager.getSandboxWebSocket() && status === "connecting") { this.broadcaster.broadcast({ type: "sandbox_status", status: "connecting" }); } @@ -2861,38 +2169,6 @@ export class SandboxLifecycleManager return true; } - private async destroyLateProviderResult(providerObjectId: string | undefined): Promise { - if (!providerObjectId || !this.canStopProviderSandbox()) return false; - const controller = new AbortController(); - let timeoutId: ReturnType | undefined; - try { - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - controller.abort(); - reject(new Error("Late provider cleanup timed out")); - }, PROVIDER_REPLACEMENT_STOP_TIMEOUT_MS); - }); - await Promise.race([ - this.stopProviderSandbox( - "startup_superseded", - "destroy", - controller.signal, - providerObjectId - ), - timeout, - ]); - return true; - } catch (error) { - this.log.warn("Failed to destroy superseded provider sandbox", { - provider_object_id: providerObjectId, - error: error instanceof Error ? error.message : String(error), - }); - return false; - } finally { - if (timeoutId !== undefined) clearTimeout(timeoutId); - } - } - private async enterProviderStartup( status: "spawning" | "connecting", createdAt: number, @@ -2936,7 +2212,7 @@ export class SandboxLifecycleManager * in flight, and for the user, who sees the boot begin. */ onSandboxSocketAttached(generation: SandboxGeneration): void { - this.resolvePendingBridge(generation); + this.vmStartup.resolvePendingBridge(generation); if (this.storage.transitionSandboxStatus(generation, "spawning", "connecting")) { this.broadcaster.broadcast({ type: "sandbox_status", status: "connecting" }); return; diff --git a/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts b/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts index b7edd0a479..8934af43e9 100644 --- a/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/pending-vm-respawn.test.ts @@ -8,7 +8,7 @@ import { formatPendingVmReference, parsePendingVmReference, } from "../providers/pending-vm-reference"; -import { DEFAULT_LIFECYCLE_CONFIG, SandboxLifecycleManager } from "./manager"; +import { DEFAULT_LIFECYCLE_CONFIG } from "./manager"; import { SandboxShutdownCoordinator } from "../../session/sandbox-shutdown"; import type { ShutdownRecord } from "../../session/sandbox-shutdown-repository"; import { @@ -26,6 +26,7 @@ import { createMockAlarmScheduler, createMockIdGenerator, createTestConfig, + createTestLifecycleManager, } from "./test-helpers"; describe("pending VM reference recovery", () => { @@ -130,7 +131,7 @@ describe("pending VM reference recovery", () => { retireAccess: vi.fn(), }; const shutdown = new SandboxShutdownCoordinator(deps as never); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, diff --git a/packages/control-plane/src/sandbox/lifecycle/ports.ts b/packages/control-plane/src/sandbox/lifecycle/ports.ts index 7792d0432e..b625cce996 100644 --- a/packages/control-plane/src/sandbox/lifecycle/ports.ts +++ b/packages/control-plane/src/sandbox/lifecycle/ports.ts @@ -23,7 +23,11 @@ export type SandboxStartupDecision = /** Internal facts used by lifecycle queue and push policies, not a caller-assembled protocol. */ export type SandboxWorkAdmission = - "unmanaged" | "ready" | "restore_required" | "spawn_required" | "held"; + | "unmanaged" + | "ready" + | "restore_required" + | "spawn_required" + | "held"; /** Only unmanaged sessions retain the legacy manual-push fallback when no socket exists. */ export type SandboxPushAdmission = "ready" | "unmanaged" | "held" | "start_required"; diff --git a/packages/control-plane/src/sandbox/lifecycle/provider-stop.ts b/packages/control-plane/src/sandbox/lifecycle/provider-stop.ts new file mode 100644 index 0000000000..83a8f342a5 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/provider-stop.ts @@ -0,0 +1,23 @@ +const PROVIDER_STOP_TIMEOUT_MS = 10_000; + +export type ProviderStopOutcome = "confirmed" | "not_stopped"; + +/** Bounds local waiting, not remote cancellation; callers own retirement policy. */ +export async function boundedProviderStop( + stop: (signal: AbortSignal) => Promise, + timeoutMessage: string +): Promise { + const controller = new AbortController(); + let timeoutId: ReturnType | undefined; + try { + const stopTimeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + reject(new Error(timeoutMessage)); + }, PROVIDER_STOP_TIMEOUT_MS); + }); + return await Promise.race([stop(controller.signal), stopTimeoutPromise]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} diff --git a/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts index aefda4cc9a..2d5acda6d5 100644 --- a/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts @@ -3,12 +3,14 @@ import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; import { createAlarmHandler } from "../../session/alarm/handler"; import { SandboxLaunchRejectedError } from "../provider"; import { ModalSandboxProvider } from "../providers/modal-provider"; -import type { ModalClient } from "../client"; +import { ModalApiError, type ModalClient } from "../client"; +import { formatPendingVmReference } from "../providers/pending-vm-reference"; +import { PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS } from "./decisions"; import { createAlarmFixture, createMockProvider, createMockSandbox } from "./test-helpers"; describe("rejected provider allocation", () => { - it.each(["stopped", "stale"] as const)( - "preserves %s when a cancelled launch is later rejected", + it.each(["failed", "stopped", "stale"] as const)( + "preserves %s when a terminalized launch is later rejected", async (status) => { const sandbox = createMockSandbox({ status: "pending", modal_object_id: null }); const provider = createMockProvider({ @@ -18,6 +20,8 @@ describe("rejected provider allocation", () => { }, createSandbox: async () => { sandbox.status = status; + sandbox.last_spawn_error = "existing terminal error"; + sandbox.last_spawn_error_at = 123; throw new SandboxLaunchRejectedError("incompatible", "sb-rejected"); }, }); @@ -30,6 +34,12 @@ describe("rejected provider allocation", () => { type: "sandbox_status", status: "failed", }); + expect(fixture.broadcaster.broadcast).not.toHaveBeenCalledWith({ + type: "sandbox_error", + error: "incompatible", + }); + expect(sandbox.last_spawn_error).toBe("existing terminal error"); + expect(sandbox.last_spawn_error_at).toBe(123); expect(fixture.storage.incrementCircuitBreakerFailure).not.toHaveBeenCalled(); } ); @@ -53,10 +63,15 @@ describe("rejected provider allocation", () => { }); it("fences an early connected generation before waiting for mismatch cleanup", async () => { const sandbox = createMockSandbox({ status: "pending", modal_object_id: null }); + let beginStop!: () => void; let finishStop!: () => void; + const stopping = new Promise((resolve) => (beginStop = resolve)); const client = { createSandbox: vi.fn(async () => { sandbox.status = "ready"; + sandbox.active_socket_id = "early-socket"; + sandbox.code_server_url = "https://early-access.test"; + sandbox.code_server_password = "early-secret"; vi.mocked(fixture.wsManager.getSandboxWebSocket).mockReturnValue({} as WebSocket); return { sandboxId: sandbox.modal_sandbox_id, @@ -69,6 +84,7 @@ describe("rejected provider allocation", () => { () => new Promise((resolve) => { finishStop = resolve; + beginStop(); }) ), }; @@ -78,11 +94,31 @@ describe("rejected provider allocation", () => { ); const spawning = fixture.manager.spawnSandbox(); try { - await vi.waitFor(() => expect(client.stopSandbox).toHaveBeenCalledOnce()); + await stopping; expect(sandbox.fenced).toBe(1); expect(sandbox.auth_token_hash).toBe(""); + expect(sandbox.active_socket_id).toBe(""); expect(sandbox.modal_object_id).toBe("sb-rejected"); - expect(fixture.wsManager.detachSandboxWebSocket).toHaveBeenCalled(); + expect(fixture.wsManager.detachSandboxWebSocket).toHaveBeenCalledWith( + 1008, + "Provider allocation rejected" + ); + expect(sandbox.code_server_url).toBeNull(); + expect(sandbox.code_server_password).toBeNull(); + expect( + vi.mocked(fixture.storage.rejectProviderStartup).mock.invocationCallOrder[0] + ).toBeLessThan( + vi.mocked(fixture.wsManager.detachSandboxWebSocket).mock.invocationCallOrder[0] + ); + expect( + vi.mocked(fixture.wsManager.detachSandboxWebSocket).mock.invocationCallOrder[0] + ).toBeLessThan(vi.mocked(fixture.storage.clearSandboxAccess).mock.invocationCallOrder[0]); + expect(fixture.storage.incrementCircuitBreakerFailure).toHaveBeenCalledOnce(); + expect( + fixture.broadcaster.messages.filter( + (message) => "type" in message && message.type === "sandbox_error" + ) + ).toEqual([{ type: "sandbox_error", error: expect.any(String) }]); expect(fixture.manager.mayProcessQueuedWork()).toBe(false); } finally { finishStop(); @@ -144,6 +180,67 @@ describe("rejected provider allocation", () => { expect(sandbox.fenced).toBe(1); expect(provider.createSandbox).toHaveBeenCalledTimes(1); }); + it.each(["new generation", "new timestamp", "new handle"] as const)( + "rearms cleanup before stop and does not clear a %s after the old stop succeeds", + async (replacement) => { + const sandbox = createMockSandbox({ status: "pending", modal_object_id: null }); + let beginStop!: () => void; + let releaseStop!: () => void; + const stopping = new Promise((resolve) => (beginStop = resolve)); + const gate = new Promise((resolve) => (releaseStop = resolve)); + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: true }, + createSandbox: async () => { + throw new SandboxLaunchRejectedError("incompatible", "sb-rejected"); + }, + stopSandbox: vi.fn(async () => { + beginStop(); + await gate; + return { success: true }; + }), + }); + const fixture = createAlarmFixture(sandbox, provider); + const spawning = fixture.manager.spawnSandbox(); + let replacementRow!: typeof sandbox; + try { + await stopping; + expect(sandbox).toMatchObject({ + status: "failed", + startup_rejected: 1, + fenced: 1, + modal_object_id: "sb-rejected", + }); + expect(fixture.alarmScheduler.schedule).toHaveBeenCalledTimes(2); + expect(vi.mocked(fixture.alarmScheduler.schedule).mock.invocationCallOrder[1]).toBeLessThan( + vi.mocked(provider.stopSandbox!).mock.invocationCallOrder[0] + ); + expect(provider.stopSandbox).toHaveBeenCalledExactlyOnceWith({ + providerObjectId: "sb-rejected", + sessionId: "test-session", + reason: "startup_superseded", + intent: "destroy", + signal: expect.any(AbortSignal), + generationCreatedAtMs: undefined, + }); + if (replacement === "new handle") sandbox.modal_object_id = "sb-newer"; + if (replacement === "new generation") { + sandbox.modal_sandbox_id = "newer-generation"; + } + if (replacement === "new timestamp") sandbox.created_at += 1; + sandbox.status = "ready"; + sandbox.fenced = 0; + sandbox.startup_rejected = 0; + sandbox.code_server_url = "https://replacement.test"; + sandbox.code_server_password = "replacement-secret"; + sandbox.active_socket_id = "replacement-socket"; + replacementRow = structuredClone(sandbox); + } finally { + releaseStop(); + await spawning; + } + expect(sandbox).toEqual(replacementRow); + } + ); it.each(["create", "restore"] as const)( "rearms %s cleanup through the assembled alarm handler even under a shutdown hold", async (launch) => { @@ -171,7 +268,8 @@ describe("rejected provider allocation", () => { async () => "hold_watchdogs" as never ); const handler = createAlarmHandler({ - preserveBeforeWatchdogs: () => restarted.manager.handleShutdownAlarm(), + preserveBeforeWatchdogs: (allowCaptureRetry: boolean) => + restarted.manager.handleShutdownAlarm(allowCaptureRetry), lifecycleManager: restarted.manager, terminalMessageProjection: { flushPending: vi.fn(async () => {}) }, } as never); @@ -183,6 +281,161 @@ describe("rejected provider allocation", () => { await handler.handle(); expect(sandbox.modal_object_id).toBeNull(); expect(sandbox.fenced).toBe(1); + const cleaned = structuredClone(sandbox); + const stopCount = stopSandbox.mock.calls.length; + await handler.handle(); + expect(stopSandbox).toHaveBeenCalledTimes(stopCount); + expect(sandbox).toEqual(cleaned); + } + ); + + it.each(["rejected", "successful"] as const)( + "retires a superseded %s allocation without changing the held successor", + async (outcome) => { + const sandbox = createMockSandbox({ status: "pending", modal_object_id: null }); + let replacementRow!: typeof sandbox; + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: true }, + stopSandbox: vi.fn(async () => ({ success: true })), + createSandbox: async () => { + sandbox.modal_sandbox_id = "successor"; + sandbox.created_at += 1; + sandbox.modal_object_id = "successor-handle"; + sandbox.status = "ready"; + sandbox.code_server_url = "https://successor.test"; + sandbox.code_server_password = "successor-secret"; + sandbox.active_socket_id = "successor-socket"; + replacementRow = structuredClone(sandbox); + vi.spyOn(fixture.shutdown, "isHolding").mockReturnValue(true); + if (outcome === "rejected") + throw new SandboxLaunchRejectedError("incompatible", "old-handle"); + return { + sandboxId: "old-generation", + providerObjectId: "old-handle", + createdAt: Date.now(), + lifetime: { kind: "none", observedAtMs: Date.now() }, + }; + }, + }); + const fixture = createAlarmFixture(sandbox, provider); + await fixture.manager.spawnSandbox(); + expect(provider.stopSandbox).toHaveBeenCalledExactlyOnceWith({ + providerObjectId: "old-handle", + sessionId: "test-session", + reason: "startup_superseded", + intent: "destroy", + signal: expect.any(AbortSignal), + generationCreatedAtMs: undefined, + }); + expect(sandbox).toEqual(replacementRow); + expect(fixture.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + expect(fixture.storage.clearSandboxAccess).not.toHaveBeenCalled(); + expect(fixture.storage.incrementCircuitBreakerFailure).not.toHaveBeenCalled(); + expect(fixture.broadcaster.messages).not.toContainEqual({ + type: "sandbox_status", + status: "failed", + }); + expect(fixture.broadcaster.messages).not.toContainEqual({ + type: "sandbox_error", + error: "incompatible", + }); + } + ); + + it.each(["capability disabled", "method absent"] as const)( + "retains and rearms rejected cleanup without explicit stop (%s)", + async (unsupported) => { + const sandbox = createMockSandbox({ status: "pending", modal_object_id: null }); + const stopSandbox = vi.fn(async () => ({ success: true })); + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: unsupported !== "capability disabled" }, + stopSandbox: unsupported === "method absent" ? undefined : stopSandbox, + createSandbox: async () => { + throw new SandboxLaunchRejectedError("incompatible", "sb-rejected"); + }, + }); + const fixture = createAlarmFixture(sandbox, provider); + await fixture.manager.spawnSandbox(); + await fixture.manager.handleShutdownAlarm(); + expect(sandbox.modal_object_id).toBe("sb-rejected"); + expect(fixture.alarmScheduler.schedule).toHaveBeenCalledTimes(3); + expect(stopSandbox).not.toHaveBeenCalled(); + } + ); + + it("retains the rejected handle when the provider reports an unsuccessful stop", async () => { + const sandbox = createMockSandbox({ status: "pending", modal_object_id: null }); + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: true }, + stopSandbox: vi.fn(async () => ({ success: false, error: "retirement unconfirmed" })), + createSandbox: async () => { + throw new SandboxLaunchRejectedError("incompatible", "sb-rejected"); + }, + }); + const fixture = createAlarmFixture(sandbox, provider); + await fixture.manager.spawnSandbox(); + await fixture.manager.handleShutdownAlarm(); + expect(sandbox.modal_object_id).toBe("sb-rejected"); + expect(provider.stopSandbox).toHaveBeenCalledTimes(2); + expect(fixture.alarmScheduler.schedule).toHaveBeenCalledTimes(3); + }); + + it("retains and rearms rejected cleanup when session context prevents stop dispatch", async () => { + const sandbox = createMockSandbox({ + status: "failed", + fenced: 1, + startup_rejected: 1, + modal_object_id: "sb-rejected", + }); + const provider = createMockProvider({ + capabilities: { supportsExplicitStop: true }, + stopSandbox: vi.fn(async () => ({ success: true })), + }); + const fixture = createAlarmFixture(sandbox, provider); + const session = fixture.storage.getSession(); + vi.mocked(fixture.storage.getSession).mockReturnValue(null); + expect(await fixture.manager.handleShutdownAlarm()).toBe("hold_watchdogs"); + expect(provider.stopSandbox).not.toHaveBeenCalled(); + expect(sandbox.modal_object_id).toBe("sb-rejected"); + expect(fixture.alarmScheduler.schedule).toHaveBeenCalledOnce(); + vi.mocked(fixture.storage.getSession).mockReturnValue(session); + await fixture.manager.handleShutdownAlarm(); + expect(provider.stopSandbox).toHaveBeenCalledOnce(); + expect(sandbox.modal_object_id).toBeNull(); + expect(fixture.alarmScheduler.schedule).toHaveBeenCalledTimes(2); + }); + + it.each([0, PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS])( + "does not reinterpret invisible pending cleanup as retirement based on row age (%s ms)", + async (ageMs) => { + const handle = formatPendingVmReference("test-session", "rejected-generation"); + const sandbox = createMockSandbox({ + status: "failed", + fenced: 1, + startup_rejected: 1, + modal_sandbox_id: "rejected-generation", + modal_object_id: handle, + created_at: Date.now() - ageMs, + }); + const client = { + stopSandbox: vi.fn(async () => { + throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); + }), + }; + const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const stop = vi.spyOn(provider, "stopSandbox"); + const fixture = createAlarmFixture(sandbox, provider); + expect(await fixture.manager.handleShutdownAlarm()).toBe("hold_watchdogs"); + expect(stop).toHaveBeenCalledWith({ + providerObjectId: handle, + sessionId: "test-session", + reason: "startup_superseded", + intent: "destroy", + signal: expect.any(AbortSignal), + generationCreatedAtMs: undefined, + }); + expect(sandbox.modal_object_id).toBe(handle); + expect(fixture.alarmScheduler.schedule).toHaveBeenCalledOnce(); } ); }); diff --git a/packages/control-plane/src/sandbox/lifecycle/sandbox-access.test.ts b/packages/control-plane/src/sandbox/lifecycle/sandbox-access.test.ts new file mode 100644 index 0000000000..d5244c5c44 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/sandbox-access.test.ts @@ -0,0 +1,342 @@ +import { createHmac } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + SandboxAccess, + TERMINAL_TOKEN_TTL_SECONDS, + type SandboxAccessDependencies, + type SandboxAccessStorage, +} from "./sandbox-access"; + +const URL = "https://access.test"; +const SECRET = "private-sandbox-secret"; +const CHANGED = { type: "sandbox_access_changed" } as const; + +function fixture(overrides: Partial = {}) { + const calls: string[] = []; + const storage = { + updateSandboxAccess: vi.fn(), + clearSandboxAccess: vi.fn((kind) => { + calls.push(`clear:${kind}`); + }), + clearSandboxAccessUrl: vi.fn>( + (kind) => { + calls.push(`clearUrl:${kind}`); + } + ), + updateSandboxTunnelUrls: vi.fn(), + clearSandboxTunnelUrls: vi.fn(() => { + calls.push("clearTunnels"); + }), + }; + const broadcaster = { + broadcast: vi.fn(() => { + calls.push("broadcast"); + }), + }; + const sockets = { + getSandboxWebSocket: vi.fn( + () => null + ), + detachSandboxWebSocket: vi.fn(() => { + calls.push("detach"); + }), + }; + const logger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + const getLogger = vi.fn(() => logger); + const canResumeAfterStop = vi.fn(() => false); + const sandboxDashboardUrlBuilder = vi.fn< + NonNullable + >(() => null); + const dependencies: SandboxAccessDependencies = { + storage, + broadcaster, + sockets, + canResumeAfterStop, + getLogger, + sandboxDashboardUrlBuilder, + ...overrides, + }; + return { + access: new SandboxAccess(dependencies), + dependencies, + storage, + broadcaster, + sockets, + logger, + getLogger, + canResumeAfterStop, + sandboxDashboardUrlBuilder, + calls, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("SandboxAccess", () => { + it("does no dependency work at construction, even without an initialized logger", () => { + const getLogger = vi.fn(() => { + throw new Error("session not initialized"); + }); + const f = fixture({ getLogger }); + expect(f.access).toBeInstanceOf(SandboxAccess); + + for (const dependency of [ + ...Object.values(f.storage), + ...Object.values(f.broadcaster), + ...Object.values(f.sockets), + ...Object.values(f.logger), + getLogger, + f.canResumeAfterStop, + f.sandboxDashboardUrlBuilder, + ]) { + expect(dependency).not.toHaveBeenCalled(); + } + }); + + it.each([ + { resumable: false, urlSupport: true, clear: "clear" }, + { resumable: true, urlSupport: true, clear: "clearUrl" }, + { resumable: true, urlSupport: false, clear: "clear" }, + ])( + "clears all access and tunnels before notifying and detaching (resumable=$resumable, urlSupport=$urlSupport)", + ({ resumable, urlSupport, clear }) => { + const f = fixture(); + f.canResumeAfterStop.mockReturnValue(resumable); + if (!urlSupport) delete f.dependencies.storage.clearSandboxAccessUrl; + const cleared = [ + `${clear}:codeServer`, + `${clear}:vnc`, + `${clear}:ttyd`, + "clearTunnels", + "broadcast", + ]; + + f.access.clearAccess(); + expect(f.calls).toEqual(cleared); + expect(f.sockets.detachSandboxWebSocket).not.toHaveBeenCalled(); + + f.access.retireShutdownAccess(); + expect(f.calls).toEqual([...cleared, ...cleared, "detach"]); + expect(f.broadcaster.broadcast.mock.calls).toEqual([[CHANGED], [CHANGED]]); + expect(f.sockets.detachSandboxWebSocket).toHaveBeenCalledExactlyOnceWith( + 1000, + "Sandbox state preserved" + ); + expect(f.canResumeAfterStop).toHaveBeenCalledTimes(2); + expect(f.getLogger).not.toHaveBeenCalled(); + } + ); + + it.each(["credentials", "urls", "tunnels", "broadcast"] as const)( + "propagates a %s retirement failure without detaching or catching it", + (stage) => { + const f = fixture(); + const error = new Error("retirement failed"); + f.canResumeAfterStop.mockReturnValue(stage === "urls"); + const failing = { + credentials: f.storage.clearSandboxAccess, + urls: f.storage.clearSandboxAccessUrl, + tunnels: f.storage.clearSandboxTunnelUrls, + broadcast: f.broadcaster.broadcast, + }[stage]; + failing.mockImplementationOnce(() => { + throw error; + }); + + expect(() => f.access.retireShutdownAccess()).toThrow(error); + expect(f.sockets.detachSandboxWebSocket).not.toHaveBeenCalled(); + if (stage !== "broadcast") expect(f.broadcaster.broadcast).not.toHaveBeenCalled(); + expect(f.getLogger).not.toHaveBeenCalled(); + } + ); + + it.each([ + { + kind: "codeServer", + store: (access: SandboxAccess) => access.storeCodeServer(URL, SECRET), + message: "Storing code-server info", + }, + { + kind: "vnc", + store: (access: SandboxAccess) => access.storeVnc(URL, SECRET), + message: "Storing VNC info", + }, + { + kind: "ttyd", + store: (access: SandboxAccess) => access.storeTtyd(URL, SECRET, "session-1", "sandbox-1"), + message: "Storing ttyd info", + }, + ])( + "awaits $kind persistence, propagates failures, and resolves the current logger without logging secrets", + async ({ kind, store, message }) => { + const f = fixture(); + let resolve!: () => void; + f.storage.updateSandboxAccess.mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }) + ); + const settled = vi.fn(); + const storing = store(f.access).then(settled); + + await vi.waitFor(() => expect(f.storage.updateSandboxAccess).toHaveBeenCalledOnce()); + expect(f.storage.updateSandboxAccess).toHaveBeenCalledWith( + kind, + URL, + kind === "ttyd" ? expect.any(String) : SECRET + ); + if (kind === "ttyd") { + expect(f.storage.updateSandboxAccess.mock.calls[0][2]).not.toBe(SECRET); + } + expect(settled).not.toHaveBeenCalled(); + resolve(); + await storing; + expect(settled).toHaveBeenCalledOnce(); + + const nextLogger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn() }; + f.getLogger.mockReturnValue(nextLogger); + const error = new Error("persistence failed"); + f.storage.updateSandboxAccess.mockRejectedValueOnce(error); + await expect(store(f.access)).rejects.toBe(error); + + expect(f.logger.info.mock.calls).toEqual([[message, { url: URL }]]); + expect(nextLogger.info.mock.calls).toEqual([[message, { url: URL }]]); + expect(f.getLogger).toHaveBeenCalledTimes(2); + expect(f.logger.warn).not.toHaveBeenCalled(); + expect(nextLogger.warn).not.toHaveBeenCalled(); + expect(f.broadcaster.broadcast).not.toHaveBeenCalled(); + } + ); + + it("mints only the terminal claims with the TTL and an HS256 signature, without dependency work", async () => { + const f = fixture(); + const nowMs = 1_700_000_000_999; + vi.spyOn(Date, "now").mockReturnValue(nowMs); + + const token = await f.access.mintTtydToken(SECRET, "session-1", "sandbox-1"); + const [header, body, signature] = token.split("."); + expect(JSON.parse(Buffer.from(header, "base64url").toString())).toEqual({ + alg: "HS256", + typ: "JWT", + }); + expect(JSON.parse(Buffer.from(body, "base64url").toString())).toEqual({ + sub: "session-1", + sid: "sandbox-1", + iat: Math.floor(nowMs / 1000), + exp: Math.floor(nowMs / 1000) + TERMINAL_TOKEN_TTL_SECONDS, + }); + expect(signature).toBe( + createHmac("sha256", SECRET).update(`${header}.${body}`).digest("base64url") + ); + expect(f.storage.updateSandboxAccess).not.toHaveBeenCalled(); + expect(f.broadcaster.broadcast).not.toHaveBeenCalled(); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("reuses an unexpired token and warns only for advertised access with a missing or expired token", async () => { + const f = fixture(); + const nowMs = 1_700_000_000_000; + const clock = vi.spyOn(Date, "now").mockReturnValue(nowMs); + const token = await f.access.mintTtydToken(SECRET, "session-1", "sandbox-1"); + + expect(f.access.reusableTtydToken(token, URL, "provider-1")).toBe(token); + expect(f.access.reusableTtydToken(token, undefined, "provider-1")).toBe(token); + expect(f.access.reusableTtydToken(null, undefined, "provider-1")).toBeNull(); + clock.mockReturnValue(nowMs + TERMINAL_TOKEN_TTL_SECONDS * 1000); + expect(f.access.reusableTtydToken(token, undefined, "provider-1")).toBeNull(); + expect(f.getLogger).not.toHaveBeenCalled(); + + expect(f.access.reusableTtydToken(null, URL, "provider-1")).toBeNull(); + expect(f.access.reusableTtydToken(token, URL, "provider-1")).toBeNull(); + expect(f.logger.warn.mock.calls).toEqual( + ["missing", "invalid_or_expired"].map((reason) => [ + "Terminal credential unavailable; resuming without terminal access", + { + event: "sandbox.resume_terminal_credential_unavailable", + provider_object_id: "provider-1", + reason, + }, + ]) + ); + expect(f.storage.updateSandboxAccess).not.toHaveBeenCalled(); + expect(f.broadcaster.broadcast).not.toHaveBeenCalled(); + }); + + it("skips absent or empty tunnels without storage, notification, or logger resolution", async () => { + const f = fixture(); + + await f.access.storeAndBroadcastTunnelUrls(undefined); + await f.access.storeAndBroadcastTunnelUrls({}); + + expect(f.storage.updateSandboxTunnelUrls).not.toHaveBeenCalled(); + expect(f.broadcaster.broadcast).not.toHaveBeenCalled(); + expect(f.getLogger).not.toHaveBeenCalled(); + }); + + it("yields before each tunnel notification even for synchronous storage, and propagates rejection without notifying", async () => { + const f = fixture(); + const urls = { "3000": "https://preview.test", "8080": "https://api.test" }; + + for (let count = 0; count < 2; count++) { + const storing = f.access.storeAndBroadcastTunnelUrls(urls); + expect(f.storage.updateSandboxTunnelUrls).toHaveBeenCalledWith(urls); + expect(f.broadcaster.broadcast).toHaveBeenCalledTimes(count); + await storing; + expect(f.broadcaster.broadcast).toHaveBeenCalledTimes(count + 1); + } + + const error = new Error("tunnel persistence failed"); + f.storage.updateSandboxTunnelUrls.mockRejectedValueOnce(error); + await expect(f.access.storeAndBroadcastTunnelUrls(urls)).rejects.toBe(error); + expect(f.storage.updateSandboxTunnelUrls.mock.calls).toEqual([[urls], [urls], [urls]]); + expect(f.broadcaster.broadcast.mock.calls).toEqual([[CHANGED], [CHANGED]]); + expect(f.logger.info.mock.calls).toEqual( + Array.from({ length: 3 }, () => [ + "Storing and broadcasting tunnel URLs", + { ports: ["3000", "8080"] }, + ]) + ); + expect(f.logger.warn).not.toHaveBeenCalled(); + }); + + it("keeps dashboard and connected notifications distinct and repeatable, with no notification when unavailable", () => { + const f = fixture(); + const withoutBuilder = new SandboxAccess({ + ...f.dependencies, + sandboxDashboardUrlBuilder: undefined, + }); + + expect(withoutBuilder.broadcastSandboxDashboardUrl("provider-1")).toBe(false); + expect(f.sandboxDashboardUrlBuilder).not.toHaveBeenCalled(); + expect(f.access.broadcastSandboxDashboardUrl("provider-1")).toBe(false); + f.access.broadcastProviderAccessIfConnected(); + expect(f.broadcaster.broadcast).not.toHaveBeenCalled(); + expect(f.getLogger).not.toHaveBeenCalled(); + + f.sandboxDashboardUrlBuilder.mockReturnValue("https://dashboard.test/provider-1"); + f.sockets.getSandboxWebSocket.mockReturnValue({ + readyState: 1, + send: vi.fn(), + close: vi.fn(), + }); + for (let count = 0; count < 2; count++) { + expect(f.access.broadcastSandboxDashboardUrl("provider-1")).toBe(true); + f.access.broadcastProviderAccessIfConnected(); + } + + expect(f.broadcaster.broadcast.mock.calls).toEqual(Array.from({ length: 4 }, () => [CHANGED])); + expect(f.sandboxDashboardUrlBuilder.mock.calls).toEqual([ + ["provider-1"], + ["provider-1"], + ["provider-1"], + ]); + expect(f.sockets.getSandboxWebSocket).toHaveBeenCalledTimes(3); + expect(f.logger.debug.mock.calls).toEqual( + Array.from({ length: 2 }, () => [ + "Broadcasting sandbox dashboard URL", + { provider_object_id: "provider-1" }, + ]) + ); + expect(f.getLogger).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/control-plane/src/sandbox/lifecycle/sandbox-access.ts b/packages/control-plane/src/sandbox/lifecycle/sandbox-access.ts new file mode 100644 index 0000000000..dadd8527dd --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/sandbox-access.ts @@ -0,0 +1,136 @@ +import type { ServerMessage } from "@open-inspect/shared/types/server-messages"; +import { isJwtUnexpired, mintJwt } from "../../auth/jwt"; +import type { Logger } from "../../logger"; +import type { SessionWebSocket } from "../../platform-ports"; +import type { SandboxAccessKind } from "../../session/types"; + +/** TTL for terminal auth JWTs. */ +export const TERMINAL_TOKEN_TTL_SECONDS = 86400; + +/** Encryption and persistence remain repository-owned. */ +export interface SandboxAccessStorage { + updateSandboxAccess(kind: SandboxAccessKind, url: string, secret: string): void | Promise; + clearSandboxAccess(kind: SandboxAccessKind): void; + clearSandboxAccessUrl?(kind: SandboxAccessKind): void; + updateSandboxTunnelUrls(urls: Record): void | Promise; + clearSandboxTunnelUrls(): void; +} + +export interface SandboxAccessDependencies { + storage: SandboxAccessStorage; + broadcaster: { broadcast(message: ServerMessage): void }; + sockets: { + getSandboxWebSocket(): SessionWebSocket | null; + detachSandboxWebSocket(code: number, reason: string): void; + }; + /** Capability check only, not a lifecycle/admission decision. */ + canResumeAfterStop: () => boolean; + /** Construction may precede session initialization. */ + getLogger: () => Pick; + sandboxDashboardUrlBuilder?: (providerObjectId: string) => string | null; +} + +/** Internal access mechanics, with no readiness, generation or shutdown authority. */ +export class SandboxAccess { + constructor(private readonly deps: SandboxAccessDependencies) {} + + clearAccess(): void { + const { storage, broadcaster, canResumeAfterStop } = this.deps; + // Retained executions reuse credentials, while snapshot restores rotate them. + if (canResumeAfterStop() && storage.clearSandboxAccessUrl) { + storage.clearSandboxAccessUrl("codeServer"); + storage.clearSandboxAccessUrl("vnc"); + storage.clearSandboxAccessUrl("ttyd"); + } else { + storage.clearSandboxAccess("codeServer"); + storage.clearSandboxAccess("vnc"); + storage.clearSandboxAccess("ttyd"); + } + storage.clearSandboxTunnelUrls(); + broadcaster.broadcast({ type: "sandbox_access_changed" }); + } + + retireShutdownAccess(): void { + this.clearAccess(); + this.deps.sockets.detachSandboxWebSocket(1000, "Sandbox state preserved"); + } + + async storeCodeServer(url: string, password: string): Promise { + this.deps.getLogger().info("Storing code-server info", { url }); + await this.deps.storage.updateSandboxAccess("codeServer", url, password); + } + + async storeVnc(url: string, password: string): Promise { + this.deps.getLogger().info("Storing VNC info", { url }); + await this.deps.storage.updateSandboxAccess("vnc", url, password); + } + + async storeAndBroadcastTunnelUrls(urls: Record | undefined): Promise { + if (!urls || Object.keys(urls).length === 0) return; + this.deps + .getLogger() + .info("Storing and broadcasting tunnel URLs", { ports: Object.keys(urls) }); + await this.deps.storage.updateSandboxTunnelUrls(urls); + this.deps.broadcaster.broadcast({ type: "sandbox_access_changed" }); + } + + async storeTtyd( + url: string, + sandboxAuthToken: string, + sessionId: string, + sandboxId: string + ): Promise { + const token = await this.mintTtydToken(sandboxAuthToken, sessionId, sandboxId); + this.deps.getLogger().info("Storing ttyd info", { url }); + await this.deps.storage.updateSandboxAccess("ttyd", url, token); + } + + mintTtydToken(sandboxAuthToken: string, sessionId: string, sandboxId: string): Promise { + return mintJwt( + { + sub: sessionId, + sid: sandboxId, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + TERMINAL_TOKEN_TTL_SECONDS, + }, + sandboxAuthToken + ); + } + + reusableTtydToken( + token: string | null, + url: string | undefined, + providerObjectId: string + ): string | null { + const validToken = token && isJwtUnexpired(token) ? token : null; + if (url && !validToken) { + // The signing key is transient; its persisted hash cannot renew terminal access. + this.deps + .getLogger() + .warn("Terminal credential unavailable; resuming without terminal access", { + event: "sandbox.resume_terminal_credential_unavailable", + provider_object_id: providerObjectId, + reason: token ? "invalid_or_expired" : "missing", + }); + } + return validToken; + } + + broadcastSandboxDashboardUrl(providerObjectId: string): boolean { + const url = this.deps.sandboxDashboardUrlBuilder?.(providerObjectId); + if (url) { + this.deps.getLogger().debug("Broadcasting sandbox dashboard URL", { + provider_object_id: providerObjectId, + }); + this.deps.broadcaster.broadcast({ type: "sandbox_access_changed" }); + return true; + } + return false; + } + + broadcastProviderAccessIfConnected(): void { + if (this.deps.sockets.getSandboxWebSocket()) { + this.deps.broadcaster.broadcast({ type: "sandbox_access_changed" }); + } + } +} diff --git a/packages/control-plane/src/sandbox/lifecycle/startup-errors.ts b/packages/control-plane/src/sandbox/lifecycle/startup-errors.ts new file mode 100644 index 0000000000..58007c4866 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/startup-errors.ts @@ -0,0 +1,19 @@ +import { SandboxProviderError } from "../provider"; + +/** A replaced reservation must abandon without writing another attempt's failure. */ +export class SpawnSupersededError extends Error { + constructor() { + super("Spawn reservation superseded before its auth hash was published"); + this.name = "SpawnSupersededError"; + } +} + +export class SandboxLaunchExpiredError extends SandboxProviderError { + constructor() { + super( + "The sandbox timeout leaves no time before the final save begins. Increase the sandbox timeout or reduce the final snapshot buffer in the sandbox settings.", + "transient" + ); + this.name = "SandboxLaunchExpiredError"; + } +} diff --git a/packages/control-plane/src/sandbox/lifecycle/test-helpers.ts b/packages/control-plane/src/sandbox/lifecycle/test-helpers.ts index 4094ac152c..321a9d3ef0 100644 --- a/packages/control-plane/src/sandbox/lifecycle/test-helpers.ts +++ b/packages/control-plane/src/sandbox/lifecycle/test-helpers.ts @@ -13,6 +13,14 @@ import { type SandboxShutdownLifecycle, } from "./manager"; import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; +import { createLogger } from "../../logger"; +import type { BackgroundTasks } from "../../platform-ports"; +import type { ImageBuildLookup } from "./image-selection"; +import { + SandboxAccess, + type SandboxAccessDependencies, + type SandboxAccessStorage, +} from "./sandbox-access"; import { SandboxShutdownCoordinator } from "../../session/sandbox-shutdown"; import type { ShutdownRecord } from "../../session/sandbox-shutdown-repository"; import type { @@ -30,6 +38,7 @@ import type { StopConfig, StopResult, } from "../provider"; +import { providerResumesAfterStop } from "../provider"; import type { SandboxAccessKind, SandboxRow, SessionRow } from "../../session/types"; import type { SandboxStatus } from "@open-inspect/shared/types/sessions"; @@ -118,7 +127,7 @@ export function createMockStorage( | null = createMockSandbox(), userEnvVars: Record | undefined = undefined, sessionRepositories: SessionRepositoryInfo[] = [] -): SandboxStorage & SessionContextReader & { calls: string[] } { +): SandboxStorage & SandboxAccessStorage & SessionContextReader & { calls: string[] } { const calls: string[] = []; return { @@ -449,6 +458,47 @@ export function createTestConfig(): SandboxLifecycleConfig { }; } +export function createTestLifecycleManager( + provider: SandboxProvider, + storage: SandboxStorage & SandboxAccessStorage, + sessionContext: SessionContextReader, + broadcaster: SandboxBroadcaster, + wsManager: WebSocketManager, + alarmScheduler: AlarmScheduler, + idGenerator: IdGenerator, + shutdown: SandboxShutdownLifecycle, + config: SandboxLifecycleConfig & Pick, + imageBuildLookup?: ImageBuildLookup, + backgroundTasks?: BackgroundTasks +): SandboxLifecycleManager { + const access = new SandboxAccess({ + storage, + broadcaster, + sockets: wsManager, + canResumeAfterStop: () => providerResumesAfterStop(provider), + getLogger: () => { + const log = createLogger("lifecycle-manager"); + const sessionId = config.getSessionId?.(); + return sessionId ? log.child({ session_id: sessionId }) : log; + }, + sandboxDashboardUrlBuilder: config.sandboxDashboardUrlBuilder, + }); + return new SandboxLifecycleManager( + provider, + storage, + sessionContext, + broadcaster, + wsManager, + alarmScheduler, + idGenerator, + shutdown, + access, + config, + imageBuildLookup, + backgroundTasks + ); +} + export function createUnmanagedShutdown() { return { reserveStartup: vi.fn((_createdAt, _policy, persist) => persist()), @@ -535,7 +585,7 @@ export function createAlarmFixture( const wsManager = createMockWebSocketManager(false, clientCount); const alarmScheduler = createMockAlarmScheduler(); const shutdown = createCheckpointShutdown(provider, storage, broadcaster, onLifecycleChange); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, diff --git a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts index b972627940..f01449f317 100644 --- a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts @@ -1,9 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ModalApiError, type ModalClient, type ResolveVmSandboxResponse } from "../client"; +import { + ModalApiError, + type ModalClient, + type CreateSandboxRequest, + type ResolveVmSandboxResponse, +} from "../client"; import { ModalSandboxProvider } from "../providers/modal-provider"; import { formatPendingVmReference } from "../providers/pending-vm-reference"; import { RequestDeadlineError } from "../request-deadline"; -import { SandboxLifecycleManager } from "./manager"; import type { ImageBuildLookup } from "./image-selection"; import { computeRepositoriesFingerprint } from "../../image-builds/fingerprint"; import { PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS } from "./decisions"; @@ -18,6 +22,7 @@ import { createMockAlarmScheduler, createMockIdGenerator, createTestConfig, + createTestLifecycleManager, } from "./test-helpers"; import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; @@ -54,7 +59,7 @@ function fixture(action: "create" | "restore" = "create", imageBuildLookup?: Ima const client = { createSandbox: vi.fn( async ( - _config: unknown + _config: CreateSandboxRequest ): Promise<{ sandboxId: string; modalObjectId: string; @@ -121,7 +126,7 @@ function fixture(action: "create" | "restore" = "create", imageBuildLookup?: Ima retireAccess: vi.fn(), }; const makeManager = () => - new SandboxLifecycleManager( + createTestLifecycleManager( provider, storage, storage, @@ -134,11 +139,52 @@ function fixture(action: "create" | "restore" = "create", imageBuildLookup?: Ima imageBuildLookup, backgroundTasks ); - return { sandbox, storage, broadcaster, client, store, makeManager, wsManager, backgroundTasks }; + return { + session, + sandbox, + storage, + broadcaster, + client, + provider, + store, + makeManager, + wsManager, + backgroundTasks, + }; } describe("modal-vm startup resolution", () => { - afterEach(() => vi.useRealTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("resolves bridge settings only after the pending-reference eligibility checks", async () => { + const f = fixture(); + f.sandbox.status = "ready"; + const settingsRead = vi.fn(() => '{"sandboxTimeoutMs":3600000}'); + Object.defineProperty(f.session, "sandbox_settings", { get: settingsRead }); + const resolveSandbox = vi.spyOn(f.provider, "resolveSandbox"); + const manager = f.makeManager(); + const generation = { sandboxId: f.sandbox.modal_sandbox_id!, createdAt: f.sandbox.created_at }; + expect(settingsRead).not.toHaveBeenCalled(); + manager.onSandboxSocketAttached(generation); + f.sandbox.modal_object_id = formatPendingVmReference("another-session", generation.sandboxId); + manager.onSandboxSocketAttached(generation); + expect(settingsRead).not.toHaveBeenCalled(); + expect(f.client.resolveVmSandbox).not.toHaveBeenCalled(); + + f.sandbox.modal_object_id = formatPendingVmReference("test-session", generation.sandboxId); + manager.onSandboxSocketAttached(generation); + expect(settingsRead).toHaveBeenCalledOnce(); + expect(resolveSandbox).toHaveBeenCalledExactlyOnceWith({ + sessionId: "test-session", + sandboxId: generation.sandboxId, + generationCreatedAtMs: generation.createdAt, + timeoutSeconds: 3600, + }); + await vi.waitFor(() => expect(f.sandbox.modal_object_id).toBe("sb-real")); + }); it.each(["create", "restore"] as const)( "recovers an unknown %s without resetting lifetime", @@ -201,6 +247,23 @@ describe("modal-vm startup resolution", () => { expect(f.client.resolveVmSandbox).toHaveBeenCalledOnce(); }); + it("fails a create rejected before allocation without resolving, counting the failure", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); + const f = fixture(); + f.client.createSandbox.mockRejectedValue( + new ModalApiError("Modal API error: 501", 501, "docker_not_available") + ); + f.client.resolveVmSandbox.mockRejectedValue(new ModalApiError("invisible", 409, "not_visible")); + const spawning = f.makeManager().spawnSandbox(); + await vi.waitFor(() => expect(f.client.createSandbox).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS + 20_000); + await spawning; + expect(f.client.resolveVmSandbox).not.toHaveBeenCalled(); + expect(f.sandbox.status).toBe("failed"); + expect(f.sandbox.spawn_failure_count).toBe(1); + }); + it("resolves an ambiguous base-image retry after a prebuilt image is unavailable", async () => { const imageBuildLookup: ImageBuildLookup = { getLatestReady: vi.fn(async () => ({ @@ -248,6 +311,100 @@ describe("modal-vm startup resolution", () => { ); }); + it("retains the foreground token for an equal-valued bridge claim after inconclusive lookup", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); + const f = fixture(); + f.client.resolveVmSandbox.mockRejectedValue(new ModalApiError("unavailable", 503)); + const manager = f.makeManager(); + const spawning = manager.spawnSandbox(); + await vi.waitFor(() => expect(f.client.resolveVmSandbox).toHaveBeenCalledOnce()); + await vi.advanceTimersByTimeAsync(PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS + 20_000); + await spawning; + expect(f.sandbox.status).toBe("spawning"); + expect(f.sandbox.ttyd_token).toBeNull(); + expect(f.client.createSandbox).toHaveBeenCalledOnce(); + + f.client.resolveVmSandbox.mockResolvedValue({ + sandboxId: f.sandbox.modal_sandbox_id!, + modalObjectId: "sb-real", + sandboxBackend: "modal-vm", + ttydUrl: "https://terminal.example", + }); + manager.onSandboxSocketAttached({ + sandboxId: f.sandbox.modal_sandbox_id!, + createdAt: f.sandbox.created_at, + }); + await vi.waitFor(() => expect(f.sandbox.modal_object_id).toBe("sb-real")); + expect(f.sandbox.ttyd_token).toBeTruthy(); + expect(f.store.read()).toMatchObject({ phase: "running", providerObjectId: "sb-real" }); + expect(f.client.createSandbox).toHaveBeenCalledOnce(); + }); + + it("does not announce bridge access when shutdown becomes held during access completion", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); + const f = fixture(); + let lookupEntered!: () => void; + const lookupStarted = new Promise((resolve) => (lookupEntered = resolve)); + f.client.resolveVmSandbox.mockImplementation(async () => { + lookupEntered(); + throw new ModalApiError("unavailable", 503); + }); + const manager = f.makeManager(); + const spawning = manager.spawnSandbox(); + await lookupStarted; + await vi.advanceTimersByTimeAsync(PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS + 20_000); + await spawning; + + const generation = { sandboxId: f.sandbox.modal_sandbox_id!, createdAt: f.sandbox.created_at }; + const pending = f.sandbox.modal_object_id; + f.client.stopSandbox.mockClear(); + f.client.resolveVmSandbox.mockResolvedValue({ + sandboxId: generation.sandboxId, + modalObjectId: "sb-real", + sandboxBackend: "modal-vm", + ttydUrl: "https://terminal.example", + }); + let completeEntered!: () => void; + const completing = new Promise((resolve) => (completeEntered = resolve)); + let releaseCompletion!: () => void; + const completionGate = new Promise((resolve) => (releaseCompletion = resolve)); + const complete = vi.mocked(f.storage.completeProviderResume).getMockImplementation()!; + vi.mocked(f.storage.completeProviderResume).mockImplementationOnce(async (...args) => { + completeEntered(); + await completionGate; + return complete(...args); + }); + let work!: Promise; + f.backgroundTasks.submit.mockImplementation((task) => { + work = task(); + }); + vi.mocked(f.wsManager.getSandboxWebSocket).mockReturnValue({} as WebSocket); + manager.onSandboxSocketAttached(generation); + await completing; + const held = { + ...f.store.read()!, + phase: "unknown" as const, + error: "Shutdown held during completion", + }; + f.store.write(held); + releaseCompletion(); + await work; + + expect(f.sandbox.modal_object_id).toBe("sb-real"); + expect(f.sandbox.ttyd_token).toBeTruthy(); + expect(f.storage.completeProviderResume).toHaveBeenCalledWith( + generation, + expect.objectContaining({ providerObjectId: "sb-real" }), + pending + ); + expect(f.store.read()).toEqual(held); + expect(f.client.stopSandbox).not.toHaveBeenCalled(); + expect(manager.isProviderStartupPending()).toBe(false); + expect(f.broadcaster.messages).not.toContainEqual({ type: "sandbox_access_changed" }); + }); + it("completes a restore after bounded transient errors when its bridge later resolves", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); @@ -372,6 +529,8 @@ describe("modal-vm startup resolution", () => { expect(f.sandbox.modal_object_id).toBe( formatPendingVmReference("test-session", generation.sandboxId) ); + expect(f.sandbox.auth_token).toBeNull(); + expect(f.sandbox.auth_token_hash).toBeTruthy(); let resolve!: (value: Awaited>) => void; f.client.resolveVmSandbox.mockImplementationOnce(() => new Promise((done) => (resolve = done))); const restarted = f.makeManager(); @@ -389,26 +548,180 @@ describe("modal-vm startup resolution", () => { sandboxBackend: "modal-vm", codeServerUrl: "https://editor.example", codeServerPassword: "password", + ttydUrl: "https://terminal.example", }); await vi.waitFor(() => expect(f.sandbox.modal_object_id).toBe("sb-real")); expect(f.store.read()?.providerObjectId).toBe("sb-real"); expect(f.store.read()?.expiresAtMs).toBe(generation.createdAt + 3_600_000); + expect(f.sandbox.ttyd_url).toBeNull(); expect(f.sandbox.ttyd_token).toBeNull(); + expect(f.storage.completeProviderResume).toHaveBeenCalledExactlyOnceWith( + generation, + { + providerObjectId: "sb-real", + codeServer: { url: "https://editor.example", password: "password" }, + vnc: null, + ttyd: null, + tunnelUrls: null, + }, + formatPendingVmReference("test-session", generation.sandboxId) + ); + expect(f.storage.updateSandboxAccess).not.toHaveBeenCalled(); + expect(f.storage.updateSandboxTunnelUrls).not.toHaveBeenCalled(); }); it("mints terminal access when the bridge resolves while the original instance holds the token", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); const f = fixture(); f.client.createSandbox.mockImplementationOnce(() => new Promise(() => {})); const manager = f.makeManager(); void manager.spawnSandbox(); await vi.waitFor(() => expect(f.client.createSandbox).toHaveBeenCalledOnce()); - manager.onSandboxSocketAttached({ + const createConfig = f.client.createSandbox.mock.calls[0][0]; + expect(createConfig.sessionId).toBe("test-session"); + const generation = { sandboxId: f.sandbox.modal_sandbox_id!, createdAt: f.sandbox.created_at, - }); + }; + expect(f.sandbox.auth_token).toBeNull(); + expect(f.sandbox.auth_token_hash).toBeTruthy(); + expect(createConfig.sandboxAuthToken).not.toBe(f.sandbox.auth_token_hash); + manager.onSandboxSocketAttached(generation); await vi.waitFor(() => expect(f.sandbox.modal_object_id).toBe("sb-real")); expect(f.sandbox.ttyd_url).toBe("https://terminal.example"); expect(f.sandbox.ttyd_token).toBeTruthy(); + const token = f.sandbox.ttyd_token!; + const [header, payload, signature] = token.split("."); + expect(token.split(".")).toHaveLength(3); + expect(JSON.parse(Buffer.from(header, "base64url").toString("utf8"))).toEqual({ + alg: "HS256", + typ: "JWT", + }); + expect(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))).toEqual({ + sub: createConfig.sessionId, + sid: generation.sandboxId, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 86400, + }); + for (const [secret, valid] of [ + [createConfig.sandboxAuthToken, true], + [f.sandbox.auth_token_hash!, false], + ] as const) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"] + ); + await expect( + crypto.subtle.verify( + "HMAC", + key, + Buffer.from(signature, "base64url"), + new TextEncoder().encode(`${header}.${payload}`) + ) + ).resolves.toBe(valid); + } + expect(f.storage.completeProviderResume).toHaveBeenCalledExactlyOnceWith( + generation, + { + providerObjectId: "sb-real", + codeServer: { url: "https://editor.example", password: "editor-password" }, + vnc: { url: "https://desktop.example", password: "desktop-password" }, + ttyd: { url: "https://terminal.example", token }, + tunnelUrls: { "8080": "https://port.example" }, + }, + formatPendingVmReference(createConfig.sessionId, generation.sandboxId) + ); + expect(f.storage.updateSandboxAccess).not.toHaveBeenCalled(); + expect(f.storage.updateSandboxTunnelUrls).not.toHaveBeenCalled(); + }); + + it("retains a committed bridge identity when access publication fails in the background", async () => { + const f = fixture(); + f.client.createSandbox.mockImplementationOnce(() => new Promise(() => {})); + const manager = f.makeManager(); + void manager.spawnSandbox(); + await vi.waitFor(() => expect(f.client.createSandbox).toHaveBeenCalledOnce()); + const generation = { sandboxId: f.sandbox.modal_sandbox_id!, createdAt: f.sandbox.created_at }; + const pending = f.sandbox.modal_object_id!; + f.client.stopSandbox.mockClear(); + f.sandbox.spawn_failure_count = 2; + f.sandbox.last_spawn_failure = Date.now() - 1000; + const lastSpawnFailure = f.sandbox.last_spawn_failure; + const shutdownBefore = f.store.read()!; + const holdFailedRecovery = vi.spyOn(SandboxShutdownCoordinator.prototype, "holdFailedRecovery"); + const holdFailedRetainedBoot = vi.spyOn( + SandboxShutdownCoordinator.prototype, + "holdFailedRetainedBoot" + ); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(f.wsManager.getSandboxWebSocket).mockReturnValue({} as WebSocket); + vi.mocked(f.broadcaster.broadcast).mockImplementation((message) => { + if (message.type === "sandbox_access_changed") { + throw new Error("access publication unavailable"); + } + f.broadcaster.messages.push(message); + }); + let work!: Promise; + f.backgroundTasks.submit.mockImplementation((task) => { + work = task(); + }); + + manager.onSandboxSocketAttached(generation); + await expect(work).resolves.toBeUndefined(); + + expect(warning.mock.calls.map(([entry]) => JSON.parse(String(entry)))).toContainEqual( + expect.objectContaining({ + level: "warn", + event: "sandbox.vm_resolve_failed", + msg: "Bridge VM resolution failed", + error: "access publication unavailable", + }) + ); + expect(f.sandbox).toMatchObject({ + modal_sandbox_id: generation.sandboxId, + created_at: generation.createdAt, + modal_object_id: "sb-real", + status: "connecting", + fenced: 0, + startup_rejected: 0, + code_server_url: "https://editor.example", + code_server_password: "editor-password", + vnc_url: "https://desktop.example", + vnc_password: "desktop-password", + ttyd_url: "https://terminal.example", + ttyd_token: expect.any(String), + tunnel_urls: JSON.stringify({ "8080": "https://port.example" }), + last_spawn_error: null, + last_spawn_error_at: null, + spawn_failure_count: 2, + last_spawn_failure: lastSpawnFailure, + }); + expect(f.store.read()).toEqual({ ...shutdownBefore, providerObjectId: "sb-real" }); + expect(f.storage.completeProviderResume).toHaveBeenCalledExactlyOnceWith( + generation, + expect.objectContaining({ providerObjectId: "sb-real" }), + pending + ); + expect(f.storage.transitionSandboxStatus).toHaveBeenCalledExactlyOnceWith( + generation, + "spawning", + "connecting" + ); + expect(f.storage.updateSandboxStatus).not.toHaveBeenCalled(); + expect(f.storage.rejectProviderStartup).not.toHaveBeenCalled(); + expect(f.storage.fenceSandboxGeneration).not.toHaveBeenCalled(); + expect(f.storage.incrementCircuitBreakerFailure).not.toHaveBeenCalled(); + expect(f.storage.resetCircuitBreaker).not.toHaveBeenCalled(); + expect(holdFailedRecovery).not.toHaveBeenCalled(); + expect(holdFailedRetainedBoot).not.toHaveBeenCalled(); + expect(f.client.stopSandbox).not.toHaveBeenCalled(); + expect(f.broadcaster.messages).not.toContainEqual( + expect.objectContaining({ type: "sandbox_error" }) + ); }); it("claims a restore when the bridge resolved its handle before the lost response", async () => { @@ -450,6 +763,10 @@ describe("modal-vm startup resolution", () => { await vi.waitFor(() => expect(f.client.createSandbox).toHaveBeenCalledOnce()); let resolve!: (value: ResolveVmSandboxResponse) => void; f.client.resolveVmSandbox.mockImplementationOnce(() => new Promise((done) => (resolve = done))); + let work!: Promise; + f.backgroundTasks.submit.mockImplementation((task) => { + work = task(); + }); const restarted = f.makeManager(); restarted.onSandboxSocketAttached({ sandboxId: f.sandbox.modal_sandbox_id!, @@ -460,10 +777,36 @@ describe("modal-vm startup resolution", () => { f.sandbox.modal_sandbox_id = "newer-generation"; f.sandbox.created_at += 1; f.sandbox.modal_object_id = "sb-newer"; - resolve({ sandboxId: oldSandboxId, modalObjectId: "sb-real", sandboxBackend: "modal-vm" }); - await vi.waitFor(() => expect(f.storage.completeProviderResume).toHaveBeenCalledOnce()); + Object.assign(f.sandbox, { + code_server_url: "https://newer-editor.example", + code_server_password: "newer-editor-password", + vnc_url: "https://newer-desktop.example", + vnc_password: "newer-desktop-password", + ttyd_url: "https://newer-terminal.example", + ttyd_token: "newer-terminal-token", + tunnel_urls: JSON.stringify({ "3000": "https://newer-port.example" }), + }); + const successor = { ...f.sandbox }; + const shutdownBefore = f.store.read(); + resolve({ + sandboxId: oldSandboxId, + modalObjectId: "sb-real", + sandboxBackend: "modal-vm", + codeServerUrl: "https://late-editor.example", + codeServerPassword: "late-editor-password", + vncUrl: "https://late-desktop.example", + vncPassword: "late-desktop-password", + ttydUrl: "https://late-terminal.example", + tunnelUrls: { "8080": "https://late-port.example" }, + }); + await expect(work).resolves.toBeUndefined(); + expect(f.storage.completeProviderResume).toHaveBeenCalledOnce(); + expect(f.sandbox).toEqual(successor); expect(f.sandbox.modal_object_id).toBe("sb-newer"); + expect(f.store.read()).toEqual(shutdownBefore); expect(f.store.read()?.providerObjectId).not.toBe("sb-real"); + expect(f.storage.updateSandboxAccess).not.toHaveBeenCalled(); + expect(f.storage.updateSandboxTunnelUrls).not.toHaveBeenCalled(); }); it("retries transient bridge lookups after readiness until the same generation resolves", async () => { diff --git a/packages/control-plane/src/sandbox/lifecycle/vm-startup-reconciliation.test.ts b/packages/control-plane/src/sandbox/lifecycle/vm-startup-reconciliation.test.ts new file mode 100644 index 0000000000..1807b4fc02 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/vm-startup-reconciliation.test.ts @@ -0,0 +1,400 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ModalApiError } from "../client"; +import { + SandboxProviderError, + type CreateSandboxConfig, + type ResolveSandboxResult, +} from "../provider"; +import { formatPendingVmReference } from "../providers/pending-vm-reference"; +import { PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS } from "./decisions"; +import { SandboxLaunchExpiredError, SpawnSupersededError } from "./startup-errors"; +import { createMockSandbox, createMockSession } from "./test-helpers"; +import { + VmStartupReconciliation, + type VmStartupReconciliationDependencies, +} from "./vm-startup-reconciliation"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + +function fixture() { + const generation = { sandboxId: "logical-vm", createdAt: Date.now() }; + const reference = formatPendingVmReference("test-session", generation.sandboxId); + const row = createMockSandbox({ + status: "connecting", + modal_sandbox_id: generation.sandboxId, + created_at: generation.createdAt, + modal_object_id: reference, + }); + const session = createMockSession(); + const lifetime = { + kind: "finite" as const, + source: "conservative_start_bound" as const, + observedAtMs: generation.createdAt, + expiresAtMs: generation.createdAt + 3_600_000, + }; + const result: ResolveSandboxResult = { + sandboxId: generation.sandboxId, + providerObjectId: "sb-real", + lifetime, + ttydUrl: "https://terminal.example", + codeServerUrl: "https://editor.example", + codeServerPassword: "editor-secret", + vncAccess: { url: "https://desktop.example", password: "desktop-secret" }, + tunnelUrls: { "8080": "https://port.example" }, + }; + const config: CreateSandboxConfig = { + sessionId: "test-session", + sandboxId: generation.sandboxId, + generationCreatedAtMs: generation.createdAt, + timeoutSeconds: 3600, + repoOwner: null, + repoName: null, + controlPlaneUrl: "https://control.example", + sandboxAuthToken: "launch-key", + harness: "opencode", + provider: "openai", + model: "gpt-5.4", + }; + const work: Promise[] = []; + const deps = { + provider: { + name: "modal-vm", + createSandbox: vi.fn(async () => ({ ...result, createdAt: generation.createdAt })), + pendingSandboxAllocation: vi.fn(() => ({ reference, lifetime })), + isUnknownStartupError: vi.fn((error: unknown) => error instanceof TypeError), + resolveSandbox: vi.fn(async (): Promise => result), + }, + storage: { + getSandbox: vi.fn(() => row), + updateSandboxModalObjectId: vi.fn((id: string | null) => { + row.modal_object_id = id; + }), + completeProviderResume: vi.fn< + VmStartupReconciliationDependencies["storage"]["completeProviderResume"] + >(async (target, access, expectedReference) => { + if ( + row.modal_sandbox_id !== target.sandboxId || + row.created_at !== target.createdAt || + row.fenced || + !["connecting", "ready"].includes(row.status) || + row.modal_object_id !== expectedReference + ) + return false; + row.modal_object_id = access.providerObjectId; + return true; + }), + }, + shutdown: { + recordPendingProviderHandle: vi.fn< + VmStartupReconciliationDependencies["shutdown"]["recordPendingProviderHandle"] + >(async () => "registered"), + recordResolvedProviderHandle: vi.fn(), + }, + sessionContext: { getSession: vi.fn(() => session) }, + launchContext: { + resolveSandboxSettings: vi.fn(() => ({ sandboxSettings: {}, timeoutSeconds: 3600 })), + }, + access: { + mintTtydToken: vi.fn(async () => "signed-terminal-token"), + broadcastProviderAccessIfConnected: vi.fn(), + }, + acceptResolvedStartup: vi.fn(async () => true), + getLogger: vi.fn(() => ({ warn: vi.fn() })), + backgroundTasks: { + submit: vi.fn((task: () => Promise) => { + work.push(task()); + }), + }, + } satisfies VmStartupReconciliationDependencies; + const reconciliation = new VmStartupReconciliation(deps); + return { reconciliation, deps, work, generation, reference, row, result, config, lifetime }; +} + +describe("VM startup reconciliation boundaries", () => { + afterEach(() => vi.useRealTimers()); + + it("constructs lazily and enters the background factory synchronously", async () => { + const f = fixture(); + expect(f.deps.sessionContext.getSession).not.toHaveBeenCalled(); + expect(f.deps.getLogger).not.toHaveBeenCalled(); + expect(f.deps.launchContext.resolveSandboxSettings).not.toHaveBeenCalled(); + f.reconciliation.resolvePendingBridge(f.generation); + expect(f.deps.provider.resolveSandbox).toHaveBeenCalledExactlyOnceWith({ + sessionId: "test-session", + sandboxId: f.generation.sandboxId, + generationCreatedAtMs: f.generation.createdAt, + timeoutSeconds: 3600, + }); + await f.work[0]; + }); + + it.each(["same object", "equal values", "older generation"] as const)( + "%s foreground finalization preserves its identity-sensitive token ownership", + async (mode) => { + const f = fixture(); + f.reconciliation.registerForegroundAuth(f.generation, "test-session", "current-key"); + const finalizer = + mode === "same object" + ? f.generation + : { + ...f.generation, + createdAt: f.generation.createdAt - (mode === "older generation" ? 1 : 0), + }; + f.reconciliation.finalizeForeground(finalizer); + f.reconciliation.resolvePendingBridge({ ...f.generation }); + await f.work[0]; + if (mode === "same object") expect(f.deps.access.mintTtydToken).not.toHaveBeenCalled(); + else + expect(f.deps.access.mintTtydToken).toHaveBeenCalledExactlyOnceWith( + "current-key", + "test-session", + f.generation.sandboxId + ); + } + ); + + it("clears retry auth before replacement and does not let an old finalizer clear new auth", async () => { + const f = fixture(); + const newer = { ...f.generation, createdAt: f.generation.createdAt + 1 }; + f.reconciliation.registerForegroundAuth(f.generation, "test-session", "old-key"); + f.reconciliation.beginForegroundRetry(); + f.row.created_at = newer.createdAt; + f.reconciliation.resolvePendingBridge(newer); + await f.work[0]; + expect(f.deps.access.mintTtydToken).not.toHaveBeenCalled(); + + f.row.modal_object_id = f.reference; + f.reconciliation.registerForegroundAuth(newer, "test-session", "new-key"); + f.reconciliation.finalizeForeground(f.generation); + f.reconciliation.resolvePendingBridge({ ...newer }); + await f.work[1]; + expect(f.deps.access.mintTtydToken).toHaveBeenCalledExactlyOnceWith( + "new-key", + "test-session", + newer.sandboxId + ); + }); + + it("hands an inconclusive foreground lookup to an equal-valued bridge and clears completed auth", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.reconciliation.registerForegroundAuth(f.generation, "test-session", "launch-key"); + f.deps.provider.resolveSandbox.mockRejectedValueOnce(new TypeError("transport unknown")); + vi.setSystemTime(f.generation.createdAt + PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS); + await expect( + f.reconciliation.resolveUnknownVmStartup(f.generation, f.config) + ).resolves.toBeNull(); + f.reconciliation.finalizeForeground(f.generation); + f.reconciliation.resolvePendingBridge({ ...f.generation }); + await f.work[0]; + expect(f.deps.acceptResolvedStartup).toHaveBeenCalledExactlyOnceWith( + f.generation, + "sb-real", + f.lifetime + ); + expect(f.deps.access.mintTtydToken).toHaveBeenCalledOnce(); + expect(f.deps.shutdown.recordResolvedProviderHandle).not.toHaveBeenCalled(); + expect(f.deps.access.broadcastProviderAccessIfConnected).toHaveBeenCalledOnce(); + f.row.modal_object_id = f.reference; + f.reconciliation.resolvePendingBridge({ ...f.generation }); + await f.work[1]; + expect(f.deps.access.mintTtydToken).toHaveBeenCalledOnce(); + expect(f.deps.provider.createSandbox).not.toHaveBeenCalled(); + }); + + it("does not publish access when the manager refuses the deferred startup claim", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.reconciliation.registerForegroundAuth(f.generation, "test-session", "launch-key"); + f.deps.provider.resolveSandbox.mockRejectedValueOnce(new TypeError("transport unknown")); + vi.setSystemTime(f.generation.createdAt + PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS); + await expect( + f.reconciliation.resolveUnknownVmStartup(f.generation, f.config) + ).resolves.toBeNull(); + f.reconciliation.finalizeForeground(f.generation); + f.deps.acceptResolvedStartup.mockResolvedValueOnce(false); + + f.reconciliation.resolvePendingBridge({ ...f.generation }); + await f.work[0]; + + expect(f.deps.acceptResolvedStartup).toHaveBeenCalledExactlyOnceWith( + f.generation, + "sb-real", + f.lifetime + ); + expect(f.deps.shutdown.recordResolvedProviderHandle).not.toHaveBeenCalled(); + expect(f.deps.access.broadcastProviderAccessIfConnected).not.toHaveBeenCalled(); + f.row.modal_object_id = f.reference; + f.reconciliation.resolvePendingBridge({ ...f.generation }); + await f.work[1]; + expect(f.deps.access.mintTtydToken).toHaveBeenCalledOnce(); + }); + + it("reuses only cached identity and lifetime, never provider credentials", async () => { + const f = fixture(); + f.reconciliation.registerForegroundAuth(f.generation, "test-session", "launch-key"); + f.reconciliation.resolvePendingBridge({ ...f.generation }); + await f.work[0]; + f.result.codeServerPassword = "mutated-secret"; + await expect(f.reconciliation.resolveUnknownVmStartup(f.generation, f.config)).resolves.toEqual( + { + sandboxId: f.generation.sandboxId, + providerObjectId: "sb-real", + lifetime: f.lifetime, + } + ); + expect(f.deps.provider.resolveSandbox).toHaveBeenCalledOnce(); + f.row.created_at += 1; + await expect( + f.reconciliation.resolveUnknownVmStartup(f.generation, f.config) + ).resolves.toBeNull(); + expect(f.deps.provider.resolveSandbox).toHaveBeenCalledOnce(); + }); + + it.each(["fenced", "stopped", "replaced", "reference changed"] as const)( + "publishes nothing when atomic bridge completion refuses a %s row", + async (change) => { + const f = fixture(); + const completion = deferred(); + const entered = deferred(); + const commit = f.deps.storage.completeProviderResume.getMockImplementation()!; + f.deps.storage.completeProviderResume.mockImplementationOnce(async (...args) => { + entered.resolve(); + await completion.promise; + return commit(...args); + }); + f.reconciliation.resolvePendingBridge(f.generation); + await entered.promise; + if (change === "fenced") f.row.fenced = 1; + if (change === "stopped") f.row.status = "stopped"; + if (change === "replaced") f.row.created_at += 1; + if (change === "reference changed") f.row.modal_object_id = "new-reference"; + const successor = { ...f.row }; + completion.resolve(true); + await f.work[0]; + expect(f.row).toEqual(successor); + expect(f.deps.acceptResolvedStartup).not.toHaveBeenCalled(); + expect(f.deps.shutdown.recordResolvedProviderHandle).not.toHaveBeenCalled(); + expect(f.deps.access.broadcastProviderAccessIfConnected).not.toHaveBeenCalled(); + expect(f.deps.storage.completeProviderResume.mock.calls[0][2]).toBe(f.reference); + } + ); + + it("queues the latest timestamp generation after an older successful lookup without adopting it", async () => { + const f = fixture(); + const lookup = deferred(); + f.deps.provider.resolveSandbox.mockReturnValueOnce(lookup.promise); + f.reconciliation.registerForegroundAuth(f.generation, "test-session", "old-key"); + f.reconciliation.resolvePendingBridge(f.generation); + f.reconciliation.resolvePendingBridge({ ...f.generation }); + expect(f.deps.backgroundTasks.submit).toHaveBeenCalledOnce(); + const newer = { ...f.generation, createdAt: f.generation.createdAt + 1 }; + f.reconciliation.resolvePendingBridge(newer); + const latest = { ...newer, createdAt: newer.createdAt + 1 }; + f.row.created_at = latest.createdAt; + f.reconciliation.registerForegroundAuth(latest, "test-session", "latest-key"); + f.reconciliation.resolvePendingBridge(latest); + lookup.resolve({ ...f.result, providerObjectId: "sb-old" }); + await f.work[0]; + expect(f.work).toHaveLength(2); + await f.work[1]; + expect(f.deps.provider.resolveSandbox).toHaveBeenCalledTimes(2); + expect(f.deps.provider.resolveSandbox.mock.calls[1]).toEqual([ + expect.objectContaining({ + generationCreatedAtMs: latest.createdAt, + }), + ]); + expect(f.deps.access.mintTtydToken).toHaveBeenCalledExactlyOnceWith( + "latest-key", + "test-session", + latest.sandboxId + ); + expect(f.deps.shutdown.recordResolvedProviderHandle).toHaveBeenCalledExactlyOnceWith( + latest, + f.reference, + "sb-real" + ); + expect(f.row.modal_object_id).toBe("sb-real"); + expect(f.deps.acceptResolvedStartup).not.toHaveBeenCalled(); + }); + + it.each(["expired", "superseded"] as const)( + "preserves pending-registration %s classification without dispatching create", + async (outcome) => { + const f = fixture(); + f.row.modal_object_id = "prior-handle"; + f.deps.shutdown.recordPendingProviderHandle.mockResolvedValueOnce(outcome); + await expect( + f.reconciliation.recordPendingProviderReference(f.generation, f.config) + ).rejects.toBeInstanceOf( + outcome === "expired" ? SandboxLaunchExpiredError : SpawnSupersededError + ); + expect(f.row.modal_object_id).toBe(outcome === "expired" ? "prior-handle" : f.reference); + expect(f.deps.shutdown.recordPendingProviderHandle.mock.calls[0][0]).toBe(f.generation); + expect(f.deps.provider.createSandbox).not.toHaveBeenCalled(); + } + ); + + it("does not turn null recovery into another create", async () => { + const f = fixture(); + f.deps.provider.createSandbox.mockRejectedValueOnce(new TypeError("lost create")); + f.row.fenced = 1; + await expect(f.reconciliation.createWithVmRecovery(f.config, f.generation)).resolves.toBeNull(); + expect(f.deps.provider.createSandbox).toHaveBeenCalledOnce(); + expect(f.deps.provider.resolveSandbox).not.toHaveBeenCalled(); + }); + + it("does not infer VM support from hooks that return no pending allocation", async () => { + const f = fixture(); + const provider = { + ...f.deps.provider, + name: "modal", + pendingSandboxAllocation: vi.fn(() => undefined), + isUnknownStartupError: vi.fn(() => false), + }; + const reconciliation = new VmStartupReconciliation({ ...f.deps, provider }); + f.row.modal_object_id = "ordinary-handle"; + reconciliation.registerForegroundAuth(f.generation, "test-session", "unused-key"); + await reconciliation.recordPendingProviderReference(f.generation, f.config); + await reconciliation.createWithVmRecovery(f.config, f.generation); + reconciliation.resolvePendingBridge(f.generation); + expect(f.deps.storage.updateSandboxModalObjectId).not.toHaveBeenCalled(); + expect(f.deps.shutdown.recordPendingProviderHandle).not.toHaveBeenCalled(); + expect(provider.resolveSandbox).not.toHaveBeenCalled(); + expect(f.deps.access.mintTtydToken).not.toHaveBeenCalled(); + expect(f.deps.launchContext.resolveSandboxSettings).not.toHaveBeenCalled(); + expect(f.deps.backgroundTasks.submit).not.toHaveBeenCalled(); + }); + + it("distinguishes not-visible from transport uncertainty and another generation", async () => { + vi.useFakeTimers(); + const f = fixture(); + vi.setSystemTime(f.generation.createdAt + PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS); + f.deps.provider.resolveSandbox.mockRejectedValueOnce( + new ModalApiError("invisible", 409, "not_visible") + ); + await expect( + f.reconciliation.resolveUnknownVmStartup(f.generation, f.config) + ).rejects.toMatchObject({ + errorType: "transient", + }); + const occupied = new SandboxProviderError( + "occupied", + "permanent", + new ModalApiError("occupied", 409, "other_generation") + ); + f.deps.provider.resolveSandbox.mockRejectedValueOnce(occupied); + await expect(f.reconciliation.resolveUnknownVmStartup(f.generation, f.config)).rejects.toBe( + occupied + ); + f.deps.provider.resolveSandbox.mockRejectedValueOnce(new TypeError("unknown transport")); + await expect( + f.reconciliation.resolveUnknownVmStartup(f.generation, f.config) + ).resolves.toBeNull(); + expect(f.deps.provider.createSandbox).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/sandbox/lifecycle/vm-startup-reconciliation.ts b/packages/control-plane/src/sandbox/lifecycle/vm-startup-reconciliation.ts new file mode 100644 index 0000000000..acf64a4645 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/vm-startup-reconciliation.ts @@ -0,0 +1,402 @@ +import type { Logger } from "../../logger"; +import type { BackgroundTasks } from "../../platform-ports"; +import type { SandboxRow, SessionRow } from "../../session/types"; +import { + SandboxProviderError, + type CreateSandboxConfig, + type CreateSandboxResult, + type ResolveSandboxResult, + type SandboxLifetime, + type SandboxProvider, +} from "../provider"; +import { modalVmAllocationDetail } from "../providers/modal-provider"; +import { parsePendingVmReference } from "../providers/pending-vm-reference"; +import { PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS } from "./decisions"; +import type { SandboxLaunchContext } from "./launch-context"; +import type { SandboxGeneration } from "./ports"; +import type { SandboxAccess } from "./sandbox-access"; +import { SandboxLaunchExpiredError, SpawnSupersededError } from "./startup-errors"; + +const VM_RESOLVE_RETRY_MS = 10_000; + +type PendingStartupConfig = Pick< + CreateSandboxConfig, + "sessionId" | "sandboxId" | "generationCreatedAtMs" | "timeoutSeconds" +>; + +export interface VmStartupReconciliationStorage { + getSandbox(): SandboxRow | null; + updateSandboxModalObjectId(modalObjectId: string | null): void; + /** Repository rechecks generation, fence, status and expected reference after encryption. */ + completeProviderResume( + generation: SandboxGeneration, + access: { + providerObjectId: string; + codeServer: { url: string; password: string } | null; + vnc: { url: string; password: string } | null; + ttyd: { url: string | null; token: string } | null; + tunnelUrls: Record | null; + }, + expectedProviderObjectId?: string + ): Promise; +} + +export interface VmStartupReconciliationShutdown { + /** Records a generation-scoped pending handle and its conservative expiry. */ + recordPendingProviderHandle( + generation: SandboxGeneration, + reference: string, + lifetime: Extract + ): Promise<"registered" | "expired" | "superseded">; + /** Swaps a recovered handle without changing shutdown policy or lifetime. */ + recordResolvedProviderHandle?( + generation: SandboxGeneration, + expectedReference: string, + providerObjectId: string + ): void; +} + +export interface VmStartupReconciliationDependencies { + provider: Pick< + SandboxProvider, + | "name" + | "createSandbox" + | "pendingSandboxAllocation" + | "isUnknownStartupError" + | "resolveSandbox" + >; + storage: VmStartupReconciliationStorage; + shutdown: VmStartupReconciliationShutdown; + sessionContext: { getSession(): SessionRow | null }; + launchContext: Pick; + access: Pick; + /** Manager-owned generic acceptance, cleanup, lifetime recording and announcements. */ + acceptResolvedStartup( + generation: SandboxGeneration, + providerObjectId: string | undefined, + lifetime: SandboxLifetime + ): Promise; + getLogger: () => Pick; + backgroundTasks?: BackgroundTasks; +} + +/** Reconciles an already-authorized startup; owns no admission or preservation policy. */ +export class VmStartupReconciliation { + private bridgeResolution: SandboxGeneration | null = null; + private bridgeRetryGeneration: SandboxGeneration | null = null; + private bridgeStartupClaim: SandboxGeneration | null = null; + private bridgeResolvedStartup: { + generation: SandboxGeneration; + result: ResolveSandboxResult; + } | null = null; + private vmStartupAuth: { + generation: SandboxGeneration; + sessionId: string; + token: string; + } | null = null; + + constructor(private readonly deps: VmStartupReconciliationDependencies) {} + + registerForegroundAuth(generation: SandboxGeneration, sessionId: string, token: string): void { + if (this.deps.provider.name === "modal-vm") + this.vmStartupAuth = { generation, sessionId, token }; + } + + /** The base-image retry drops old auth before reservation/hash publication can yield. */ + beginForegroundRetry(): void { + this.vmStartupAuth = null; + } + + finalizeForeground(generation: SandboxGeneration | null): void { + // Foreground ownership is object identity; bridges authenticate equal-valued generations. + if (this.vmStartupAuth?.generation === generation && this.bridgeStartupClaim !== generation) + this.vmStartupAuth = null; + } + + async recordPendingProviderReference( + generation: SandboxGeneration, + config: PendingStartupConfig + ): Promise { + const { provider, storage, shutdown } = this.deps; + if (!generation.sandboxId || config.sandboxId !== generation.sandboxId) + throw new SpawnSupersededError(); + const pending = provider.pendingSandboxAllocation?.(config); + if (!pending) return; + const row = storage.getSandbox(); + if ( + row?.modal_sandbox_id !== generation.sandboxId || + row.created_at !== generation.createdAt || + row.fenced + ) { + throw new SpawnSupersededError(); + } + const previousProviderObjectId = row.modal_object_id; + storage.updateSandboxModalObjectId(pending.reference); + const registered = await shutdown.recordPendingProviderHandle( + generation, + pending.reference, + pending.lifetime + ); + if (registered === "superseded") throw new SpawnSupersededError(); + if (registered === "expired") { + const current = storage.getSandbox(); + if ( + current?.modal_sandbox_id === generation.sandboxId && + current.created_at === generation.createdAt && + current.modal_object_id === pending.reference && + !current.fenced + ) { + storage.updateSandboxModalObjectId(previousProviderObjectId); + } + throw new SandboxLaunchExpiredError(); + } + } + + async createWithVmRecovery( + config: CreateSandboxConfig, + generation: SandboxGeneration + ): Promise { + const { provider } = this.deps; + try { + return await provider.createSandbox(config); + } catch (error) { + if (!provider.isUnknownStartupError?.(error)) throw error; + const recovered = await this.resolveUnknownVmStartup(generation, config); + return recovered ? { ...recovered, createdAt: generation.createdAt } : null; + } + } + + private knownBridgeStartup( + generation: SandboxGeneration, + row: SandboxRow | null + ): ResolveSandboxResult | null { + const known = this.bridgeResolvedStartup; + if ( + !known || + row?.modal_sandbox_id !== generation.sandboxId || + row.created_at !== generation.createdAt || + row.fenced || + !["spawning", "connecting", "ready"].includes(row.status) || + row.modal_object_id !== known.result.providerObjectId || + known.generation.sandboxId !== generation.sandboxId || + known.generation.createdAt !== generation.createdAt + ) + return null; + return known.result; + } + + async resolveUnknownVmStartup( + generation: SandboxGeneration, + config: PendingStartupConfig + ): Promise { + const { provider, storage } = this.deps; + if (!provider.resolveSandbox) return null; + const reference = provider.pendingSandboxAllocation?.(config)?.reference; + while (true) { + const row = storage.getSandbox(); + const bridged = this.knownBridgeStartup(generation, row); + if (bridged) return bridged; + const resolvedByBridge = + !!row?.modal_object_id && + row.modal_object_id !== reference && + parsePendingVmReference(row.modal_object_id) === null; + if ( + row?.modal_sandbox_id !== generation.sandboxId || + row.created_at !== generation.createdAt || + row.fenced || + !["spawning", "connecting", "ready"].includes(row.status) || + (row.modal_object_id !== reference && !resolvedByBridge) + ) + return null; + try { + return await provider.resolveSandbox({ + ...config, + generationCreatedAtMs: generation.createdAt, + }); + } catch (error) { + const detail = modalVmAllocationDetail(error); + if (detail === "other_generation") throw error; + if (detail !== "not_visible" && !provider.isUnknownStartupError?.(error)) throw error; + if (Date.now() - generation.createdAt >= PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS) { + const current = storage.getSandbox(); + const bridgedAfterLookup = this.knownBridgeStartup(generation, current); + if (bridgedAfterLookup) return bridgedAfterLookup; + if ( + current?.modal_sandbox_id === generation.sandboxId && + current.created_at === generation.createdAt && + !current.fenced && + current.modal_object_id && + parsePendingVmReference(current.modal_object_id) === null + ) { + const lifetime = provider.pendingSandboxAllocation?.(config)?.lifetime; + if (lifetime) + return { + sandboxId: config.sandboxId, + providerObjectId: current.modal_object_id, + lifetime, + }; + } + if (detail === "not_visible") + throw new SandboxProviderError( + "The VM allocation did not appear for this attempt. Please retry.", + "transient", + error instanceof Error ? error : undefined + ); + if ( + current?.modal_sandbox_id === generation.sandboxId && + current.created_at === generation.createdAt && + !current.fenced && + current.modal_object_id === reference + ) + this.bridgeStartupClaim = generation; + return null; + } + } + await new Promise((resolve) => setTimeout(resolve, VM_RESOLVE_RETRY_MS)); + } + } + + /** Synchronous bridge entry submits lookup-only work without holding readiness. */ + resolvePendingBridge(generation: SandboxGeneration): void { + if (this.bridgeResolution) { + if ( + this.bridgeResolution.sandboxId !== generation.sandboxId || + this.bridgeResolution.createdAt !== generation.createdAt + ) + this.bridgeRetryGeneration = generation; + return; + } + const { provider, storage, sessionContext, launchContext, access, shutdown, backgroundTasks } = + this.deps; + if (!provider.resolveSandbox) return; + const row = storage.getSandbox(); + const reference = row?.modal_object_id; + const pending = reference ? parsePendingVmReference(reference) : null; + const session = sessionContext.getSession(); + if ( + !row || + row.fenced || + !["spawning", "connecting", "ready"].includes(row.status) || + row.created_at !== generation.createdAt || + row.modal_sandbox_id !== generation.sandboxId || + !reference || + !pending || + !session || + pending.sandboxId !== row.modal_sandbox_id || + pending.sessionId !== (session.session_name || session.id) + ) + return; + const config = { + sessionId: pending.sessionId, + sandboxId: pending.sandboxId, + generationCreatedAtMs: generation.createdAt, + timeoutSeconds: launchContext.resolveSandboxSettings(session).timeoutSeconds, + }; + const retryDeadlineAtMs = Date.now() + PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS; + this.bridgeResolution = generation; + const work = () => + (async () => { + let result: ResolveSandboxResult; + while (true) { + const current = storage.getSandbox(); + if ( + current?.modal_sandbox_id !== generation.sandboxId || + current.created_at !== generation.createdAt || + current.fenced || + !["spawning", "connecting", "ready"].includes(current.status) || + current.modal_object_id !== reference + ) + return; + try { + result = await provider.resolveSandbox!(config); + break; + } catch (error) { + const detail = modalVmAllocationDetail(error); + if (detail !== "not_visible" && !provider.isUnknownStartupError?.(error)) throw error; + if ( + Date.now() >= retryDeadlineAtMs || + (detail === "not_visible" && + Date.now() - generation.createdAt >= PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS) || + this.bridgeRetryGeneration + ) + return; + await new Promise((resolve) => setTimeout(resolve, VM_RESOLVE_RETRY_MS)); + } + } + if (!result.providerObjectId) return; + const auth = this.vmStartupAuth; + const terminalToken = + result.ttydUrl && + auth && + auth.generation.sandboxId === generation.sandboxId && + auth.generation.createdAt === generation.createdAt + ? await access.mintTtydToken(auth.token, auth.sessionId, generation.sandboxId!) + : null; + const committed = await storage.completeProviderResume( + generation, + { + providerObjectId: result.providerObjectId, + codeServer: + result.codeServerUrl && result.codeServerPassword + ? { url: result.codeServerUrl, password: result.codeServerPassword } + : null, + vnc: result.vncAccess ?? null, + ttyd: + result.ttydUrl && terminalToken + ? { url: result.ttydUrl, token: terminalToken } + : null, + tunnelUrls: result.tunnelUrls ?? null, + }, + reference + ); + if (!committed) return; + this.bridgeResolvedStartup = { + generation, + result: { + sandboxId: result.sandboxId, + providerObjectId: result.providerObjectId, + lifetime: result.lifetime, + }, + }; + if ( + this.bridgeStartupClaim?.sandboxId === generation.sandboxId && + this.bridgeStartupClaim.createdAt === generation.createdAt + ) { + this.bridgeStartupClaim = null; + try { + if ( + !(await this.deps.acceptResolvedStartup( + generation, + result.providerObjectId, + result.lifetime + )) + ) + return; + } finally { + if ( + this.vmStartupAuth?.generation.sandboxId === generation.sandboxId && + this.vmStartupAuth.generation.createdAt === generation.createdAt + ) + this.vmStartupAuth = null; + } + } else { + shutdown.recordResolvedProviderHandle?.(generation, reference, result.providerObjectId); + } + access.broadcastProviderAccessIfConnected(); + })() + .catch((error) => { + this.deps.getLogger().warn("Bridge VM resolution failed", { + event: "sandbox.vm_resolve_failed", + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + this.bridgeResolution = null; + const queued = this.bridgeRetryGeneration; + this.bridgeRetryGeneration = null; + if (queued) this.resolvePendingBridge(queued); + }); + if (backgroundTasks) backgroundTasks.submit(work, { name: "sandbox.vm_resolve" }); + else void work(); + } +} diff --git a/packages/control-plane/src/sandbox/provider.test.ts b/packages/control-plane/src/sandbox/provider.test.ts index 114c5b3d9e..038dd5a2e7 100644 --- a/packages/control-plane/src/sandbox/provider.test.ts +++ b/packages/control-plane/src/sandbox/provider.test.ts @@ -1,5 +1,38 @@ -import { describe, expect, it } from "vitest"; -import { createVncAccess, signalUntilDeadline } from "./provider"; +import { describe, expect, it, vi } from "vitest"; +import { createVncAccess, providerResumesAfterStop, signalUntilDeadline } from "./provider"; + +describe("providerResumesAfterStop", () => { + it.each([ + [true, true, true, true], + [true, true, false, false], + [true, false, true, false], + [true, false, false, false], + [false, true, true, false], + [false, true, false, false], + [false, false, true, false], + [false, false, false, false], + [undefined, true, true, false], + [true, true, undefined, false], + ])( + "requires explicit stop (%s), a stop method (%s), and persistent resume (%s)", + (explicitStop, hasStop, persistentResume, expected) => { + const stopSandbox = vi.fn(async () => ({ success: true })); + expect( + providerResumesAfterStop({ + capabilities: { + supportsSandboxTimeout: false, + supportsSnapshots: false, + supportsRestore: false, + supportsExplicitStop: explicitStop, + supportsPersistentResume: persistentResume, + }, + stopSandbox: hasStop ? stopSandbox : undefined, + }) + ).toBe(expected); + expect(stopSandbox).not.toHaveBeenCalled(); + } + ); +}); describe("createVncAccess", () => { it("returns only complete VNC credentials", () => { diff --git a/packages/control-plane/src/sandbox/provider.ts b/packages/control-plane/src/sandbox/provider.ts index b4f019a90e..a34b6bee1a 100644 --- a/packages/control-plane/src/sandbox/provider.ts +++ b/packages/control-plane/src/sandbox/provider.ts @@ -647,3 +647,14 @@ export interface SandboxProvider { */ stopSandbox?(config: StopConfig): Promise; } + +/** Existing capability-based preserve-stop policy; resume support is checked separately at startup. */ +export function providerResumesAfterStop( + provider: Pick +): boolean { + return ( + !!provider.capabilities.supportsExplicitStop && + !!provider.stopSandbox && + !!provider.capabilities.supportsPersistentResume + ); +} diff --git a/packages/control-plane/src/sandbox/providers/daytona-provider.test.ts b/packages/control-plane/src/sandbox/providers/daytona-provider.test.ts index eb9961dedd..a432d53e9b 100644 --- a/packages/control-plane/src/sandbox/providers/daytona-provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/daytona-provider.test.ts @@ -60,21 +60,27 @@ function createMockClient( if (!config.baseSnapshot) throw new Error("DAYTONA_BASE_SNAPSHOT is required"); return config.baseSnapshot; }), - createSandbox: vi.fn(async (): Promise => ({ - id: "daytona-sandbox-id", - state: "started", - })), - getSandbox: vi.fn(async (): Promise => ({ - id: "daytona-sandbox-id", - state: "started", - })), + createSandbox: vi.fn( + async (): Promise => ({ + id: "daytona-sandbox-id", + state: "started", + }) + ), + getSandbox: vi.fn( + async (): Promise => ({ + id: "daytona-sandbox-id", + state: "started", + }) + ), startSandbox: vi.fn(async () => {}), stopSandbox: vi.fn(async () => {}), deleteSandbox: vi.fn(async () => {}), recoverSandbox: vi.fn(async () => {}), - getSignedPreviewUrl: vi.fn(async (): Promise => ({ - url: "https://preview.test/signed", - })), + getSignedPreviewUrl: vi.fn( + async (): Promise => ({ + url: "https://preview.test/signed", + }) + ), ...overrides, } as unknown as DaytonaRestClient; } diff --git a/packages/control-plane/src/sandbox/providers/e2b-provider-shutdown.test.ts b/packages/control-plane/src/sandbox/providers/e2b-provider-shutdown.test.ts index baedf0b5b7..2fcb08b7a5 100644 --- a/packages/control-plane/src/sandbox/providers/e2b-provider-shutdown.test.ts +++ b/packages/control-plane/src/sandbox/providers/e2b-provider-shutdown.test.ts @@ -135,11 +135,13 @@ describe("E2BSandboxProvider graceful shutdown", () => { pauseSandbox: vi.fn(async () => { throw new E2BConflictError("already transitioning"); }), - getSandbox: vi.fn(async (): Promise => ({ - sandboxID: "x", - templateID: "tmpl", - state: "running", - })), + getSandbox: vi.fn( + async (): Promise => ({ + sandboxID: "x", + templateID: "tmpl", + state: "running", + }) + ), }); await expect( new E2BSandboxProvider(client, providerConfig).stopSandbox({ diff --git a/packages/control-plane/src/sandbox/providers/e2b-provider.test-helpers.ts b/packages/control-plane/src/sandbox/providers/e2b-provider.test-helpers.ts index 0bf1711bf8..f4cbba5404 100644 --- a/packages/control-plane/src/sandbox/providers/e2b-provider.test-helpers.ts +++ b/packages/control-plane/src/sandbox/providers/e2b-provider.test-helpers.ts @@ -17,11 +17,13 @@ export function mockClient(overrides: Partial = {}): E2BRestClien templateID: "tmpl", envdAccessToken: "envd-token", })), - getSandbox: vi.fn(async (): Promise => ({ - sandboxID: "e2b-id", - templateID: "tmpl", - state: "paused", - })), + getSandbox: vi.fn( + async (): Promise => ({ + sandboxID: "e2b-id", + templateID: "tmpl", + state: "paused", + }) + ), pauseSandbox: vi.fn(async () => {}), connectSandbox: vi.fn(async () => ({ sandboxID: "e2b-id", diff --git a/packages/control-plane/src/sandbox/providers/modal-provider.test.ts b/packages/control-plane/src/sandbox/providers/modal-provider.test.ts index 3cfdfd556b..c5120e13e7 100644 --- a/packages/control-plane/src/sandbox/providers/modal-provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/modal-provider.test.ts @@ -5,11 +5,11 @@ */ import { describe, it, expect, vi } from "vitest"; -import { ModalSandboxProvider } from "./modal-provider"; +import { ModalSandboxProvider, modalVmAllocationDetail } from "./modal-provider"; import { formatPendingVmReference } from "./pending-vm-reference"; import { PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS } from "../lifecycle/decisions"; import { PrebuiltImageUnavailableError, SandboxProviderError } from "../provider"; -import { ModalApiError } from "../client"; +import { ModalApiError, ModalVmStartupError } from "../client"; import { RequestDeadlineError } from "../request-deadline"; import type { ModalClient, @@ -44,24 +44,34 @@ function createMockModalClient( }> = {} ): ModalClient { return { - createSandbox: vi.fn(async (): Promise => ({ - sandboxId: "sandbox-123", - modalObjectId: "modal-obj-123", - createdAt: Date.now(), - })), - restoreSandbox: vi.fn(async (): Promise => ({ - sandboxId: "sandbox-123", - modalObjectId: "modal-obj-123", - })), - snapshotSandbox: vi.fn(async (): Promise => ({ - imageId: "image-123", - })), - snapshotBuildSandbox: vi.fn(async (): Promise => ({ - imageId: "build-image-123", - })), - createImageBuildSandbox: vi.fn(async (): Promise => ({ - providerSessionId: "modal-session-123", - })), + createSandbox: vi.fn( + async (): Promise => ({ + sandboxId: "sandbox-123", + modalObjectId: "modal-obj-123", + createdAt: Date.now(), + }) + ), + restoreSandbox: vi.fn( + async (): Promise => ({ + sandboxId: "sandbox-123", + modalObjectId: "modal-obj-123", + }) + ), + snapshotSandbox: vi.fn( + async (): Promise => ({ + imageId: "image-123", + }) + ), + snapshotBuildSandbox: vi.fn( + async (): Promise => ({ + imageId: "build-image-123", + }) + ), + createImageBuildSandbox: vi.fn( + async (): Promise => ({ + providerSessionId: "modal-session-123", + }) + ), startImageBuildSandbox: vi.fn(async () => undefined), terminateImageBuildSandbox: vi.fn(async () => undefined), stopSandbox: vi.fn(async () => undefined), @@ -84,15 +94,41 @@ const testConfig = { // ==================== Tests ==================== describe("ModalSandboxProvider", () => { + it.each(["not_visible", "other_generation", "unknown"] as const)( + "decodes raw and wrapped VM outcome %s without changing its classification", + (detail) => { + for (const error of [ + new ModalApiError("arbitrary message", 409, detail), + new ModalVmStartupError(detail, new Error("arbitrary message")), + ]) { + expect(modalVmAllocationDetail(error)).toBe(detail); + expect( + modalVmAllocationDetail(new SandboxProviderError("wrapped", "transient", error)) + ).toBe(detail); + } + expect(modalVmAllocationDetail(new TypeError("not_visible"))).toBeUndefined(); + } + ); + + it("standard Modal hooks do not enable VM allocation recovery", () => { + const provider = new ModalSandboxProvider(createMockModalClient(), "modal"); + expect(provider.pendingSandboxAllocation(testConfig)).toBeUndefined(); + expect(provider.isUnknownStartupError(new ModalApiError("unavailable", 503))).toBe(false); + expect( + provider.isUnknownStartupError(new ModalVmStartupError("unknown", new Error("lost response"))) + ).toBe(false); + }); + it.each([ - [409, "race_pending", true], - [409, "other_generation", false], - [409, "window_closed", false], - [502, undefined, true], - [500, undefined, true], + [409, "race_pending", true, "transient"], + [409, "other_generation", false, "permanent"], + [409, "window_closed", false, "transient"], + [502, undefined, true, "transient"], + [500, undefined, true, "transient"], + [501, "docker_not_available", false, "permanent"], ] as const)( "classifies VM launch HTTP %s / %s without matching messages", - async (status, detail, unknown) => { + async (status, detail, unknown, errorType) => { const error = new ModalApiError("arbitrary message", status, detail); const provider = new ModalSandboxProvider( createMockModalClient({ @@ -111,9 +147,7 @@ describe("ModalSandboxProvider", () => { expect(caught).toBeInstanceOf(SandboxProviderError); expect((caught as SandboxProviderError).cause).toBe(error); expect(provider.isUnknownStartupError(caught)).toBe(unknown); - expect((caught as SandboxProviderError).errorType).toBe( - detail === "other_generation" ? "permanent" : "transient" - ); + expect((caught as SandboxProviderError).errorType).toBe(errorType); } ); diff --git a/packages/control-plane/src/sandbox/providers/modal-provider.ts b/packages/control-plane/src/sandbox/providers/modal-provider.ts index 957a1902f6..48bc40d2fd 100644 --- a/packages/control-plane/src/sandbox/providers/modal-provider.ts +++ b/packages/control-plane/src/sandbox/providers/modal-provider.ts @@ -5,7 +5,7 @@ * enabling unit testing and future provider abstraction. */ -import { ModalApiError, ModalVmStartupError } from "../client"; +import { ModalApiError, ModalVmStartupError, isAmbiguousModalVmLaunchError } from "../client"; import { formatPendingVmReference, parsePendingVmReference } from "./pending-vm-reference"; import { PENDING_VM_REFERENCE_LAUNCH_WINDOW_MS, @@ -39,6 +39,14 @@ import { type StopResult, } from "../provider"; +/** Preserve typed VM lookup details separately from ambiguous-launch classification. */ +export function modalVmAllocationDetail(error: unknown): string | undefined { + const cause = error instanceof SandboxProviderError ? error.cause : error; + if (cause instanceof ModalVmStartupError) return cause.outcome; + if (cause instanceof ModalApiError) return cause.detail; + return undefined; +} + interface StartModalImageBuildConfig { buildId: string; providerSessionId: string; @@ -123,7 +131,7 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro if (cause instanceof ModalVmStartupError) return cause.outcome === "unknown" || cause.outcome === "race_pending"; if (cause instanceof ModalApiError) - return cause.detail === "race_pending" || cause.status >= 500; + return cause.detail === "race_pending" || isAmbiguousModalVmLaunchError(cause); return cause instanceof TypeError || SandboxProviderError.isTransientNetworkError(cause); } @@ -572,7 +580,8 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro error.detail === "other_generation" ? "permanent" : "transient", error ); - if (error.status >= 500) return new SandboxProviderError(context, "transient", error); + if (isAmbiguousModalVmLaunchError(error)) + return new SandboxProviderError(context, "transient", error); } return this.classifyErrorWithStatus(context, error.status, error); } diff --git a/packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts b/packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts index 1bdf262428..18ad2f42d5 100644 --- a/packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/opencomputer-provider.test.ts @@ -46,15 +46,19 @@ function createMockClient(overrides: Partial = {}): Open deleteSandbox: vi.fn(async (): Promise => undefined), deleteCheckpoint: vi.fn(async (): Promise => undefined), restoreCheckpoint: vi.fn(async (): Promise => undefined), - getSandbox: vi.fn(async (): Promise => ({ - id: "oc-sandbox-1", - state: "running", - endAt: "2032-03-04T05:06:07.000Z", - })), - wakeSandbox: vi.fn(async (): Promise => ({ - id: "oc-sandbox-1", - state: "running", - })), + getSandbox: vi.fn( + async (): Promise => ({ + id: "oc-sandbox-1", + state: "running", + endAt: "2032-03-04T05:06:07.000Z", + }) + ), + wakeSandbox: vi.fn( + async (): Promise => ({ + id: "oc-sandbox-1", + state: "running", + }) + ), hibernateSandbox: vi.fn(async (): Promise => undefined), setSandboxTimeout: vi.fn(async (): Promise => undefined), startRuntime: vi.fn(async (): Promise => undefined), diff --git a/packages/control-plane/src/sandbox/providers/vercel/base-snapshot.test.ts b/packages/control-plane/src/sandbox/providers/vercel/base-snapshot.test.ts index d2397f7b83..d5df77718c 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/base-snapshot.test.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/base-snapshot.test.ts @@ -45,10 +45,12 @@ function createMockClient( createSandbox: vi.fn(async () => createSessionResponse()), runCommandAndWait: vi.fn(async () => ({ commandId: "cmd-1", exitCode: 0 })), writeFileArchive: vi.fn(async () => {}), - snapshotSession: vi.fn(async (): Promise => ({ - snapshot: { id: "snap-base-1", status: "created", createdAt: 456 }, - session: createSessionResponse().session, - })), + snapshotSession: vi.fn( + async (): Promise => ({ + snapshot: { id: "snap-base-1", status: "created", createdAt: 456 }, + session: createSessionResponse().session, + }) + ), stopSession: vi.fn(async () => {}), ...overrides, } as unknown as VercelSandboxClient; diff --git a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts index 96e175a650..a4ba615793 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts @@ -71,21 +71,25 @@ function createMockClient( createSandbox: vi.fn(async () => createSessionResponse()), runCommandAndWait: vi.fn(async () => ({ commandId: "cmd-1", exitCode: 0 })), startCommand: vi.fn(async () => ({ commandId: "cmd-2", exitCode: null })), - snapshotSession: vi.fn(async (): Promise => ({ - snapshot: { id: "snapshot-1", status: "created", createdAt: 456 }, - session: { ...createSessionResponse().session, status: "stopped" }, - })), - listSnapshots: vi.fn(async (): Promise => [ - { - id: "base-snapshot-from-name", - sourceSessionId: "session-base", - status: "created", - region: "iad1", - sizeBytes: 1024, - createdAt: 456, - updatedAt: 789, - }, - ]), + snapshotSession: vi.fn( + async (): Promise => ({ + snapshot: { id: "snapshot-1", status: "created", createdAt: 456 }, + session: { ...createSessionResponse().session, status: "stopped" }, + }) + ), + listSnapshots: vi.fn( + async (): Promise => [ + { + id: "base-snapshot-from-name", + sourceSessionId: "session-base", + status: "created", + region: "iad1", + sizeBytes: 1024, + createdAt: 456, + updatedAt: 789, + }, + ] + ), deleteSnapshot: vi.fn(async () => {}), stopSession: vi.fn(async () => {}), ...overrides, @@ -755,10 +759,12 @@ describe("VercelSandboxProvider", () => { it("reports a failed snapshot status without throwing", async () => { const client = createMockClient({ - snapshotSession: vi.fn(async (): Promise => ({ - snapshot: { id: "snapshot-1", status: "failed", createdAt: 456 }, - session: createSessionResponse().session, - })), + snapshotSession: vi.fn( + async (): Promise => ({ + snapshot: { id: "snapshot-1", status: "failed", createdAt: 456 }, + session: createSessionResponse().session, + }) + ), }); const provider = new VercelSandboxProvider(client, providerConfig); diff --git a/packages/control-plane/src/scheduler/scheduler.test.ts b/packages/control-plane/src/scheduler/scheduler.test.ts index dc9d00c9e9..7e578ec3e0 100644 --- a/packages/control-plane/src/scheduler/scheduler.test.ts +++ b/packages/control-plane/src/scheduler/scheduler.test.ts @@ -27,6 +27,14 @@ const mockResolveSessionProviderAuth = vi.hoisted(() => const mockIsAutomationExecutionAuthorized = vi.hoisted(() => vi.fn().mockResolvedValue(true)); const mockIsPrincipalAuthorized = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +const mockGetGitHubAccessToken = vi.hoisted(() => vi.fn()); +const mockGitHubAccountInfo = vi.hoisted(() => vi.fn()); +vi.mock("../auth/user/runtime", () => ({ + getUserAuth: vi.fn(() => ({ + api: { getAccessToken: mockGetGitHubAccessToken, accountInfo: mockGitHubAccountInfo }, + })), +})); + vi.mock("../source-control", () => ({ createSourceControlProviderFromEnv: vi.fn(() => ({ checkRepositoryAccess: mockCheckRepositoryAccess, @@ -160,11 +168,15 @@ vi.mock("../db/session-index", () => ({ }), })); +const mockUserStoreGetIdentitiesForUser = vi.fn().mockResolvedValue([]); +const mockUserStoreGetUserById = vi.fn().mockResolvedValue(null); const mockUserStoreGetIdentity = vi.fn().mockResolvedValue(null); vi.mock("../db/user-store", () => ({ UserStore: vi.fn().mockImplementation(function () { return { getIdentity: mockUserStoreGetIdentity, + getIdentitiesForUser: mockUserStoreGetIdentitiesForUser, + getUserById: mockUserStoreGetUserById, }; }), })); @@ -496,6 +508,8 @@ function lastInsertedChildren(): Array> { describe("Scheduler", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetGitHubAccessToken.mockReset().mockResolvedValue({ accessToken: "" }); + mockGitHubAccountInfo.mockReset(); mockResolveSessionProviderAuth.mockResolvedValue([ { provider: "openai", authMode: "api_key", selectionSource: "unattended_policy" }, { provider: "xai", authMode: "api_key", selectionSource: "unattended_policy" }, @@ -506,6 +520,8 @@ describe("Scheduler", () => { mockUserStoreGetIdentity.mockImplementation(async (provider: string) => provider === "slack" ? { userId: "slack-actor-user" } : null ); + mockUserStoreGetIdentitiesForUser.mockReset().mockResolvedValue([]); + mockUserStoreGetUserById.mockResolvedValue(null); capturedInvocationParams = []; mockStore = createMockStore(); mockGetSlackAutomationsForChannel.mockResolvedValue([]); @@ -570,6 +586,54 @@ describe("Scheduler", () => { }); }); + it("attributes scheduled prompts to the owner's linked GitHub identity", async () => { + mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); + selectRepositories("auto-1", [repositoryRow("auto-1")]); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "42", providerLogin: "owner" }, + ]); + mockUserStoreGetUserById.mockResolvedValue({ displayName: "Automation Owner" }); + const stub = createMockSessionStub(); + await expect(createScheduler(createEnv(undefined, stub)).tick()).resolves.toMatchObject({ + processed: 1, + }); + await expect(getInitBody(vi.mocked(stub.fetch))).resolves.toMatchObject({ + canonicalUserId: sampleAutomation.user_id, + scmUserId: "42", + scmLogin: "owner", + scmName: "Automation Owner", + scmEmail: "42+owner@users.noreply.github.com", + }); + }); + + it("launches without attribution when GitHub identity is ambiguous", async () => { + mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); + selectRepositories("auto-1", [repositoryRow("auto-1")]); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "42", providerLogin: "one" }, + { provider: "github", providerUserId: "43", providerLogin: "two" }, + ]); + const stub = createMockSessionStub(); + await expect(createScheduler(createEnv(undefined, stub)).tick()).resolves.toMatchObject({ + processed: 1, + failed: 0, + }); + const body = await getInitBody(vi.mocked(stub.fetch)); + expect(body.scmUserId).toBeUndefined(); + expect(mockStore.incrementConsecutiveFailures).not.toHaveBeenCalled(); + }); + + it("still fails launch when the attribution store is unavailable", async () => { + mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); + selectRepositories("auto-1", [repositoryRow("auto-1")]); + mockUserStoreGetIdentitiesForUser.mockRejectedValue(new Error("D1 unavailable")); + await expect(createScheduler().tick()).resolves.toMatchObject({ processed: 0, failed: 1 }); + expect(mockStore.updateRun).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: "failed", failure_reason: "D1 unavailable" }) + ); + }); + it("rejects unattended execution before invocation work when the owner is unauthorized", async () => { mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); selectRepositories("auto-1", [repositoryRow("auto-1")]); @@ -661,6 +725,84 @@ describe("Scheduler", () => { expect(mockStore.claimRunSession).toHaveBeenCalledTimes(2); }); + it("settles one attribution snapshot after admission for every fan-out child", async () => { + mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); + selectRepositories("auto-1", [ + repositoryRow("auto-1"), + repositoryRow("auto-1", { repo_name: "api" }), + ]); + mockUserStoreGetIdentitiesForUser + .mockResolvedValueOnce([ + { provider: "github", providerUserId: "42", providerLogin: "owner" }, + ]) + .mockResolvedValueOnce([ + { provider: "github", providerUserId: "43", providerLogin: "changed" }, + ]); + const stub = createMockSessionStub(); + expect(await createScheduler(createEnv(undefined, stub)).tick()).toMatchObject({ + processed: 1, + failed: 0, + }); + expect(mockUserStoreGetIdentitiesForUser).toHaveBeenCalledTimes(1); + expect(mockStore.insertInvocationGuarded.mock.invocationCallOrder[0]).toBeLessThan( + mockUserStoreGetIdentitiesForUser.mock.invocationCallOrder[0] + ); + const initRequests = vi + .mocked(stub.fetch) + .mock.calls.filter(([request]) => + new URL((request as Request).url).pathname.endsWith("/init") + ); + expect(initRequests).toHaveLength(2); + for (const [request] of initRequests) { + expect(await (request as Request).json()).toMatchObject({ + scmUserId: "42", + scmLogin: "owner", + }); + } + }); + + it("keeps a settled null manual attribution without looking it up again", async () => { + mockStore.getById.mockResolvedValue(sampleAutomation); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "42", providerLogin: "owner" }, + ]); + const stub = createMockSessionStub(); + await createScheduler(createEnv(undefined, stub)).trigger("auto-1", "manual-user", null); + expect(mockUserStoreGetIdentitiesForUser).not.toHaveBeenCalled(); + expect((await getInitBody(vi.mocked(stub.fetch))).scmUserId).toBeUndefined(); + }); + + it("resolves the scheduled owner's verified profile when no login is cached", async () => { + mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "42", providerLogin: null }, + ]); + mockGetGitHubAccessToken.mockResolvedValue({ accessToken: "grant" }); + mockGitHubAccountInfo.mockResolvedValue({ + user: { id: "42" }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "42", + login: "verified-owner", + verifiedEmails: [], + primaryEmail: null, + }, + }); + const stub = createMockSessionStub(); + expect(await createScheduler(createEnv(undefined, stub)).tick()).toMatchObject({ + processed: 1, + }); + expect(await getInitBody(vi.mocked(stub.fetch))).toMatchObject({ + scmUserId: "42", + scmLogin: "verified-owner", + scmEmail: "42+verified-owner@users.noreply.github.com", + }); + expect(mockGitHubAccountInfo).toHaveBeenCalledWith({ + query: { providerId: "github", accountId: "42", userId: "user-1" }, + }); + }); + it("resolves one provider auth snapshot for every child in a fan-out invocation", async () => { mockStore.getOverdueAutomations.mockResolvedValue([sampleAutomation]); selectRepositories("auto-1", [ @@ -2596,6 +2738,168 @@ describe("Scheduler", () => { expect(mockStore.insertSkippedInvocation).not.toHaveBeenCalled(); }); + it("attributes a Slack automation follow-up to the actor, not the automation owner", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue( + sampleRunRow({ session_id: "sess-running" }) + ); + mockUserStoreGetIdentitiesForUser.mockImplementation(async (userId: string) => + userId === "slack-actor-user" + ? [{ provider: "github", providerUserId: "77", providerLogin: "reviewer" }] + : [] + ); + mockUserStoreGetUserById.mockResolvedValue({ displayName: "Slack Reviewer" }); + const stub = createMockSessionStub(); + await expect( + createScheduler(createEnv(undefined, stub)).event(makeSlackEvent()) + ).resolves.toMatchObject({ steered: 1 }); + await expect(getPromptBody(vi.mocked(stub.fetch))).resolves.toMatchObject({ + authorId: "slack:U1", + canonicalUserId: "slack-actor-user", + scmEnrichment: { + userId: "77", + login: "reviewer", + name: "Slack Reviewer", + email: "77+reviewer@users.noreply.github.com", + }, + }); + }); + + it("resolves the Slack actor's verified profile when no login is cached", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue( + sampleRunRow({ session_id: "sess-running" }) + ); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "77", providerLogin: null }, + ]); + mockGetGitHubAccessToken.mockResolvedValue({ accessToken: "grant" }); + mockGitHubAccountInfo.mockResolvedValue({ + user: { id: "77" }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "77", + login: "verified-actor", + verifiedEmails: [], + primaryEmail: null, + }, + }); + const stub = createMockSessionStub(); + expect( + await createScheduler(createEnv(undefined, stub)).event( + makeSlackEvent({ text: "follow up" }) + ) + ).toMatchObject({ steered: 1, triggered: 0 }); + expect(await getPromptBody(vi.mocked(stub.fetch))).toMatchObject({ + canonicalUserId: "slack-actor-user", + scmEnrichment: { + userId: "77", + login: "verified-actor", + email: "77+verified-actor@users.noreply.github.com", + }, + }); + expect(mockGitHubAccountInfo).toHaveBeenCalledWith({ + query: { providerId: "github", accountId: "77", userId: "slack-actor-user" }, + }); + }); + + it.each(["empty", "unavailable"])( + "steers without attribution when the actor grant is %s", + async (scenario) => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue( + sampleRunRow({ session_id: "sess-running" }) + ); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "77", providerLogin: null }, + ]); + if (scenario === "empty") mockGetGitHubAccessToken.mockResolvedValue({ accessToken: "" }); + else mockGetGitHubAccessToken.mockRejectedValue(new Error("revoked grant")); + const stub = createMockSessionStub(); + expect( + await createScheduler(createEnv(undefined, stub)).event( + makeSlackEvent({ text: "follow up" }) + ) + ).toMatchObject({ steered: 1, triggered: 0 }); + const prompt = await getPromptBody(vi.mocked(stub.fetch)); + if (scenario === "empty") + expect(prompt.scmEnrichment).toMatchObject({ userId: "77", login: null }); + else + expect(prompt.scmEnrichment).toEqual({ + userId: null, + login: null, + name: null, + email: null, + }); + expect(mockGitHubAccountInfo).not.toHaveBeenCalled(); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + } + ); + + it("does not steer or start an owner run for a mismatched actor profile", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue( + sampleRunRow({ session_id: "sess-running" }) + ); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "77", providerLogin: null }, + ]); + mockGetGitHubAccessToken.mockResolvedValue({ accessToken: "grant" }); + mockGitHubAccountInfo.mockResolvedValue({ + user: { id: "42" }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "42", + login: "wrong", + verifiedEmails: [], + primaryEmail: null, + }, + }); + const stub = createMockSessionStub(); + await expect( + createScheduler(createEnv(undefined, stub)).event(makeSlackEvent()) + ).rejects.toThrow("mismatched GitHub account"); + expect(promptCallCount(vi.mocked(stub.fetch))).toBe(0); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + }); + + it("steers a natural follow-up without attribution when GitHub identity is ambiguous", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue( + sampleRunRow({ session_id: "sess-running" }) + ); + mockUserStoreGetIdentitiesForUser.mockResolvedValue([ + { provider: "github", providerUserId: "77" }, + { provider: "github", providerUserId: "78" }, + ]); + const stub = createMockSessionStub(); + expect( + await createScheduler(createEnv(undefined, stub)).event( + makeSlackEvent({ text: "also update the changelog" }) + ) + ).toEqual({ triggered: 0, skipped: 0, steered: 1 }); + const body = await getPromptBody(vi.mocked(stub.fetch)); + expect(body.canonicalUserId).toBe("slack-actor-user"); + expect(body.scmEnrichment).toEqual({ userId: null, login: null, name: null, email: null }); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + }); + + it("does not start an owner run when steering attribution storage fails", async () => { + mockGetSlackAutomationsForChannel.mockResolvedValue([sampleSlackAutomation]); + mockStore.getLatestSteerableRunForThread.mockResolvedValue( + sampleRunRow({ session_id: "sess-running" }) + ); + mockUserStoreGetIdentitiesForUser.mockRejectedValue(new Error("D1 unavailable")); + const stub = createMockSessionStub(); + await expect( + createScheduler(createEnv(undefined, stub)).event(makeSlackEvent()) + ).rejects.toThrow("D1 unavailable"); + expect(mockStore.insertInvocationGuarded).not.toHaveBeenCalled(); + expect(promptCallCount(vi.mocked(stub.fetch))).toBe(0); + }); + it("resolves and authorizes the Slack actor once across several steering candidates", async () => { mockGetSlackAutomationsForChannel.mockResolvedValue([ sampleSlackAutomation, diff --git a/packages/control-plane/src/scheduler/scheduler.ts b/packages/control-plane/src/scheduler/scheduler.ts index 03b98a605c..a12760ffad 100644 --- a/packages/control-plane/src/scheduler/scheduler.ts +++ b/packages/control-plane/src/scheduler/scheduler.ts @@ -62,6 +62,8 @@ import { type SlackRunMetadata, type SlackCompletionContext, } from "./slack-completion"; +import { getUserAuth } from "../auth/user/runtime"; +import { GitHubAttributionUnavailableError } from "../source-control/github-credential-authority"; import { UserStore } from "../db/user-store"; import { createRequestMetrics } from "../db/instrumented-sql-database"; import { generateId } from "../auth/crypto"; @@ -89,7 +91,12 @@ import { } from "../automation/authorization-guard"; import type { RequestContext } from "../routes/shared"; import { deliverWithRetry } from "../session/callback-delivery"; -import type { GitHubEnrichment } from "../session/identity"; +import { + AmbiguousGitHubIdentityError, + resolveGitHubEnrichmentForCanonicalUser, + type GitHubEnrichment, +} from "../session/identity"; +import { resolveScmProviderFromEnv } from "../source-control/config"; /** Max automations to process per tick (backpressure). */ const MAX_PER_TICK = 25; @@ -292,7 +299,7 @@ interface StartInvocationParams { interface ExecutionPrincipal { platformUserId: string; participantUserId: string; - scmEnrichment?: GitHubEnrichment; + scmEnrichment: GitHubEnrichment | null; } type StartInvocationResult = @@ -309,7 +316,7 @@ type StartInvocationResult = type SchedulerPromptRequest = Pick< EnqueuePromptRequest, - "content" | "authorId" | "canonicalUserId" | "source" + "content" | "authorId" | "canonicalUserId" | "source" | "scmEnrichment" > & { callbackContext: AutomationCallbackContext | SlackCallbackContext; }; @@ -419,6 +426,7 @@ export class Scheduler { ? { platformUserId: automation.user_id, participantUserId: automation.created_by, + scmEnrichment: null, } : null); if (!executionPrincipal) return { outcome: "unauthorized" }; @@ -499,21 +507,25 @@ export class Scheduler { // deleted environment fails through the launch-failure path. No targets → // one repo-less child. const children: AutomationRunRow[] = [ - ...resolutions.map((resolution): AutomationRunRow => ({ - ...childBase(), - status: resolution.error ? "failed" : "starting", - failure_reason: resolution.error, - completed_at: resolution.error ? now : null, - repo_owner: resolution.repository?.repoOwner ?? resolution.requested.repo_owner, - repo_name: resolution.repository?.repoName ?? resolution.requested.repo_name, - repo_id: resolution.repository?.repoId ?? resolution.requested.repo_id, - base_branch: resolution.repository?.baseBranch ?? resolution.requested.base_branch, - })), - ...environmentSelection.map((environment): AutomationRunRow => ({ - ...childBase(), - status: "starting", - environment_id: environment.environment_id, - })), + ...resolutions.map( + (resolution): AutomationRunRow => ({ + ...childBase(), + status: resolution.error ? "failed" : "starting", + failure_reason: resolution.error, + completed_at: resolution.error ? now : null, + repo_owner: resolution.repository?.repoOwner ?? resolution.requested.repo_owner, + repo_name: resolution.repository?.repoName ?? resolution.requested.repo_name, + repo_id: resolution.repository?.repoId ?? resolution.requested.repo_id, + base_branch: resolution.repository?.baseBranch ?? resolution.requested.base_branch, + }) + ), + ...environmentSelection.map( + (environment): AutomationRunRow => ({ + ...childBase(), + status: "starting", + environment_id: environment.environment_id, + }) + ), ]; if (children.length === 0) { children.push({ ...childBase(), status: "starting" }); @@ -525,7 +537,8 @@ export class Scheduler { // for this firing: edits made after the conditional insert cannot change which // account an admitted child uses. let providerAuthSnapshot: - { providerAuth: SessionModelProviderAuthInput[] } | { error: unknown } = { providerAuth: [] }; + | { providerAuth: SessionModelProviderAuthInput[] } + | { error: unknown } = { providerAuth: [] }; if (launchCandidates.length > 0) { try { providerAuthSnapshot = { @@ -589,6 +602,17 @@ export class Scheduler { return this.recordOverlapSkip(store, params, { advanceSchedule: false }); } + let attributionError: unknown; + if (!params.executionPrincipal && launchCandidates.length > 0) { + try { + executionPrincipal.scmEnrichment = await this.resolveScmEnrichment( + executionPrincipal.platformUserId + ); + } catch (error) { + attributionError = error; + } + } + // Admitted. Only now is it worth paying for anything the prompt needs. // Contain provider failures here: children already exist in `starting`, so // a rejected lazy override must not escape and strand persisted state. @@ -608,6 +632,7 @@ export class Scheduler { const launchChild = async (child: AutomationRunRow): Promise => { try { + if (attributionError !== undefined) throw attributionError; if ("error" in providerAuthSnapshot) throw providerAuthSnapshot.error; const sessionId = generateId(); // Claim the generated session before initialization. Otherwise the orphan sweep can @@ -1249,7 +1274,7 @@ export class Scheduler { async trigger( automationId: string, requesterUserId: string, - requesterEnrichment?: GitHubEnrichment + requesterEnrichment: GitHubEnrichment | null = null ): Promise { const store = new AutomationStore(this.db); const automation = await store.getById(automationId); @@ -1621,6 +1646,8 @@ export class Scheduler { executionPrincipal.platformUserId ); + const scmEnrichment = executionPrincipal.scmEnrichment; + const sessionInput: SessionInitInput = { ownerTeamId: null, visibility: "workspace", @@ -1632,10 +1659,11 @@ export class Scheduler { reasoningEffort: automation.reasoning_effort, participantUserId: executionPrincipal.participantUserId, platformUserId: executionPrincipal.platformUserId, - scmUserId: executionPrincipal.scmEnrichment?.scmUserId, - scmLogin: executionPrincipal.scmEnrichment?.scmLogin, - scmName: executionPrincipal.scmEnrichment?.displayName, - scmEmail: executionPrincipal.scmEnrichment?.email, + participantCanonicalUserId: executionPrincipal.platformUserId, + scmUserId: scmEnrichment?.scmUserId, + scmLogin: scmEnrichment?.scmLogin, + scmName: scmEnrichment?.displayName, + scmEmail: scmEnrichment?.email, codeServerEnabled, vncEnabled, sandboxSettings, @@ -1677,6 +1705,29 @@ export class Scheduler { ); } + private async resolveScmEnrichment(userId: string): Promise { + if (resolveScmProviderFromEnv(this.env.SCM_PROVIDER) !== "github") return null; + try { + return await resolveGitHubEnrichmentForCanonicalUser( + new UserStore(this.db), + userId, + () => getUserAuth(this.env, this.db).api + ); + } catch (error) { + if ( + !(error instanceof AmbiguousGitHubIdentityError) && + !(error instanceof GitHubAttributionUnavailableError) + ) + throw error; + this.log.warn("GitHub attribution unavailable; continuing without it", { + event: "scheduler.github_enrichment_unavailable", + user_id: userId, + error, + }); + return null; + } + } + /** * Route a follow-up slack message in a thread to its run's existing session as * the next turn — whether that run is still in flight, completed, or failed — @@ -1709,6 +1760,7 @@ export class Scheduler { automationId: automation.id, }; + const enrichment = await this.resolveScmEnrichment(actorUserId); try { await this.enqueueSessionPrompt( sessionId, @@ -1716,6 +1768,15 @@ export class Scheduler { content: event.text, authorId: `slack:${event.actorUserId}`, canonicalUserId: actorUserId, + scmEnrichment: + resolveScmProviderFromEnv(this.env.SCM_PROVIDER) === "github" + ? { + userId: enrichment?.scmUserId ?? null, + login: enrichment?.scmLogin ?? null, + name: enrichment?.displayName ?? null, + email: enrichment?.email ?? null, + } + : undefined, source: "slack", callbackContext, }, diff --git a/packages/control-plane/src/session/alarm/handler.test.ts b/packages/control-plane/src/session/alarm/handler.test.ts index c0b766432a..f3b4ced936 100644 --- a/packages/control-plane/src/session/alarm/handler.test.ts +++ b/packages/control-plane/src/session/alarm/handler.test.ts @@ -5,7 +5,9 @@ import type { MessageRepository } from "../message-repository"; import { createEarliestAlarmScheduler } from "./scheduler"; import type { SandboxAlarmResult } from "../../sandbox/lifecycle/manager"; -function createHandler(preserveBeforeWatchdogs?: () => Promise<"continue" | "hold_watchdogs">) { +function createHandler( + preserveBeforeWatchdogs?: (allowCaptureRetry: boolean) => Promise<"continue" | "hold_watchdogs"> +) { const repository = { getProcessingMessageWithStartedAt: vi.fn(), getNextPendingMessage: vi.fn(() => null as { id: string } | null), @@ -168,6 +170,8 @@ describe("createAlarmHandler", () => { await handler.handle(); expect(preserve).toHaveBeenCalledTimes(2); + expect(preserve).toHaveBeenNthCalledWith(1, true); + expect(preserve).toHaveBeenNthCalledWith(2, false); expect(terminalMessageProjection.flushPending).toHaveBeenCalledOnce(); expect(executionStop.recoverStopConfirmationTimeout).not.toHaveBeenCalled(); expect(lifecycleManager.handleAlarm).not.toHaveBeenCalled(); diff --git a/packages/control-plane/src/session/alarm/handler.ts b/packages/control-plane/src/session/alarm/handler.ts index 8254362c59..c5aeb60d75 100644 --- a/packages/control-plane/src/session/alarm/handler.ts +++ b/packages/control-plane/src/session/alarm/handler.ts @@ -8,7 +8,7 @@ import type { MessageRepository } from "../message-repository"; import type { SessionTerminalMessageProjection } from "../terminal-message-projection"; export interface AlarmHandlerDeps { - preserveBeforeWatchdogs?: () => Promise<"continue" | "hold_watchdogs">; + preserveBeforeWatchdogs?: (allowCaptureRetry: boolean) => Promise<"continue" | "hold_watchdogs">; repository: MessageRepository; messageQueue: Pick; executionStop: Pick< @@ -41,7 +41,7 @@ export function createAlarmHandler(deps: AlarmHandlerDeps): AlarmHandler { async handle(): Promise { // Graceful shutdown must not wait behind a remote index projection or a // generic stop timeout. Recheck below if projection I/O crosses D. - await deps.preserveBeforeWatchdogs?.(); + await deps.preserveBeforeWatchdogs?.(true); let projectionFailure: { error: unknown } | undefined; try { await deps.terminalMessageProjection.flushPending(); @@ -50,7 +50,8 @@ export function createAlarmHandler(deps: AlarmHandlerDeps): AlarmHandler { // Rethrow after recovery so transient storage failures still retry. projectionFailure = { error }; } - if ((await deps.preserveBeforeWatchdogs?.()) === "hold_watchdogs") { + // Recheck shutdown deadlines without starting another retry in this delivery. + if ((await deps.preserveBeforeWatchdogs?.(false)) === "hold_watchdogs") { if (projectionFailure) throw projectionFailure.error; return; } diff --git a/packages/control-plane/src/session/callback-delivery.ts b/packages/control-plane/src/session/callback-delivery.ts index 7a47998529..a32d9b918b 100644 --- a/packages/control-plane/src/session/callback-delivery.ts +++ b/packages/control-plane/src/session/callback-delivery.ts @@ -3,7 +3,8 @@ const CALLBACK_RETRY_DELAY_MS = 1000; const CALLBACK_ATTEMPT_TIMEOUT_MS = 10_000; export type RetryAttemptResult = - { outcome: "delivered"; value: TValue } | { outcome: "retryable_failure"; failure: TFailure }; + | { outcome: "delivered"; value: TValue } + | { outcome: "retryable_failure"; failure: TFailure }; type RetryFailure = | { attempt: number; failure: TFailure; error?: never } diff --git a/packages/control-plane/src/session/client-command-facade.ts b/packages/control-plane/src/session/client-command-facade.ts index 76545e579f..561f214d0f 100644 --- a/packages/control-plane/src/session/client-command-facade.ts +++ b/packages/control-plane/src/session/client-command-facade.ts @@ -14,13 +14,14 @@ import type { ClientPresence, ClientPrompt, ClientSubscribe, + ClientCommandAuthorization, FetchHistory, } from "./message-router"; import type { SessionEventStream, SessionHistoryPage } from "./event-stream"; import type { SessionConnectionAuthenticator } from "./connection-authenticator"; import type { SessionMessageQueue } from "./message-queue"; import type { PresenceService } from "./presence-service"; -import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { SessionAction } from "@open-inspect/shared"; import type { SessionWebSocket } from "../platform-ports"; import type { ShutdownRecoveryAction } from "@open-inspect/shared/types/sandbox-shutdown"; @@ -80,9 +81,10 @@ export class SessionClientCommandFacade implements SessionClientCommands< } authorize( + connection: SessionWebSocket, client: ClientInfo, - permission: PermissionId - ): Promise<"allowed" | "denied" | "unavailable"> { - return this.authenticator.authorizeClientCommand(client.userId, permission); + action: SessionAction + ): Promise { + return this.authenticator.authorizeClientCommand(connection, client.userId, action); } } diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index 2df0e0178c..98d940bfe4 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -24,11 +24,11 @@ import { resolveAppName } from "@open-inspect/shared/app-name"; import { DEFAULT_MODEL } from "@open-inspect/shared/models"; import { sandboxPromptBlockReason } from "@open-inspect/shared/types/sandbox-shutdown"; -import { generateId, hashToken, encryptToken } from "../auth/crypto"; +import { generateId, hashToken } from "../auth/crypto"; import { getUserAuth } from "../auth/user/runtime"; import { resolveSandboxBackendName } from "../sandbox/provider-name"; import { createSandboxProviderFromEnv } from "../sandbox/provider-factory"; -import type { SandboxProvider } from "../sandbox/provider"; +import { providerResumesAfterStop, type SandboxProvider } from "../sandbox/provider"; import { resolveExecutionBudgetMs } from "../sandbox/execution-budget"; import { createImageBuildLookup } from "../image-builds/lookup"; import { resolveImageBuildAdmission } from "../image-builds/provider-policy"; @@ -42,20 +42,24 @@ import { type SandboxStorage, type SessionContextReader, type IdGenerator, - type ImageBuildLookup, - type McpServerLookup, - type SlackAgentNotifyLookup, type SandboxShutdownLifecycle, } from "../sandbox/lifecycle/manager"; +import type { ImageBuildLookup } from "../sandbox/lifecycle/image-selection"; +import type { McpServerLookup, SlackAgentNotifyLookup } from "../sandbox/lifecycle/launch-context"; +// The composition root shares the internal access collaborator with shutdown and lifecycle only. +// eslint-disable-next-line no-restricted-imports +import { SandboxAccess } from "../sandbox/lifecycle/sandbox-access"; import { resolveBootBudgetTimeoutMs } from "../sandbox/lifecycle/decisions"; import { McpServerStore } from "../db/mcp-servers"; import { UserStore } from "../db/user-store"; import { IntegrationSettingsStore, resolveSlackSettings } from "../db/integration-settings"; import { SessionIndexStore } from "../db/session-index"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; import { parsePersistedSandboxSettings } from "../sandbox/settings"; import type { SandboxSettings } from "@open-inspect/shared/types/integrations"; import { createSourceControlProviderFromEnv, type SourceControlProvider } from "../source-control"; -import { requireRepoSecretsEncryptionKey, requireTokenEncryptionKey } from "../env-validation"; +import { requireRepoSecretsEncryptionKey } from "../env-validation"; import type { Env, ClientInfo } from "../types"; import type { SessionRow } from "./types"; import type { SqlDatabase } from "../db/sql-database"; @@ -154,6 +158,9 @@ import { createSessionRuntimeClientForTrace } from "./runtime-client"; import { SessionTitleService } from "./title-service"; import { parseArtifactMetadata } from "./artifact-metadata"; import { AuthorizationError, AuthorizationService } from "../authorization/service"; +import { parseTeamsEnforcementMode, resolverDecides } from "../authorization/teams-enforcement"; +import { auditSocketPrivateBreakGlass } from "../authorization/session-socket-audit"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; import type { SessionWebSocket } from "../platform-ports"; /** @@ -264,7 +271,6 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi // Secrets-at-rest encryption is not optional. Every consumer below takes // the validated key, so no fallback path can persist a secret in plaintext. const repoSecretsEncryptionKey = requireRepoSecretsEncryptionKey(env); - const tokenEncryptionKey = requireTokenEncryptionKey(env); // The session-scoped logger, created before anything can capture a logger // at all. Its `session_id` is injected per emit through the latched @@ -317,6 +323,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi // Shared single instances/closures — every consumer below takes these // rather than re-deriving its own copy. const sessionIndexStore = new SessionIndexStore(db); + const teamMembershipStore = new TeamMembershipStore(db); + const sessionCollaboratorStore = new SessionCollaboratorStore(db); const sessionPullRequestStore = new SessionPullRequestStore(db); const resolveRepoId = (sessionRow: SessionRow) => resolveSessionRepoId(sessionRow, sessionCoreRepository, sourceControlProvider); @@ -363,7 +371,6 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi const participantService = new ParticipantService({ repository: participantRepository, getProcessingMessageAuthor: () => messageRepository.getProcessingMessageAuthor(), - env, log, generateId: () => generateId(), resolveCurrentGitHubAccessToken: @@ -424,11 +431,26 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi const diffsHandler = new SessionDiffsHandler(diffService); const eventStream = new SessionEventStream(eventRepository); - // Tier 5 — the lifecycle manager. - const sandboxProvider = createSandboxProviderFromEnv( - env, - resolveSandboxBackendName(env.SANDBOX_PROVIDER) + // Tier 5: access precedes shutdown and the lifecycle manager, so retirement has no manager cycle. + const sandboxBackend = resolveSandboxBackendName(env.SANDBOX_PROVIDER); + const sandboxProvider = createSandboxProviderFromEnv(env, sandboxBackend); + const lifecycleSockets = new LifecycleSocketAdapter(wsManager); + const accessLog = createSessionScopedLogger( + createLogger("lifecycle-manager"), + getPublicSessionId ); + const access = new SandboxAccess({ + storage: sandboxRepository, + broadcaster: messenger, + sockets: lifecycleSockets, + canResumeAfterStop: () => providerResumesAfterStop(sandboxProvider), + getLogger: () => accessLog, + sandboxDashboardUrlBuilder: + sandboxBackend === "modal" || sandboxBackend === "modal-vm" + ? (providerObjectId) => + resolveSandboxDashboardUrl(sandboxDashboardSettings, providerObjectId) + : undefined, + }); // Tier 6 — the message queue. const getExecutionTimeoutMs = () => resolveExecutionTimeoutMs(sessionCoreRepository, env, log); const messageFailures = new MessageFailureService( @@ -455,11 +477,12 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi // composition function has constructed and returned the complete graph. onLifecycleChange: () => messageQueue.processMessageQueue(), reconcileStatusFromMessages: () => statusService.reconcileFromMessageState(), - retireAccess: () => lifecycleManager.retireShutdownAccess(), + retireAccess: () => access.retireShutdownAccess(), }); const lifecycleManager = createLifecycleManager({ provider: sandboxProvider, shutdown, + access, env, db, getSessionId: getPublicSessionId, @@ -467,9 +490,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi sessionContext: new LifecycleSessionContext(sessionCoreRepository, userEnvResolver), repoSecretsEncryptionKey, messenger, - wsManager, + lifecycleSockets, alarmScheduler, - sandboxDashboardSettings, backgroundTasks, recordWarning: (message, eventId) => recordSessionWarning(eventRepository, messenger, message, eventId), @@ -627,7 +649,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi getExecutionTimeoutMs, now: () => Date.now(), log, - preserveBeforeWatchdogs: () => lifecycleManager.handleShutdownAlarm(), + preserveBeforeWatchdogs: (allowCaptureRetry) => + lifecycleManager.handleShutdownAlarm(allowCaptureRetry), }); const schedulePullRequestRefresh = (trigger: "open" | "manual"): void => { @@ -733,7 +756,6 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi backgroundTasks.submit(() => lifecycleManager.warmSandbox(), { name: "sandbox.warm", }), - (token) => encryptToken(token, tokenEncryptionKey), generateId ); const sessionLifecycleHandler = new SessionLifecycleHandler( @@ -810,8 +832,6 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi }); const connectionAuthenticator = new SessionConnectionAuthenticator({ - getSessionOwnerId: async () => - (await sessionIndexStore.get(getPublicSessionId()))?.userId ?? null, wsManager, sessionCoreRepository, sandboxRepository, @@ -824,12 +844,42 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi snapshotReader, schedulePullRequestRefresh, scmProviderName, - resolveAuthorization: async (userId) => { + resolveSessionViewer: async (userId, options) => { try { - const authorization = await new AuthorizationService(db).getEffectiveAuthorization(userId); - return authorization.suspendedAt === null - ? { kind: "valid", authorization } - : { kind: "rejected" }; + const mode = parseTeamsEnforcementMode(env.TEAMS_ENFORCEMENT); + const [authorization, session] = await Promise.all([ + new AuthorizationService(db).getEffectiveAuthorization(userId), + sessionIndexStore.get(getPublicSessionId()), + ]); + if (authorization.suspendedAt !== null) return { kind: "rejected" }; + if (!session) return { kind: "rejected" }; + const enforceScope = resolverDecides(mode, session, "read"); + const [memberships, collaboratorIds] = await Promise.all([ + resolverDecides(mode, session, "collaborate") || options?.includeMemberships + ? teamMembershipStore.listForUser(userId) + : new Map(), + enforceScope ? sessionCollaboratorStore.listUserIds(session.id) : [], + ]); + return { + kind: "valid", + mode, + authorization, + viewer: { + kind: "user", + userId: authorization.userId, + roleKey: authorization.role.key, + permissions: authorization.permissions, + suspended: false, + memberships, + }, + row: { + id: session.id, + ownerUserId: session.userId ?? null, + ownerTeamId: session.ownerTeamId, + visibility: session.visibility, + collaboratorIds, + }, + }; } catch (error) { if (error instanceof AuthorizationError) return { kind: "rejected" }; log.error("WebSocket authorization verification failed", { @@ -839,6 +889,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi return { kind: "unavailable" }; } }, + auditPrivateBreakGlass: (userId, row) => auditSocketPrivateBreakGlass(db, userId, row), log, }); @@ -1001,6 +1052,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi interface LifecycleManagerDeps { recordWarning: (message: string, eventId: string) => void; shutdown: SandboxShutdownLifecycle; + access: SandboxAccess; provider: SandboxProvider; env: Env; db: SqlDatabase; @@ -1011,9 +1063,8 @@ interface LifecycleManagerDeps { sessionContext: SessionContextReader; repoSecretsEncryptionKey: string; messenger: SessionMessenger; - wsManager: SessionWebSocketManager; + lifecycleSockets: LifecycleSocketAdapter; alarmScheduler: RehydratableAlarmScheduler; - sandboxDashboardSettings: SandboxDashboardSettings; backgroundTasks: BackgroundTasks; } @@ -1022,6 +1073,7 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan const { provider, shutdown, + access, env, db, getSessionId, @@ -1029,18 +1081,10 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan sessionContext, repoSecretsEncryptionKey, messenger, - wsManager, + lifecycleSockets, alarmScheduler, - sandboxDashboardSettings, backgroundTasks, } = deps; - // Both throw on a misconfigured deployment — deliberately at graph - // construction, so every session request fails at initialization instead of - // the error surfacing later at the first spawn. - const sandboxBackend = resolveSandboxBackendName(env.SANDBOX_PROVIDER); - - const lifecycleWsManager = new LifecycleSocketAdapter(wsManager); - // ID generator adapter const idGenerator: IdGenerator = { generateId: () => generateId(), @@ -1073,12 +1117,6 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan }, }; - const sandboxDashboardUrlBuilder = - sandboxBackend === "modal" || sandboxBackend === "modal-vm" - ? (providerObjectId: string) => - resolveSandboxDashboardUrl(sandboxDashboardSettings, providerObjectId) - : undefined; - // A malformed budget must not take every session down at construction the // way a missing provider does; it falls back to the default and says so. const bootBudget = resolveBootBudgetTimeoutMs(env.SANDBOX_BOOT_TIMEOUT_MS, { @@ -1112,7 +1150,6 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan bootBudget: { timeoutMs: bootBudget.timeoutMs }, mcpServerLookup, slackAgentNotifyLookup, - sandboxDashboardUrlBuilder, recordWarning: deps.recordWarning, }; @@ -1123,7 +1160,7 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan const imageBuildAdmission = resolveImageBuildAdmission(env); const imageBuildLookup: ImageBuildLookup | undefined = imageBuildAdmission.admitted && imageBuildAdmission.provider - ? createImageBuildLookup(db, imageBuildAdmission.provider) + ? createImageBuildLookup(db, imageBuildAdmission.provider, getSessionId) : undefined; return new SandboxLifecycleManager( @@ -1131,10 +1168,11 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan storage, sessionContext, messenger, - lifecycleWsManager, + lifecycleSockets, alarmScheduler, idGenerator, shutdown, + access, config, imageBuildLookup, backgroundTasks diff --git a/packages/control-plane/src/session/connection-authenticator.test.ts b/packages/control-plane/src/session/connection-authenticator.test.ts index 784e407212..1c95cc9047 100644 --- a/packages/control-plane/src/session/connection-authenticator.test.ts +++ b/packages/control-plane/src/session/connection-authenticator.test.ts @@ -6,6 +6,8 @@ import { describe, it, expect, vi } from "vitest"; import { hashToken } from "../auth/crypto"; +import { permissionsForBuiltInRole, type PermissionId } from "@open-inspect/shared/rbac"; +import type { SessionAccessRow, SessionViewer } from "@open-inspect/shared"; import type { Logger } from "../logger"; import type { BackgroundTasks } from "../platform-ports"; import { @@ -556,3 +558,415 @@ describe("UpgradeDecision.attach", () => { expect(h.wsManager.acceptAndSetSandboxSocket).toHaveBeenCalledOnce(); }); }); + +describe("client session access", () => { + type UserViewer = Extract; + const teamRow: SessionAccessRow = { + id: "session", + ownerUserId: "owner-user", + ownerTeamId: "team-b", + visibility: "team", + collaboratorIds: [], + }; + const member: UserViewer = { + kind: "user", + userId: "member-user", + roleKey: "member", + suspended: false, + permissions: permissionsForBuiltInRole("member"), + memberships: new Map([["team-a", "member"]]), + }; + const owner: UserViewer = { + ...member, + userId: "workspace-owner", + roleKey: "owner", + permissions: permissionsForBuiltInRole("owner"), + }; + + function accessHarness(mode: "off" | "shadow" | "on", viewer: UserViewer, row: SessionAccessRow) { + const authorization = { + userId: viewer.userId, + role: { key: viewer.roleKey }, + permissions: viewer.permissions, + suspendedAt: null, + }; + const resolution = { kind: "valid" as const, mode, authorization, viewer, row }; + const close = vi.fn(); + const removeClient = vi.fn(); + const auditPrivateBreakGlass = vi.fn(async () => undefined); + const send = vi.fn((_ws: WebSocket, _message: { type: string; session?: unknown }) => true); + const snapshot = { + session: { + codeServerUrl: "https://code.example.test", + sandboxDashboardUrl: "https://dashboard.example.test", + ttydUrl: "https://terminal.example.test", + vncUrl: "https://vnc.example.test", + tunnelUrls: { app: "https://app.example.test" }, + }, + artifacts: [], + timeline: { events: [], hasMore: false, cursor: null }, + promptQueue: [], + }; + const deps = { + resolveSessionViewer: vi.fn(async () => resolution), + wsManager: { + close, + removeClient, + send, + isClientAuthenticated: vi.fn(() => false), + isClientSynchronizing: vi.fn(() => false), + setClientSynchronizing: vi.fn(), + activateClient: vi.fn(async (_ws: WebSocket, _info: unknown, synchronize: () => boolean) => + synchronize() + ), + }, + participantService: { + getByWsTokenHash: vi.fn(() => ({ + id: "participant-1", + user_id: "member-user", + canonical_user_id: authorization.userId, + ws_token_created_at: Date.now(), + scm_login: null, + })), + }, + snapshotReader: { + resolveSessionSnapshotEnrichment: vi.fn(async () => ({})), + readSessionSnapshot: vi.fn(() => snapshot), + }, + scmProviderName: "github", + presenceService: { sendPresence: vi.fn(), broadcastPresence: vi.fn() }, + schedulePullRequestRefresh: vi.fn(), + auditPrivateBreakGlass, + log: createLogger(), + } as unknown as SessionConnectionAuthenticatorDeps; + return { + authenticator: new SessionConnectionAuthenticator(deps), + close, + removeClient, + send, + auditPrivateBreakGlass, + resolveSessionViewer: deps.resolveSessionViewer, + }; + } + + it.each(["off", "shadow", "on"] as const)( + "uses the %s mode for the team rule at subscribe and on commands", + async (mode) => { + const { authenticator, close, resolveSessionViewer } = accessHarness(mode, member, teamRow); + await authenticator.handleSubscribe({} as WebSocket, { token: "token", clientId: "client" }); + expect(close).toHaveBeenCalledTimes(mode === "on" ? 1 : 0); + if (mode === "on") expect(close).toHaveBeenCalledWith({}, 4010, expect.any(String)); + expect( + await authenticator.authorizeClientCommand({} as WebSocket, member.userId, "collaborate") + ).toEqual(mode === "on" ? { kind: "revoked" } : { kind: "denied", reason: "not_member" }); + expect(resolveSessionViewer).toHaveBeenNthCalledWith(1, member.userId, { + includeMemberships: true, + }); + expect(resolveSessionViewer).toHaveBeenNthCalledWith(2, member.userId); + } + ); + + it("retains a read-only socket when only collaboration is denied", async () => { + const viewer: UserViewer = { + ...member, + memberships: new Map([["team-b", "member"]]), + permissions: ["sessions.read"], + }; + const { authenticator, close, removeClient } = accessHarness("on", viewer, teamRow); + const socket = {} as WebSocket; + await authenticator.handleSubscribe(socket, { token: "token", clientId: "read-only" }); + + expect( + await authenticator.authorizeClientCommand(socket, viewer.userId, "collaborate") + ).toEqual({ + kind: "denied", + reason: "missing_permission", + }); + expect(close).not.toHaveBeenCalled(); + expect(removeClient).not.toHaveBeenCalled(); + }); + + it.each( + (["off", "shadow", "on"] as const).flatMap((mode) => + (["workspace", "team", "private"] as const).map((visibility) => ({ mode, visibility })) + ) + )( + "sends fresh team-member capabilities for $visibility subscriptions in $mode mode", + async ({ mode, visibility }) => { + const permissions: PermissionId[] = [ + "sessions.read", + "sessions.collaborate", + "sessions.lifecycle", + "sessions.delete", + "sessions.sandbox_access", + ]; + const viewer: UserViewer = { + ...member, + memberships: new Map([["team-b", "member"]]), + permissions, + }; + const { authenticator, send, close } = accessHarness(mode, viewer, { + ...teamRow, + visibility, + collaboratorIds: visibility === "private" ? [viewer.userId] : [], + }); + const subscribe = () => + authenticator.handleSubscribe({} as WebSocket, { token: "token", clientId: "client" }); + const expectedCapabilities = { + canRead: true, + canCollaborate: true, + canManageLifecycle: true, + canDelete: false, + canSandbox: true, + canManageCollaborators: false, + canChangeVisibility: false, + }; + + await subscribe(); + expect(send).toHaveBeenLastCalledWith( + {}, + expect.objectContaining({ + type: "subscribed", + session: expect.objectContaining({ capabilities: expectedCapabilities }), + }) + ); + expect(send.mock.calls.at(-1)?.[1]).toHaveProperty( + "session.codeServerUrl", + "https://code.example.test" + ); + for (const action of ["collaborate", "lifecycle", "sandbox"] as const) { + expect( + await authenticator.authorizeClientCommand({} as WebSocket, viewer.userId, action) + ).toEqual({ kind: "allowed" }); + } + expect( + await authenticator.authorizeClientCommand({} as WebSocket, viewer.userId, "delete") + ).toEqual({ kind: "denied", reason: "not_owner_or_lead" }); + + const originalPermissions = [...permissions]; + permissions.splice(0, permissions.length, "sessions.read"); + await subscribe(); + expect(send).toHaveBeenLastCalledWith( + {}, + expect.objectContaining({ + type: "subscribed", + session: expect.objectContaining({ + capabilities: { + ...expectedCapabilities, + canCollaborate: false, + canManageLifecycle: false, + canSandbox: false, + }, + }), + }) + ); + + permissions.push(...originalPermissions.slice(1)); + await subscribe(); + expect(send).toHaveBeenLastCalledWith( + {}, + expect.objectContaining({ + type: "subscribed", + session: expect.objectContaining({ capabilities: expectedCapabilities }), + }) + ); + expect(close).not.toHaveBeenCalled(); + } + ); + + describe.each(["off", "shadow", "on"] as const)( + "team-owned subscriptions and commands in %s mode", + (mode) => { + it.each([ + { + label: "workspace Member", + viewer: member, + row: { ...teamRow, visibility: "workspace" as const }, + }, + { + label: "workspace Admin", + viewer: { + ...member, + userId: "workspace-admin", + roleKey: "administrator" as const, + permissions: permissionsForBuiltInRole("administrator"), + }, + row: { ...teamRow, visibility: "workspace" as const }, + }, + { + label: "workspace Owner", + viewer: owner, + row: { ...teamRow, visibility: "workspace" as const }, + }, + { + label: "removed private session owner", + viewer: { ...member, userId: "owner-user" }, + row: { ...teamRow, visibility: "private" as const }, + }, + { + label: "private Owner break-glass reader", + viewer: owner, + row: { ...teamRow, visibility: "private" as const }, + }, + ])("keeps a $label read-only without team membership", async ({ viewer, row }) => { + const { authenticator, send, close, removeClient } = accessHarness(mode, viewer, row); + const socket = {} as WebSocket; + + await authenticator.handleSubscribe(socket, { token: "token", clientId: "nonmember" }); + + const subscribed = send.mock.calls.find( + ([, message]) => message.type === "subscribed" + )?.[1]; + expect(subscribed).toHaveProperty("session.capabilities", { + canRead: true, + canCollaborate: false, + canManageLifecycle: false, + canDelete: false, + canSandbox: false, + canManageCollaborators: false, + canChangeVisibility: false, + }); + for (const field of [ + "codeServerUrl", + "sandboxDashboardUrl", + "ttydUrl", + "vncUrl", + "tunnelUrls", + ]) { + expect(subscribed).not.toHaveProperty(`session.${field}`); + } + expect(await authenticator.authorizeClientCommand(socket, viewer.userId, "read")).toEqual({ + kind: "allowed", + }); + for (const action of [ + "collaborate", + "lifecycle", + "delete", + "sandbox", + "manageCollaborators", + "changeVisibility", + ] as const) { + expect(await authenticator.authorizeClientCommand(socket, viewer.userId, action)).toEqual( + { + kind: "denied", + reason: "not_member", + } + ); + } + expect(close).not.toHaveBeenCalled(); + expect(removeClient).not.toHaveBeenCalled(); + }); + } + ); + + it("closes on lost read access but not on a collaboration-only denial", async () => { + const memberships = new Map([["team-b", "member"]] as const); + const row = { ...teamRow }; + const viewer: UserViewer = { ...member, memberships }; + const { authenticator, close, removeClient, resolveSessionViewer } = accessHarness( + "on", + viewer, + row + ); + const socket = {} as WebSocket; + + await authenticator.handleSubscribe(socket, { token: "token", clientId: "member" }); + expect(close).not.toHaveBeenCalled(); + expect( + await authenticator.authorizeClientCommand(socket, viewer.userId, "collaborate") + ).toEqual({ + kind: "allowed", + }); + + memberships.delete("team-b"); + expect( + await authenticator.authorizeClientCommand(socket, viewer.userId, "collaborate") + ).toEqual({ + kind: "revoked", + }); + expect(removeClient).toHaveBeenCalledWith(socket); + expect(close).toHaveBeenCalledWith(socket, 4010, expect.any(String)); + + memberships.set("team-b", "member"); + row.ownerTeamId = "team-a"; + expect(await authenticator.authorizeClientCommand(socket, viewer.userId, "read")).toEqual({ + kind: "revoked", + }); + expect(resolveSessionViewer).toHaveBeenCalledTimes(4); + }); + + it.each( + (["off", "shadow", "on"] as const).flatMap((mode) => [ + { mode, label: "non-collaborators", collaboratorIds: [] }, + { mode, label: "nonmember collaborators", collaboratorIds: [member.userId] }, + ]) + )("refuses private $label in $mode mode", async ({ mode, collaboratorIds }) => { + const { authenticator, close } = accessHarness(mode, member, { + ...teamRow, + visibility: "private", + collaboratorIds, + }); + await authenticator.handleSubscribe({} as WebSocket, { token: "token", clientId: "client" }); + expect(close).toHaveBeenCalledWith({}, 4010, expect.any(String)); + expect( + await authenticator.authorizeClientCommand({} as WebSocket, member.userId, "read") + ).toEqual({ + kind: "revoked", + }); + }); + + it.each(["off", "shadow", "on"] as const)( + "redacts workspace-owned Owner break-glass URLs in %s, permits lifecycle but not collaboration", + async (mode) => { + const { authenticator, send, close, auditPrivateBreakGlass } = accessHarness(mode, owner, { + ...teamRow, + ownerTeamId: null, + visibility: "private", + }); + await authenticator.handleSubscribe({} as WebSocket, { token: "token", clientId: "client" }); + + expect(close).not.toHaveBeenCalled(); + const subscribed = send.mock.calls.find(([, message]) => message.type === "subscribed")?.[1]; + expect(subscribed).toBeDefined(); + expect(subscribed).not.toHaveProperty("session.codeServerUrl"); + expect(subscribed).not.toHaveProperty("session.sandboxDashboardUrl"); + expect(subscribed).not.toHaveProperty("session.ttydUrl"); + expect(subscribed).not.toHaveProperty("session.vncUrl"); + expect(subscribed).not.toHaveProperty("session.tunnelUrls"); + expect(subscribed).toHaveProperty("session.capabilities", { + canRead: true, + canCollaborate: false, + canManageLifecycle: true, + canDelete: true, + canSandbox: false, + canManageCollaborators: true, + canChangeVisibility: true, + }); + expect(auditPrivateBreakGlass).toHaveBeenCalledExactlyOnceWith( + owner.userId, + expect.objectContaining({ id: "session", visibility: "private" }) + ); + expect( + await authenticator.authorizeClientCommand({} as WebSocket, owner.userId, "collaborate") + ).toEqual({ kind: "denied", reason: "not_collaborator" }); + expect( + await authenticator.authorizeClientCommand({} as WebSocket, owner.userId, "lifecycle") + ).toEqual({ kind: "allowed" }); + expect(auditPrivateBreakGlass).toHaveBeenCalledOnce(); + } + ); + + it("refuses a break-glass subscription if its audit write fails", async () => { + const { authenticator, auditPrivateBreakGlass, close, send } = accessHarness("on", owner, { + ...teamRow, + visibility: "private", + }); + auditPrivateBreakGlass.mockRejectedValue(new Error("D1 unavailable")); + const socket = {} as WebSocket; + + await authenticator.handleSubscribe(socket, { token: "token", clientId: "owner" }); + + expect(close).toHaveBeenCalledWith(socket, 1011, "Authorization temporarily unavailable"); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/session/connection-authenticator.ts b/packages/control-plane/src/session/connection-authenticator.ts index e236cd6c7b..6dae326349 100644 --- a/packages/control-plane/src/session/connection-authenticator.ts +++ b/packages/control-plane/src/session/connection-authenticator.ts @@ -1,5 +1,13 @@ import { isSessionPromptable } from "@open-inspect/shared/types/session-activity"; -import type { EffectiveAuthorization, PermissionId } from "@open-inspect/shared/rbac"; +import type { EffectiveAuthorization } from "@open-inspect/shared/rbac"; +import { + checkSessionAccess, + type AccessDecision, + type SessionAction, + type SessionAccessRow, + type SessionCapabilities, + type SessionViewer, +} from "@open-inspect/shared"; import { redactSessionSnapshotSandboxAccess, type ServerMessage, @@ -30,6 +38,13 @@ import type { SandboxRow } from "./types"; import type { SessionWebSocketManager } from "./websocket-manager"; import { WS_AUTHORIZATION_LEASE_MS } from "./authorization-lease"; import { canManageSessionBudget } from "./budget-authorization"; +import { + legacyPermissionForAction, + resolverDecides, + type TeamsEnforcementMode, +} from "../authorization/teams-enforcement"; +import type { ClientCommandAuthorization } from "./message-router"; +import { effectiveSessionCapabilities } from "../authorization/session-admission"; /** * Maximum age of a WebSocket authentication token (in milliseconds). @@ -52,17 +67,25 @@ export interface SessionConnectionAuthenticatorDeps { snapshotReader: SessionSnapshotReader; schedulePullRequestRefresh: (trigger: "open" | "manual") => void; scmProviderName: SourceControlProviderName; - /** Resolve a user's current authorization at the start of a subscription or command. */ - resolveAuthorization: (userId: string) => Promise; - getSessionOwnerId: () => Promise; + /** Resolve the current D1 session scope and user's authorization on every gated action. */ + resolveSessionViewer: ( + userId: string, + options?: { includeMemberships?: boolean } + ) => Promise; + auditPrivateBreakGlass: (userId: string, row: SessionAccessRow) => Promise; /** The session-scoped logger; upgrade/subscribe paths also receive request-scoped children. */ log: Logger; } -type AuthorizationResolution = - { kind: "valid"; authorization: EffectiveAuthorization } | { kind: "rejected" | "unavailable" }; - -export type ClientCommandAuthorization = "allowed" | "denied" | "unavailable"; +type SessionViewerResolution = + | { + kind: "valid"; + mode: TeamsEnforcementMode; + authorization: EffectiveAuthorization; + viewer: SessionViewer; + row: SessionAccessRow; + } + | { kind: "rejected" | "unavailable" }; interface SandboxAdmission { sandboxId: string | null; @@ -363,23 +386,23 @@ export class SessionConnectionAuthenticator implements SessionUpgradeAdmission { // Authorization is intentionally sampled once at the start of this // subscription request. A concurrent role change takes effect when this // bounded lease expires, not midway through an in-flight request. - const authorization = await this.deps.resolveAuthorization(participant.canonical_user_id); - if ( - authorization.kind !== "valid" || - !authorization.authorization.permissions.includes("sessions.read") - ) { + const resolution = await this.deps.resolveSessionViewer(participant.canonical_user_id, { + includeMemberships: true, + }); + const read = resolution.kind === "valid" ? this.decide(resolution, "read") : null; + if (resolution.kind !== "valid" || !read?.allowed) { log.warn("ws.connect", { event: "ws.connect", ws_type: "client", outcome: "auth_failed", reject_reason: - authorization.kind === "unavailable" + resolution.kind === "unavailable" ? "authorization_unavailable" : "authorization_denied", participant_id: participant.id, user_id: participant.canonical_user_id, }); - if (authorization.kind === "unavailable") { + if (resolution.kind === "unavailable") { wsManager.close(ws, WS_CLOSE_INTERNAL_ERROR, "Authorization temporarily unavailable"); } else { wsManager.close(ws, WS_CLOSE_AUTHORIZATION_REVOKED, WS_AUTHORIZATION_REVOKED_REASON); @@ -405,10 +428,20 @@ export class SessionConnectionAuthenticator implements SessionUpgradeAdmission { return; } - const [enrichment, ownerUserId] = await Promise.all([ - this.deps.snapshotReader.resolveSessionSnapshotEnrichment(), - this.deps.getSessionOwnerId(), - ]); + if (read.audit === "session.private_break_glass") { + try { + await this.deps.auditPrivateBreakGlass(participant.canonical_user_id, resolution.row); + } catch (error) { + log.error("WebSocket break-glass audit failed", { + user_id: participant.canonical_user_id, + error: error instanceof Error ? error : String(error), + }); + wsManager.close(ws, WS_CLOSE_INTERNAL_ERROR, "Authorization temporarily unavailable"); + return; + } + } + + const enrichment = await this.deps.snapshotReader.resolveSessionSnapshotEnrichment(); const clientInfo: ClientInfo = { participantId: participant.id, userId: participant.canonical_user_id ?? participant.user_id, @@ -426,8 +459,8 @@ export class SessionConnectionAuthenticator implements SessionUpgradeAdmission { ws, clientInfo, enrichment, - authorization.authorization.permissions.includes("sessions.sandbox_access"), - canManageSessionBudget(ownerUserId, authorization.authorization) + effectiveSessionCapabilities(resolution.viewer, resolution.row, resolution.mode), + canManageSessionBudget(resolution.row.ownerUserId, resolution.authorization) ) ); if (!activated) { @@ -468,20 +501,21 @@ export class SessionConnectionAuthenticator implements SessionUpgradeAdmission { ws: SessionWebSocket, client: ClientInfo, enrichment: Parameters[0], - canAccessSandbox: boolean, + capabilities: SessionCapabilities, canManageBudget: boolean ): boolean { const { wsManager, snapshotReader } = this.deps; const snapshot = snapshotReader.readSessionSnapshot(enrichment); if (!snapshot) return false; - const authorizedSnapshot = canAccessSandbox + const authorizedSnapshot = capabilities.canSandbox ? snapshot : redactSessionSnapshotSandboxAccess(snapshot); if ( !wsManager.send(ws, { type: "subscribed", ...authorizedSnapshot, + session: { ...authorizedSnapshot.session, capabilities }, participantId: client.participantId, participant: { participantId: client.participantId, @@ -498,16 +532,37 @@ export class SessionConnectionAuthenticator implements SessionUpgradeAdmission { return true; } - /** Samples one permission before dispatching a WebSocket command. */ + /** Samples the current D1 scope and access before dispatching a WebSocket command. */ async authorizeClientCommand( + ws: SessionWebSocket, userId: string, - permission: PermissionId + action: SessionAction ): Promise { - const resolution = await this.deps.resolveAuthorization(userId); - if (resolution.kind === "unavailable") return "unavailable"; - if (resolution.kind === "rejected") return "denied"; - if (resolution.kind !== "valid") return "denied"; - return resolution.authorization.permissions.includes(permission) ? "allowed" : "denied"; + const resolution = await this.deps.resolveSessionViewer(userId); + if (resolution.kind === "unavailable") return { kind: "unavailable" }; + if (resolution.kind !== "valid" || !this.decide(resolution, "read").allowed) { + this.deps.wsManager.removeClient(ws); + this.deps.wsManager.close( + ws, + WS_CLOSE_AUTHORIZATION_REVOKED, + WS_AUTHORIZATION_REVOKED_REASON + ); + return { kind: "revoked" }; + } + const decision = this.decide(resolution, action); + return decision.allowed ? { kind: "allowed" } : { kind: "denied", reason: decision.reason }; + } + + private decide( + resolution: Extract, + action: SessionAction + ): AccessDecision { + if (resolverDecides(resolution.mode, resolution.row, action)) { + return checkSessionAccess(resolution.viewer, resolution.row, action); + } + return resolution.authorization.permissions.includes(legacyPermissionForAction(action)) + ? { allowed: true } + : { allowed: false, reason: "missing_permission" }; } /** Return authorized client state, recovering an unexpired lease after hibernation. */ diff --git a/packages/control-plane/src/session/enqueue-prompt-contract.ts b/packages/control-plane/src/session/enqueue-prompt-contract.ts index ad74d7d337..b9294dd126 100644 --- a/packages/control-plane/src/session/enqueue-prompt-contract.ts +++ b/packages/control-plane/src/session/enqueue-prompt-contract.ts @@ -17,7 +17,8 @@ export const enqueuePromptRequestSchema = z reasoningEffort: z.string().optional(), attachments: sessionAttachmentReferencesSchema.optional(), callbackContext: z.record(z.string(), z.unknown()).optional(), - // Trusted SCM enrichment resolved by the router at prompt time. + // Authoritative SCM snapshot resolved by the router at prompt time. + // Explicit null fields clear stored attribution; absence leaves it unchanged. scmEnrichment: z .object({ userId: z.string().nullable(), diff --git a/packages/control-plane/src/session/event-cursor.ts b/packages/control-plane/src/session/event-cursor.ts index 734f22eaec..764425fa6a 100644 --- a/packages/control-plane/src/session/event-cursor.ts +++ b/packages/control-plane/src/session/event-cursor.ts @@ -15,7 +15,8 @@ interface LegacyEventCursor { export type EventListCursor = EventTimelineCursor | LegacyEventCursor; export type ParseEventCursorResult = - { ok: true; cursor: TCursor | null } | { ok: false; error: string }; + | { ok: true; cursor: TCursor | null } + | { ok: false; error: string }; export function eventTimelineCursorFromRow( event: Pick diff --git a/packages/control-plane/src/session/http/handlers/child-session-summary.ts b/packages/control-plane/src/session/http/handlers/child-session-summary.ts index d7106525ea..88f71f908e 100644 --- a/packages/control-plane/src/session/http/handlers/child-session-summary.ts +++ b/packages/control-plane/src/session/http/handlers/child-session-summary.ts @@ -36,7 +36,8 @@ interface ChildSummaryOptions { } export type ChildSummaryOptionsResult = - { ok: true; options: ChildSummaryOptions } | { ok: false; error: string }; + | { ok: true; options: ChildSummaryOptions } + | { ok: false; error: string }; export interface ChildSummaryFinalResponseInput { message: MessageRow | null; diff --git a/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts b/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts index 4555f207cd..74bf413c9a 100644 --- a/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/session-init.handler.test.ts @@ -17,7 +17,6 @@ function createHandler() { const sandboxRepository = { createSandbox: vi.fn(), } as unknown as SandboxRepository; - const encryptScmToken = vi.fn(); const generateId = vi.fn(); const now = vi.fn(() => 1234); const scheduleWarmSandbox = vi.fn(); @@ -35,7 +34,6 @@ function createHandler() { repository as unknown as ParticipantRepository, "session-do-id", scheduleWarmSandbox, - encryptScmToken, generateId, now ); @@ -50,7 +48,6 @@ function createHandler() { handler, repository, sandboxRepository, - encryptScmToken, generateId, now, scheduleWarmSandbox, @@ -59,9 +56,32 @@ function createHandler() { } describe("SessionInitHandler", () => { + it("ignores legacy OAuth secrets during initialization", async () => { + const { handler, repository } = createHandler(); + const response = await handler.init( + new Request("http://internal/internal/init", { + method: "POST", + body: JSON.stringify({ + sessionName: "session", + repoOwner: null, + repoName: null, + userId: "user-1", + scmToken: "secret", + scmTokenEncrypted: "encrypted-secret", + scmRefreshTokenEncrypted: "refresh-secret", + scmTokenExpiresAt: 9999, + }), + }) + ); + expect(response.status).toBe(200); + const written = repository.createParticipant.mock.calls[0][0]; + expect(written).not.toHaveProperty("scmAccessTokenEncrypted"); + expect(written).not.toHaveProperty("scmRefreshTokenEncrypted"); + expect(written).not.toHaveProperty("scmTokenExpiresAt"); + }); + it("does not repeat initialization side effects for an existing session", async () => { - const { handler, repository, sandboxRepository, encryptScmToken, scheduleWarmSandbox } = - createHandler(); + const { handler, repository, sandboxRepository, scheduleWarmSandbox } = createHandler(); repository.getSession.mockReturnValue({ id: "session-do-id" } as never); const response = await handler.init( @@ -82,7 +102,6 @@ describe("SessionInitHandler", () => { expect(repository.replaceSessionRepositories).not.toHaveBeenCalled(); expect(sandboxRepository.createSandbox).not.toHaveBeenCalled(); expect(repository.createParticipant).not.toHaveBeenCalled(); - expect(encryptScmToken).not.toHaveBeenCalled(); expect(scheduleWarmSandbox).not.toHaveBeenCalled(); }); @@ -116,16 +135,8 @@ describe("SessionInitHandler", () => { }); it("initializes session, sandbox, and owner participant", async () => { - const { - handler, - repository, - sandboxRepository, - encryptScmToken, - generateId, - scheduleWarmSandbox, - log, - } = createHandler(); - encryptScmToken.mockResolvedValue("encrypted-scm-token"); + const { handler, repository, sandboxRepository, generateId, scheduleWarmSandbox, log } = + createHandler(); generateId.mockReturnValueOnce("sandbox-1").mockReturnValueOnce("participant-1"); const response = await handler.init( @@ -147,9 +158,6 @@ describe("SessionInitHandler", () => { scmLogin: "octocat", scmName: "The Octocat", scmEmail: "octocat@example.com", - scmToken: "plain-scm-token", - scmRefreshTokenEncrypted: "encrypted-refresh-token", - scmTokenExpiresAt: 9999999, scmUserId: "github-user-123", parentSessionId: "parent-1", spawnSource: "agent", @@ -198,9 +206,6 @@ describe("SessionInitHandler", () => { scmLogin: "octocat", scmName: "The Octocat", scmEmail: "octocat@example.com", - scmAccessTokenEncrypted: "encrypted-scm-token", - scmRefreshTokenEncrypted: "encrypted-refresh-token", - scmTokenExpiresAt: 9999999, role: "owner", joinedAt: 1234, }); @@ -292,10 +297,6 @@ describe("SessionInitHandler", () => { scmLogin: null, scmName: null, scmEmail: null, - scmToken: null, - scmTokenEncrypted: null, - scmRefreshTokenEncrypted: null, - scmTokenExpiresAt: null, scmUserId: null, parentSessionId: null, sandboxSettings: { @@ -474,39 +475,6 @@ describe("SessionInitHandler", () => { expect(repository.upsertSession).not.toHaveBeenCalled(); }); - it("falls back to pre-encrypted token when plain-token encryption fails", async () => { - const { handler, repository, encryptScmToken, generateId, log } = createHandler(); - encryptScmToken.mockRejectedValue(new Error("encrypt failed")); - generateId.mockReturnValueOnce("sandbox-1").mockReturnValueOnce("participant-1"); - - const response = await handler.init( - new Request("http://internal/internal/init", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - sessionName: "session-public-id", - repoOwner: "acme", - repoName: "repo", - repoId: 123, - userId: "user-1", - scmToken: "plain-scm-token", - scmTokenEncrypted: "existing-encrypted-token", - }), - }) - ); - - expect(response.status).toBe(200); - expect(repository.createParticipant).toHaveBeenCalledWith( - expect.objectContaining({ - scmAccessTokenEncrypted: "existing-encrypted-token", - }) - ); - expect(log.error).toHaveBeenCalledWith( - "Failed to encrypt SCM token", - expect.objectContaining({ error: expect.any(Error) }) - ); - }); - it("logs invalid model warning and stores normalized model", async () => { const { handler, repository, generateId, log } = createHandler(); generateId.mockReturnValueOnce("sandbox-1").mockReturnValueOnce("participant-1"); diff --git a/packages/control-plane/src/session/http/handlers/session-init.handler.ts b/packages/control-plane/src/session/http/handlers/session-init.handler.ts index 6aac90293b..a8b2c5fe57 100644 --- a/packages/control-plane/src/session/http/handlers/session-init.handler.ts +++ b/packages/control-plane/src/session/http/handlers/session-init.handler.ts @@ -57,10 +57,6 @@ const initRequestSchema = z.object({ scmLogin: z.string().nullable().optional(), scmName: z.string().nullable().optional(), scmEmail: z.string().nullable().optional(), - scmToken: z.string().nullable().optional(), - scmTokenEncrypted: z.string().nullable().optional(), - scmRefreshTokenEncrypted: z.string().nullable().optional(), - scmTokenExpiresAt: z.number().nullable().optional(), scmUserId: z.string().nullable().optional(), parentSessionId: z.string().nullable().optional(), spawnSource: spawnSourceSchema.optional(), @@ -92,7 +88,6 @@ export class SessionInitHandler { private readonly participantRepository: ParticipantRepository, private readonly durableObjectId: string, private readonly scheduleWarmSandbox: () => void, - private readonly encryptScmToken: (token: string) => Promise, private readonly generateId: (bytes?: number) => string, private readonly now: () => number = Date.now ) {} @@ -137,20 +132,6 @@ export class SessionInitHandler { return Response.json({ sessionId, status: "created" }); } - // Current SessionInitInput never sends token fields. Accept them here only - // so already-running pre-cutover producers remain readable. - let encryptedToken = body.scmTokenEncrypted ?? null; - if (body.scmToken) { - try { - encryptedToken = await this.encryptScmToken(body.scmToken); - log.debug("Encrypted SCM token for storage"); - } catch (error) { - log.error("Failed to encrypt SCM token", { - error: error instanceof Error ? error : String(error), - }); - } - } - const model = getValidModelOrDefault(body.model); if (body.model && !isValidModel(body.model)) { log.warn("Invalid model name, using default", { @@ -253,9 +234,6 @@ export class SessionInitHandler { scmLogin: body.scmLogin ?? null, scmName: body.scmName ?? null, scmEmail: body.scmEmail ?? null, - scmAccessTokenEncrypted: encryptedToken, - scmRefreshTokenEncrypted: body.scmRefreshTokenEncrypted ?? null, - scmTokenExpiresAt: body.scmTokenExpiresAt ?? null, role: "owner", joinedAt: now, }); diff --git a/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts b/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts index 8e407d65ca..e36240e6a3 100644 --- a/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts +++ b/packages/control-plane/src/session/http/handlers/ws-token.handler.test.ts @@ -84,6 +84,34 @@ function createHandler() { } describe("WsTokenHandler", () => { + it.each([false, true])( + "ignores legacy OAuth secrets when minting tokens (existing=%s)", + async (existing) => { + const { handler, repository, getParticipantByUserId } = createHandler(); + getParticipantByUserId + .mockReturnValueOnce(existing ? createParticipant() : null) + .mockReturnValue(createParticipant()); + const response = await handler.generateWsToken( + new Request("http://internal/internal/ws-token", { + method: "POST", + body: JSON.stringify({ + userId: "user-1", + scmTokenEncrypted: "secret", + scmRefreshTokenEncrypted: "refresh", + scmTokenExpiresAt: 9999, + }), + }) + ); + expect(response.status).toBe(200); + const written = existing + ? repository.updateParticipantCoalesce.mock.calls[0][1] + : repository.createParticipant.mock.calls[0][0]; + expect(written).not.toHaveProperty("scmAccessTokenEncrypted"); + expect(written).not.toHaveProperty("scmRefreshTokenEncrypted"); + expect(written).not.toHaveProperty("scmTokenExpiresAt"); + } + ); + it("returns 400 when userId is missing", async () => { const { handler } = createHandler(); @@ -99,23 +127,6 @@ describe("WsTokenHandler", () => { expect(await response.json()).toEqual({ error: "userId is required" }); }); - it("returns 400 for malformed token metadata", async () => { - const { handler, repository } = createHandler(); - - const response = await handler.generateWsToken( - new Request("http://internal/internal/ws-token", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ userId: "user-1", scmTokenExpiresAt: "tomorrow" }), - }) - ); - - expect(response.status).toBe(400); - expect(await response.json()).toEqual({ error: "Invalid request body" }); - expect(repository.createParticipant).not.toHaveBeenCalled(); - expect(repository.updateParticipantCoalesce).not.toHaveBeenCalled(); - }); - it("updates an existing participant and issues a new token", async () => { const { handler, repository, getParticipantByUserId, hashToken, log } = createHandler(); const participant = createParticipant({ scm_token_expires_at: 1000 }); @@ -131,9 +142,6 @@ describe("WsTokenHandler", () => { scmLogin: "octocat-updated", scmName: "Updated Octocat", scmEmail: "updated@example.com", - scmTokenEncrypted: "enc-access-new", - scmRefreshTokenEncrypted: "enc-refresh-new", - scmTokenExpiresAt: 2000, }), }) ); @@ -149,9 +157,6 @@ describe("WsTokenHandler", () => { scmLogin: "octocat-updated", scmName: "Updated Octocat", scmEmail: "updated@example.com", - scmAccessTokenEncrypted: "enc-access-new", - scmRefreshTokenEncrypted: "enc-refresh-new", - scmTokenExpiresAt: 2000, }); expect(hashToken).toHaveBeenCalledWith("plain-token"); expect(repository.updateParticipantWsToken).toHaveBeenCalledWith( @@ -165,40 +170,6 @@ describe("WsTokenHandler", () => { }); }); - it("does not overwrite newer existing tokens with stale client values", async () => { - const { handler, repository, getParticipantByUserId } = createHandler(); - const participant = createParticipant({ - scm_token_expires_at: 5000, - scm_refresh_token_encrypted: "server-refresh", - }); - getParticipantByUserId.mockReturnValue(participant); - - const response = await handler.generateWsToken( - new Request("http://internal/internal/ws-token", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - userId: "user-1", - scmTokenEncrypted: "stale-access", - scmRefreshTokenEncrypted: "stale-refresh", - scmTokenExpiresAt: 4000, - }), - }) - ); - - expect(response.status).toBe(200); - expect(repository.updateParticipantCoalesce).toHaveBeenCalledWith("participant-1", { - canonicalUserId: "user-1", - scmUserId: null, - scmLogin: null, - scmName: null, - scmEmail: null, - scmAccessTokenEncrypted: null, - scmRefreshTokenEncrypted: null, - scmTokenExpiresAt: null, - }); - }); - it("creates a new participant when one does not exist", async () => { const { handler, repository, getParticipantByUserId, generateId } = createHandler(); const createdParticipant = createParticipant({ id: "participant-new" }); @@ -215,9 +186,6 @@ describe("WsTokenHandler", () => { scmLogin: "octocat", scmName: "The Octocat", scmEmail: "octocat@example.com", - scmTokenEncrypted: "enc-access", - scmRefreshTokenEncrypted: "enc-refresh", - scmTokenExpiresAt: 2000, }), }) ); @@ -235,9 +203,6 @@ describe("WsTokenHandler", () => { scmLogin: "octocat", scmName: "The Octocat", scmEmail: "octocat@example.com", - scmAccessTokenEncrypted: "enc-access", - scmRefreshTokenEncrypted: "enc-refresh", - scmTokenExpiresAt: 2000, role: "member", joinedAt: 1234, }); @@ -249,7 +214,7 @@ describe("WsTokenHandler", () => { expect(getParticipantByUserId).toHaveBeenCalledTimes(2); }); - it("accepts nullable optional token fields", async () => { + it("accepts nullable optional SCM display fields", async () => { const { handler, repository, getParticipantByUserId } = createHandler(); const createdParticipant = createParticipant({ id: "participant-new" }); getParticipantByUserId.mockReturnValueOnce(null).mockReturnValueOnce(createdParticipant); @@ -264,9 +229,6 @@ describe("WsTokenHandler", () => { scmLogin: null, scmName: null, scmEmail: null, - scmTokenEncrypted: null, - scmRefreshTokenEncrypted: null, - scmTokenExpiresAt: null, }), }) ); @@ -280,9 +242,6 @@ describe("WsTokenHandler", () => { scmLogin: null, scmName: null, scmEmail: null, - scmAccessTokenEncrypted: null, - scmRefreshTokenEncrypted: null, - scmTokenExpiresAt: null, role: "member", joinedAt: 1234, }); diff --git a/packages/control-plane/src/session/http/handlers/ws-token.handler.ts b/packages/control-plane/src/session/http/handlers/ws-token.handler.ts index e7066144ed..496d8f3757 100644 --- a/packages/control-plane/src/session/http/handlers/ws-token.handler.ts +++ b/packages/control-plane/src/session/http/handlers/ws-token.handler.ts @@ -9,17 +9,14 @@ const generateWsTokenRequestSchema = sessionScmDisplayFieldsSchema.extend({ userId: z.string().optional(), canonicalUserId: z.string().min(1), scmUserId: nullableOptionalString, - scmTokenEncrypted: nullableOptionalString, - scmRefreshTokenEncrypted: nullableOptionalString, - scmTokenExpiresAt: z.number().nullable().optional(), + replaceScmIdentity: z.boolean().optional(), }); type GenerateWsTokenRequest = z.infer; /** * HTTP boundary for WS-token minting: upserts the requesting participant and - * rotates their WebSocket token. Token inputs remain only for pre-cutover - * internal callers; current router requests send identity/display fields. + * rotates their WebSocket token. OAuth grants are held only by Better Auth. */ export class WsTokenHandler { constructor( @@ -52,37 +49,18 @@ export class WsTokenHandler { let participant = this.repository.getParticipantByUserId(body.userId); if (participant) { - // Only accept client tokens if they're newer than what we have in the DB. - // The server-side refresh may have rotated tokens, and the client could - // be sending stale values from an old session cookie. - const clientExpiresAt = body.scmTokenExpiresAt ?? null; - const dbExpiresAt = participant.scm_token_expires_at; - const clientSentAnyToken = - body.scmTokenEncrypted != null || body.scmRefreshTokenEncrypted != null; - - const shouldUpdateTokens = - clientSentAnyToken && - (dbExpiresAt == null || (clientExpiresAt != null && clientExpiresAt > dbExpiresAt)); - - // If we already have a refresh token (server-side refresh may rotate it), - // only accept an incoming refresh token when we're also accepting the - // access token update, or when we don't have one yet. - const shouldUpdateRefreshToken = - body.scmRefreshTokenEncrypted != null && - (participant.scm_refresh_token_encrypted == null || shouldUpdateTokens); - - this.repository.updateParticipantCoalesce(participant.id, { + const identity = { canonicalUserId: body.canonicalUserId, scmUserId: body.scmUserId ?? null, scmLogin: body.scmLogin ?? null, scmName: body.scmName ?? null, scmEmail: body.scmEmail ?? null, - scmAccessTokenEncrypted: shouldUpdateTokens ? (body.scmTokenEncrypted ?? null) : null, - scmRefreshTokenEncrypted: shouldUpdateRefreshToken - ? (body.scmRefreshTokenEncrypted ?? null) - : null, - scmTokenExpiresAt: shouldUpdateTokens ? clientExpiresAt : null, - }); + }; + if (body.replaceScmIdentity) { + this.repository.updateParticipantIdentity(participant.id, identity); + } else { + this.repository.updateParticipantCoalesce(participant.id, identity); + } } else { const id = this.generateId(); this.repository.createParticipant({ @@ -93,9 +71,6 @@ export class WsTokenHandler { scmLogin: body.scmLogin ?? null, scmName: body.scmName ?? null, scmEmail: body.scmEmail ?? null, - scmAccessTokenEncrypted: body.scmTokenEncrypted ?? null, - scmRefreshTokenEncrypted: body.scmRefreshTokenEncrypted ?? null, - scmTokenExpiresAt: body.scmTokenExpiresAt ?? null, role: "member", joinedAt: now, }); diff --git a/packages/control-plane/src/session/identity.test.ts b/packages/control-plane/src/session/identity.test.ts index cca4cb5301..d4809f8053 100644 --- a/packages/control-plane/src/session/identity.test.ts +++ b/packages/control-plane/src/session/identity.test.ts @@ -292,12 +292,63 @@ describe("resolveCurrentGitHubAccessToken", () => { accountInfo: vi.fn(async () => GITHUB_ACCOUNT_INFO), }; - it("does not construct Better Auth when the GitHub identity was unlinked", async () => { + it.each(["42", null])( + "does not resolve an unlinked GitHub identity (cached ID: %s)", + async (cachedId) => { + const getAccountClient = vi.fn(() => accountClient); + + await expect( + resolveCurrentGitHubAccessToken(fakeStore([]), getAccountClient, "user-1", cachedId) + ).resolves.toBeNull(); + expect(getAccountClient).not.toHaveBeenCalled(); + } + ); + + it("recovers a missing cached subject using only the canonical author's GitHub account", async () => { + const getAccessToken = vi.fn(async () => ({ accessToken: "current-access-token" })); + const accountInfo = vi.fn(async () => GITHUB_ACCOUNT_INFO); + + await expect( + resolveCurrentGitHubAccessToken( + fakeStore([{ provider: "github", providerUserId: "42" }]), + () => ({ ...accountClient, getAccessToken, accountInfo }), + "prompt-author", + null + ) + ).resolves.toBe("current-access-token"); + const selection = { providerId: "github", accountId: "42", userId: "prompt-author" }; + expect(getAccessToken).toHaveBeenCalledWith({ body: selection }); + expect(accountInfo).toHaveBeenCalledWith({ query: selection }); + }); + + it("rejects a conflicting cached subject before resolving credentials", async () => { const getAccountClient = vi.fn(() => accountClient); await expect( - resolveCurrentGitHubAccessToken(fakeStore([]), getAccountClient, "user-1", "42") - ).resolves.toBeNull(); + resolveCurrentGitHubAccessToken( + fakeStore([{ provider: "github", providerUserId: "42" }]), + getAccountClient, + "prompt-author", + "7" + ) + ).rejects.toThrow("Session GitHub account no longer matches the canonical user"); + expect(getAccountClient).not.toHaveBeenCalled(); + }); + + it("rejects ambiguous canonical accounts when the cached subject is missing", async () => { + const getAccountClient = vi.fn(() => accountClient); + + await expect( + resolveCurrentGitHubAccessToken( + fakeStore([ + { provider: "github", providerUserId: "42" }, + { provider: "github", providerUserId: "7" }, + ]), + getAccountClient, + "prompt-author", + null + ) + ).rejects.toThrow("User resolves to multiple GitHub provider accounts"); expect(getAccountClient).not.toHaveBeenCalled(); }); @@ -409,7 +460,7 @@ describe("resolveCurrentGitHubAccessToken", () => { expect(accountInfo).not.toHaveBeenCalled(); }); - it("returns null for a linked identity without an OAuth grant", async () => { + it.each(["42", null])("returns null without an OAuth grant (cached ID: %s)", async (cachedId) => { const accountInfo = vi.fn(async () => GITHUB_ACCOUNT_INFO); const grantlessClient = { ...accountClient, @@ -422,7 +473,7 @@ describe("resolveCurrentGitHubAccessToken", () => { fakeStore([{ provider: "github", providerUserId: "42" }]), () => grantlessClient, "user-1", - "42" + cachedId ) ).resolves.toBeNull(); expect(accountInfo).not.toHaveBeenCalled(); @@ -442,24 +493,27 @@ describe("resolveCurrentGitHubAccessToken", () => { }); }); - it("rejects provider profile substitution", async () => { - const substitutedClient = { - ...accountClient, - accountInfo: vi.fn(async () => ({ - user: { id: "7" }, - data: { ...GITHUB_ACCOUNT_INFO.data, subject: "7", login: "mallory" }, - })), - }; - - await expect( - resolveCurrentGitHubAccessToken( - fakeStore([{ provider: "github", providerUserId: "42" }]), - () => substitutedClient, - "user-1", - "42" - ) - ).rejects.toThrow("Better Auth returned a mismatched GitHub account"); - }); + it.each(["42", null])( + "rejects provider profile substitution (cached ID: %s)", + async (cachedId) => { + const substitutedClient = { + ...accountClient, + accountInfo: vi.fn(async () => ({ + user: { id: "7" }, + data: { ...GITHUB_ACCOUNT_INFO.data, subject: "7", login: "mallory" }, + })), + }; + + await expect( + resolveCurrentGitHubAccessToken( + fakeStore([{ provider: "github", providerUserId: "42" }]), + () => substitutedClient, + "user-1", + cachedId + ) + ).rejects.toThrow("Better Auth returned a mismatched GitHub account"); + } + ); it("treats a malformed token response as an integrity failure", async () => { const malformedClient = { diff --git a/packages/control-plane/src/session/identity.ts b/packages/control-plane/src/session/identity.ts index 535f3edc7d..dbb88f40be 100644 --- a/packages/control-plane/src/session/identity.ts +++ b/packages/control-plane/src/session/identity.ts @@ -3,10 +3,11 @@ import { githubLoginSchema, } from "@open-inspect/shared/types/github-identity"; import { z } from "zod"; -import type { - GitHubCredentialAuthority, - ProviderAccountSelection, - ProviderAccountClient, +import { + resolveGitHubAccountProfile, + type GitHubCredentialAuthority, + type ProviderAccountSelection, + type ProviderAccountClient, } from "../source-control/github-credential-authority"; import type { UserStore } from "../db/user-store"; import type { SourceControlProviderName } from "../source-control"; @@ -77,6 +78,13 @@ const betterAuthGitHubAccountInfoSchema = z.object({ }), }); +export class AmbiguousGitHubIdentityError extends Error { + constructor() { + super("User resolves to multiple GitHub provider accounts"); + this.name = "AmbiguousGitHubIdentityError"; + } +} + export class BetterAuthGitHubTokenUnavailableError extends Error { constructor(readonly retrievalError: unknown) { super("Better Auth GitHub token is unavailable", { cause: retrievalError }); @@ -104,17 +112,18 @@ export async function resolveCurrentGitHubAccessToken( userStore: UserStore, getAccountClient: () => ProviderAccountClient, canonicalUserId: string, - expectedScmUserId: string + expectedScmUserId: string | null ): Promise { const enrichment = await resolveGitHubEnrichment(userStore, canonicalUserId); if (!enrichment) return null; - if (enrichment.scmUserId !== expectedScmUserId) { + // Browser joins may lack a cached SCM subject; the canonical identity is authoritative. + if (expectedScmUserId !== null && enrichment.scmUserId !== expectedScmUserId) { throw new Error("Session GitHub account no longer matches the canonical user"); } const selection: ProviderAccountSelection = { providerId: "github", - accountId: expectedScmUserId, + accountId: enrichment.scmUserId, userId: canonicalUserId, }; const accountClient = getAccountClient(); @@ -143,7 +152,7 @@ export async function resolveCurrentGitHubAccessToken( parseBetterAuthGitHubProfile( await accountClient.accountInfo({ query: selection }), - expectedScmUserId + enrichment.scmUserId ); return token.accessToken; } @@ -171,7 +180,7 @@ export async function resolveGitHubEnrichment( const identities = await userStore.getIdentitiesForUser(userId); const githubIdentities = identities.filter((identity) => identity.provider === "github"); if (githubIdentities.length > 1) { - throw new Error("User resolves to multiple GitHub provider accounts"); + throw new AmbiguousGitHubIdentityError(); } const githubIdentity = githubIdentities[0]; if (!githubIdentity) return null; @@ -220,7 +229,30 @@ export async function resolveGitHubEnrichmentForRequest( const profileResponse = await authority.githubAccount.resolveProfile(); if (profileResponse === null) return enrichment; - const profile = parseBetterAuthGitHubProfile(profileResponse, authority.githubAccount.subject); + return enrichFromVerifiedGitHubProfile(enrichment, profileResponse); +} + +/** Attribute unattended turns to the one linked account of the admitted canonical user. */ +export async function resolveGitHubEnrichmentForCanonicalUser( + userStore: UserStore, + userId: string, + getAccountClient: () => ProviderAccountClient +): Promise { + const enrichment = await resolveGitHubEnrichment(userStore, userId); + if (!enrichment || enrichment.scmLogin) return enrichment; + const response = await resolveGitHubAccountProfile(getAccountClient(), { + providerId: "github", + accountId: enrichment.scmUserId, + userId, + }); + return response === null ? enrichment : enrichFromVerifiedGitHubProfile(enrichment, response); +} + +function enrichFromVerifiedGitHubProfile( + enrichment: GitHubEnrichment, + profileResponse: unknown +): GitHubEnrichment { + const profile = parseBetterAuthGitHubProfile(profileResponse, enrichment.scmUserId); const displayName = enrichment.displayName ?? profile.displayName ?? profile.login; const authorIdentity = resolveGitAuthorIdentity({ scmProvider: "github", diff --git a/packages/control-plane/src/session/initialize.test.ts b/packages/control-plane/src/session/initialize.test.ts index ff4cdb29c4..c0f2df4c5c 100644 --- a/packages/control-plane/src/session/initialize.test.ts +++ b/packages/control-plane/src/session/initialize.test.ts @@ -29,6 +29,7 @@ describe("initializeSession", () => { reasoningEffort: null, participantUserId: "user-1", platformUserId: "platform-user-1", + participantCanonicalUserId: "platform-user-1", scmLogin: "acmedev", scmName: "Acme Dev", scmEmail: "dev@acme.test", @@ -89,6 +90,25 @@ describe("initializeSession", () => { stubFetchMock = vi.fn(async () => Response.json({ status: "created" })); }); + it("rejects a missing participant credential identity instead of borrowing ownership", async () => { + const { participantCanonicalUserId: _unused, ...input } = { + ...baseInput, + participantCanonicalUserId: null, + }; + await expect( + initializeSession(createEnv(), input as SessionInitInput, ctx as never) + ).rejects.toThrow("Participant canonical identity must be explicit"); + }); + + it("keeps a null participant identity distinct from the session owner", async () => { + await initializeSession( + createEnv(), + { ...baseInput, participantCanonicalUserId: null }, + ctx as never + ); + expect(await stubFetchMock.mock.calls[0][0].json()).toMatchObject({ canonicalUserId: null }); + }); + it("writes D1 before calling DO init", async () => { await initializeSession(createEnv(), baseInput, ctx as never); diff --git a/packages/control-plane/src/session/initialize.ts b/packages/control-plane/src/session/initialize.ts index 17cbb88830..4d7e427fd0 100644 --- a/packages/control-plane/src/session/initialize.ts +++ b/packages/control-plane/src/session/initialize.ts @@ -77,10 +77,13 @@ export interface SessionInitInput { // Identity /** Participant identity for the session creator — becomes the owner participant's user_id in the DO. */ participantUserId: string; - /** Canonical platform user ID for D1 analytics attribution. Null when unresolved. */ + /** Canonical session owner for D1 access control and attribution. Null when unresolved. */ platformUserId: string | null; + /** Creator credential identity, when different from inherited session ownership. */ + participantCanonicalUserId: string | null; ownerTeamId: string | null; visibility: SessionVisibility; + collaboratorSourceSessionId?: string; // SCM identity scmLogin?: string | null; @@ -125,6 +128,9 @@ export async function initializeSession( input: SessionInitInput, ctx: RequestContext ): Promise<{ sessionId: string; status: string }> { + if (input.participantCanonicalUserId === undefined) { + throw new Error("Participant canonical identity must be explicit"); + } if ( (input.managedSkillsManifest === undefined) === (input.managedSkillsSourceSessionId === undefined) @@ -191,6 +197,10 @@ export async function initializeSession( }); } + if (input.visibility === "private" && !input.platformUserId) { + throw new Error("Private sessions require a canonical owner"); + } + // Step 1: GitHub review-generation fence. Runs before the D1 session row // and DO exist at all — a stale generation here means a newer review // already claimed this PR, so this create must leave no trace. Run as its @@ -251,6 +261,14 @@ export async function initializeSession( userId: input.platformUserId, ownerTeamId: input.ownerTeamId, visibility: input.visibility, + collaboratorSourceSessionId: input.collaboratorSourceSessionId, + privateCreationActor: + input.visibility === "private" && input.platformUserId + ? { + requestId: ctx.request_id, + actorUserId: input.platformUserId, + } + : undefined, createdAt: now, updatedAt: now, skillManifest: input.managedSkillsManifest, @@ -285,7 +303,7 @@ export async function initializeSession( model: input.model, reasoningEffort: input.reasoningEffort, userId: input.participantUserId, - canonicalUserId: input.platformUserId, + canonicalUserId: input.participantCanonicalUserId, scmLogin: input.scmLogin, scmName: input.scmName, scmEmail: input.scmEmail, diff --git a/packages/control-plane/src/session/message-queue.test.ts b/packages/control-plane/src/session/message-queue.test.ts index 3784881c4e..e9f04bdcdc 100644 --- a/packages/control-plane/src/session/message-queue.test.ts +++ b/packages/control-plane/src/session/message-queue.test.ts @@ -185,6 +185,7 @@ function buildQueue( getParticipantById: vi.fn(() => createParticipant()), getSession: vi.fn(() => createSession()), updateParticipantCoalesce: vi.fn(), + updateParticipantIdentity: vi.fn(), recordMessageCompletion: vi.fn((event: { messageId: string }, completedAt: number) => ({ messageId: event.messageId, messageCreatedAt: 1000, @@ -2371,7 +2372,8 @@ describe("SessionMessageQueue", () => { }, }); - expect(h.repository.updateParticipantCoalesce).toHaveBeenCalledWith("part-1", { + expect(h.repository.updateParticipantIdentity).toHaveBeenCalledWith("part-1", { + canonicalUserId: null, scmName: "Trusted Octo Cat", scmEmail: "1001+octocat@users.noreply.github.com", scmLogin: "octocat", @@ -2389,6 +2391,7 @@ describe("SessionMessageQueue", () => { }); expect(h.repository.updateParticipantCoalesce).not.toHaveBeenCalled(); + expect(h.repository.updateParticipantIdentity).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/control-plane/src/session/message-queue.ts b/packages/control-plane/src/session/message-queue.ts index 54ca189d09..fdedfa2ecb 100644 --- a/packages/control-plane/src/session/message-queue.ts +++ b/packages/control-plane/src/session/message-queue.ts @@ -723,7 +723,8 @@ export class SessionMessageQueue { if (data.scmEnrichment !== undefined) { const enrichment = data.scmEnrichment; - this.participantRepository.updateParticipantCoalesce(participant.id, { + this.participantRepository.updateParticipantIdentity(participant.id, { + canonicalUserId: data.canonicalUserId ?? participant.canonical_user_id ?? null, scmName: enrichment.name, scmEmail: enrichment.email, scmLogin: enrichment.login, diff --git a/packages/control-plane/src/session/message-router.ts b/packages/control-plane/src/session/message-router.ts index 480e0f6619..faf93ac897 100644 --- a/packages/control-plane/src/session/message-router.ts +++ b/packages/control-plane/src/session/message-router.ts @@ -2,7 +2,8 @@ import { sandboxEventSchema, type SandboxEvent } from "@open-inspect/shared/type import { clientRequestIdSchema, promptValidationError } from "@open-inspect/shared/types/prompts"; import type { ZodError } from "zod"; import { clientMessageSchema, type ClientMessage } from "@open-inspect/shared/types/websocket"; -import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { SessionAction } from "@open-inspect/shared"; +import type { AccessDenialReason } from "@open-inspect/shared"; import { ShutdownRecoveryRejectedError } from "../sandbox/lifecycle/ports"; import type { Logger } from "../logger"; import type { SessionHistoryPage } from "./event-stream"; @@ -15,15 +16,23 @@ export type ClientPresence = Extract; export type ClientPrompt = Extract; export type ClientSubscribe = Extract; export type FetchHistory = Extract; +type ValidHistoryRequest = FetchHistory & { cursor: NonNullable }; export type RecoverShutdownCommand = Extract; +export type ClientCommandAuthorization = + | { kind: "allowed" } + | { kind: "denied"; reason: AccessDenialReason } + | { kind: "unavailable" } + | { kind: "revoked" }; + type BoundarySchema = { safeParse(input: unknown): { success: true; data: T } | { success: false; error: ZodError }; }; // Retain valid JSON on schema failure so correlated errors do not parse the payload twice. type ParsedMessage = - { valid: true; data: T } | { valid: false; raw?: unknown; error?: ZodError }; + | { valid: true; data: T } + | { valid: false; raw?: unknown; error?: ZodError }; export interface SessionClientCommands { subscribe: (connection: Connection, message: ClientSubscribe) => Promise; @@ -38,9 +47,10 @@ export interface SessionClientCommands SessionHistoryPage; authorize: ( + connection: Connection, client: Client, - permission: PermissionId - ) => Promise<"allowed" | "denied" | "unavailable">; + action: SessionAction + ) => Promise; } export interface SessionMessageRouterDeps { @@ -141,26 +151,19 @@ export class SessionMessageRouter { switch (data.type) { case "prompt": - if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break; + if (!(await this.authorizeCommand(connection, client, "collaborate"))) break; await this.deps.clientCommands.submitPrompt(connection, client, data); break; case "cancel_prompt": - if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break; + if (!(await this.authorizeCommand(connection, client, "lifecycle"))) break; await this.deps.clientCommands.cancelPrompt(connection, data); break; case "stop": - if (!(await this.authorizeCommand(connection, client, "sessions.lifecycle"))) break; + if (!(await this.authorizeCommand(connection, client, "lifecycle"))) break; await this.deps.clientCommands.stopExecution(); break; case "recover_preservation": - if ( - !(await this.authorizeCommand( - connection, - client, - "sessions.lifecycle", - data.clientRequestId - )) - ) + if (!(await this.authorizeCommand(connection, client, "lifecycle", data.clientRequestId))) break; await this.deps.clientCommands.recoverShutdown(data.action); if (data.clientRequestId) @@ -171,13 +174,16 @@ export class SessionMessageRouter { }); break; case "typing": - if (!(await this.authorizeCommand(connection, client, "sessions.collaborate"))) break; + if (!(await this.authorizeCommand(connection, client, "collaborate"))) break; await this.deps.clientCommands.notifyTyping(); break; case "fetch_history": + if (!this.canFetchHistory(connection, client, data)) break; + if (!(await this.authorizeCommand(connection, client, "read"))) break; this.handleFetchHistory(connection, client, data); break; case "presence": + if (!(await this.authorizeCommand(connection, client, "read"))) break; this.deps.clientCommands.updatePresence(client, data); break; default: @@ -206,24 +212,29 @@ export class SessionMessageRouter { private async authorizeCommand( connection: Connection, client: Client, - permission: PermissionId, + action: SessionAction, clientRequestId?: string ): Promise { - const result = await this.deps.clientCommands.authorize(client, permission); - if (result === "allowed") return true; + const result = await this.deps.clientCommands.authorize(connection, client, action); + if (result.kind === "allowed") return true; + if (result.kind === "revoked") return false; this.deps.sockets.send(connection, { type: "error", - code: result === "unavailable" ? "AUTHORIZATION_UNAVAILABLE" : "PERMISSION_REQUIRED", + code: result.kind === "unavailable" ? "AUTHORIZATION_UNAVAILABLE" : "PERMISSION_REQUIRED", message: - result === "unavailable" + result.kind === "unavailable" ? "Authorization is temporarily unavailable" - : `Permission required: ${permission}`, + : `Access denied: ${result.reason}`, ...(clientRequestId ? { clientRequestId } : {}), }); return false; } - private handleFetchHistory(connection: Connection, client: Client, data: FetchHistory): void { + private canFetchHistory( + connection: Connection, + client: Client, + data: FetchHistory + ): data is ValidHistoryRequest { if ( !data.cursor || typeof data.cursor.timestamp !== "number" || @@ -236,7 +247,7 @@ export class SessionMessageRouter { code: "INVALID_CURSOR", message: "Invalid cursor", }); - return; + return false; } const now = this.deps.clock.nowMs(); @@ -249,10 +260,17 @@ export class SessionMessageRouter { code: "RATE_LIMITED", message: "Too many requests", }); - return; + return false; } client.lastFetchHistoryAtMs = now; + return true; + } + private handleFetchHistory( + connection: Connection, + client: Client, + data: ValidHistoryRequest + ): void { const page = this.deps.clientCommands.getHistoryPage({ cursor: data.cursor, limit: data.limit, diff --git a/packages/control-plane/src/session/messenger.test.ts b/packages/control-plane/src/session/messenger.test.ts index 8180b46bf7..1bdb947f6c 100644 --- a/packages/control-plane/src/session/messenger.test.ts +++ b/packages/control-plane/src/session/messenger.test.ts @@ -16,8 +16,9 @@ function harness(overrides: { sandboxSocket?: WebSocket | null; sendResult?: boo fn(clientB); } ), - getSandboxCommandTarget: vi.fn((): SandboxCommandTarget => - sandbox ? { kind: "dispatch", socket: sandbox } : { kind: "unavailable" } + getSandboxCommandTarget: vi.fn( + (): SandboxCommandTarget => + sandbox ? { kind: "dispatch", socket: sandbox } : { kind: "unavailable" } ), send: vi.fn(() => overrides.sendResult ?? true), }; diff --git a/packages/control-plane/src/session/participant-repository.test.ts b/packages/control-plane/src/session/participant-repository.test.ts index 11618afaef..52d14129b6 100644 --- a/packages/control-plane/src/session/participant-repository.test.ts +++ b/packages/control-plane/src/session/participant-repository.test.ts @@ -99,13 +99,13 @@ describe("ParticipantRepository", () => { scmLogin: "testuser", scmName: "Test User", scmEmail: "test@example.com", - scmAccessTokenEncrypted: "encrypted-token", - scmTokenExpiresAt: 9000, role: "owner", joinedAt: 1000, }); expect(mock.calls[0].query).toContain("INSERT INTO participants"); + expect(mock.calls[0].query).not.toContain("scm_access_token"); + expect(mock.calls[0].query).not.toContain("scm_refresh_token"); expect(mock.calls[0].params).toEqual([ "p-1", "user-1", @@ -114,9 +114,6 @@ describe("ParticipantRepository", () => { "testuser", "Test User", "test@example.com", - "encrypted-token", - null, - 9000, "owner", 1000, ]); @@ -132,9 +129,6 @@ describe("ParticipantRepository", () => { null, null, null, - null, - null, - null, "member", 1000, ]); @@ -147,26 +141,8 @@ describe("ParticipantRepository", () => { scmEmail: "new@example.com", }); expect(mock.calls[0].query).toContain("COALESCE"); - expect(mock.calls[0].params).toEqual([ - null, - null, - "newlogin", - null, - "new@example.com", - null, - null, - null, - "p-1", - ]); - }); - - it("updates participant tokens", () => { - repository.updateParticipantTokens("p-1", { - scmAccessTokenEncrypted: "access", - scmRefreshTokenEncrypted: "refresh", - scmTokenExpiresAt: 9000, - }); - expect(mock.calls[0].params).toEqual(["access", "refresh", 9000, "p-1"]); + expect(mock.calls[0].query).not.toContain("scm_access_token"); + expect(mock.calls[0].params).toEqual([null, null, "newlogin", null, "new@example.com", "p-1"]); }); it("updates the WebSocket token", () => { diff --git a/packages/control-plane/src/session/participant-repository.ts b/packages/control-plane/src/session/participant-repository.ts index c02ac3206c..d374caeee6 100644 --- a/packages/control-plane/src/session/participant-repository.ts +++ b/packages/control-plane/src/session/participant-repository.ts @@ -11,9 +11,6 @@ interface CreateParticipantData { scmLogin?: string | null; scmName?: string | null; scmEmail?: string | null; - scmAccessTokenEncrypted?: string | null; - scmRefreshTokenEncrypted?: string | null; - scmTokenExpiresAt?: number | null; role: ParticipantRole; joinedAt: number; } @@ -25,9 +22,6 @@ interface UpdateParticipantData { scmLogin?: string | null; scmName?: string | null; scmEmail?: string | null; - scmAccessTokenEncrypted?: string | null; - scmRefreshTokenEncrypted?: string | null; - scmTokenExpiresAt?: number | null; } /** Persistence for participants scoped to one session. */ @@ -54,8 +48,8 @@ export class ParticipantRepository { createParticipant(data: CreateParticipantData): void { this.sql.exec( - `INSERT INTO participants (id, user_id, canonical_user_id, scm_user_id, scm_login, scm_name, scm_email, scm_access_token_encrypted, scm_refresh_token_encrypted, scm_token_expires_at, role, joined_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO participants (id, user_id, canonical_user_id, scm_user_id, scm_login, scm_name, scm_email, role, joined_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, data.id, data.userId, data.canonicalUserId ?? null, @@ -63,9 +57,6 @@ export class ParticipantRepository { data.scmLogin ?? null, data.scmName ?? null, data.scmEmail ?? null, - data.scmAccessTokenEncrypted ?? null, - data.scmRefreshTokenEncrypted ?? null, - data.scmTokenExpiresAt ?? null, data.role, data.joinedAt ); @@ -78,40 +69,36 @@ export class ParticipantRepository { scm_user_id = COALESCE(?, scm_user_id), scm_login = COALESCE(?, scm_login), scm_name = COALESCE(?, scm_name), - scm_email = COALESCE(?, scm_email), - scm_access_token_encrypted = COALESCE(?, scm_access_token_encrypted), - scm_refresh_token_encrypted = COALESCE(?, scm_refresh_token_encrypted), - scm_token_expires_at = COALESCE(?, scm_token_expires_at) + scm_email = COALESCE(?, scm_email) WHERE id = ?`, data.canonicalUserId ?? null, data.scmUserId ?? null, data.scmLogin ?? null, data.scmName ?? null, data.scmEmail ?? null, - data.scmAccessTokenEncrypted ?? null, - data.scmRefreshTokenEncrypted ?? null, - data.scmTokenExpiresAt ?? null, participantId ); } - updateParticipantTokens( + /** Replace an authoritative SCM snapshot, including cleared identity fields. */ + updateParticipantIdentity( participantId: string, data: { - scmAccessTokenEncrypted: string; - scmRefreshTokenEncrypted?: string | null; - scmTokenExpiresAt: number; + canonicalUserId: string | null; + scmUserId: string | null; + scmLogin: string | null; + scmName: string | null; + scmEmail: string | null; } ): void { this.sql.exec( - `UPDATE participants SET - scm_access_token_encrypted = ?, - scm_refresh_token_encrypted = COALESCE(?, scm_refresh_token_encrypted), - scm_token_expires_at = ? - WHERE id = ?`, - data.scmAccessTokenEncrypted, - data.scmRefreshTokenEncrypted ?? null, - data.scmTokenExpiresAt, + `UPDATE participants SET canonical_user_id = ?, scm_user_id = ?, scm_login = ?, + scm_name = ?, scm_email = ? WHERE id = ?`, + data.canonicalUserId, + data.scmUserId, + data.scmLogin, + data.scmName, + data.scmEmail, participantId ); } diff --git a/packages/control-plane/src/session/participant-service.test.ts b/packages/control-plane/src/session/participant-service.test.ts index 45fb7b7ba9..812f5a2e88 100644 --- a/packages/control-plane/src/session/participant-service.test.ts +++ b/packages/control-plane/src/session/participant-service.test.ts @@ -5,28 +5,10 @@ import { ParticipantService, getAvatarUrl, type ParticipantServiceDeps, - type ParticipantServiceEnv, } from "./participant-service"; import type { ParticipantRepository } from "./participant-repository"; import { BetterAuthGitHubTokenUnavailableError } from "./identity"; -// ---- Module-level mocks for local refresh tests ---- - -vi.mock("../auth/crypto", () => ({ - encryptToken: vi.fn(async (token: string) => `enc:${token}`), - decryptToken: vi.fn(async (encrypted: string) => { - if (encrypted.startsWith("enc:")) return encrypted.slice(4); - return `dec:${encrypted}`; - }), -})); - -vi.mock("../auth/github", () => ({ - refreshAccessToken: vi.fn(), -})); - -import { refreshAccessToken } from "../auth/github"; -import { decryptToken } from "../auth/crypto"; - // ---- Mock factories ---- function createMockLogger(): Logger { @@ -66,31 +48,22 @@ function createMockRepository() { getParticipantById: vi.fn<() => ParticipantRow | null>(() => null), getProcessingMessageAuthor: vi.fn<() => { author_id: string } | null>(() => null), createParticipant: vi.fn(), - updateParticipantTokens: vi.fn(), }; } function createTestHarness(overrides?: { - env?: Partial; resolveCurrentGitHubAccessToken?: ParticipantServiceDeps["resolveCurrentGitHubAccessToken"]; }) { const log = createMockLogger(); const repository = createMockRepository(); let idCounter = 0; - const env: ParticipantServiceEnv = { - GITHUB_CLIENT_ID: "client-id", - GITHUB_CLIENT_SECRET: "client-secret", - TOKEN_ENCRYPTION_KEY: "test-encryption-key-32-chars-long", - ...overrides?.env, - }; const resolveCurrentGitHubAccessToken = overrides?.resolveCurrentGitHubAccessToken ?? vi.fn(async () => null); const deps: ParticipantServiceDeps = { repository: repository as unknown as ParticipantRepository, getProcessingMessageAuthor: repository.getProcessingMessageAuthor, - env, log, generateId: () => `gen-id-${++idCounter}`, resolveCurrentGitHubAccessToken, @@ -100,7 +73,6 @@ function createTestHarness(overrides?: { service: new ParticipantService(deps), repository, log, - env, resolveCurrentGitHubAccessToken, }; } @@ -225,100 +197,6 @@ describe("ParticipantService", () => { }); }); - describe("isScmTokenExpired", () => { - it("returns false when no expiry is set", () => { - const participant = createParticipant({ scm_token_expires_at: null }); - expect(harness.service.isScmTokenExpired(participant)).toBe(false); - }); - - it("returns false when token is still valid", () => { - const participant = createParticipant({ - scm_token_expires_at: Date.now() + 120000, // 2 minutes from now - }); - expect(harness.service.isScmTokenExpired(participant)).toBe(false); - }); - - it("returns true when token is within default buffer", () => { - const participant = createParticipant({ - scm_token_expires_at: Date.now() + 30000, // 30 seconds from now, within 60s buffer - }); - expect(harness.service.isScmTokenExpired(participant)).toBe(true); - }); - - it("returns true when token is already expired", () => { - const participant = createParticipant({ - scm_token_expires_at: Date.now() - 1000, - }); - expect(harness.service.isScmTokenExpired(participant)).toBe(true); - }); - - it("respects custom buffer", () => { - const participant = createParticipant({ - scm_token_expires_at: Date.now() + 30000, - }); - // With 10s buffer, 30s remaining should NOT be expired - expect(harness.service.isScmTokenExpired(participant, 10000)).toBe(false); - // With 60s buffer, 30s remaining SHOULD be expired - expect(harness.service.isScmTokenExpired(participant, 60000)).toBe(true); - }); - }); - - describe("refreshToken", () => { - it("returns null when no refresh token stored", async () => { - const participant = createParticipant({ scm_refresh_token_encrypted: null }); - - const result = await harness.service.refreshToken(participant); - - expect(result).toBeNull(); - expect(harness.log.warn).toHaveBeenCalledWith( - "Cannot refresh: no refresh token stored", - expect.any(Object) - ); - }); - - it("returns null when GitHub OAuth credentials not configured", async () => { - const h = createTestHarness({ - env: { GITHUB_CLIENT_ID: undefined, GITHUB_CLIENT_SECRET: undefined }, - }); - const participant = createParticipant({ - scm_refresh_token_encrypted: "enc:refresh-token", - }); - - const result = await h.service.refreshToken(participant); - - expect(result).toBeNull(); - expect(h.log.warn).toHaveBeenCalledWith("Cannot refresh: OAuth credentials not configured"); - }); - - it("refreshes credentials copied into an existing participant", async () => { - const participant = createParticipant({ - scm_user_id: "42", - scm_refresh_token_encrypted: "enc:old-refresh", - }); - vi.mocked(refreshAccessToken).mockResolvedValue({ - access_token: "new-access", - refresh_token: "new-refresh", - token_type: "bearer", - scope: "repo", - expires_in: 28800, - }); - const refreshedParticipant = createParticipant({ - scm_user_id: "42", - scm_access_token_encrypted: "enc:new-access", - }); - vi.mocked(harness.repository.getParticipantById).mockReturnValue(refreshedParticipant); - - await expect(harness.service.refreshToken(participant)).resolves.toBe(refreshedParticipant); - - expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh", expect.any(Object)); - expect(harness.repository.updateParticipantTokens).toHaveBeenCalledWith("part-1", { - scmAccessTokenEncrypted: "enc:new-access", - scmRefreshTokenEncrypted: "enc:new-refresh", - scmTokenExpiresAt: expect.any(Number), - }); - }); - }); - describe("resolveAuthForPR", () => { it("uses a current Better Auth token for the canonical GitHub user", async () => { const resolveCurrentGitHubAccessToken = vi.fn(async () => "current-access-token"); @@ -335,39 +213,62 @@ describe("ParticipantService", () => { expect(resolveCurrentGitHubAccessToken).toHaveBeenCalledWith("user-1", "42"); }); - it("uses a pre-cutover participant token without consulting Better Auth", async () => { - const resolveCurrentGitHubAccessToken = vi.fn(async () => { - throw new Error("Better Auth unavailable"); - }); - const h = createTestHarness({ resolveCurrentGitHubAccessToken }); - const participant = createParticipant({ - canonical_user_id: "user-1", - scm_user_id: "42", - scm_access_token_encrypted: "enc:existing-access-token", - scm_refresh_token_encrypted: "enc:existing-refresh-token", - }); - - await expect(h.service.resolveAuthForPR(participant)).resolves.toEqual({ - auth: { authType: "oauth", token: "existing-access-token" }, - }); - expect(resolveCurrentGitHubAccessToken).not.toHaveBeenCalled(); - }); - - it("treats access-token presence as an explicit pre-cutover participant", async () => { - const resolveCurrentGitHubAccessToken = vi.fn(async () => null); - const h = createTestHarness({ resolveCurrentGitHubAccessToken }); - const participant = createParticipant({ - canonical_user_id: "user-1", - scm_user_id: "42", - scm_access_token_encrypted: "enc:cached-access-token", + it.each([ + { + scm_access_token_encrypted: "enc:old-access", scm_refresh_token_encrypted: null, - }); + scm_token_expires_at: 1, + }, + { scm_access_token_encrypted: null, scm_refresh_token_encrypted: "enc:old-refresh" }, + { + scm_access_token_encrypted: "enc:old-access", + scm_refresh_token_encrypted: "enc:old-refresh", + scm_token_expires_at: 1, + }, + { + scm_access_token_encrypted: "enc:old-access", + scm_refresh_token_encrypted: null, + scm_token_expires_at: null, + }, + ])( + "uses current Better Auth credentials despite copied legacy fields: %j", + async (legacyFields) => { + const h = createTestHarness({ + resolveCurrentGitHubAccessToken: vi.fn(async () => "current-access-token"), + }); + const participant = createParticipant({ + canonical_user_id: "user-1", + scm_user_id: "42", + ...legacyFields, + }); + await expect(h.service.resolveAuthForPR(participant)).resolves.toEqual({ + auth: { authType: "oauth", token: "current-access-token" }, + }); + } + ); - await expect(h.service.resolveAuthForPR(participant)).resolves.toEqual({ - auth: { authType: "oauth", token: "cached-access-token" }, - }); - expect(resolveCurrentGitHubAccessToken).not.toHaveBeenCalled(); - }); + it.each(["missing canonical identity", "missing current grant", "identity mismatch"])( + "never falls back to copied credentials on %s", + async (failure) => { + const h = createTestHarness({ + resolveCurrentGitHubAccessToken: vi.fn(async () => { + if (failure === "identity mismatch") throw new Error("GitHub account does not match"); + return null; + }), + }); + const participant = createParticipant({ + canonical_user_id: failure === "missing canonical identity" ? null : "user-1", + scm_user_id: "42", + scm_access_token_encrypted: "enc:old-access", + scm_refresh_token_encrypted: "enc:old-refresh", + }); + await expect(h.service.resolveAuthForPR(participant)).resolves.toEqual( + failure === "identity mismatch" + ? { error: "Failed to resolve GitHub credentials", status: 500 } + : { auth: null } + ); + } + ); it("fails closed when current credential integrity validation fails", async () => { const resolveCurrentGitHubAccessToken = vi.fn(async () => { @@ -415,39 +316,5 @@ describe("ParticipantService", () => { expect.any(Object) ); }); - - it("returns auth: null when token expired and refresh fails (falls back to app token)", async () => { - const participant = createParticipant({ - scm_access_token_encrypted: "enc:encrypted-access", - scm_refresh_token_encrypted: null, - scm_token_expires_at: Date.now() - 1000, - }); - - const result = await harness.service.resolveAuthForPR(participant); - - expect(result).toEqual({ auth: null }); - expect(harness.log.warn).toHaveBeenCalledWith( - "SCM token expired and refresh failed, falling back to app token", - expect.any(Object) - ); - }); - - it("returns auth: null when token decryption fails (falls back to app token)", async () => { - const participant = createParticipant({ - scm_access_token_encrypted: "enc:encrypted-access", - scm_refresh_token_encrypted: null, - scm_token_expires_at: null, // not expired — goes straight to decrypt - }); - - vi.mocked(decryptToken).mockRejectedValueOnce(new Error("bad key")); - - const result = await harness.service.resolveAuthForPR(participant); - - expect(result).toEqual({ auth: null }); - expect(harness.log.error).toHaveBeenCalledWith( - "Failed to decrypt SCM token for PR creation, falling back to app token", - expect.any(Object) - ); - }); }); }); diff --git a/packages/control-plane/src/session/participant-service.ts b/packages/control-plane/src/session/participant-service.ts index d2f9a1c911..bd49d56c6d 100644 --- a/packages/control-plane/src/session/participant-service.ts +++ b/packages/control-plane/src/session/participant-service.ts @@ -4,46 +4,32 @@ * Extracted from SessionDO to reduce its size. Handles: * - Creating and looking up participants * - Resolving current GitHub credentials through Better Auth - * - Refreshing legacy credentials copied into existing session participants * - Resolving auth context for PR creation */ -import { decryptToken, encryptToken } from "../auth/crypto"; -import { refreshAccessToken } from "../auth/github"; import type { SourceControlAuthContext, SourceControlProviderName } from "../source-control"; import type { Logger } from "../logger"; import { BetterAuthGitHubTokenUnavailableError } from "./identity"; import type { ParticipantRow } from "./types"; import type { ParticipantRepository } from "./participant-repository"; -const GITHUB_DEFAULT_TOKEN_LIFETIME_MS = 8 * 60 * 60 * 1000; - -/** - * Environment config — only the secrets ParticipantService needs. - */ -export interface ParticipantServiceEnv { - GITHUB_CLIENT_ID?: string; - GITHUB_CLIENT_SECRET?: string; - TOKEN_ENCRYPTION_KEY: string; -} - /** * Dependencies injected into ParticipantService. */ export interface ParticipantServiceDeps { repository: ParticipantRepository; getProcessingMessageAuthor: () => { author_id: string } | null; - env: ParticipantServiceEnv; log: Logger; generateId: () => string; resolveCurrentGitHubAccessToken?: ( canonicalUserId: string, - scmUserId: string + scmUserId: string | null ) => Promise; } export type PromptingAuthResolution = - { auth: SourceControlAuthContext | null } | { error: string; status: number }; + | { auth: SourceControlAuthContext | null } + | { error: string; status: number }; /** * Build avatar URL from SCM login. @@ -60,7 +46,6 @@ export function getAvatarUrl( export class ParticipantService { private readonly repository: ParticipantRepository; - private readonly env: ParticipantServiceEnv; private readonly log: Logger; private readonly generateId: () => string; private readonly resolveCurrentGitHubAccessToken?: ParticipantServiceDeps["resolveCurrentGitHubAccessToken"]; @@ -68,7 +53,6 @@ export class ParticipantService { constructor(deps: ParticipantServiceDeps) { this.repository = deps.repository; - this.env = deps.env; this.log = deps.log; this.generateId = deps.generateId; this.resolveCurrentGitHubAccessToken = deps.resolveCurrentGitHubAccessToken; @@ -155,81 +139,6 @@ export class ParticipantService { return { participant }; } - /** - * Check whether a participant's SCM token is expired (with buffer). - */ - isScmTokenExpired(participant: ParticipantRow, bufferMs = 60000): boolean { - if (!participant.scm_token_expires_at) { - return false; - } - return Date.now() + bufferMs >= participant.scm_token_expires_at; - } - - /** - * Refresh credentials copied into an existing participant. New GitHub - * sessions resolve through Better Auth instead of copying refresh tokens. - */ - async refreshToken(participant: ParticipantRow): Promise { - return this.refreshTokenLocal(participant); - } - - /** - * Local-only refresh using the per-DO SQLite refresh token. Retained for - * already-running sessions created before Better Auth became authoritative. - */ - private async refreshTokenLocal(participant: ParticipantRow): Promise { - if (!participant.scm_refresh_token_encrypted) { - this.log.warn("Cannot refresh: no refresh token stored", { user_id: participant.user_id }); - return null; - } - - if (!this.env.GITHUB_CLIENT_ID || !this.env.GITHUB_CLIENT_SECRET) { - this.log.warn("Cannot refresh: OAuth credentials not configured"); - return null; - } - - try { - const refreshToken = await decryptToken( - participant.scm_refresh_token_encrypted, - this.env.TOKEN_ENCRYPTION_KEY - ); - - const newTokens = await refreshAccessToken(refreshToken, { - clientId: this.env.GITHUB_CLIENT_ID, - clientSecret: this.env.GITHUB_CLIENT_SECRET, - }); - - const newAccessTokenEncrypted = await encryptToken( - newTokens.access_token, - this.env.TOKEN_ENCRYPTION_KEY - ); - - const newRefreshTokenEncrypted = newTokens.refresh_token - ? await encryptToken(newTokens.refresh_token, this.env.TOKEN_ENCRYPTION_KEY) - : null; - - const newExpiresAt = newTokens.expires_in - ? Date.now() + newTokens.expires_in * 1000 - : Date.now() + GITHUB_DEFAULT_TOKEN_LIFETIME_MS; - - this.repository.updateParticipantTokens(participant.id, { - scmAccessTokenEncrypted: newAccessTokenEncrypted, - scmRefreshTokenEncrypted: newRefreshTokenEncrypted, - scmTokenExpiresAt: newExpiresAt, - }); - - this.log.info("Server-side token refresh succeeded", { user_id: participant.user_id }); - - return this.repository.getParticipantById(participant.id); - } catch (error) { - this.log.error("Server-side token refresh failed", { - user_id: participant.user_id, - error: error instanceof Error ? error : String(error), - }); - return null; - } - } - /** * Resolve the OAuth auth context for the prompting user to create a PR. * @@ -239,17 +148,7 @@ export class ParticipantService { * - `{ error, status }` on unexpected failure */ async resolveAuthForPR(participant: ParticipantRow): Promise { - // Token-bearing participant rows predate Better Auth authority. Keep their - // local refresh/decrypt flow isolated from current identity-only sessions. - if (participant.scm_access_token_encrypted || participant.scm_refresh_token_encrypted) { - return this.resolveLegacyAuthForPR(participant); - } - - if ( - this.resolveCurrentGitHubAccessToken && - participant.canonical_user_id && - participant.scm_user_id - ) { + if (this.resolveCurrentGitHubAccessToken && participant.canonical_user_id) { try { const accessToken = await this.resolveCurrentGitHubAccessToken( participant.canonical_user_id, @@ -282,57 +181,4 @@ export class ParticipantService { }); return { auth: null }; } - - private async resolveLegacyAuthForPR( - participant: ParticipantRow - ): Promise<{ auth: SourceControlAuthContext | null }> { - let resolvedParticipant = participant; - - if (!resolvedParticipant.scm_access_token_encrypted) { - this.log.info("PR creation: legacy participant has no OAuth token, using app fallback", { - user_id: resolvedParticipant.user_id, - }); - return { auth: null }; - } - - if (this.isScmTokenExpired(resolvedParticipant)) { - this.log.warn("SCM token expired, attempting server-side refresh", { - userId: resolvedParticipant.user_id, - }); - - const refreshed = await this.refreshToken(resolvedParticipant); - if (refreshed) { - resolvedParticipant = refreshed; - } else { - this.log.warn("SCM token expired and refresh failed, falling back to app token", { - user_id: resolvedParticipant.user_id, - }); - return { auth: null }; - } - } - - if (!resolvedParticipant.scm_access_token_encrypted) { - return { auth: null }; - } - - try { - const accessToken = await decryptToken( - resolvedParticipant.scm_access_token_encrypted, - this.env.TOKEN_ENCRYPTION_KEY - ); - - return { - auth: { - authType: "oauth", - token: accessToken, - }, - }; - } catch (error) { - this.log.error("Failed to decrypt SCM token for PR creation, falling back to app token", { - user_id: resolvedParticipant.user_id, - error: error instanceof Error ? error : String(error), - }); - return { auth: null }; - } - } } diff --git a/packages/control-plane/src/session/ports.ts b/packages/control-plane/src/session/ports.ts index 8c3fdb410c..2266e17c2c 100644 --- a/packages/control-plane/src/session/ports.ts +++ b/packages/control-plane/src/session/ports.ts @@ -23,7 +23,8 @@ export interface ConnectedClient { * authoritative (see `SocketRegistry.isActiveSandbox`). */ export type ConnectionClassification = - { kind: "sandbox"; sandboxId?: string; socketId?: string } | { kind: "client"; wsId?: string }; + | { kind: "sandbox"; sandboxId?: string; socketId?: string } + | { kind: "client"; wsId?: string }; /** Wall and monotonic time sources used by session application code. */ export interface Clock { diff --git a/packages/control-plane/src/session/sandbox-events/processor.test.ts b/packages/control-plane/src/session/sandbox-events/processor.test.ts index f95dd3059d..fe64a7fdac 100644 --- a/packages/control-plane/src/session/sandbox-events/processor.test.ts +++ b/packages/control-plane/src/session/sandbox-events/processor.test.ts @@ -81,9 +81,11 @@ function createProcessor( const wsManager = { getSandboxSocket: vi.fn(() => null as WebSocket | null), - getSandboxCommandTarget: vi.fn((): SandboxCommandTarget => ({ - kind: "unavailable", - })), + getSandboxCommandTarget: vi.fn( + (): SandboxCommandTarget => ({ + kind: "unavailable", + }) + ), send: vi.fn(() => true), }; diff --git a/packages/control-plane/src/session/sandbox-push-service.test.ts b/packages/control-plane/src/session/sandbox-push-service.test.ts index 6a40128f84..e35a683527 100644 --- a/packages/control-plane/src/session/sandbox-push-service.test.ts +++ b/packages/control-plane/src/session/sandbox-push-service.test.ts @@ -20,10 +20,9 @@ function createService(admission: () => SandboxPushAdmission = () => "unmanaged" const sandboxWs = { readyState: WebSocket.OPEN } as WebSocket; const wsManager = { getSandboxSocket: vi.fn(() => sandboxWs as WebSocket | null), - getSandboxCommandTarget: vi.fn((): SandboxCommandTarget => ({ - kind: "dispatch", - socket: sandboxWs, - })), + getSandboxCommandTarget: vi.fn( + (): SandboxCommandTarget => ({ kind: "dispatch", socket: sandboxWs }) + ), send: vi.fn(() => true), }; const log = { diff --git a/packages/control-plane/src/session/sandbox-repository.test.ts b/packages/control-plane/src/session/sandbox-repository.test.ts index cf8cb05704..83cfd13b6b 100644 --- a/packages/control-plane/src/session/sandbox-repository.test.ts +++ b/packages/control-plane/src/session/sandbox-repository.test.ts @@ -470,6 +470,46 @@ describe("SandboxRepository boot state (SQLite)", () => { return { db, sql, repository, set }; } + it("characterizes the unguarded fresh/restore artifact write across replacement", async () => { + const { repository } = createSqliteRepository(); + const encrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let beginEncryption!: () => void; + let releaseEncryption!: () => void; + const encrypting = new Promise((resolve) => (beginEncryption = resolve)); + const gate = new Promise((resolve) => (releaseEncryption = resolve)); + const spy = vi.spyOn(crypto.subtle, "encrypt").mockImplementation(async (...args) => { + const encrypted = await encrypt(...args); + beginEncryption(); + await gate; + return encrypted; + }); + try { + const writing = repository.updateSandboxAccess( + "codeServer", + "https://old.test", + "old-secret" + ); + await encrypting; + repository.updateSandboxForSpawn({ + status: "spawning", + createdAt: 3000, + modalSandboxId: "replacement", + }); + releaseEncryption(); + await writing; + // This is an existing gap, not an atomicity guarantee granted by the extraction. + expect(repository.getSandbox()).toMatchObject({ + modal_sandbox_id: "replacement", + created_at: 3000, + code_server_url: "https://old.test", + }); + await expect(repository.getSandboxAccessSecret("codeServer")).resolves.toBe("old-secret"); + } finally { + releaseEncryption(); + spy.mockRestore(); + } + }); + describe("rejectProviderStartup", () => { const generation = { sandboxId: "sb-1", createdAt: 1000 }; @@ -727,6 +767,86 @@ describe("SandboxRepository boot state (SQLite)", () => { tunnel_urls: null, }); }); + + it.each([ + ["replaced", "modal_sandbox_id = 'sb-2', created_at = 3000"], + ["timestamp-only supersession", "created_at = 3000"], + ["stopped", "status = 'stopped'"], + ["stale", "status = 'stale'"], + ["failed", "status = 'failed'"], + ["fenced", "fenced = 1"], + ["bridge reference changed", "modal_object_id = 'another-pending'"], + ])("rejects access encrypted across a %s row", async (_case, change) => { + const { repository, set } = createSqliteRepository(); + set( + "status = 'connecting', modal_sandbox_id = 'sb-1', created_at = 2000, modal_object_id = 'pending'" + ); + const encrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let beginEncryption!: () => void; + let releaseEncryption!: () => void; + const encrypting = new Promise((resolve) => (beginEncryption = resolve)); + const gate = new Promise((resolve) => (releaseEncryption = resolve)); + const spy = vi.spyOn(crypto.subtle, "encrypt").mockImplementation(async (...args) => { + const encrypted = await encrypt(...args); + beginEncryption(); + await gate; + return encrypted; + }); + try { + const completion = repository.completeProviderResume(generation, access, "pending"); + await encrypting; + set(change); + const superseded = repository.getSandbox(); + releaseEncryption(); + await expect(completion).resolves.toBe(false); + expect(repository.getSandbox()).toEqual(superseded); + expect(repository.getSandbox()).toMatchObject({ + modal_object_id: change.includes("modal_object_id") ? "another-pending" : "pending", + code_server_url: null, + code_server_password: null, + vnc_url: null, + vnc_password: null, + ttyd_url: null, + ttyd_token: null, + tunnel_urls: null, + }); + } finally { + releaseEncryption(); + spy.mockRestore(); + } + }); + + it("ordinary resume does not require an expected bridge reference after encryption", async () => { + const { repository, set } = createSqliteRepository(); + set( + "status = 'connecting', modal_sandbox_id = 'sb-1', created_at = 2000, modal_object_id = 'old'" + ); + const encrypt = crypto.subtle.encrypt.bind(crypto.subtle); + let beginEncryption!: () => void; + let releaseEncryption!: () => void; + const encrypting = new Promise((resolve) => (beginEncryption = resolve)); + const gate = new Promise((resolve) => (releaseEncryption = resolve)); + const spy = vi.spyOn(crypto.subtle, "encrypt").mockImplementation(async (...args) => { + beginEncryption(); + await gate; + return encrypt(...args); + }); + try { + const completion = repository.completeProviderResume(generation, access); + await encrypting; + set("modal_object_id = 'changed-during-encryption'"); + releaseEncryption(); + await expect(completion).resolves.toBe(true); + expect(repository.getSandbox()).toMatchObject({ + modal_object_id: "provider-2", + code_server_url: "https://code.test", + }); + await expect(repository.getSandboxAccessSecret("codeServer")).resolves.toBe("code-secret"); + } finally { + releaseEncryption(); + spy.mockRestore(); + } + }); }); describe("recordBootProgress", () => { diff --git a/packages/control-plane/src/session/sandbox-shutdown-repository.test.ts b/packages/control-plane/src/session/sandbox-shutdown-repository.test.ts index 0e9f12acc4..866e2aaeed 100644 --- a/packages/control-plane/src/session/sandbox-shutdown-repository.test.ts +++ b/packages/control-plane/src/session/sandbox-shutdown-repository.test.ts @@ -45,6 +45,16 @@ describe("SandboxShutdownRepository", () => { fixture.db.close(); }); + it.each([true, false])("round-trips capture failure provenance (%s)", (captureFailure) => { + const fixture = repository(); + const failed = record({ phase: "unknown", captureFailure }); + + fixture.repository.write(failed); + + expect(fixture.repository.read()).toEqual(failed); + fixture.db.close(); + }); + it("atomically replaces the singleton while preserving a verified receipt", () => { const fixture = repository(); fixture.repository.write(record()); diff --git a/packages/control-plane/src/session/sandbox-shutdown-repository.ts b/packages/control-plane/src/session/sandbox-shutdown-repository.ts index f090099ddb..cd1f6fae66 100644 --- a/packages/control-plane/src/session/sandbox-shutdown-repository.ts +++ b/packages/control-plane/src/session/sandbox-shutdown-repository.ts @@ -28,6 +28,8 @@ const stateSchema = sandboxShutdownSchema lifecyclePolicy: z.enum(["confirmed", "legacy"]).optional(), restoreInvoked: z.boolean().optional(), checkpointInFlight: z.boolean().optional(), + /** Provenance for unattended retries, distinct from authenticated recovery eligibility. */ + captureFailure: z.boolean().optional(), /** A durably claimed discard; no other recovery may act while it is set. */ discarding: z.string().optional(), operationId: z.string().optional(), diff --git a/packages/control-plane/src/session/sandbox-shutdown-safety.test.ts b/packages/control-plane/src/session/sandbox-shutdown-safety.test.ts index e9ee09a3ad..b8103648f1 100644 --- a/packages/control-plane/src/session/sandbox-shutdown-safety.test.ts +++ b/packages/control-plane/src/session/sandbox-shutdown-safety.test.ts @@ -87,6 +87,8 @@ function fixture( runtime_version: "v72-runtime", status: "ready", }; + const alarm = { schedule: vi.fn(async (_atMs: number) => undefined) }; + const backgroundTasks: Array<() => Promise> = []; const shutdown = new SandboxShutdownCoordinator({ store, provider, @@ -111,19 +113,20 @@ function fixture( }, session: { getSession: () => ({ id: "session-1", session_name: "shutdown-safety" }), + transaction: (operation: () => T) => operation(), }, messages: { getProcessingMessage: () => null }, failures: { record: vi.fn(), deliver: vi.fn() }, messenger: { broadcast: vi.fn() }, sockets: { getSandboxSocket: () => null, send: vi.fn() }, - alarm: { schedule: vi.fn(async () => undefined) }, - background: { submit: vi.fn() }, + alarm, + background: { submit: vi.fn((task: () => Promise) => backgroundTasks.push(task)) }, onLifecycleChange: vi.fn(async () => undefined), reconcileStatusFromMessages: vi.fn(async () => undefined), retireAccess: vi.fn(), now: () => options.now ?? 100_000, } as never); - return { shutdown, provider, sandboxRow, stopSandbox, store }; + return { shutdown, provider, sandboxRow, stopSandbox, store, alarm, backgroundTasks, options }; } describe("sandbox shutdown safety", () => { @@ -197,6 +200,71 @@ describe("sandbox shutdown safety", () => { } ); + it("keeps checkpoint uncertainty during draining out of automatic capture recovery", async () => { + let rejectCheckpoint!: (error: Error) => void; + const takeSnapshot = vi + .fn>() + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectCheckpoint = reject; + }) + ) + .mockResolvedValue({ success: true, imageId: "user-retry-image", sourceStopped: false }); + const h = fixture(runningRecord(), { takeSnapshot }); + const checkpoint = h.shutdown.captureCheckpoint(GENERATION, "execution_complete"); + await h.shutdown.requestShutdown("inactivity_timeout"); + expect(h.store.value).toMatchObject({ phase: "draining", checkpointInFlight: true }); + h.alarm.schedule.mockClear(); + + rejectCheckpoint(new Error("checkpoint response lost")); + await expect(checkpoint).resolves.toEqual({ outcome: "unknown" }); + await Promise.all(h.backgroundTasks.splice(0).map((task) => task())); + expect(h.alarm.schedule).not.toHaveBeenCalled(); + expect(h.store.value).toMatchObject({ + phase: "unknown", + checkpointInFlight: false, + captureFailure: false, + }); + + h.options.now = h.store.value!.captureByMs!; + await expect(h.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + expect(h.shutdown.onRefusedReconnect()).toBe("retry"); + await Promise.all(h.backgroundTasks.splice(0).map((task) => task())); + expect(takeSnapshot).toHaveBeenCalledOnce(); + expect(h.stopSandbox).not.toHaveBeenCalled(); + expect(h.sandboxRow.status).toBe("snapshotting"); + + // The existing authenticated recovery choice is unchanged. + expect(h.shutdown.snapshot()?.availableRecoveryActions).toContain("retry"); + await h.shutdown.recover("retry"); + expect(takeSnapshot).toHaveBeenCalledTimes(2); + expect(h.stopSandbox).toHaveBeenCalledOnce(); + expect(h.store.value?.phase).toBe("saved"); + }); + + it("leaves an unclassified held record available only for authenticated recovery", async () => { + const takeSnapshot = vi.fn(); + const h = fixture( + runningRecord({ + phase: "unknown", + operationId: "unclassified-operation", + stopByMs: 160_000, + captureByMs: 460_000, + retireByMs: 1_970_000, + }), + { now: 460_000, takeSnapshot } + ); + + await expect(h.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + expect(h.shutdown.onRefusedReconnect()).toBe("retry"); + await Promise.all(h.backgroundTasks.splice(0).map((task) => task())); + expect(takeSnapshot).not.toHaveBeenCalled(); + expect(h.stopSandbox).not.toHaveBeenCalled(); + expect(h.alarm.schedule).not.toHaveBeenCalled(); + expect(h.shutdown.snapshot()?.availableRecoveryActions).toContain("retry"); + }); + it("projects legacy interrupted saved state as paused until explicit resume", async () => { const h = fixture( recoveryRecord({ diff --git a/packages/control-plane/src/session/sandbox-shutdown.test.ts b/packages/control-plane/src/session/sandbox-shutdown.test.ts index 730ce2d57c..a5d487379f 100644 --- a/packages/control-plane/src/session/sandbox-shutdown.test.ts +++ b/packages/control-plane/src/session/sandbox-shutdown.test.ts @@ -3,6 +3,7 @@ import type { SandboxEvent } from "@open-inspect/shared/types/sandbox-events"; import type { SandboxProvider } from "../sandbox/provider"; import { SandboxShutdownCoordinator } from "./sandbox-shutdown"; import type { ShutdownRecord, ShutdownStore } from "./sandbox-shutdown-repository"; +import { createAlarmHandler } from "./alarm/handler"; const GENERATION = { sandboxId: "sandbox-1", createdAt: 1_000 }; const PENDING_VM_REFERENCE = 'modal-vm-session:["session-1","sandbox-1"]'; @@ -93,6 +94,7 @@ function fixture(providerValue = provider()) { reconcileStatusFromMessages: vi.fn(async () => undefined), retireAccess: vi.fn(() => calls.push("access-retired")), now: () => now, + log: { info: vi.fn(), warn: vi.fn() }, }; const shutdown = new SandboxShutdownCoordinator(deps as never); return { @@ -1055,6 +1057,374 @@ describe("SandboxShutdownCoordinator", () => { ); }); + describe("alarm capture retries", () => { + async function failedGracefulCapture() { + const takeSnapshot = vi + .fn>() + .mockRejectedValue(new Error("transient provider error")); + const stopSandbox = vi.fn(async () => ({ success: true as const })); + const f = fixture(provider({ takeSnapshot, stopSandbox })); + await readyWithoutDeadline(f); + await f.shutdown.requestShutdown("inactivity_timeout"); + f.shutdown.prepared(preparedEvent(f.store.value!)); + await f.shutdown.handleAlarm(); + expect(f.store.value?.phase).toBe("unknown"); + expect(f.sandboxRow.status).toBe("ready"); + expect(f.deps.sandbox.updateSandboxStatus).not.toHaveBeenCalled(); + f.deps.alarm.schedule.mockClear(); + f.backgroundTasks.length = 0; + return { ...f, takeSnapshot, stopSandbox }; + } + + it("reasserts the capture deadline after an earlier alarm without retrying yet", async () => { + const f = await failedGracefulCapture(); + const failed = f.store.value!; + f.setNow(failed.captureByMs! - 1); + + await expect(f.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + + expect(f.takeSnapshot).toHaveBeenCalledOnce(); + expect(f.store.value).toEqual(failed); + expect(f.deps.alarm.schedule).toHaveBeenCalledExactlyOnceWith(failed.captureByMs); + }); + + it.each(["provider rejection", "deadline timeout"] as const)( + "re-arms a consumed in-flight alarm after %s", + async (outcome) => { + vi.useFakeTimers(); + try { + let rejectCapture!: (error: Error) => void; + const takeSnapshot = vi + .fn>() + .mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectCapture = reject; + }) + ) + .mockResolvedValue({ success: true, imageId: "retry-image", sourceStopped: true }); + const f = fixture(provider({ takeSnapshot })); + let pendingAlarm: number | null = null; + f.deps.alarm.schedule.mockImplementation(async (atMs) => { + pendingAlarm = Math.min(pendingAlarm ?? Infinity, atMs); + }); + await readyWithoutDeadline(f); + await f.shutdown.requestShutdown("inactivity_timeout"); + f.shutdown.prepared(preparedEvent(f.store.value!)); + + pendingAlarm = null; + const capturing = f.shutdown.handleAlarm(); + await vi.advanceTimersByTimeAsync(0); + const state = f.store.value!; + expect(state.phase).toBe("capturing"); + if (outcome === "provider rejection") await f.deps.alarm.schedule(state.stopByMs!); + expect(pendingAlarm).toBe( + outcome === "provider rejection" ? state.stopByMs : state.captureByMs + ); + f.setNow(pendingAlarm!); + pendingAlarm = null; + await expect(f.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + expect(takeSnapshot).toHaveBeenCalledOnce(); + + if (outcome === "provider rejection") + rejectCapture(new Error("transient provider error")); + else await vi.advanceTimersByTimeAsync(state.captureByMs! - 100_000); + await capturing; + await Promise.all(f.backgroundTasks.splice(0).map((task) => task())); + + expect(f.store.value?.phase).toBe("unknown"); + expect(pendingAlarm).toBe(state.captureByMs); + f.setNow(pendingAlarm!); + pendingAlarm = null; + await f.shutdown.handleAlarm(); + expect(takeSnapshot).toHaveBeenCalledTimes(2); + expect(f.store.value).toMatchObject({ + phase: "saved", + receipt: { artifactId: "retry-image" }, + }); + } finally { + vi.useRealTimers(); + } + } + ); + + it.each(["failed", "unknown"] as const)( + "retries a held %s capture at its deadline without a reconnect", + async (phase) => { + const f = await failedGracefulCapture(); + f.store.write({ ...f.store.value!, phase }); + const failed = f.store.value!; + f.takeSnapshot.mockResolvedValue({ + success: true, + imageId: "retry-image", + sourceStopped: false, + }); + f.setNow(failed.captureByMs!); + + await expect(f.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + + expect(f.takeSnapshot).toHaveBeenCalledTimes(2); + expect(f.takeSnapshot).toHaveBeenLastCalledWith( + expect.objectContaining({ deadlineAtMs: failed.captureByMs! + 300_000 }) + ); + expect(f.store.value?.operationId).not.toBe(failed.operationId); + expect(f.store.value).toMatchObject({ + phase: "saved", + sourceRetired: true, + receipt: { artifactId: "retry-image" }, + }); + expect(f.calls).toContain("phase:retiring"); + expect(f.stopSandbox).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ providerObjectId: "provider-object-1", intent: "destroy" }) + ); + expect(f.deps.sandbox.updateSandboxStatus).toHaveBeenCalledWith("stopped"); + expect(f.deps.log.info).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ event: "sandbox.preservation_retry", trigger: "alarm" }) + ); + } + ); + + it("runs only one timed-out retry per composed alarm delivery", async () => { + vi.useFakeTimers(); + try { + const f = await failedGracefulCapture(); + f.takeSnapshot + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue({ success: true, imageId: "retry-image", sourceStopped: true }); + f.setNow(f.store.value!.captureByMs!); + const preserve = vi.fn((allowCaptureRetry?: boolean) => + f.shutdown.handleAlarm(allowCaptureRetry) + ); + const flushPending = vi.fn(async () => {}); + const recoverStopConfirmationTimeout = vi.fn(); + const handler = createAlarmHandler({ + preserveBeforeWatchdogs: preserve, + terminalMessageProjection: { flushPending }, + executionStop: { recoverStopConfirmationTimeout }, + repository: { + getProcessingMessageWithStartedAt: vi.fn(() => null), + getNextPendingMessage: vi.fn(() => null), + }, + lifecycleManager: { handleAlarm: vi.fn(async () => "no_action") }, + log: f.deps.log, + } as never); + + const delivery = handler.handle(); + await vi.advanceTimersByTimeAsync(0); + expect(f.takeSnapshot).toHaveBeenCalledTimes(2); + f.setNow(f.store.value!.captureByMs!); + await vi.advanceTimersByTimeAsync(300_000); + await delivery; + + expect(f.takeSnapshot).toHaveBeenCalledTimes(2); + expect(f.store.value?.phase).toBe("unknown"); + expect(f.shutdown.snapshot()).not.toHaveProperty("captureFailure"); + expect(flushPending).toHaveBeenCalledOnce(); + expect(recoverStopConfirmationTimeout).not.toHaveBeenCalled(); + await handler.handle(); + expect(f.takeSnapshot).toHaveBeenCalledTimes(3); + expect(f.store.value?.phase).toBe("saved"); + } finally { + vi.useRealTimers(); + } + }); + + it("spaces repeated failures by capture windows without extending the retry window", async () => { + const f = await failedGracefulCapture(); + const stopByMs = f.store.value!.stopByMs!; + const retryEndMs = stopByMs + 30 * 60_000; + + for (let attempt = 2; attempt <= 6; attempt++) { + const failed = f.store.value!; + f.deps.alarm.schedule.mockClear(); + f.setNow(failed.captureByMs! - 1); + await f.shutdown.handleAlarm(); + expect(f.takeSnapshot).toHaveBeenCalledTimes(attempt - 1); + expect(f.deps.alarm.schedule).toHaveBeenCalledWith(failed.captureByMs); + + f.setNow(failed.captureByMs!); + await f.shutdown.handleAlarm(); + expect(f.takeSnapshot).toHaveBeenCalledTimes(attempt); + expect(f.store.value).toMatchObject({ + phase: "unknown", + stopByMs, + captureByMs: failed.captureByMs! + 300_000, + }); + expect(f.store.value?.operationId).not.toBe(failed.operationId); + } + + expect(f.store.value?.captureByMs).toBe(retryEndMs); + f.deps.alarm.schedule.mockClear(); + for (const now of [retryEndMs, retryEndMs + 300_000]) { + f.setNow(now); + await expect(f.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + } + expect(f.takeSnapshot).toHaveBeenCalledTimes(6); + expect(f.deps.alarm.schedule).not.toHaveBeenCalled(); + expect(f.stopSandbox).not.toHaveBeenCalled(); + expect(recoveryActions(f.shutdown)).not.toContain("retry"); + }); + + it.each([ + { discarding: "discard-operation" }, + { restoreInvoked: true }, + { checkpointInFlight: true }, + { sourceRetired: true }, + { providerObjectId: null }, + { provider: "other-provider" }, + { expiresAtMs: 500_000, lifetimeKind: "finite" as const }, + { + receipt: { + kind: "snapshot" as const, + artifactId: "saved-image", + provider: "modal", + savedAtMs: 100_000, + runtimeVersion: "runtime-1", + }, + }, + { + receipt: { + kind: "snapshot" as const, + artifactId: "older-image", + provider: "other-provider", + savedAtMs: 500, + runtimeVersion: "runtime-1", + }, + }, + ])("does not retry an ineligible held capture (%j)", async (changes) => { + const f = await failedGracefulCapture(); + f.store.write({ ...f.store.value!, ...changes }); + f.setNow(f.store.value!.captureByMs!); + + await expect(f.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + + expect(f.takeSnapshot).toHaveBeenCalledOnce(); + expect(f.deps.alarm.schedule).not.toHaveBeenCalled(); + expect(f.store.value?.phase).toBe("unknown"); + }); + + it("does not retry a superseded generation", async () => { + const f = await failedGracefulCapture(); + f.sandboxRow.created_at += 1; + f.setNow(f.store.value!.captureByMs!); + + await f.shutdown.handleAlarm(); + + expect(f.takeSnapshot).toHaveBeenCalledOnce(); + expect(f.deps.alarm.schedule).not.toHaveBeenCalled(); + }); + + it("does not overlap an active capture even if its record is held", async () => { + const f = await failedGracefulCapture(); + let resolveCapture!: (value: { success: true; imageId: string; sourceStopped: true }) => void; + f.takeSnapshot.mockImplementation( + () => + new Promise((resolve) => { + resolveCapture = resolve; + }) + ); + const retry = f.shutdown.recover("retry"); + await vi.waitFor(() => expect(f.takeSnapshot).toHaveBeenCalledTimes(2)); + f.store.write({ ...f.store.value!, phase: "unknown" }); + f.setNow(f.store.value!.captureByMs!); + f.deps.alarm.schedule.mockClear(); + + try { + await expect(f.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + expect(f.takeSnapshot).toHaveBeenCalledTimes(2); + expect(f.deps.alarm.schedule).not.toHaveBeenCalled(); + } finally { + resolveCapture({ success: true, imageId: "late-image", sourceStopped: true }); + await retry; + } + }); + + it("does not retry a failed restore that has no capture deadlines", async () => { + const takeSnapshot = vi.fn(); + const f = fixture(provider({ takeSnapshot })); + await readyWithoutDeadline(f); + f.store.write({ + ...f.store.value!, + receipt: { + kind: "snapshot", + artifactId: "older-image", + provider: "modal", + savedAtMs: 500, + runtimeVersion: "runtime-1", + }, + }); + f.shutdown.holdFailedRecovery("restore failed", GENERATION); + expect(f.store.value).toMatchObject({ phase: "unknown", sourceRetired: false }); + expect(f.store.value?.stopByMs).toBeUndefined(); + f.setNow(460_000); + + await expect(f.shutdown.handleAlarm()).resolves.toBe("hold_watchdogs"); + + expect(takeSnapshot).not.toHaveBeenCalled(); + expect(f.deps.alarm.schedule).not.toHaveBeenCalled(); + }); + + it("retries a capture interrupted by restart on a later due alarm", async () => { + const f = await failedGracefulCapture(); + f.store.write({ ...f.store.value!, phase: "capturing" }); + f.takeSnapshot.mockResolvedValue({ + success: true, + imageId: "retry-image", + sourceStopped: true, + }); + const restarted = new SandboxShutdownCoordinator(f.deps as never); + + await restarted.handleAlarm(); + expect(f.store.value).toMatchObject({ + phase: "unknown", + error: expect.stringContaining("control-plane restart"), + }); + expect(f.takeSnapshot).toHaveBeenCalledOnce(); + await restarted.handleAlarm(); + expect(f.deps.alarm.schedule).toHaveBeenCalledWith(f.store.value!.captureByMs); + + f.setNow(f.store.value!.captureByMs!); + await restarted.handleAlarm(); + + expect(f.takeSnapshot).toHaveBeenCalledTimes(2); + expect(f.store.value).toMatchObject({ + phase: "saved", + receipt: { artifactId: "retry-image" }, + }); + }); + + it.each(["alarm", "reconnect"] as const)( + "does not double fire when the %s retry wins a race", + async (first) => { + const f = await failedGracefulCapture(); + f.setNow(f.store.value!.captureByMs!); + expect(f.shutdown.onRefusedReconnect()).toBe("retry"); + const reconnectRetry = f.backgroundTasks.splice(0)[0]!; + + if (first === "alarm") { + await f.shutdown.handleAlarm(); + await reconnectRetry(); + } else { + await reconnectRetry(); + await f.shutdown.handleAlarm(); + } + + await Promise.all(f.backgroundTasks.splice(0).map((task) => task())); + expect(f.takeSnapshot).toHaveBeenCalledTimes(2); + expect(f.store.value?.phase).toBe("unknown"); + expect(f.shutdown.onRefusedReconnect()).toBe("retry"); + expect(f.backgroundTasks).toHaveLength(0); + expect(f.deps.alarm.schedule).toHaveBeenCalledWith(f.store.value!.captureByMs); + expect( + f.deps.log.info.mock.calls.filter( + ([, fields]) => fields.event === "sandbox.preservation_retry" + ) + ).toEqual([[expect.any(String), expect.objectContaining({ trigger: first })]]); + } + ); + }); + it("stops offering a retry once the window after the shutdown closes", async () => { const f = fixture( provider({ diff --git a/packages/control-plane/src/session/sandbox-shutdown.ts b/packages/control-plane/src/session/sandbox-shutdown.ts index 6bae341944..0fcf94d571 100644 --- a/packages/control-plane/src/session/sandbox-shutdown.ts +++ b/packages/control-plane/src/session/sandbox-shutdown.ts @@ -579,9 +579,34 @@ export class SandboxShutdownCoordinator { ); } + private canAutomaticallyRetryCapture(state: ShutdownRecord): boolean { + return ( + state.captureFailure === true && + this.current(state) && + (state.provider === undefined || state.provider === this.deps.provider.name) && + (!state.receipt || state.receipt.provider === this.deps.provider.name) && + this.canRetryShutdown(state) + ); + } + + private async scheduleCaptureRetry(state: ShutdownRecord): Promise { + if (this.owns(state) && this.canAutomaticallyRetryCapture(state)) + await this.deps.alarm.schedule(state.captureByMs!); + } + /** Captures the held source again under a new operation, without waiting for its runtime. */ - private async retryCapture(state: ShutdownRecord): Promise { + private async retryCapture( + state: ShutdownRecord, + trigger?: "alarm" | "reconnect" + ): Promise { if (this.activeOperation !== null || !this.owns(state) || !this.canRetryShutdown(state)) return; + if (trigger) + this.deps.log?.info("Retrying a failed sandbox save", { + event: "sandbox.preservation_retry", + trigger, + operation_id: state.operationId, + reason: state.reason, + }); const next: ShutdownRecord = { ...state, error: undefined, @@ -638,13 +663,12 @@ export class SandboxShutdownCoordinator { return "retry"; if ((state.phase !== "failed" && state.phase !== "unknown") || !this.canRetryShutdown(state)) return "exit"; - if (this.activeOperation === null && this.now() >= state.captureByMs!) { - this.deps.log?.info("Retrying a failed save after the runtime reconnected", { - event: "sandbox.preservation_retry", - operation_id: state.operationId, - reason: state.reason, - }); - this.deps.background.submit(() => this.retryCapture(state), { + if ( + this.activeOperation === null && + this.canAutomaticallyRetryCapture(state) && + this.now() >= state.captureByMs! + ) { + this.deps.background.submit(() => this.retryCapture(state, "reconnect"), { name: "sandbox.preservation_retry", }); } @@ -980,7 +1004,7 @@ export class SandboxShutdownCoordinator { } /** Runs before generic watchdogs, and reasserts the absolute deadline on every alarm. */ - async handleAlarm(): Promise<"continue" | "hold_watchdogs"> { + async handleAlarm(allowCaptureRetry = true): Promise<"continue" | "hold_watchdogs"> { const state = this.normalizeInterruptedRestore(); if (!state) return "continue"; if (state.phase === "running") { @@ -996,6 +1020,14 @@ export class SandboxShutdownCoordinator { } if (state.phase === "saved") return this.continuationPaused(state) ? "hold_watchdogs" : "continue"; + if (state.phase === "failed" || state.phase === "unknown") { + if (this.activeOperation === null && this.canAutomaticallyRetryCapture(state)) { + if (allowCaptureRetry && this.now() >= state.captureByMs!) + await this.retryCapture(state, "alarm"); + else await this.deps.alarm.schedule(state.captureByMs!); + } + return "hold_watchdogs"; + } await this.advance(); return "hold_watchdogs"; } @@ -1029,10 +1061,13 @@ export class SandboxShutdownCoordinator { } if (state.phase === "capturing") { if (this.activeOperation !== state.operationId) - this.fail( - state, - "unknown", - "The save was interrupted by a control-plane restart; its result is unknown." + await this.scheduleCaptureRetry( + this.fail( + state, + "unknown", + "The save was interrupted by a control-plane restart; its result is unknown.", + true + ) ); return; } @@ -1064,13 +1099,16 @@ export class SandboxShutdownCoordinator { private async capture(state: ShutdownRecord): Promise { const { provider } = this.deps; if (!state.providerObjectId || this.now() >= state.captureByMs!) { - this.fail(state, "failed", "No time or provider handle remained to save the sandbox."); + await this.scheduleCaptureRetry( + this.fail(state, "failed", "No time or provider handle remained to save the sandbox.", true) + ); return; } this.activeOperation = state.operationId!; const capturing = { ...state, phase: "capturing" as const }; this.publish(capturing); await this.deps.alarm.schedule(state.captureByMs!); + let failed: ShutdownRecord | undefined; try { const retained = !!provider.capabilities.supportsPersistentResume && @@ -1114,15 +1152,18 @@ export class SandboxShutdownCoordinator { else await this.retire(retiring); } catch (error) { if (this.owns(capturing)) - this.fail( + failed = this.fail( capturing, "unknown", error instanceof ShutdownDeadlineError ? "The save did not finish before its deadline; its result is unknown." - : "The provider did not confirm the save. The previous recovery point is unchanged." + : "The provider did not confirm the save. The previous recovery point is unchanged.", + true ); } finally { this.activeOperation = null; + // Re-arm after releasing ownership: an overdue alarm may be delivered immediately. + if (failed) await this.scheduleCaptureRetry(failed); } } @@ -1208,12 +1249,19 @@ export class SandboxShutdownCoordinator { this.notifyLifecycleChange(); } - private fail(state: ShutdownRecord, phase: "failed" | "unknown", error: string): void { - this.publish({ ...state, phase, error }); + private fail( + state: ShutdownRecord, + phase: "failed" | "unknown", + error: string, + captureFailure = false + ): ShutdownRecord { + const failed: ShutdownRecord = { ...state, phase, error, captureFailure }; + this.publish(failed); this.broadcast({ type: "sandbox_warning", message: `${phase === "failed" ? "Sandbox save failed" : "Sandbox save could not be confirmed"}: ${error}`, }); + return failed; } /** Confirmed provider stop of one source, bounded by the caller's deadline. */ diff --git a/packages/control-plane/src/session/server.test.ts b/packages/control-plane/src/session/server.test.ts index bee2809886..0e5d6f3c52 100644 --- a/packages/control-plane/src/session/server.test.ts +++ b/packages/control-plane/src/session/server.test.ts @@ -60,7 +60,7 @@ function createHarness() { notifyTyping: vi.fn(async () => undefined), updatePresence: vi.fn(), getHistoryPage: vi.fn(() => ({ items: [], hasMore: false, cursor: null })), - authorize: vi.fn(async () => "allowed" as const), + authorize: vi.fn(async () => ({ kind: "allowed" as const })), }; const sandbox: SandboxDisconnectMonitor = { getStatus: vi.fn((): "ready" => "ready"), @@ -403,7 +403,10 @@ describe("SessionServer", () => { ); const denied = createHarness(); - vi.mocked(denied.clientCommands.authorize).mockResolvedValue("denied"); + vi.mocked(denied.clientCommands.authorize).mockResolvedValue({ + kind: "denied", + reason: "missing_permission", + }); await denied.server.onMessage( "client", JSON.stringify({ @@ -422,7 +425,7 @@ describe("SessionServer", () => { ); const unavailable = createHarness(); - vi.mocked(unavailable.clientCommands.authorize).mockResolvedValue("unavailable"); + vi.mocked(unavailable.clientCommands.authorize).mockResolvedValue({ kind: "unavailable" }); await unavailable.server.onMessage( "client", JSON.stringify({ @@ -470,29 +473,35 @@ describe("SessionServer", () => { }); it.each([ - [{ type: "prompt", content: "work", clientRequestId: "request-1" }, "sessions.collaborate"], - [ - { type: "cancel_prompt", messageId: "message-1", clientRequestId: "request-1" }, - "sessions.lifecycle", - ], - [{ type: "stop" }, "sessions.lifecycle"], - [{ type: "recover_preservation", action: "restore_saved" }, "sessions.lifecycle"], - ] as const)("rejects %s without its command permission", async (message, permission) => { + [{ type: "prompt", content: "work", clientRequestId: "request-1" }, "collaborate"], + [{ type: "cancel_prompt", messageId: "message-1", clientRequestId: "request-1" }, "lifecycle"], + [{ type: "stop" }, "lifecycle"], + [{ type: "recover_preservation", action: "restore_saved" }, "lifecycle"], + [{ type: "typing" }, "collaborate"], + [{ type: "fetch_history", cursor: { timestamp: 10, id: "event-1" } }, "read"], + [{ type: "presence", status: "idle" }, "read"], + ] as const)("rejects %s without its command action", async (message, action) => { const { server, sockets, clientCommands, client } = createHarness(); - vi.mocked(clientCommands.authorize).mockResolvedValue("denied"); + vi.mocked(clientCommands.authorize).mockResolvedValue({ + kind: "denied", + reason: "missing_permission", + }); await server.onMessage("client", JSON.stringify(message)); - expect(clientCommands.authorize).toHaveBeenCalledWith(client, permission); + expect(clientCommands.authorize).toHaveBeenCalledWith("client", client, action); expect(sockets.send).toHaveBeenCalledWith("client", { type: "error", code: "PERMISSION_REQUIRED", - message: `Permission required: ${permission}`, + message: "Access denied: missing_permission", }); expect(clientCommands.submitPrompt).not.toHaveBeenCalled(); expect(clientCommands.cancelPrompt).not.toHaveBeenCalled(); expect(clientCommands.stopExecution).not.toHaveBeenCalled(); expect(clientCommands.recoverShutdown).not.toHaveBeenCalled(); + expect(clientCommands.notifyTyping).not.toHaveBeenCalled(); + expect(clientCommands.getHistoryPage).not.toHaveBeenCalled(); + expect(clientCommands.updatePresence).not.toHaveBeenCalled(); }); it("routes fetch_history and enforces throttling with the injected clock", async () => { @@ -505,6 +514,11 @@ describe("SessionServer", () => { await server.onMessage("client", JSON.stringify({ type: "fetch_history", cursor })); expect(clientCommands.getHistoryPage).toHaveBeenCalledOnce(); + expect(clientCommands.authorize).toHaveBeenCalledExactlyOnceWith( + "client", + expect.objectContaining({ userId: "user-1" }), + "read" + ); expect(sockets.send).toHaveBeenCalledWith("client", { type: "history_page", items: [], @@ -527,6 +541,7 @@ describe("SessionServer", () => { await server.onMessage("client", JSON.stringify({ type: "fetch_history", cursor })); expect(clientCommands.getHistoryPage).toHaveBeenCalledExactlyOnceWith({ cursor }); + expect(clientCommands.authorize).toHaveBeenCalledOnce(); expect(sockets.send).toHaveBeenNthCalledWith(1, "client", { type: "error", code: "INVALID_CURSOR", @@ -540,6 +555,47 @@ describe("SessionServer", () => { }); }); + it("reserves the history throttle window before authorization even when read is denied", async () => { + const { server, sockets, clientCommands, setNow } = createHarness(); + const cursor = { timestamp: 10, id: "event-1" }; + vi.mocked(clientCommands.authorize).mockResolvedValueOnce({ + kind: "denied", + reason: "missing_permission", + }); + + setNow(0); + await server.onMessage("client", JSON.stringify({ type: "fetch_history", cursor })); + setNow(100); + await server.onMessage("client", JSON.stringify({ type: "fetch_history", cursor })); + + expect(clientCommands.authorize).toHaveBeenCalledOnce(); + expect(clientCommands.getHistoryPage).not.toHaveBeenCalled(); + expect(sockets.send).toHaveBeenCalledWith("client", { + type: "error", + code: "RATE_LIMITED", + message: "Too many requests", + }); + }); + + it("reserves one window for a simultaneous burst of history frames", async () => { + const { server, sockets, clientCommands, setNow } = createHarness(); + setNow(0); + await Promise.all( + Array.from({ length: 5 }, () => + server.onMessage( + "client", + JSON.stringify({ type: "fetch_history", cursor: { timestamp: 10, id: "event-1" } }) + ) + ) + ); + + expect(clientCommands.authorize).toHaveBeenCalledOnce(); + expect(clientCommands.getHistoryPage).toHaveBeenCalledOnce(); + expect( + vi.mocked(sockets.send).mock.calls.filter(([, message]) => message.type === "error") + ).toHaveLength(4); + }); + it("parses and routes sandbox events without exposing a socket type", async () => { const { server, messageDeps, setConnectionKind } = createHarness(); setConnectionKind("sandbox"); diff --git a/packages/control-plane/src/session/services/session-attachment-storage.ts b/packages/control-plane/src/session/services/session-attachment-storage.ts index 637025ece1..85c54b9488 100644 --- a/packages/control-plane/src/session/services/session-attachment-storage.ts +++ b/packages/control-plane/src/session/services/session-attachment-storage.ts @@ -20,7 +20,10 @@ export interface SessionAttachmentRecord { } export type SessionAttachmentStorageErrorReason = - "session_not_found" | "quota_exceeded" | "registry_unavailable" | "cleanup_failed"; + | "session_not_found" + | "quota_exceeded" + | "registry_unavailable" + | "cleanup_failed"; export class SessionAttachmentStorageError extends Error { constructor( diff --git a/packages/control-plane/src/session/session-target-secrets.test.ts b/packages/control-plane/src/session/session-target-secrets.test.ts index 60221627ea..82b08913bc 100644 --- a/packages/control-plane/src/session/session-target-secrets.test.ts +++ b/packages/control-plane/src/session/session-target-secrets.test.ts @@ -5,6 +5,7 @@ import { } from "./session-target-secrets"; import type { SessionRepositoryEntry } from "./repository-target"; import type { SessionRow } from "./types"; +import { mergeSecretSources } from "../db/secrets-validation"; function member( repoOwner: string, @@ -113,6 +114,42 @@ describe("resolveSessionOAuthSecretScope", () => { }); describe("buildSessionTargetSecretSources", () => { + it.each([null, "env_team"])( + "merges team after global and before target secrets for target %s", + async (environmentId) => { + const sources = await buildSessionTargetSecretSources({ + environmentId, + globalSecrets: { SHARED: "global", GLOBAL_TEAM: "global" }, + teamSecrets: { shared: "team", global_team: "team", TEAM_ONLY: "team" }, + members: [member("acme", "web", 0, true)], + loadMemberSecrets: async () => ({ SHARED: "repo" }), + loadEnvironmentSecrets: async () => ({ SHARED: "environment" }), + }); + expect(sources.map((source) => source.label)).toEqual([ + "global", + "team", + environmentId ? "environment" : "acme/web", + ]); + expect(mergeSecretSources(sources).merged).toEqual({ + SHARED: environmentId ? "environment" : "repo", + GLOBAL_TEAM: "team", + TEAM_ONLY: "team", + }); + } + ); + + it("includes team secrets in an ad-hoc session without repositories", async () => { + const sources = await buildSessionTargetSecretSources({ + environmentId: null, + globalSecrets: { SHARED: "global" }, + teamSecrets: { SHARED: "team" }, + members: [], + loadMemberSecrets: vi.fn(), + loadEnvironmentSecrets: noEnvironmentSecrets, + }); + expect(mergeSecretSources(sources).merged).toEqual({ SHARED: "team" }); + }); + it("folds members lowest-precedence-first with the primary (position 0) last", async () => { const secretsByRepo: Record> = { "acme/web": { A: "web" }, @@ -122,6 +159,7 @@ describe("buildSessionTargetSecretSources", () => { const sources = await buildSessionTargetSecretSources({ environmentId: null, globalSecrets: { G: "g" }, + teamSecrets: {}, members: [member("acme", "web", 0, true), member("acme", "backend", 1, false)], loadMemberSecrets: async (m) => secretsByRepo[`${m.repoOwner}/${m.repoName}`] ?? {}, loadEnvironmentSecrets: noEnvironmentSecrets, @@ -137,6 +175,7 @@ describe("buildSessionTargetSecretSources", () => { const sources = await buildSessionTargetSecretSources({ environmentId: "env_flagship", globalSecrets: { G: "g" }, + teamSecrets: {}, members: [member("acme", "web", 0, true)], loadMemberSecrets, loadEnvironmentSecrets: async (id): Promise> => @@ -152,6 +191,7 @@ describe("buildSessionTargetSecretSources", () => { const sources = await buildSessionTargetSecretSources({ environmentId: "env_empty", globalSecrets: { G: "g" }, + teamSecrets: {}, members: [member("acme", "web", 0, true)], loadMemberSecrets: vi.fn(), loadEnvironmentSecrets: noEnvironmentSecrets, @@ -164,6 +204,7 @@ describe("buildSessionTargetSecretSources", () => { const sources = await buildSessionTargetSecretSources({ environmentId: null, globalSecrets: {}, + teamSecrets: {}, members: [member("acme", "web", 0, true), member("acme", "empty", 1, false)], loadMemberSecrets: async (m): Promise> => m.repoName === "empty" ? {} : { A: "1" }, @@ -177,6 +218,7 @@ describe("buildSessionTargetSecretSources", () => { const sources = await buildSessionTargetSecretSources({ environmentId: null, globalSecrets: { G: "g" }, + teamSecrets: {}, members: [], loadMemberSecrets: async () => ({}), loadEnvironmentSecrets: noEnvironmentSecrets, diff --git a/packages/control-plane/src/session/session-target-secrets.ts b/packages/control-plane/src/session/session-target-secrets.ts index 33eb1501e5..2515bc4c9a 100644 --- a/packages/control-plane/src/session/session-target-secrets.ts +++ b/packages/control-plane/src/session/session-target-secrets.ts @@ -33,12 +33,14 @@ export async function resolveSessionOAuthSecretScope( export interface SessionTargetSecretSourcesInput { /** * The session target's environment id (`session.environment_id`), or null for - * repo-launched/ad-hoc sessions. When set, secrets come from global + + * repo-launched/ad-hoc sessions. When set, secrets come from global + team + * environment only — the session's member repos never contribute (session-target * scoping, §6.4/§7.4). */ environmentId: string | null; globalSecrets: Record; + /** The session's owning team secrets, or {} for workspace-owned sessions. */ + teamSecrets: Record; /** Session member repositories in position order (index 0 = primary). */ members: SessionRepositoryEntry[]; /** Decrypt a member's secrets, or {} when it has no resolvable repo id. */ @@ -53,11 +55,11 @@ export interface SessionTargetSecretSourcesInput { /** * Build the ordered secret sources for a session target, lowest - * precedence first (design §6.4). Global is always the base; environment- - * launched sessions add environment secrets only (member repo secrets never + * precedence first. Global is the base, followed by the owning team's secrets; + * environment-launched sessions then add environment secrets (member repo secrets never * inherit — session-target scoping, §6.4/§7.4), while repo-launched and ad-hoc * sessions fold their member repos with the primary (position 0) merged last so - * it wins collisions. A single-repo session degenerates to today's global+repo. + * it wins collisions. Workspace-owned sessions have no team layer. * * This owns the session-target sourcing policy so the DO only loads sources, merges * (mergeSecretSources), and audits the cap. @@ -66,6 +68,9 @@ export async function buildSessionTargetSecretSources( input: SessionTargetSecretSourcesInput ): Promise { const sources: SecretSource[] = [{ label: "global", secrets: input.globalSecrets }]; + if (Object.keys(input.teamSecrets).length > 0) { + sources.push({ label: "team", secrets: input.teamSecrets }); + } if (input.environmentId !== null) { const environmentSecrets = await input.loadEnvironmentSecrets(input.environmentId); diff --git a/packages/control-plane/src/session/title.ts b/packages/control-plane/src/session/title.ts index 235aac5e5e..f7d9f9103d 100644 --- a/packages/control-plane/src/session/title.ts +++ b/packages/control-plane/src/session/title.ts @@ -5,10 +5,12 @@ export interface SessionTitleUpdateOptions { type SessionTitleUpdateErrorReason = "invalid" | "not_found" | "already_set"; export type SessionTitleValidationResult = - { ok: true; title: string } | { ok: false; reason: "invalid"; error: string }; + | { ok: true; title: string } + | { ok: false; reason: "invalid"; error: string }; export type SessionTitleUpdateResult = - { ok: true; title: string } | { ok: false; reason: SessionTitleUpdateErrorReason; error: string }; + | { ok: true; title: string } + | { ok: false; reason: SessionTitleUpdateErrorReason; error: string }; export function normalizeSessionTitle(title: unknown): SessionTitleValidationResult { if (typeof title !== "string") { diff --git a/packages/control-plane/src/session/user-env-resolver.test.ts b/packages/control-plane/src/session/user-env-resolver.test.ts index 296a7228f9..044b06344f 100644 --- a/packages/control-plane/src/session/user-env-resolver.test.ts +++ b/packages/control-plane/src/session/user-env-resolver.test.ts @@ -89,6 +89,10 @@ class FakeStatement implements SqlStatement { } first>(): Promise { + if (this.query === "SELECT * FROM sessions WHERE id = ?") { + this.db.sessionIndexBinds.push(this.bound[0]); + return Promise.resolve(this.db.sessionIndexRow as T | null); + } if (this.query.includes("FROM model_provider_accounts")) { return Promise.resolve((this.db.accountRow as T | null) ?? null); } @@ -112,9 +116,12 @@ class FakeSqlDatabase implements SqlDatabase { providerAuthRows: D1Row[] = []; /** The bound provider account the pre-spawn check reads; null means removed. */ accountRow: D1Row | null = null; + sessionIndexRow: D1Row | null = null; + readonly sessionIndexBinds: unknown[] = []; globalSecretRows: D1Row[] = []; readonly repoSecretRowsByRepoId = new Map(); readonly environmentSecretRowsById = new Map(); + readonly teamSecretRowsById = new Map(); prepare(query: string): SqlStatement { return new FakeStatement(this, query.replace(/\s+/g, " ").trim()); @@ -133,6 +140,9 @@ class FakeSqlDatabase implements SqlDatabase { if (query === "SELECT key, encrypted_value FROM global_secrets") { return this.globalSecretRows; } + if (query === "SELECT key, encrypted_value FROM team_secrets WHERE team_id = ?") { + return this.teamSecretRowsById.get(bound[0] as string) ?? []; + } if (query === "SELECT key, encrypted_value FROM repo_secrets WHERE repo_id = ?") { return this.repoSecretRowsByRepoId.get(bound[0] as number) ?? []; } @@ -252,6 +262,28 @@ function makeHarness( const session = options.session === undefined ? sessionRow() : options.session; const storage = fakeSqlStorage({ session, memberRows: options.memberRows ?? [] }); const db = new FakeSqlDatabase(); + db.sessionIndexRow = session + ? { + ...session, + id: session.session_name ?? session.id, + root_session_id: session.session_name ?? session.id, + automation_id: null, + automation_run_id: null, + scm_login: null, + user_id: null, + owner_team_id: null, + visibility: "workspace", + project_id: null, + active_duration_ms: 0, + message_count: 0, + pr_count: 0, + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + } + : null; const logs: LogEntry[] = []; const log = recordingLogger(logs); const sessionCoreRepository = new SessionCoreRepository(storage.sql, (closure) => closure()); @@ -324,6 +356,95 @@ describe("UserEnvResolver", () => { }); describe("session-target secret fold", () => { + it("loads team secrets from current D1 ownership without advertising team-only OAuth", async () => { + const h = makeHarness(); + h.db.sessionIndexRow!.owner_team_id = "team_a"; + h.db.providerAuthRows = providerAuthRows({ ...API_KEY_MODES, xai: "legacy_scoped_oauth" }); + h.db.globalSecretRows = await secretRows({ SHARED: "global" }); + h.db.teamSecretRowsById.set( + "team_a", + await secretRows({ + SHARED: "team", + XAI_OAUTH_REFRESH_TOKEN: "team-refresh", + }) + ); + expect(await h.resolver.getUserEnvVars()).toEqual({ SHARED: "team" }); + expect(h.db.sessionIndexBinds).toEqual(["sess-public-1"]); + }); + + it.each([ + ["openai", null], + ["openai", "env-1"], + ["xai", null], + ["xai", "env-1"], + ] as const)( + "retains the %s API key when only team OAuth is configured for %s", + async (provider, environmentId) => { + const h = makeHarness({ session: sessionRow({ environment_id: environmentId }) }); + h.db.sessionIndexRow!.owner_team_id = "team_a"; + h.db.providerAuthRows = providerAuthRows({ + ...API_KEY_MODES, + [provider]: "legacy_scoped_oauth", + }); + const prefix = provider.toUpperCase(); + h.db.teamSecretRowsById.set( + "team_a", + await secretRows({ + [`${prefix}_API_KEY`]: "team-api-key", + [`${prefix}_OAUTH_REFRESH_TOKEN`]: "unreadable-team-token", + }) + ); + expect(await h.resolver.getUserEnvVars()).toEqual({ + [`${prefix}_API_KEY`]: "team-api-key", + }); + expect(await h.resolver.getProviderAuthenticationError(`${provider}/model`)).toBeNull(); + h.db.teamSecretRowsById.set( + "team_a", + await secretRows({ + [`${prefix}_OAUTH_REFRESH_TOKEN`]: "unreadable-team-token", + }) + ); + expect(await h.resolver.getProviderAuthenticationError(`${provider}/model`)).toContain( + "authentication" + ); + } + ); + + it.each([null, "env-1"])( + "still advertises readable global OAuth for target %s", + async (environmentId) => { + const h = makeHarness({ session: sessionRow({ environment_id: environmentId }) }); + h.db.sessionIndexRow!.owner_team_id = "team_a"; + h.db.providerAuthRows = providerAuthRows({ ...API_KEY_MODES, xai: "legacy_scoped_oauth" }); + h.db.globalSecretRows = await secretRows({ + XAI_OAUTH_REFRESH_TOKEN: "readable-global-token", + }); + h.db.teamSecretRowsById.set( + "team_a", + await secretRows({ + XAI_API_KEY: "team-key", + XAI_OAUTH_REFRESH_TOKEN: "unreadable-team-token", + }) + ); + expect(await h.resolver.getUserEnvVars()).toEqual({ XAI_OAUTH_MANAGED: "1" }); + } + ); + + it("does not query team secrets for workspace ownership", async () => { + const h = makeHarness(); + h.db.providerAuthRows = providerAuthRows(API_KEY_MODES); + h.db.teamSecretRowsById.set("team_a", await secretRows({ TEAM_ONLY: "private" })); + expect(await h.resolver.getUserEnvVars()).toBeUndefined(); + expect(h.db.queries.some((query) => query.includes("team_secrets"))).toBe(false); + }); + + it("fails closed when the authoritative session index row is missing", async () => { + const h = makeHarness(); + h.db.sessionIndexRow = null; + h.db.providerAuthRows = providerAuthRows(API_KEY_MODES); + await expect(h.resolver.getUserEnvVars()).rejects.toThrow("Session index row not found"); + }); + async function foldHarness( secondarySecrets: Record, primarySecrets: Record diff --git a/packages/control-plane/src/session/user-env-resolver.ts b/packages/control-plane/src/session/user-env-resolver.ts index 75c26f9747..5b98a41a5d 100644 --- a/packages/control-plane/src/session/user-env-resolver.ts +++ b/packages/control-plane/src/session/user-env-resolver.ts @@ -1,6 +1,6 @@ /** * Resolves the user-defined environment a session's sandbox receives: decrypts - * and folds global/repo/environment secrets, derives the managed-provider env + * and folds global/team/repo/environment secrets, derives the managed-provider env * from the session's provider auth modes, and answers whether a model's * provider has usable authentication in that environment. Legacy session rows * that predate `repo_id` resolve it through the injected `resolveRepoId` @@ -11,6 +11,7 @@ import { SessionIndexStore } from "../db/session-index"; import { GlobalSecretsStore } from "../db/global-secrets"; import { RepoSecretsStore } from "../db/repo-secrets"; import { EnvironmentSecretsStore } from "../db/environment-secrets"; +import { TeamSecretsStore } from "../db/team-secrets"; import { auditSecretsMerge, mergeSecretSources, @@ -148,9 +149,12 @@ export class UserEnvResolver { } const db = this.db; - const providerAuth = await new SessionIndexStore(db).getCompleteProviderAuth( - resolvePublicSessionId(session, this.durableObjectId) - ); + const sessionId = resolvePublicSessionId(session, this.durableObjectId); + const indexStore = new SessionIndexStore(db); + // Team ownership can change without changing the DO's launch snapshot. + const indexedSession = await indexStore.get(sessionId); + if (!indexedSession) throw new Error("Session index row not found"); + const providerAuth = await indexStore.getCompleteProviderAuth(sessionId); const providerAuthModes = Object.fromEntries( providerAuth.map(({ provider, authMode }) => [provider, authMode]) ) as Record; @@ -166,6 +170,12 @@ export class UserEnvResolver { const encryptionKey = this.repoSecretsEncryptionKey; const globalStore = new GlobalSecretsStore(db, encryptionKey); const globalSecrets = await globalStore.getDecryptedSecrets(); + const teamSecrets = + indexedSession.ownerTeamId === null + ? {} + : await new TeamSecretsStore(db, encryptionKey).getDecryptedSecrets( + indexedSession.ownerTeamId + ); const repoStore = new RepoSecretsStore(db, encryptionKey); const environmentSecretsStore = new EnvironmentSecretsStore(db, encryptionKey); @@ -173,6 +183,7 @@ export class UserEnvResolver { const sources = await buildSessionTargetSecretSources({ environmentId: session.environment_id, globalSecrets, + teamSecrets, members, loadMemberSecrets: (member) => this.loadMemberRepoSecrets(session, member, repoStore), loadEnvironmentSecrets: (environmentId) => @@ -198,13 +209,14 @@ export class UserEnvResolver { } const primary = members.find((member) => member.isPrimary); - const managedSources = session.environment_id - ? sources - : sources.filter( - (source) => - source.label === "global" || - (primary && source.label === `${primary.repoOwner}/${primary.repoName}`) - ); + // Legacy OAuth brokers can read only global and target scopes, not team secrets. + const managedSources = sources.filter( + (source) => + source.label === "global" || + (session.environment_id + ? source.label === "environment" + : primary && source.label === `${primary.repoOwner}/${primary.repoName}`) + ); const managedSecrets = mergeSecretSources(managedSources).merged; const sandboxEnv = prepareManagedProviderEnv({ exposedSecrets: merge.merged, diff --git a/packages/control-plane/src/source-control/github-credential-authority.ts b/packages/control-plane/src/source-control/github-credential-authority.ts index f2338661d9..4fb797e107 100644 --- a/packages/control-plane/src/source-control/github-credential-authority.ts +++ b/packages/control-plane/src/source-control/github-credential-authority.ts @@ -1,4 +1,7 @@ import { z } from "zod"; +import { APIError } from "better-auth/api"; +import { OAuthProviderError } from "../auth/user/providers/types"; +import { AdmissionDeniedError } from "../auth/user/admission-policy"; import type { AuthenticationContext, Principal } from "../auth/principal"; const providerAccountSchema = z.object({ @@ -11,6 +14,42 @@ const providerAccessTokenSchema = z.object({ accessToken: z.string(), }); +/** Retrieval failures may omit optional attribution; integrity errors must not. */ +export class GitHubAttributionUnavailableError extends Error { + constructor(cause: unknown) { + super("GitHub attribution is unavailable", { cause }); + this.name = "GitHubAttributionUnavailableError"; + } +} + +async function retrieveAttribution(retrieve: () => Promise): Promise { + try { + return await retrieve(); + } catch (cause) { + if ( + (cause instanceof OAuthProviderError && + cause.failure !== "provider_unavailable" && + cause.failure !== "provider_rejected") || + cause instanceof AdmissionDeniedError || + (cause instanceof APIError && cause.body?.code === "AMBIGUOUS_ACCOUNT") + ) + throw cause; + throw new GitHubAttributionUnavailableError(cause); + } +} + +export async function resolveGitHubAccountProfile( + accountClient: ProviderAccountClient, + selection: ProviderAccountSelection +): Promise { + const response = await retrieveAttribution(() => + accountClient.getAccessToken({ body: selection }) + ); + const token = providerAccessTokenSchema.parse(response); + if (token.accessToken === "") return null; + return retrieveAttribution(() => accountClient.accountInfo({ query: selection })); +} + export interface GitHubAccountSelection { readonly subject: string; readonly resolveProfile: () => Promise; @@ -72,7 +111,7 @@ export async function resolveGitHubCredentialAuthority( const accountClient = context.getUserAuth().api; const parsedAccounts = z .array(providerAccountSchema) - .safeParse(await accountClient.listUserAccounts({ headers })); + .safeParse(await retrieveAttribution(() => accountClient.listUserAccounts({ headers }))); if ( !parsedAccounts.success || parsedAccounts.data.some((account) => account.userId !== userId) @@ -95,11 +134,7 @@ export async function resolveGitHubCredentialAuthority( accountId: githubAccount.accountId, userId, }; - const token = providerAccessTokenSchema.parse( - await accountClient.getAccessToken({ body: selection }) - ); - if (token.accessToken === "") return null; - return accountClient.accountInfo({ query: selection }); + return resolveGitHubAccountProfile(accountClient, selection); }, } : null, diff --git a/packages/control-plane/test/conformance/session-core-conformance.ts b/packages/control-plane/test/conformance/session-core-conformance.ts index f747e0e07f..6135fd42ed 100644 --- a/packages/control-plane/test/conformance/session-core-conformance.ts +++ b/packages/control-plane/test/conformance/session-core-conformance.ts @@ -512,15 +512,10 @@ export const STORAGE_CONTRACTS: Record< scm_name: "Conformance User", }); repository.updateParticipantWsToken("participant-conformance", "ws-hash", 200); - repository.updateParticipantTokens("participant-conformance", { - scmAccessTokenEncrypted: "access-2", - scmRefreshTokenEncrypted: "refresh-2", - scmTokenExpiresAt: 300, - }); expect(repository.getParticipantByWsTokenHash("ws-hash")).toMatchObject({ - scm_access_token_encrypted: "access-2", - scm_refresh_token_encrypted: "refresh-2", - scm_token_expires_at: 300, + id: "participant-conformance", + scm_login: "original", + ws_token_created_at: 200, }); repository.createParticipant({ diff --git a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap index 2ea9203883..1906d30879 100644 --- a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap @@ -11,86 +11,93 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /api/auth/error","pathname":"/api/auth/error","groups":{},"authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /internal/auth/sign-in-providers","pathname":"/internal/auth/sign-in-providers","groups":{},"authentication":"web-service","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /me/teams","pathname":"/me/teams","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-global","service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams","pathname":"/teams","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams","pathname":"/teams","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-global","service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /teams","pathname":"/teams","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id","pathname":"/teams/fixture-11-id%2Fraw","groups":{"id":"fixture-11-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id","pathname":"/teams/fixture-11-id%2Fraw","groups":{"id":"fixture-11-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"PATCH /teams/:id","pathname":"/teams/fixture-12-id%2Fraw","groups":{"id":"fixture-12-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canEditMetadata"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /teams/:id/archive","pathname":"/teams/fixture-13-id%2Fraw/archive","groups":{"id":"fixture-13-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canArchive"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /teams/:id/restore","pathname":"/teams/fixture-14-id%2Fraw/restore","groups":{"id":"fixture-14-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canArchive"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id/members","pathname":"/teams/fixture-15-id%2Fraw/members","groups":{"id":"fixture-15-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/members","pathname":"/teams/fixture-15-id%2Fraw/members","groups":{"id":"fixture-15-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"PUT /teams/:id/members/:userId","pathname":"/teams/fixture-16-id%2Fraw/members/fixture-16-userId%2Fraw","groups":{"id":"fixture-16-id%2Fraw","userId":"fixture-16-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageMembers"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /teams/:id/members/:userId","pathname":"/teams/fixture-17-id%2Fraw/members/fixture-17-userId%2Fraw","groups":{"id":"fixture-17-id%2Fraw","userId":"fixture-17-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"read"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/members/:userId","pathname":"/teams/fixture-17-id%2Fraw/members/fixture-17-userId%2Fraw","groups":{"id":"fixture-17-id%2Fraw","userId":"fixture-17-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"removeMember","targetUserIdParam":"userId"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /teams/:id/join","pathname":"/teams/fixture-18-id%2Fraw/join","groups":{"id":"fixture-18-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canJoin"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/sessions","pathname":"/teams/fixture-19-id%2Fraw/sessions","groups":{"id":"fixture-19-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"},{"kind":"permission","permission":"sessions.read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /settings/teams","pathname":"/settings/teams","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /settings/teams","pathname":"/settings/teams","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /sessions","pathname":"/sessions","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":true}", + "{"identity":"PUT /sessions/:id/visibility","pathname":"/sessions/fixture-23-id%2Fraw/visibility","groups":{"id":"fixture-23-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"changeVisibility","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-24-id%2Fraw/collaborators/fixture-24-userId%2Fraw","groups":{"id":"fixture-24-id%2Fraw","userId":"fixture-24-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-25-id%2Fraw/collaborators/fixture-25-userId%2Fraw","groups":{"id":"fixture-25-id%2Fraw","userId":"fixture-25-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/collaborator-candidates","pathname":"/sessions/fixture-26-id%2Fraw/collaborator-candidates","groups":{"id":"fixture-26-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /sessions","pathname":"/sessions","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /sessions/inbox","pathname":"/sessions/inbox","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-22-id%2Fraw/read-state","groups":{"id":"fixture-22-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-23-id%2Fraw","groups":{"id":"fixture-23-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"delete"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-29-id%2Fraw/read-state","groups":{"id":"fixture-29-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-30-id%2Fraw","groups":{"id":"fixture-30-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"delete"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /sessions/export","pathname":"/sessions/export","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.export"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/export","pathname":"/sessions/fixture-25-id%2Fraw/export","groups":{"id":"fixture-25-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"permission","permission":"sessions.export"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-26-id%2Fraw/sandbox-access","groups":{"id":"fixture-26-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-27-id%2Fraw","groups":{"id":"fixture-27-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-28-id%2Fraw/stop","groups":{"id":"fixture-28-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-29-id%2Fraw/sandbox-error","groups":{"id":"fixture-29-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-30-id%2Fraw/events","groups":{"id":"fixture-30-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-31-id%2Fraw/artifacts","groups":{"id":"fixture-31-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-32-id%2Fraw/participants","groups":{"id":"fixture-32-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-33-id%2Fraw/participant-profiles","groups":{"id":"fixture-33-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-34-id%2Fraw/messages","groups":{"id":"fixture-34-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-35-id%2Fraw/pr","groups":{"id":"fixture-35-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-36-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-36-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-37-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-37-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-38-id%2Fraw/scm-credentials","groups":{"id":"fixture-38-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-39-id%2Fraw/tunnel-urls","groups":{"id":"fixture-39-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-40-id%2Fraw/title","groups":{"id":"fixture-40-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-41-id%2Fraw/archive","groups":{"id":"fixture-41-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-42-id%2Fraw/unarchive","groups":{"id":"fixture-42-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-43-id%2Fraw/budget","groups":{"id":"fixture-43-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/export","pathname":"/sessions/fixture-32-id%2Fraw/export","groups":{"id":"fixture-32-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"permission","permission":"sessions.export"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-33-id%2Fraw/sandbox-access","groups":{"id":"fixture-33-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-34-id%2Fraw","groups":{"id":"fixture-34-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-35-id%2Fraw/stop","groups":{"id":"fixture-35-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-36-id%2Fraw/sandbox-error","groups":{"id":"fixture-36-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-37-id%2Fraw/events","groups":{"id":"fixture-37-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-38-id%2Fraw/artifacts","groups":{"id":"fixture-38-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-39-id%2Fraw/participants","groups":{"id":"fixture-39-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-40-id%2Fraw/participant-profiles","groups":{"id":"fixture-40-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-41-id%2Fraw/messages","groups":{"id":"fixture-41-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-42-id%2Fraw/pr","groups":{"id":"fixture-42-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-43-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-43-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-44-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-44-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-45-id%2Fraw/scm-credentials","groups":{"id":"fixture-45-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-46-id%2Fraw/tunnel-urls","groups":{"id":"fixture-46-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-47-id%2Fraw/title","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-48-id%2Fraw/archive","groups":{"id":"fixture-48-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-49-id%2Fraw/unarchive","groups":{"id":"fixture-49-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-50-id%2Fraw/budget","groups":{"id":"fixture-50-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /sessions/batch-archive","pathname":"/sessions/batch-archive","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.bulk_archive"}],"auditAllowed":true,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-45-id%2Fraw/ws-token","groups":{"id":"fixture-45-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-46-id%2Fraw/prompt","groups":{"id":"fixture-46-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-47-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-48-id%2Fraw/media","groups":{"id":"fixture-48-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-49-id%2Fraw/media/fixture-49-artifactId%2Fraw","groups":{"id":"fixture-49-id%2Fraw","artifactId":"fixture-49-artifactId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-50-id%2Fraw/attachments","groups":{"id":"fixture-50-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-51-id%2Fraw/attachments/fixture-51-attachmentId%2Fraw","groups":{"id":"fixture-51-id%2Fraw","attachmentId":"fixture-51-attachmentId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-52-id%2Fraw/diff","groups":{"id":"fixture-52-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-53-id%2Fraw/diff","groups":{"id":"fixture-53-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-54-id%2Fraw/diff/failure","groups":{"id":"fixture-54-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-55-id%2Fraw/diff/fixture-55-revisionId%2Fraw/files/fixture-55-fileId%2Fraw","groups":{"id":"fixture-55-id%2Fraw","revisionId":"fixture-55-revisionId%2Fraw","fileId":"fixture-55-fileId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-56-id%2Fraw/diff/retry","groups":{"id":"fixture-56-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-57-id%2Fraw/skills","groups":{"id":"fixture-57-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-58-id%2Fraw/sandbox-skills","groups":{"id":"fixture-58-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-59-id%2Fraw/children","groups":{"id":"fixture-59-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"},{"kind":"permission","permission":"sessions.create"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-60-id%2Fraw/children","groups":{"id":"fixture-60-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-61-id%2Fraw/children/fixture-61-childId%2Fraw","groups":{"id":"fixture-61-id%2Fraw","childId":"fixture-61-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-62-id%2Fraw/children/fixture-62-childId%2Fraw/cancel","groups":{"id":"fixture-62-id%2Fraw","childId":"fixture-62-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-63-id%2Fraw/children/fixture-63-childId%2Fraw/prompt","groups":{"id":"fixture-63-id%2Fraw","childId":"fixture-63-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-64-id%2Fraw/slack-notify","groups":{"id":"fixture-64-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-52-id%2Fraw/ws-token","groups":{"id":"fixture-52-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-53-id%2Fraw/prompt","groups":{"id":"fixture-53-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-54-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-54-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-55-id%2Fraw/media","groups":{"id":"fixture-55-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-56-id%2Fraw/media/fixture-56-artifactId%2Fraw","groups":{"id":"fixture-56-id%2Fraw","artifactId":"fixture-56-artifactId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-57-id%2Fraw/attachments","groups":{"id":"fixture-57-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-58-id%2Fraw/attachments/fixture-58-attachmentId%2Fraw","groups":{"id":"fixture-58-id%2Fraw","attachmentId":"fixture-58-attachmentId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-59-id%2Fraw/diff","groups":{"id":"fixture-59-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-60-id%2Fraw/diff","groups":{"id":"fixture-60-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-61-id%2Fraw/diff/failure","groups":{"id":"fixture-61-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-62-id%2Fraw/diff/fixture-62-revisionId%2Fraw/files/fixture-62-fileId%2Fraw","groups":{"id":"fixture-62-id%2Fraw","revisionId":"fixture-62-revisionId%2Fraw","fileId":"fixture-62-fileId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-63-id%2Fraw/diff/retry","groups":{"id":"fixture-63-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-64-id%2Fraw/skills","groups":{"id":"fixture-64-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-65-id%2Fraw/sandbox-skills","groups":{"id":"fixture-65-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-66-id%2Fraw/children","groups":{"id":"fixture-66-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"},{"kind":"permission","permission":"sessions.create"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-67-id%2Fraw/children","groups":{"id":"fixture-67-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-68-id%2Fraw/children/fixture-68-childId%2Fraw","groups":{"id":"fixture-68-id%2Fraw","childId":"fixture-68-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-69-id%2Fraw/children/fixture-69-childId%2Fraw/cancel","groups":{"id":"fixture-69-id%2Fraw","childId":"fixture-69-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-70-id%2Fraw/children/fixture-70-childId%2Fraw/prompt","groups":{"id":"fixture-70-id%2Fraw","childId":"fixture-70-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-71-id%2Fraw/slack-notify","groups":{"id":"fixture-71-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /repos","pathname":"/repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-66-owner%2Fraw/fixture-66-name%2Fraw/metadata","groups":{"owner":"fixture-66-owner%2Fraw","name":"fixture-66-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-67-owner%2Fraw/fixture-67-name%2Fraw/metadata","groups":{"owner":"fixture-67-owner%2Fraw","name":"fixture-67-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-68-owner%2Fraw/fixture-68-name%2Fraw/branches","groups":{"owner":"fixture-68-owner%2Fraw","name":"fixture-68-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-69-owner%2Fraw/fixture-69-name%2Fraw/secrets","groups":{"owner":"fixture-69-owner%2Fraw","name":"fixture-69-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-70-owner%2Fraw/fixture-70-name%2Fraw/secrets","groups":{"owner":"fixture-70-owner%2Fraw","name":"fixture-70-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-71-owner%2Fraw/fixture-71-name%2Fraw/secrets/fixture-71-key%2Fraw","groups":{"owner":"fixture-71-owner%2Fraw","name":"fixture-71-name%2Fraw","key":"fixture-71-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-73-owner%2Fraw/fixture-73-name%2Fraw/metadata","groups":{"owner":"fixture-73-owner%2Fraw","name":"fixture-73-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-74-owner%2Fraw/fixture-74-name%2Fraw/metadata","groups":{"owner":"fixture-74-owner%2Fraw","name":"fixture-74-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-75-owner%2Fraw/fixture-75-name%2Fraw/branches","groups":{"owner":"fixture-75-owner%2Fraw","name":"fixture-75-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-76-owner%2Fraw/fixture-76-name%2Fraw/secrets","groups":{"owner":"fixture-76-owner%2Fraw","name":"fixture-76-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-77-owner%2Fraw/fixture-77-name%2Fraw/secrets","groups":{"owner":"fixture-77-owner%2Fraw","name":"fixture-77-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-78-owner%2Fraw/fixture-78-name%2Fraw/secrets/fixture-78-key%2Fraw","groups":{"owner":"fixture-78-owner%2Fraw","name":"fixture-78-name%2Fraw","key":"fixture-78-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /secrets","pathname":"/secrets","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /secrets","pathname":"/secrets","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-74-key%2Fraw","groups":{"key":"fixture-74-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-81-key%2Fraw","groups":{"key":"fixture-81-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /environments","pathname":"/environments","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /environments","pathname":"/environments","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id","pathname":"/environments/fixture-77-id%2Fraw","groups":{"id":"fixture-77-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-78-id%2Fraw","groups":{"id":"fixture-78-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-79-id%2Fraw","groups":{"id":"fixture-79-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-80-id%2Fraw/secrets","groups":{"id":"fixture-80-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-81-id%2Fraw/secrets","groups":{"id":"fixture-81-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-82-id%2Fraw/secrets/import","groups":{"id":"fixture-82-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-83-id%2Fraw/secrets/fixture-83-key%2Fraw","groups":{"id":"fixture-83-id%2Fraw","key":"fixture-83-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id","pathname":"/environments/fixture-84-id%2Fraw","groups":{"id":"fixture-84-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-85-id%2Fraw","groups":{"id":"fixture-85-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-86-id%2Fraw","groups":{"id":"fixture-86-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-87-id%2Fraw/secrets","groups":{"id":"fixture-87-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-88-id%2Fraw/secrets","groups":{"id":"fixture-88-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-89-id%2Fraw/secrets/import","groups":{"id":"fixture-89-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-90-id%2Fraw/secrets/fixture-90-key%2Fraw","groups":{"id":"fixture-90-id%2Fraw","key":"fixture-90-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-complete","pathname":"/image-builds/build-complete","groups":{},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /image-builds/build-failed","pathname":"/image-builds/build-failed","groups":{},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-86-id%2Fraw","groups":{"id":"fixture-86-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-87-owner%2Fraw/fixture-87-name%2Fraw","groups":{"owner":"fixture-87-owner%2Fraw","name":"fixture-87-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-88-owner%2Fraw/fixture-88-name%2Fraw","groups":{"owner":"fixture-88-owner%2Fraw","name":"fixture-88-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-93-id%2Fraw","groups":{"id":"fixture-93-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-94-owner%2Fraw/fixture-94-name%2Fraw","groups":{"owner":"fixture-94-owner%2Fraw","name":"fixture-94-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-95-owner%2Fraw/fixture-95-name%2Fraw","groups":{"owner":"fixture-95-owner%2Fraw","name":"fixture-95-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/status","pathname":"/image-builds/status","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled","pathname":"/image-builds/enabled","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled-repos","pathname":"/image-builds/enabled-repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", @@ -100,66 +107,66 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /model-provider-accounts/legacy-credentials","pathname":"/model-provider-accounts/legacy-credentials","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-98-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-98-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-99-provider%2Fraw/device-authorizations/fixture-99-id%2Fraw/poll","groups":{"provider":"fixture-99-provider%2Fraw","id":"fixture-99-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-100-provider%2Fraw/device-authorizations/fixture-100-id%2Fraw","groups":{"provider":"fixture-100-provider%2Fraw","id":"fixture-100-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-101-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-101-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-102-provider%2Fraw/authorization-codes/fixture-102-id%2Fraw","groups":{"provider":"fixture-102-provider%2Fraw","id":"fixture-102-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-103-provider%2Fraw/authorization-codes/fixture-103-id%2Fraw/complete","groups":{"provider":"fixture-103-provider%2Fraw","id":"fixture-103-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-104-provider%2Fraw/authorization-codes/fixture-104-id%2Fraw","groups":{"provider":"fixture-104-provider%2Fraw","id":"fixture-104-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-105-id%2Fraw","groups":{"id":"fixture-105-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-106-id%2Fraw","groups":{"id":"fixture-106-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-107-id%2Fraw/verify","groups":{"id":"fixture-107-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-108-id%2Fraw/disable","groups":{"id":"fixture-108-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-109-id%2Fraw/enable","groups":{"id":"fixture-109-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-110-id%2Fraw/reconnect","groups":{"id":"fixture-110-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-111-id%2Fraw","groups":{"id":"fixture-111-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-105-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-105-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-106-provider%2Fraw/device-authorizations/fixture-106-id%2Fraw/poll","groups":{"provider":"fixture-106-provider%2Fraw","id":"fixture-106-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-107-provider%2Fraw/device-authorizations/fixture-107-id%2Fraw","groups":{"provider":"fixture-107-provider%2Fraw","id":"fixture-107-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-108-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-108-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-109-provider%2Fraw/authorization-codes/fixture-109-id%2Fraw","groups":{"provider":"fixture-109-provider%2Fraw","id":"fixture-109-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-110-provider%2Fraw/authorization-codes/fixture-110-id%2Fraw/complete","groups":{"provider":"fixture-110-provider%2Fraw","id":"fixture-110-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-111-provider%2Fraw/authorization-codes/fixture-111-id%2Fraw","groups":{"provider":"fixture-111-provider%2Fraw","id":"fixture-111-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-112-id%2Fraw","groups":{"id":"fixture-112-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-113-id%2Fraw","groups":{"id":"fixture-113-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-114-id%2Fraw/verify","groups":{"id":"fixture-114-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-115-id%2Fraw/disable","groups":{"id":"fixture-115-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-116-id%2Fraw/enable","groups":{"id":"fixture-116-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-117-id%2Fraw/reconnect","groups":{"id":"fixture-117-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-118-id%2Fraw","groups":{"id":"fixture-118-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-account-defaults","pathname":"/model-provider-account-defaults","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-113-provider%2Fraw","groups":{"provider":"fixture-113-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-114-provider%2Fraw","groups":{"provider":"fixture-114-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-115-id%2Fraw/provider-auth/fixture-115-provider%2Fraw/access-token","groups":{"id":"fixture-115-id%2Fraw","provider":"fixture-115-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/runtime-credential","pathname":"/sessions/fixture-116-id%2Fraw/provider-auth/fixture-116-provider%2Fraw/runtime-credential","groups":{"id":"fixture-116-id%2Fraw","provider":"fixture-116-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/review-token","pathname":"/sessions/fixture-117-id%2Fraw/review-token","groups":{"id":"fixture-117-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-118-id%2Fraw","groups":{"id":"fixture-118-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-119-id%2Fraw","groups":{"id":"fixture-119-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-120-id%2Fraw","groups":{"id":"fixture-120-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-121-id%2Fraw/repos","groups":{"id":"fixture-121-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-122-id%2Fraw/repos/fixture-122-owner%2Fraw/fixture-122-name%2Fraw","groups":{"id":"fixture-122-id%2Fraw","owner":"fixture-122-owner%2Fraw","name":"fixture-122-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-123-id%2Fraw/repos/fixture-123-owner%2Fraw/fixture-123-name%2Fraw","groups":{"id":"fixture-123-id%2Fraw","owner":"fixture-123-owner%2Fraw","name":"fixture-123-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-124-id%2Fraw/repos/fixture-124-owner%2Fraw/fixture-124-name%2Fraw","groups":{"id":"fixture-124-id%2Fraw","owner":"fixture-124-owner%2Fraw","name":"fixture-124-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-125-id%2Fraw/environments/fixture-125-environmentId%2Fraw","groups":{"id":"fixture-125-id%2Fraw","environmentId":"fixture-125-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-126-id%2Fraw/environments/fixture-126-environmentId%2Fraw","groups":{"id":"fixture-126-id%2Fraw","environmentId":"fixture-126-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-127-id%2Fraw/environments/fixture-127-environmentId%2Fraw","groups":{"id":"fixture-127-id%2Fraw","environmentId":"fixture-127-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-128-id%2Fraw/resolved/fixture-128-owner%2Fraw/fixture-128-name%2Fraw","groups":{"id":"fixture-128-id%2Fraw","owner":"fixture-128-owner%2Fraw","name":"fixture-128-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-120-provider%2Fraw","groups":{"provider":"fixture-120-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-121-provider%2Fraw","groups":{"provider":"fixture-121-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-122-id%2Fraw/provider-auth/fixture-122-provider%2Fraw/access-token","groups":{"id":"fixture-122-id%2Fraw","provider":"fixture-122-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/provider-auth/:provider/runtime-credential","pathname":"/sessions/fixture-123-id%2Fraw/provider-auth/fixture-123-provider%2Fraw/runtime-credential","groups":{"id":"fixture-123-id%2Fraw","provider":"fixture-123-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/review-token","pathname":"/sessions/fixture-124-id%2Fraw/review-token","groups":{"id":"fixture-124-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-125-id%2Fraw","groups":{"id":"fixture-125-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot","pathParams":{"id":"slack"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id","pathname":"/integration-settings/fixture-126-id%2Fraw","groups":{"id":"fixture-126-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-127-id%2Fraw","groups":{"id":"fixture-127-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-128-id%2Fraw/repos","groups":{"id":"fixture-128-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-129-id%2Fraw/repos/fixture-129-owner%2Fraw/fixture-129-name%2Fraw","groups":{"id":"fixture-129-id%2Fraw","owner":"fixture-129-owner%2Fraw","name":"fixture-129-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-130-id%2Fraw/repos/fixture-130-owner%2Fraw/fixture-130-name%2Fraw","groups":{"id":"fixture-130-id%2Fraw","owner":"fixture-130-owner%2Fraw","name":"fixture-130-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-131-id%2Fraw/repos/fixture-131-owner%2Fraw/fixture-131-name%2Fraw","groups":{"id":"fixture-131-id%2Fraw","owner":"fixture-131-owner%2Fraw","name":"fixture-131-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-132-id%2Fraw/environments/fixture-132-environmentId%2Fraw","groups":{"id":"fixture-132-id%2Fraw","environmentId":"fixture-132-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-133-id%2Fraw/environments/fixture-133-environmentId%2Fraw","groups":{"id":"fixture-133-id%2Fraw","environmentId":"fixture-133-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-134-id%2Fraw/environments/fixture-134-environmentId%2Fraw","groups":{"id":"fixture-134-id%2Fraw","environmentId":"fixture-134-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-135-id%2Fraw/resolved/fixture-135-owner%2Fraw/fixture-135-name%2Fraw","groups":{"id":"fixture-135-id%2Fraw","owner":"fixture-135-owner%2Fraw","name":"fixture-135-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot","pathParams":{"id":"github"}},{"service":"linear-bot","pathParams":{"id":"linear"}}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /commit-signing","pathname":"/commit-signing","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"commit_signing.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-132-id%2Fraw/commit-signing","groups":{"id":"fixture-132-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-133-id%2Fraw/commit-signing","groups":{"id":"fixture-133-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-139-id%2Fraw/commit-signing","groups":{"id":"fixture-139-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-140-id%2Fraw/commit-signing","groups":{"id":"fixture-140-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /scm-settings","pathname":"/scm-settings","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /scm-settings/repos","pathname":"/scm-settings/repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-138-owner%2Fraw/fixture-138-name%2Fraw","groups":{"owner":"fixture-138-owner%2Fraw","name":"fixture-138-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-139-owner%2Fraw/fixture-139-name%2Fraw","groups":{"owner":"fixture-139-owner%2Fraw","name":"fixture-139-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-145-owner%2Fraw/fixture-145-name%2Fraw","groups":{"owner":"fixture-145-owner%2Fraw","name":"fixture-145-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-146-owner%2Fraw/fixture-146-name%2Fraw","groups":{"owner":"fixture-146-owner%2Fraw","name":"fixture-146-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /integration-settings/slack/watched-channels","pathname":"/integration-settings/slack/watched-channels","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /integration-settings/slack/channels","pathname":"/integration-settings/slack/channels","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /automations","pathname":"/automations","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /automations","pathname":"/automations","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.create"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id","pathname":"/automations/fixture-144-id%2Fraw","groups":{"id":"fixture-144-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-145-id%2Fraw","groups":{"id":"fixture-145-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-146-id%2Fraw","groups":{"id":"fixture-146-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-147-id%2Fraw/pause","groups":{"id":"fixture-147-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-148-id%2Fraw/resume","groups":{"id":"fixture-148-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-149-id%2Fraw/trigger","groups":{"id":"fixture-149-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-150-id%2Fraw/invocations","groups":{"id":"fixture-150-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-151-id%2Fraw/runs/fixture-151-runId%2Fraw","groups":{"id":"fixture-151-id%2Fraw","runId":"fixture-151-runId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-152-id%2Fraw/regenerate-key","groups":{"id":"fixture-152-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":"no-store","hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id","pathname":"/automations/fixture-151-id%2Fraw","groups":{"id":"fixture-151-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /automations/:id","pathname":"/automations/fixture-152-id%2Fraw","groups":{"id":"fixture-152-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-153-id%2Fraw","groups":{"id":"fixture-153-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-154-id%2Fraw/pause","groups":{"id":"fixture-154-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/resume","pathname":"/automations/fixture-155-id%2Fraw/resume","groups":{"id":"fixture-155-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-156-id%2Fraw/trigger","groups":{"id":"fixture-156-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"trigger","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/invocations","pathname":"/automations/fixture-157-id%2Fraw/invocations","groups":{"id":"fixture-157-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /automations/:id/runs/:runId","pathname":"/automations/fixture-158-id%2Fraw/runs/fixture-158-runId%2Fraw","groups":{"id":"fixture-158-id%2Fraw","runId":"fixture-158-runId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/regenerate-key","pathname":"/automations/fixture-159-id%2Fraw/regenerate-key","groups":{"id":"fixture-159-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":"no-store","hasServiceActorClaims":false}", "{"identity":"GET /mcp-servers","pathname":"/mcp-servers","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /mcp-servers","pathname":"/mcp-servers","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-155-id%2Fraw","groups":{"id":"fixture-155-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-156-id%2Fraw","groups":{"id":"fixture-156-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-157-id%2Fraw","groups":{"id":"fixture-157-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-162-id%2Fraw","groups":{"id":"fixture-162-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-163-id%2Fraw","groups":{"id":"fixture-163-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-164-id%2Fraw","groups":{"id":"fixture-164-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/dashboard","pathname":"/analytics/dashboard","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/summary","pathname":"/analytics/summary","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /analytics/timeseries","pathname":"/analytics/timeseries","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"analytics.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", @@ -171,32 +178,35 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /skills","pathname":"/skills","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/preview","pathname":"/skills/preview","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/resolve-preview","pathname":"/skills/resolve-preview","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /skills/:id","pathname":"/skills/fixture-169-id%2Fraw","groups":{"id":"fixture-169-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skills/:id","pathname":"/skills/fixture-176-id%2Fraw","groups":{"id":"fixture-176-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills","pathname":"/skills","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import/preview","pathname":"/skills/import/preview","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /skills/import","pathname":"/skills/import","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-173-id%2Fraw/reimport/preview","groups":{"id":"fixture-173-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-174-id%2Fraw/reimport","groups":{"id":"fixture-174-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-175-id%2Fraw","groups":{"id":"fixture-175-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-176-id%2Fraw","groups":{"id":"fixture-176-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-177-id%2Fraw","groups":{"id":"fixture-177-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-180-id%2Fraw/reimport/preview","groups":{"id":"fixture-180-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport","pathname":"/skills/fixture-181-id%2Fraw/reimport","groups":{"id":"fixture-181-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-182-id%2Fraw","groups":{"id":"fixture-182-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-183-id%2Fraw","groups":{"id":"fixture-183-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-184-id%2Fraw","groups":{"id":"fixture-184-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-180-id%2Fraw","groups":{"id":"fixture-180-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-181-id%2Fraw","groups":{"id":"fixture-181-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-187-id%2Fraw","groups":{"id":"fixture-187-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-188-id%2Fraw","groups":{"id":"fixture-188-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"PUT /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /me/authorization","pathname":"/me/authorization","groups":{},"authentication":"user","authorization":{"kind":"authenticated","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /roles","pathname":"/roles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /roles/:id","pathname":"/roles/fixture-186-id%2Fraw","groups":{"id":"fixture-186-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /roles/:id","pathname":"/roles/fixture-193-id%2Fraw","groups":{"id":"fixture-193-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /members","pathname":"/members","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-188-id%2Fraw/role","groups":{"id":"fixture-188-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-189-id%2Fraw/status","groups":{"id":"fixture-189-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-195-id%2Fraw/role","groups":{"id":"fixture-195-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/status","pathname":"/members/fixture-196-id%2Fraw/status","groups":{"id":"fixture-196-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /access-tokens","pathname":"/access-tokens","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /access-tokens","pathname":"/access-tokens","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /access-tokens/:id","pathname":"/access-tokens/fixture-192-id%2Fraw","groups":{"id":"fixture-192-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-193-id%2Fraw","groups":{"id":"fixture-193-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-194-id%2Fraw","groups":{"id":"fixture-194-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /access-tokens/:id","pathname":"/access-tokens/fixture-199-id%2Fraw","groups":{"id":"fixture-199-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/secrets","pathname":"/teams/fixture-200-id%2Fraw/secrets","groups":{"id":"fixture-200-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/secrets","pathname":"/teams/fixture-201-id%2Fraw/secrets","groups":{"id":"fixture-201-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/secrets/:key","pathname":"/teams/fixture-202-id%2Fraw/secrets/fixture-202-key%2Fraw","groups":{"id":"fixture-202-id%2Fraw","key":"fixture-202-key%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-203-id%2Fraw","groups":{"id":"fixture-203-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/automation/:id","pathname":"/webhooks/automation/fixture-204-id%2Fraw","groups":{"id":"fixture-204-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-event","pathname":"/internal/github-event","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/slack-event","pathname":"/internal/slack-event","groups":{},"authentication":"service","authorization":{"kind":"service","services":["slack-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-reviews/claim","pathname":"/internal/github-reviews/claim","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", @@ -206,7 +216,7 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"POST /internal/github-reviews/start-marker/release","pathname":"/internal/github-reviews/start-marker/release","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-reviews/close-out","pathname":"/internal/github-reviews/close-out","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /internal/github-reviews/close-out/finalize","pathname":"/internal/github-reviews/close-out/finalize","groups":{},"authentication":"service","authorization":{"kind":"service","services":["github-bot"],"actor":"optional","auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/review-ownership","pathname":"/sessions/fixture-204-id%2Fraw/review-ownership","groups":{"id":"fixture-204-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id/review-ownership","pathname":"/sessions/fixture-205-id%2Fraw/review-ownership","groups":{"id":"fixture-205-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/review-ownership","pathname":"/sessions/fixture-214-id%2Fraw/review-ownership","groups":{"id":"fixture-214-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /sessions/:id/review-ownership","pathname":"/sessions/fixture-215-id%2Fraw/review-ownership","groups":{"id":"fixture-215-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", ] `; diff --git a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap index 919f68b447..2ea5001d9b 100644 --- a/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/route-admission-matrix.test.ts.snap @@ -62,7 +62,14 @@ exports[`route admission matrix > admits the workspace owner through every brows "PUT /teams/:id/members/:userId owner=400", "DELETE /teams/:id/members/:userId owner=404", "POST /teams/:id/join owner=200", + "GET /teams/:id/sessions owner=200", + "GET /settings/teams owner=200", + "PATCH /settings/teams owner=400", "POST /sessions owner=201", + "PUT /sessions/:id/visibility owner=400", + "PUT /sessions/:id/collaborators/:userId owner=404", + "DELETE /sessions/:id/collaborators/:userId owner=200", + "GET /sessions/:id/collaborator-candidates owner=200", "GET /sessions owner=200", "GET /sessions/inbox owner=200", "PATCH /sessions/:id/read-state owner=400", @@ -223,6 +230,9 @@ exports[`route admission matrix > admits the workspace owner through every brows "GET /access-tokens owner=200", "POST /access-tokens owner=400", "DELETE /access-tokens/:id owner=404", + "GET /teams/:id/secrets owner=200", + "PUT /teams/:id/secrets owner=400", + "DELETE /teams/:id/secrets/:key owner=404", ] `; @@ -247,7 +257,14 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "PUT /teams/:id/members/:userId anonymous=401", "DELETE /teams/:id/members/:userId anonymous=401", "POST /teams/:id/join anonymous=401", + "GET /teams/:id/sessions anonymous=401", + "GET /settings/teams anonymous=401", + "PATCH /settings/teams anonymous=401", "POST /sessions anonymous=401", + "PUT /sessions/:id/visibility anonymous=401", + "PUT /sessions/:id/collaborators/:userId anonymous=401", + "DELETE /sessions/:id/collaborators/:userId anonymous=401", + "GET /sessions/:id/collaborator-candidates anonymous=401", "GET /sessions anonymous=401", "GET /sessions/inbox anonymous=401", "PATCH /sessions/:id/read-state anonymous=401", @@ -421,6 +438,9 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "GET /access-tokens anonymous=401", "POST /access-tokens anonymous=401", "DELETE /access-tokens/:id anonymous=401", + "GET /teams/:id/secrets anonymous=401", + "PUT /teams/:id/secrets anonymous=401", + "DELETE /teams/:id/secrets/:key anonymous=401", "POST /webhooks/sentry/:id anonymous=404", "POST /webhooks/automation/:id anonymous=415", "POST /internal/github-event anonymous=401", @@ -445,6 +465,47 @@ exports[`route admission sentinel > admits actorless reads of team and workspace ] `; +exports[`route admission sentinel > admits team directory reads but conceals member tabs and denies capabilities in every mode 1`] = ` +[ + "GET /teams/:id off/nonmember=200 auditAllowed=false", + "PATCH /teams/:id off/nonmember=403 auditAllowed=true", + "POST /teams/:id/archive off/nonmember=403 auditAllowed=true", + "POST /teams/:id/restore off/nonmember=403 auditAllowed=true", + "GET /teams/:id/members off/nonmember=200 auditAllowed=false", + "PUT /teams/:id/members/:userId off/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/members/:userId off/nonmember=404 auditAllowed=true", + "POST /teams/:id/join off/nonmember=200 auditAllowed=true", + "GET /teams/:id/sessions off/nonmember=404 auditAllowed=false", + "GET /teams/:id/secrets off/nonmember=403 auditAllowed=true", + "PUT /teams/:id/secrets off/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/secrets/:key off/nonmember=403 auditAllowed=true", + "GET /teams/:id shadow/nonmember=200 auditAllowed=false", + "PATCH /teams/:id shadow/nonmember=403 auditAllowed=true", + "POST /teams/:id/archive shadow/nonmember=403 auditAllowed=true", + "POST /teams/:id/restore shadow/nonmember=403 auditAllowed=true", + "GET /teams/:id/members shadow/nonmember=200 auditAllowed=false", + "PUT /teams/:id/members/:userId shadow/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/members/:userId shadow/nonmember=404 auditAllowed=true", + "POST /teams/:id/join shadow/nonmember=200 auditAllowed=true", + "GET /teams/:id/sessions shadow/nonmember=404 auditAllowed=false", + "GET /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", + "PUT /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/secrets/:key shadow/nonmember=403 auditAllowed=true", + "GET /teams/:id on/nonmember=200 auditAllowed=false", + "PATCH /teams/:id on/nonmember=403 auditAllowed=true", + "POST /teams/:id/archive on/nonmember=403 auditAllowed=true", + "POST /teams/:id/restore on/nonmember=403 auditAllowed=true", + "GET /teams/:id/members on/nonmember=200 auditAllowed=false", + "PUT /teams/:id/members/:userId on/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/members/:userId on/nonmember=404 auditAllowed=true", + "POST /teams/:id/join on/nonmember=200 auditAllowed=true", + "GET /teams/:id/sessions on/nonmember=404 auditAllowed=false", + "GET /teams/:id/secrets on/nonmember=403 auditAllowed=true", + "PUT /teams/:id/secrets on/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/secrets/:key on/nonmember=403 auditAllowed=true", +] +`; + exports[`route admission sentinel > audits one workspace Owner break-glass admission for a private session 1`] = ` [ "owner-role-break-glass=200:1", @@ -453,6 +514,10 @@ exports[`route admission sentinel > audits one workspace Owner break-glass admis exports[`route admission sentinel > conceals all team item routes from another team before any handler or DO call 1`] = ` [ + "PUT /sessions/:id/visibility other-team=404", + "PUT /sessions/:id/collaborators/:userId other-team=404", + "DELETE /sessions/:id/collaborators/:userId other-team=404", + "GET /sessions/:id/collaborator-candidates other-team=404", "PATCH /sessions/:id/read-state other-team=404", "DELETE /sessions/:id other-team=404", "GET /sessions/:id/export other-team=404", @@ -493,6 +558,10 @@ exports[`route admission sentinel > conceals all team item routes from another t exports[`route admission sentinel > reports action denials for a same-team Viewer and admits a private collaborator 1`] = ` [ + "PUT /sessions/:id/visibility same-team-viewer=403", + "PUT /sessions/:id/collaborators/:userId same-team-viewer=403", + "DELETE /sessions/:id/collaborators/:userId same-team-viewer=200", + "GET /sessions/:id/collaborator-candidates same-team-viewer=403", "PATCH /sessions/:id/read-state same-team-viewer=200", "DELETE /sessions/:id same-team-viewer=403", "GET /sessions/:id/sandbox-access same-team-viewer=403", diff --git a/packages/control-plane/test/integration/access-tokens.test.ts b/packages/control-plane/test/integration/access-tokens.test.ts index 9aba0e0db4..fad9f6e4c6 100644 --- a/packages/control-plane/test/integration/access-tokens.test.ts +++ b/packages/control-plane/test/integration/access-tokens.test.ts @@ -346,8 +346,8 @@ describe("personal access tokens", () => { capabilities: { canManageMembers: true }, }); expect((await read(`/teams/${mine.id}/members`)).status).toBe(200); - // A team the owner cannot see stays hidden from the token as well. - expect((await read(`/teams/${open.id}`)).status).toBe(404); + // The team directory is readable without membership; the token reads it as its owner does. + expect((await read(`/teams/${open.id}`)).status).toBe(200); // Membership changes stay human-only, even where the owner may make them. expect((await read(`/teams/${open.id}/join`, "POST")).status).toBe(403); diff --git a/packages/control-plane/test/integration/analytics.test.ts b/packages/control-plane/test/integration/analytics.test.ts index c19bfeb2c1..62efcd4d42 100644 --- a/packages/control-plane/test/integration/analytics.test.ts +++ b/packages/control-plane/test/integration/analytics.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { env } from "cloudflare:test"; +import { createExecutionContext, env } from "cloudflare:test"; import type { AnalyticsBreakdownResponse, AnalyticsDashboardResponse, @@ -10,8 +10,11 @@ import type { import type { SpawnSource } from "@open-inspect/shared/types/sessions"; import type { HarnessId } from "@open-inspect/shared/harnesses"; import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { SessionRunStore } from "../../src/db/session-run-store"; +import { AnalyticsStore } from "../../src/db/analytics-store"; import { cleanD1Tables } from "./cleanup"; -import { serviceFetch } from "./helpers"; +import { routeRequest, serviceFetch, serviceRequestHeaders } from "./helpers"; const zeroTokens: AnalyticsTokenTotals = { inputTokens: 0, @@ -34,6 +37,9 @@ async function seedSession( baseBranch?: string | null; scmLogin: string | null; userId?: string | null; + ownerTeamId?: string | null; + visibility?: "workspace" | "team" | "private"; + parentSessionId?: string; spawnSource?: SpawnSource; harness?: HarnessId; automationId?: string; @@ -50,8 +56,8 @@ async function seedSession( ): Promise { await store.create({ id: input.id, - ownerTeamId: null, - visibility: "workspace", + ownerTeamId: input.ownerTeamId ?? null, + visibility: input.visibility ?? "workspace", title: input.id, repoOwner: input.repoOwner, repoName: input.repoName, @@ -65,6 +71,7 @@ async function seedSession( spawnSource: input.spawnSource, scmLogin: input.scmLogin, userId: input.userId, + parentSessionId: input.parentSessionId, createdAt: input.createdAt, updatedAt: input.updatedAt, }); @@ -98,6 +105,153 @@ async function seedUser( describe("Analytics API", () => { beforeEach(cleanD1Tables); + it("limits private cost to privileged viewers and the requested spawn-source window", async () => { + const now = Date.now() - 60_000; + const store = new SessionIndexStore(env.DB); + for (const [id, source, cost, createdAt] of [ + ["private-human", "user", 2, now], + ["private-agent", "agent", 5, now], + ["private-old", "user", 11, now - 45 * 24 * 60 * 60 * 1000], + ["public-human", "user", 3, now], + ] as const) { + await seedSession(store, { + id, + repoOwner: "acme", + repoName: "app", + scmLogin: "alice", + visibility: id.startsWith("private") ? "private" : "workspace", + spawnSource: source, + status: "completed", + createdAt, + updatedAt: createdAt, + totalCost: cost, + activeDurationMs: 0, + messageCount: 0, + prCount: 0, + }); + } + const owner = { as: { userId: "44444444444444444444444444444444", role: "owner" as const } }; + const human = await ( + await serviceFetch("https://test.local/analytics/summary?scope=human", owner) + ).json(); + expect(human).toMatchObject({ totalSessions: 1, totalCost: 3, privateSessionsCostUsd: 2 }); + const agent = await ( + await serviceFetch("https://test.local/analytics/summary?scope=agent", owner) + ).json(); + expect(agent).toMatchObject({ totalSessions: 0, totalCost: 0, privateSessionsCostUsd: 5 }); + const dashboard = await ( + await serviceFetch("https://test.local/analytics/dashboard?scope=all", owner) + ).json(); + expect(dashboard.summary).toMatchObject({ totalCost: 3, privateSessionsCostUsd: 7 }); + const member = await ( + await serviceFetch("https://test.local/analytics/summary?scope=all", { + as: { userId: "55555555555555555555555555555555", role: "member" }, + }) + ).json(); + expect(member).toMatchObject({ totalCost: 3, privateSessionsCostUsd: null }); + expect( + await new AnalyticsStore( + env.DB, + { kind: "internal", reason: "verify raw totals" }, + "on" + ).getSummary({ startAt: now - 1000, endAt: now + 1000, scope: "all" }) + ).toMatchObject({ totalSessions: 3, totalCost: 10, privateSessionsCostUsd: null }); + }); + + it("limits every dashboard population to visible non-private sessions without exposing private cost to members", async () => { + const member = "22222222222222222222222222222222"; + const now = Date.now() - 60_000; + await serviceRequestHeaders("https://test.local/analytics/dashboard", { + as: { userId: member, role: "member" }, + }); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('analytics-allowed', 'analytics-allowed', 'Allowed', 1, 1), ('analytics-denied', 'analytics-denied', 'Denied', 1, 1)" + ).run(); + await new TeamMembershipStore(env.DB).add("analytics-allowed", member); + const store = new SessionIndexStore(env.DB); + for (const [id, teamId, visibility, cost, parentSessionId] of [ + ["visible-root", null, "workspace", 1, undefined], + ["visible-child", "analytics-allowed", "team", 2, "visible-root"], + ["hidden-child", "analytics-denied", "team", 4, "visible-root"], + ["private-child", null, "private", 8, "visible-root"], + ["owner-private", null, "private", 16, undefined], + ] as const) { + await seedSession(store, { + id, + repoOwner: "acme", + repoName: "app", + scmLogin: id, + userId: member, + ownerTeamId: teamId, + visibility, + parentSessionId, + spawnSource: id === "owner-private" ? "agent" : "user", + status: "completed", + createdAt: now, + updatedAt: now + 10, + totalCost: cost, + activeDurationMs: 10, + messageCount: 1, + prCount: 1, + tokens: { + inputTokens: cost, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + }); + } + const fetchAnalytics = async (path: string, mode: "on" | "off") => { + const url = `https://test.local/analytics/${path}`; + return routeRequest( + new Request(url, { + headers: await serviceRequestHeaders(url, { as: { userId: member, role: "member" } }), + }), + { ...env, TEAMS_ENFORCEMENT: mode }, + createExecutionContext() + ); + }; + + const dashboardResponse = await fetchAnalytics("dashboard?scope=all", "on"); + expect(dashboardResponse.status).toBe(200); + const dashboard = await dashboardResponse.json(); + expect(dashboard.summary).toMatchObject({ + totalSessions: 2, + totalCost: 3, + inputTokens: 3, + privateSessionsCostUsd: null, + }); + expect( + dashboard.timeseries.series + .flatMap((point) => Object.values(point.groups)) + .reduce((a, b) => a + b, 0) + ).toBe(2); + for (const dimension of ["repository", "user", "model", "harness", "provider"] as const) { + expect( + dashboard.breakdowns[dimension].entries.reduce((sum, entry) => sum + entry.cost, 0) + ).toBe(3); + } + expect(dashboard.runs).toEqual([ + expect.objectContaining({ rootSessionId: "visible-root", sessionCount: 2, totalCost: 3 }), + ]); + expect( + await new SessionRunStore(env.DB, { kind: "service", teamId: null }, "on").get( + "owner-private" + ) + ).toBeNull(); + expect( + (await (await fetchAnalytics("runs", "on")).json<{ runs: { totalCost: number }[] }>()).runs[0] + .totalCost + ).toBe(3); + expect( + await (await fetchAnalytics("summary?scope=human", "on")).json() + ).toMatchObject({ totalSessions: 2, totalCost: 3, privateSessionsCostUsd: null }); + expect( + await (await fetchAnalytics("summary?scope=all", "off")).json() + ).toMatchObject({ totalSessions: 3, totalCost: 7, privateSessionsCostUsd: null }); + }); + it("sums token totals across sessions and provider merges without excluding zero-token history", async () => { const store = new SessionIndexStore(env.DB); const now = Date.now() - 60_000; @@ -348,6 +502,7 @@ describe("Analytics API", () => { cacheHitRatio: null, activeUsers: 3, totalCost: 3, + privateSessionsCostUsd: 0, avgCost: 0.5, totalPrs: 2, statusBreakdown: { diff --git a/packages/control-plane/test/integration/audit-event-store.test.ts b/packages/control-plane/test/integration/audit-event-store.test.ts index 3c95869982..f025a3eca7 100644 --- a/packages/control-plane/test/integration/audit-event-store.test.ts +++ b/packages/control-plane/test/integration/audit-event-store.test.ts @@ -1,6 +1,6 @@ import { env } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AuditEventStore } from "../../src/db/audit-event-store"; +import { AuditEventStore, toAuditEvent } from "../../src/db/audit-event-store"; import { cleanD1Tables } from "./cleanup"; import { sqlDatabase } from "./helpers"; @@ -35,4 +35,138 @@ describe("AuditEventStore integration", () => { expect(second.rows.map((event) => event.id)).toEqual(["event-b", "event-a"]); expect(second).toMatchObject({ hasMore: false, nextCursor: null }); }); + + it("filters by team through pagination without filtering actions", async () => { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_alpha', 'alpha', 'Alpha', 1, 1), ('team_beta', 'beta', 'Beta', 1, 1)" + ).run(); + for (const [id, teamId, action] of [ + ["a", "team_alpha", "team.updated"], + ["b", "team_alpha", "team.updated"], + ["c", "team_beta", "team.updated"], + ["d", "team_alpha", "team.archived"], + ]) { + await insertEvent(id, 100); + await env.DB.prepare( + "UPDATE authorization_audit_events SET team_id = ?, action = ? WHERE id = ?" + ) + .bind(teamId, action, id) + .run(); + } + const options = { limit: 1, cursor: null, teamId: "team_alpha" }; + const store = new AuditEventStore(sqlDatabase(env.DB)); + const first = await store.list(options); + expect(first.rows.map(({ id, action }) => ({ id, action }))).toEqual([ + { id: "d", action: "team.archived" }, + ]); + expect(first.hasMore).toBe(true); + const second = await store.list({ ...options, cursor: first.nextCursor }); + expect(second.rows.map(({ id }) => id)).toEqual(["b"]); + expect(second.hasMore).toBe(true); + const third = await store.list({ ...options, cursor: second.nextCursor }); + expect(third.rows.map(({ id }) => id)).toEqual(["a"]); + expect(third).toMatchObject({ hasMore: false, nextCursor: null }); + }); + + it("keeps non-session team audit rows with a nullable resource ID", async () => { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_alpha', 'alpha', 'Alpha', 1, 1)" + ).run(); + await insertEvent("team-no-resource", 100); + await env.DB.prepare( + "UPDATE authorization_audit_events SET team_id = 'team_alpha', resource_type = 'team', resource_id = NULL WHERE id = 'team-no-resource'" + ).run(); + const result = await new AuditEventStore(sqlDatabase(env.DB)).list({ + limit: 1, + cursor: null, + teamId: "team_alpha", + }); + expect(result.rows.map(({ id }) => id)).toEqual(["team-no-resource"]); + expect(toAuditEvent(result.rows[0]).resourceId).toBeNull(); + expect(result).toMatchObject({ hasMore: false, nextCursor: null }); + }); + + it("retains HTTP decisions and private-session evidence in team-filtered workspace audit", async () => { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_alpha', 'alpha', 'Alpha', 1, 1)" + ).run(); + await insertEvent("team-event", 1); + await env.DB.prepare( + "UPDATE authorization_audit_events SET team_id = 'team_alpha', resource_type = 'team', action = 'team.updated' WHERE id = 'team-event'" + ).run(); + const historicalMetadata = { + before: {}, + requested: {}, + after: { + teamId: "team_alpha", + userId: "other", + role: "member", + source: "manual", + createdAt: 1, + displayName: "Ada", + email: "historical@example.com", + avatarUrl: "https://example.com/ada.png", + }, + }; + await insertEvent("historical-membership", 1, historicalMetadata); + await env.DB.prepare( + "UPDATE authorization_audit_events SET team_id = 'team_alpha', resource_type = 'team', action = 'team.member_added' WHERE id = 'historical-membership'" + ).run(); + await env.DB.prepare( + "INSERT INTO users (id, created_at, updated_at) VALUES ('other', 1, 1)" + ).run(); + await env.DB.prepare( + "INSERT INTO sessions (id, user_id, owner_team_id, visibility, created_at, updated_at) VALUES ('private', 'other', 'team_alpha', 'private', 1, 1)" + ).run(); + const privateMetadata = { + before: { title: "Historical private title", visibility: "workspace" }, + after: { visibility: "private" }, + }; + await insertEvent("private-event", 1, privateMetadata); + await env.DB.prepare( + "UPDATE authorization_audit_events SET team_id = 'team_alpha', resource_type = 'session', resource_id = 'private', action = 'session.visibility_changed' WHERE id = 'private-event'" + ).run(); + const paths = [ + "/sessions", + "/sessions/private/scope", + "/teams/team_alpha/members", + "/repos", + "/future/alias", + null, + ]; + for (const [index, path] of paths.entries()) { + const id = `http-${index}`; + await insertEvent(id, index + 2, { + shadowDenials: [{ sessionId: "private", reason: "private" }], + }); + await env.DB.prepare( + "UPDATE authorization_audit_events SET team_id = 'team_alpha', resource_type = 'http_route', resource_id = ?, action = 'authorization.request_denied' WHERE id = ?" + ) + .bind(path, id) + .run(); + } + const store = new AuditEventStore(sqlDatabase(env.DB)); + const workspace = await store.list({ limit: 100, cursor: null, teamId: "team_alpha" }); + const events = workspace.rows.map(toAuditEvent); + expect(events).toHaveLength(paths.length + 3); + const httpEvents = events.filter(({ resourceType }) => resourceType === "http_route"); + expect(httpEvents.map(({ resourceId }) => resourceId)).toEqual([...paths].reverse()); + for (const event of httpEvents) { + expect(event.action).toBe("authorization.request_denied"); + expect(event.metadata).toEqual({ + legacy: true, + shadowDenials: [{ sessionId: "private", reason: "private" }], + }); + } + expect(events.find(({ id }) => id === "private-event")).toMatchObject({ + action: "session.visibility_changed", + resourceType: "session", + resourceId: "private", + metadata: { legacy: true, ...privateMetadata }, + }); + expect(events.find(({ id }) => id === "historical-membership")?.metadata).toEqual({ + legacy: true, + ...historicalMetadata, + }); + }); }); diff --git a/packages/control-plane/test/integration/audit-events-route.test.ts b/packages/control-plane/test/integration/audit-events-route.test.ts index 0b86f215a0..76360500b9 100644 --- a/packages/control-plane/test/integration/audit-events-route.test.ts +++ b/packages/control-plane/test/integration/audit-events-route.test.ts @@ -119,6 +119,26 @@ describe("GET /audit-events", () => { }); }); + it("filters workspace audit by team without hiding private-session evidence", async () => { + await env.DB.prepare( + `INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_alpha', 'alpha', 'Alpha', 1, 1), ('team_beta', 'beta', 'Beta', 1, 1)` + ).run(); + await env.DB.prepare( + `INSERT INTO authorization_audit_events + (id, occurred_at, request_id, principal_kind, action, resource_type, resource_id, team_id, reason_code, operation_result, metadata_json) + VALUES ('private-event', 100, 'request-private', 'user', 'session.created_private', 'session', 'private-id', 'team_alpha', 'created', 'applied', '{"legacy":true,"title":"private"}'), + ('other-event', 200, 'request-other', 'user', 'team.updated', 'team', 'team_beta', 'team_beta', 'updated', 'applied', '{"legacy":true}')` + ).run(); + const response = await serviceFetch("https://cp.test/audit-events?teamId=team_alpha"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + events: [{ id: "private-event", resourceId: "private-id", metadata: { title: "private" } }], + hasMore: false, + }); + await assignRole("role_builtin_member"); + expect((await serviceFetch("https://cp.test/audit-events?teamId=team_alpha")).status).toBe(403); + }); + it("records a successful session export as an allowed authorization event", async () => { const response = await serviceFetch("https://cp.test/sessions/export"); expect(response.status).toBe(200); diff --git a/packages/control-plane/test/integration/autofix-activity-route.test.ts b/packages/control-plane/test/integration/autofix-activity-route.test.ts index e811315850..8eb8129440 100644 --- a/packages/control-plane/test/integration/autofix-activity-route.test.ts +++ b/packages/control-plane/test/integration/autofix-activity-route.test.ts @@ -2,6 +2,7 @@ import type { GitHubAutofixEnvelope } from "@open-inspect/shared"; import { beforeEach, describe, expect, it } from "vitest"; import { env, SELF } from "cloudflare:test"; import { PrAutofixFeedbackStore } from "../../src/db/pr-autofix-feedback-store"; +import { SessionIndexStore } from "../../src/db/session-index"; import { cleanD1Tables } from "./cleanup"; import { serviceFetch } from "./helpers"; @@ -24,6 +25,41 @@ function envelope(id: string): GitHubAutofixEnvelope { describe("GET /autofix/activity", () => { beforeEach(cleanD1Tables); + it("omits private-session feedback from the signed web service's activity feed", async () => { + const store = new PrAutofixFeedbackStore(env.DB); + const visible = await store.receive(envelope("visible"), 1_000); + const privateRecord = await store.receive(envelope("private"), 2_000); + await new SessionIndexStore(env.DB).create({ + id: "private-session", + ownerTeamId: null, + visibility: "private", + title: null, + repoOwner: "acme", + repoName: "widgets", + model: "test-model", + reasoningEffort: null, + baseBranch: "main", + status: "active", + createdAt: 1_000, + updatedAt: 1_000, + }); + await store.attachContext(privateRecord.feedbackKey, { + artifactId: "artifact", + sessionId: "private-session", + authorId: "7", + authorLogin: "alice", + authorType: "User", + feedbackUrl: "https://example.com", + }); + + const response = await serviceFetch(BASE); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + records: [{ feedbackKey: visible.feedbackKey }], + nextCursor: null, + }); + }); + it("applies the default limit and paginates equal-timestamp records without gaps", async () => { const store = new PrAutofixFeedbackStore(env.DB); const ids = Array.from( diff --git a/packages/control-plane/test/integration/child-session-ops.test.ts b/packages/control-plane/test/integration/child-session-ops.test.ts index 69430e394c..bc36177527 100644 --- a/packages/control-plane/test/integration/child-session-ops.test.ts +++ b/packages/control-plane/test/integration/child-session-ops.test.ts @@ -14,6 +14,8 @@ import { openClientWs, collectMessages, seedMessage, + seedActiveUser, + serviceFetch, TEST_SESSION_PROVIDER_AUTH, } from "./helpers"; @@ -137,6 +139,23 @@ describe("Child session operations (list, get, cancel)", () => { return id; } + async function isolateChild(id: string, scope: "private" | "moved") { + if (scope === "private") { + const ownerId = "22222222222222222222222222222222"; + await seedActiveUser(ownerId); + await env.DB.prepare("UPDATE sessions SET visibility = 'private', user_id = ? WHERE id = ?") + .bind(ownerId, id) + .run(); + } else { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_other', 'other', 'Other', 1, 1)" + ).run(); + await env.DB.prepare("UPDATE sessions SET owner_team_id = 'team_other' WHERE id = ?") + .bind(id) + .run(); + } + } + describe("GET /sessions/:parentId/children", () => { it("returns children from D1", async () => { const { pName, childName, sandboxToken } = await setupParentAndChild(); @@ -507,7 +526,176 @@ describe("Child session operations (list, get, cancel)", () => { }); }); + describe("independently scoped children", () => { + it.each(["private", "moved"] as const)( + "hides a %s child from its parent sandbox's list, detail and cancel", + async (scope) => { + const { pName, childName, sandboxToken, store } = await setupParentAndChild({ + childStatus: "active", + }); + await isolateChild(childName, scope); + const headers = { Authorization: `Bearer ${sandboxToken}` }; + + const listed = await SELF.fetch(`https://test.local/sessions/${pName}/children`, { + headers, + }); + expect(listed.status).toBe(200); + const body = await listed.json<{ children: Array<{ id: string }> }>(); + expect(body.children.map(({ id }) => id)).not.toContain(childName); + + const detail = await SELF.fetch( + `https://test.local/sessions/${pName}/children/${childName}?include=result,trajectory`, + { headers } + ); + expect(detail.status).toBe(404); + expect(await detail.json()).toEqual({ error: "Child session not found" }); + + const cancelled = await SELF.fetch( + `https://test.local/sessions/${pName}/children/${childName}/cancel`, + { method: "POST", headers } + ); + expect(cancelled.status).toBe(404); + expect((await store.get(childName))?.status).toBe("active"); + } + ); + + it.each(["private", "moved"] as const)( + "refuses nested cancellation of a %s descendant before cancelling its parent", + async (scope) => { + const { pName, childName, sandboxToken, store } = await setupParentAndChild({ + childStatus: "active", + }); + const grandchildName = await setupNestedSession(store, childName, 2, "isolated-grandchild"); + await isolateChild(grandchildName, scope); + const url = `https://test.local/sessions/${pName}/children/${childName}/cancel`; + const headers = { Authorization: `Bearer ${sandboxToken}` }; + + const denied = await SELF.fetch(url, { method: "POST", headers }); + expect(denied.status).toBe(404); + expect((await store.get(childName))?.status).toBe("active"); + expect((await store.get(grandchildName))?.status).toBe("active"); + + const directOnly = await SELF.fetch(url, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ cancelNested: false }), + }); + expect(directOnly.status).toBe(200); + expect((await store.get(childName))?.status).toBe("cancelled"); + expect((await store.get(grandchildName))?.status).toBe("active"); + } + ); + + it("lets the private child's active canonical owner read and cancel it", async () => { + const { pName, childName, parentStub, sandboxToken, store } = await setupParentAndChild({ + childStatus: "active", + }); + const ownerId = "33333333333333333333333333333333"; + await serviceFetch("https://test.local/me/authorization", { + as: { userId: ownerId, role: "member" }, + }); + await env.DB.prepare("UPDATE sessions SET visibility = 'private', user_id = ? WHERE id = ?") + .bind(ownerId, childName) + .run(); + await runInSessionDO(parentStub, (_instance: SessionDO, state) => { + state.storage.sql.exec( + "UPDATE participants SET canonical_user_id = ? WHERE role = 'owner'", + ownerId + ); + }); + const url = `https://test.local/sessions/${pName}/children`; + const headers = { Authorization: `Bearer ${sandboxToken}` }; + const list = await SELF.fetch(url, { headers }); + expect((await list.json<{ children: Array<{ id: string }> }>()).children).toEqual([ + expect.objectContaining({ id: childName }), + ]); + expect((await SELF.fetch(`${url}/${childName}`, { headers })).status).toBe(200); + expect( + (await SELF.fetch(`${url}/${childName}/cancel`, { method: "POST", headers })).status + ).toBe(200); + expect((await store.get(childName))?.status).toBe("cancelled"); + }); + + it("checks a private grandchild before a human cancels any descendants", async () => { + const { pName, childName, store } = await setupParentAndChild({ childStatus: "active" }); + const grandchildName = await setupNestedSession(store, childName, 2, "private-grandchild"); + await isolateChild(grandchildName, "private"); + + const response = await serviceFetch( + `https://test.local/sessions/${pName}/children/${childName}/cancel`, + { method: "POST", as: { userId: "11111111111111111111111111111111", role: "member" } } + ); + expect(response.status).toBe(404); + expect((await store.get(childName))?.status).toBe("active"); + expect((await store.get(grandchildName))?.status).toBe("active"); + }); + }); + describe("POST /sessions/:parentId/children/:childId/prompt", () => { + it("does not prompt a private child for an unverified parent prompt author", async () => { + const { pName, childName, sandboxToken } = await setupParentAndChild(); + const ownerId = "22222222222222222222222222222222"; + await seedActiveUser(ownerId); + await env.DB.prepare("UPDATE sessions SET visibility = 'private', user_id = ? WHERE id = ?") + .bind(ownerId, childName) + .run(); + const response = await SELF.fetch( + `https://test.local/sessions/${pName}/children/${childName}/prompt`, + { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Continue" }), + } + ); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Child session not found" }); + }); + + it("lets the private child's canonical owner send a parent follow-up", async () => { + const { pName, childName, parentStub, sandboxToken } = await setupParentAndChild(); + const ownerId = "22222222222222222222222222222222"; + await seedActiveUser(ownerId); + await env.DB.prepare("UPDATE sessions SET visibility = 'private', user_id = ? WHERE id = ?") + .bind(ownerId, childName) + .run(); + await runInSessionDO(parentStub, (_instance: SessionDO, state) => { + state.storage.sql.exec( + "UPDATE participants SET canonical_user_id = ? WHERE role = 'owner'", + ownerId + ); + }); + const response = await SELF.fetch( + `https://test.local/sessions/${pName}/children/${childName}/prompt`, + { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Continue" }), + } + ); + expect(response.status).toBe(200); + }); + + it("refuses a prompt when the child moved to another team", async () => { + const { pName, childName, sandboxToken } = await setupParentAndChild(); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_other', 'other', 'Other', 1, 1)" + ).run(); + await env.DB.prepare("UPDATE sessions SET owner_team_id = 'team_other' WHERE id = ?") + .bind(childName) + .run(); + + const response = await SELF.fetch( + `https://test.local/sessions/${pName}/children/${childName}/prompt`, + { + method: "POST", + headers: { Authorization: `Bearer ${sandboxToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "Continue" }), + } + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "child_moved" }); + }); + it("queues a follow-up in the direct child as the parent prompt author", async () => { const { pName, childName, childStub, sandboxToken } = await setupParentAndChild(); diff --git a/packages/control-plane/test/integration/collaborator-candidates.test.ts b/packages/control-plane/test/integration/collaborator-candidates.test.ts new file mode 100644 index 0000000000..cb7f7f3845 --- /dev/null +++ b/packages/control-plane/test/integration/collaborator-candidates.test.ts @@ -0,0 +1,232 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { UserStore, type User } from "../../src/db/user-store"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, serviceRequestHeaders, type ServiceRequestInit } from "./helpers"; + +const BASE = "https://test.local"; +const OWNER = "11111111111111111111111111111111"; +const WORKSPACE_OWNER = "22222222222222222222222222222222"; +const OUTSIDER = "33333333333333333333333333333333"; + +async function request(path: string, init: ServiceRequestInit = {}, mode = "off") { + const url = `${BASE}${path}`; + return routeRequest( + new Request(url, { + method: init.method ?? "GET", + headers: await serviceRequestHeaders(url, init), + }), + { ...env, TEAMS_ENFORCEMENT: mode }, + createExecutionContext() + ); +} + +describe("session collaborator candidates", () => { + let candidate: User; + + beforeEach(async () => { + await cleanD1Tables(); + await serviceRequestHeaders(`${BASE}/me/authorization`, { + as: { userId: OWNER, role: "member" }, + }); + candidate = await new UserStore(env.DB).createUser({ + displayName: "Ada", + email: "ada@example.com", + avatarUrl: "https://example.com/ada.png", + }); + await new SessionIndexStore(env.DB).create({ + id: "private-session", + ownerTeamId: null, + visibility: "private", + userId: OWNER, + title: null, + repoOwner: null, + repoName: null, + baseBranch: null, + model: "anthropic/claude-haiku-4-5", + reasoningEffort: null, + status: "created", + createdAt: 1, + updatedAt: 1, + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("lets a Member owner list minimal picker identities, add, and remove without directory access", async () => { + const response = await request("/sessions/private-session/collaborator-candidates"); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await response.json()).toEqual([ + { + userId: candidate.id, + displayName: "Ada", + email: null, + avatarUrl: "https://example.com/ada.png", + }, + { + userId: OWNER, + displayName: "Integration Browser User", + email: null, + avatarUrl: `${OWNER}@test.local`, + }, + ]); + expect((await request("/members")).status).toBe(403); + const path = `/sessions/private-session/collaborators/${candidate.id}`; + expect((await request(path, { method: "PUT" })).status).toBe(200); + expect(await new SessionCollaboratorStore(env.DB).listUserIds("private-session")).toEqual([ + candidate.id, + ]); + expect((await request(path, { method: "DELETE" })).status).toBe(200); + expect(await new SessionCollaboratorStore(env.DB).listUserIds("private-session")).toEqual([]); + }); + + it.each(["off", "shadow", "on"])( + "returns emails only to a session owner with workspace member read permission in %s mode", + async (mode) => { + for (const role of ["member", "administrator"] as const) { + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY[role].id, OWNER) + .run(); + const response = await request( + "/sessions/private-session/collaborator-candidates", + {}, + mode + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + { + userId: candidate.id, + displayName: "Ada", + email: role === "administrator" ? candidate.email : null, + avatarUrl: candidate.avatarUrl, + }, + { + userId: OWNER, + displayName: "Integration Browser User", + email: role === "administrator" ? `${OWNER}@test.local` : null, + avatarUrl: `${OWNER}@test.local`, + }, + ]); + } + } + ); + + it("excludes suspended and unassigned users while retaining nullable identity fields", async () => { + const users = new UserStore(env.DB); + const suspended = await users.createUser({ displayName: "Suspended" }); + const unassigned = await users.createUser({ displayName: "Unassigned" }); + const unnamed = await users.createUser({}); + await env.DB.batch([ + env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(suspended.id), + env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?").bind(unassigned.id), + ]); + const response = await request("/sessions/private-session/collaborator-candidates"); + expect(response.status).toBe(200); + const body = await response.json>(); + expect(body.map((user) => user.userId)).toEqual( + expect.arrayContaining([OWNER, candidate.id, unnamed.id]) + ); + expect(body).toHaveLength(3); + expect(body).toContainEqual({ + userId: unnamed.id, + displayName: null, + email: null, + avatarUrl: null, + }); + }); + + it.each(["off", "shadow", "on"])( + "denies a readable nonowner collaborator before the directory handler in %s mode", + async (mode) => { + await new SessionCollaboratorStore(env.DB).add("private-session", candidate.id, OWNER); + const directory = vi.spyOn(UserStore.prototype, "listCollaboratorCandidates"); + const response = await request( + "/sessions/private-session/collaborator-candidates", + { as: { userId: candidate.id, role: "member" } }, + mode + ); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ + code: "session_action_denied", + reason_code: "not_owner_or_lead", + }); + expect(directory).not.toHaveBeenCalled(); + expect( + ( + await request(`/sessions/private-session/collaborators/${OWNER}`, { + method: "PUT", + as: { userId: candidate.id, role: "member" }, + }) + ).status + ).toBe(403); + } + ); + + it.each(["off", "shadow", "on"])( + "conceals invisible and missing sessions with identical 404s and no directory handler in %s mode", + async (mode) => { + const directory = vi.spyOn(UserStore.prototype, "listCollaboratorCandidates"); + const bodies = []; + for (const id of ["private-session", "missing-session"]) { + const response = await request( + `/sessions/${id}/collaborator-candidates`, + { as: { userId: OUTSIDER, role: "member" } }, + mode + ); + expect(response.status).toBe(404); + bodies.push(await response.json()); + } + expect(bodies).toEqual([{ error: "Session not found" }, { error: "Session not found" }]); + expect(directory).not.toHaveBeenCalled(); + } + ); + + it("allows an audited workspace-owner break-glass picker read", async () => { + const response = await request("/sessions/private-session/collaborator-candidates", { + as: { userId: WORKSPACE_OWNER, role: "owner" }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual( + expect.arrayContaining([expect.objectContaining({ userId: candidate.id })]) + ); + const audit = await env.DB.prepare( + "SELECT actor_user_id_snapshot, resource_id FROM authorization_audit_events WHERE action = 'session.private_break_glass'" + ).all(); + expect(audit.results).toEqual([ + { actor_user_id_snapshot: WORKSPACE_OWNER, resource_id: "private-session" }, + ]); + }); + + it("refuses service principals even with a user actor", async () => { + await new UserStore(env.DB).createIdentity({ + userId: OWNER, + provider: "slack", + providerUserId: "U-OWNER", + }); + const directory = vi.spyOn(UserStore.prototype, "listCollaboratorCandidates"); + const response = await request("/sessions/private-session/collaborator-candidates", { + service: "slack-bot", + actor: "slack:U-OWNER", + }); + expect(response.status).toBe(403); + expect(directory).not.toHaveBeenCalled(); + }); + + it.each(["suspended", "unassigned"])("refuses an %s session owner", async (state) => { + await env.DB.prepare( + state === "suspended" + ? "UPDATE users SET suspended_at = 1 WHERE id = ?" + : "DELETE FROM user_role_assignments WHERE user_id = ?" + ) + .bind(OWNER) + .run(); + const directory = vi.spyOn(UserStore.prototype, "listCollaboratorCandidates"); + const response = await request("/sessions/private-session/collaborator-candidates"); + expect(response.status).toBe(403); + expect(directory).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/test/integration/create-pr.test.ts b/packages/control-plane/test/integration/create-pr.test.ts index d936f69298..4d856ea8ee 100644 --- a/packages/control-plane/test/integration/create-pr.test.ts +++ b/packages/control-plane/test/integration/create-pr.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { env } from "cloudflare:test"; -import type { SourceControlProvider } from "../../src/source-control"; +import { symmetricEncrypt } from "better-auth/crypto"; +import type { SourceControlAuthContext, SourceControlProvider } from "../../src/source-control"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { componentsOf, runInSessionDO } from "./session-do-access"; import { @@ -106,7 +107,7 @@ describe("POST /internal/create-pr", () => { const body = await res.json<{ error: string }>(); expect(body.error).toBe("User not found. Please re-authenticate."); }); - it("falls back to app auth when expired OAuth token cannot be refreshed", async () => { + it("ignores copied credentials when the author has no canonical Better Auth identity", async () => { const { stub } = await initSession({ userId: "user-1" }); await modelLegacyManualPushSession(stub); @@ -309,7 +310,7 @@ describe("POST /internal/create-pr", () => { async function installSingleRepoMockProvider( stub: DurableObjectStub, - onCreatePullRequest: () => void = () => undefined + onCreatePullRequest: (auth: SourceControlAuthContext) => void = () => undefined ) { await runInSessionDO(stub, (instance: SessionDO) => { const mockProvider = { @@ -323,8 +324,8 @@ describe("POST /internal/create-pr", () => { isPrivate: true, providerRepoId: 12345, }), - createPullRequest: async () => { - onCreatePullRequest(); + createPullRequest: async (auth: SourceControlAuthContext) => { + onCreatePullRequest(auth); return { id: 42, webUrl: "https://github.com/acme/web-app/pull/42", @@ -358,6 +359,106 @@ describe("POST /internal/create-pr", () => { }); } + describe("current browser PR credentials", () => { + let fetchSpy: { mockRestore(): void }; + beforeAll(() => { + const originalFetch = globalThis.fetch.bind(globalThis); + fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://api.github.com/user") { + return Response.json({ id: 583231, login: "octocat", name: "The Octocat" }); + } + if (url.startsWith("https://api.github.com/user/emails")) { + return Response.json([ + { email: "browser@test.local", primary: true, verified: true, visibility: null }, + ]); + } + return originalFetch(input, init); + }); + }); + afterAll(() => fetchSpy.mockRestore()); + it.each([ + { label: "without cached credentials", access: null, refresh: null, expiresAt: null }, + { + label: "with expired copied credentials", + access: "invalid-access", + refresh: "invalid-refresh", + expiresAt: 1, + }, + { + label: "with copied refresh only", + access: null, + refresh: "invalid-refresh", + expiresAt: null, + }, + { + label: "with unexpired copied credentials", + access: "invalid-access", + refresh: null, + expiresAt: null, + }, + ])("uses current browser OAuth when continuing a Linear session $label", async (legacy) => { + const browserUserId = "11111111111111111111111111111111"; + const sessionName = `linear-browser-pr-${crypto.randomUUID()}`; + const { stub } = await initNamedSession(sessionName, { + userId: "linear:creator", + canonicalUserId: browserUserId, + }); + await modelLegacyManualPushSession(stub); + const tokenResponse = await serviceFetch( + `https://test.local/sessions/${sessionName}/ws-token`, + { + method: "POST", + body: "{}", + } + ); + expect(tokenResponse.status).toBe(200); + const { participantId } = await tokenResponse.json<{ participantId: string }>(); + // Persisted metadata must not choose credentials or block canonical lookup. + await runInSessionDO(stub, (_instance: SessionDO, state) => { + state.storage.sql.exec( + "UPDATE participants SET scm_user_id = NULL, scm_access_token_encrypted = ?, scm_refresh_token_encrypted = ?, scm_token_expires_at = ? WHERE id = ?", + legacy.access, + legacy.refresh, + legacy.expiresAt, + participantId + ); + }); + const accessToken = await symmetricEncrypt({ + key: env.BROWSER_AUTH_SECRET!, + data: "browser-access-token", + }); + await env.DB.prepare( + "UPDATE user_identities SET access_token = ?, access_token_expires_at = ? WHERE user_id = ? AND provider = 'github'" + ) + .bind(accessToken, Date.now() + 3_600_000, browserUserId) + .run(); + // Model the browser prompt now being processed, not the Linear creator's prompt. + await seedMessage(stub, { + id: "browser-create-pr", + authorId: participantId, + content: "Open the PR", + source: "web", + status: "processing", + createdAt: Date.now(), + startedAt: Date.now(), + }); + let prAuth: SourceControlAuthContext | undefined; + await installSingleRepoMockProvider(stub, (auth) => { + prAuth = auth; + }); + + const response = await stub.fetch("http://internal/internal/create-pr", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Browser PR", body: "Continuation of the Linear task" }), + }); + + expect(response.status).toBe(200); + expect(prAuth).toEqual({ authType: "oauth", token: "browser-access-token" }); + }); + }); + it("reuses an existing open PR recorded for the session branch", async () => { const { stub } = await initSession({ userId: "user-1" }); await modelLegacyManualPushSession(stub); diff --git a/packages/control-plane/test/integration/d1-session-index.test.ts b/packages/control-plane/test/integration/d1-session-index.test.ts index b6198bd4f1..e8e5b1a4cd 100644 --- a/packages/control-plane/test/integration/d1-session-index.test.ts +++ b/packages/control-plane/test/integration/d1-session-index.test.ts @@ -6,6 +6,8 @@ import { SessionPullRequestStore } from "../../src/db/session-pull-request-store import type { SessionStatus } from "@open-inspect/shared/types/sessions"; import { cleanD1Tables } from "./cleanup"; +const internal = { kind: "internal", reason: "session index integration tests" } as const; + describe("D1 SessionIndexStore", () => { beforeEach(cleanD1Tables); @@ -323,7 +325,7 @@ describe("D1 SessionIndexStore", () => { }); } - const { sessions } = await store.list(); + const { sessions } = await store.list({ readScope: internal, mode: "on" }); expect(sessions.map((session) => session.id)).toEqual([ "summary-session-new", @@ -373,11 +375,11 @@ describe("D1 SessionIndexStore", () => { updatedAt: now - 1000, }); - const activeResult = await store.list({ status: "active" }); + const activeResult = await store.list({ status: "active", readScope: internal, mode: "on" }); expect(activeResult.sessions.length).toBe(1); expect(activeResult.sessions[0].id).toBe("session-active-1"); - const allResult = await store.list({}); + const allResult = await store.list({ readScope: internal, mode: "on" }); expect(allResult.sessions.length).toBe(2); }); @@ -403,7 +405,7 @@ describe("D1 SessionIndexStore", () => { const session = await store.get("session-with-effort"); expect(session!.reasoningEffort).toBe("high"); - const result = await store.list({}); + const result = await store.list({ readScope: internal, mode: "on" }); const listed = result.sessions.find((s) => s.id === "session-with-effort"); expect(listed!.reasoningEffort).toBe("high"); }); @@ -740,7 +742,7 @@ describe("D1 SessionIndexStore", () => { }); it("listByParent returns children newest-first", async () => { - const children = await store.listByParent(parentId); + const children = await store.listByParent(parentId, internal, "on"); expect(children).toHaveLength(2); expect(children[0].id).toBe(childId2); // newer expect(children[1].id).toBe(childId1); // older @@ -769,7 +771,7 @@ describe("D1 SessionIndexStore", () => { updatedAt: now, }); - const children = await store.listByParent(parentId); + const children = await store.listByParent(parentId, internal, "on"); expect(children.find((child) => child.id === childId1)?.pullRequestSummary).toEqual({ total: 1, @@ -782,7 +784,7 @@ describe("D1 SessionIndexStore", () => { }); it("listByParent returns empty array when no children exist", async () => { - const children = await store.listByParent("nonexistent-parent"); + const children = await store.listByParent("nonexistent-parent", internal, "on"); expect(children).toEqual([]); }); @@ -990,6 +992,8 @@ describe("D1 SessionIndexStore", () => { const result = await store.list({ createdByUserIds: ["user-1"], excludeAutomationLineage: true, + readScope: internal, + mode: "on", }); expect(result.sessions.map((session) => session.id)).toEqual(["manual-session"]); @@ -1028,10 +1032,16 @@ describe("D1 SessionIndexStore", () => { const filtered = await store.list({ createdByUserIds: ["user-1"], excludeAutomationLineage: true, + readScope: internal, + mode: "on", }); expect(filtered.sessions.map((session) => session.id)).toEqual(["manual-session"]); - const unfiltered = await store.list({ createdByUserIds: ["user-1"] }); + const unfiltered = await store.list({ + createdByUserIds: ["user-1"], + readScope: internal, + mode: "on", + }); expect(unfiltered.sessions.map((session) => session.id)).toEqual([ "auto-review", "manual-session", diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 6288f66c0b..ce02ee2037 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -383,9 +383,9 @@ export async function seedMessage( export async function initNamedSession( sessionName: string, overrides?: { - repoOwner?: string; - repoName?: string; - repoId?: number; + repoOwner?: string | null; + repoName?: string | null; + repoId?: number | null; defaultBranch?: string; repositories?: Array<{ repoOwner: string; @@ -404,7 +404,8 @@ export async function initNamedSession( spawnDepth?: number; sandboxSettings?: Record; providerAuth?: SessionModelProviderAuthInput[]; - } + }, + beforeInit?: (stub: DurableObjectStub) => Promise ) { const defaults = { sessionName, @@ -433,6 +434,7 @@ export async function initNamedSession( updatedAt: now, }); + await beforeInit?.(env.SESSION.get(env.SESSION.idFromName(sessionName))); return initNamedSessionDO(sessionName, doDefaults); } diff --git a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts index 64dca51239..ee47866570 100644 --- a/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts +++ b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts @@ -45,7 +45,7 @@ describe("Hono route catalog conformance", () => { }; }); - expect(manifest).toHaveLength(206); + expect(manifest).toHaveLength(216); // One compact, reviewable line per frozen route keeps the fixture explicit // without thousands of snapshot-only formatting lines. expect(manifest.map((entry) => JSON.stringify(entry))).toMatchSnapshot(); diff --git a/packages/control-plane/test/integration/image-build-lookup.test.ts b/packages/control-plane/test/integration/image-build-lookup.test.ts new file mode 100644 index 0000000000..4950acce04 --- /dev/null +++ b/packages/control-plane/test/integration/image-build-lookup.test.ts @@ -0,0 +1,85 @@ +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { createImageBuildLookup } from "../../src/image-builds/lookup"; +import { cleanD1Tables } from "./cleanup"; +import { environmentScope, seedEnvironment, seedImageRowForScope } from "./image-build-helpers"; + +const TEAM_A = "team_a"; +const TEAM_B = "team_b"; +const SESSION_ID = "image-lookup-session"; + +describe("session-scoped environment image lookup", () => { + beforeEach(async () => { + await cleanD1Tables(); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES (?, ?, ?, 1, 1), (?, ?, ?, 1, 1)" + ) + .bind(TEAM_A, "a", "A", TEAM_B, "b", "B") + .run(); + await new SessionIndexStore(env.DB).create({ + id: SESSION_ID, + ownerTeamId: TEAM_A, + visibility: "team", + title: null, + repoOwner: "acme", + repoName: "web", + baseBranch: "main", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + status: "active", + createdAt: 1, + updatedAt: 1, + }); + }); + + async function readyEnvironment(ownerTeamId: string | null) { + const environmentId = await seedEnvironment({ prebuildEnabled: true }); + await env.DB.prepare("UPDATE environments SET owner_team_id = ? WHERE id = ?") + .bind(ownerTeamId, environmentId) + .run(); + const scope = environmentScope(environmentId); + await seedImageRowForScope(scope, { + id: "ready-image", + status: "ready", + providerImageId: "artifact", + }); + return scope; + } + + it.each([TEAM_B, null])( + "withholds a team's environment image from owner %s", + async (sessionTeamId) => { + const scope = await readyEnvironment(TEAM_A); + await env.DB.prepare( + "UPDATE sessions SET owner_team_id = ?, visibility = 'workspace' WHERE id = ?" + ) + .bind(sessionTeamId, SESSION_ID) + .run(); + const lookup = createImageBuildLookup(env.DB, "modal", () => SESSION_ID); + expect(await lookup.getLatestReady(scope)).toBeNull(); + } + ); + + it("selects matching ownership and reevaluates it after a session moves", async () => { + const scope = await readyEnvironment(TEAM_A); + const lookup = createImageBuildLookup(env.DB, "modal", () => SESSION_ID); + expect(await lookup.getLatestReady(scope)).toMatchObject({ id: "ready-image" }); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ? WHERE id = ?") + .bind(TEAM_B, SESSION_ID) + .run(); + expect(await lookup.getLatestReady(scope)).toBeNull(); + }); + + it("does not require a team match for a workspace environment with no team layer", async () => { + const scope = await readyEnvironment(null); + const lookup = createImageBuildLookup(env.DB, "modal", () => SESSION_ID); + expect(await lookup.getLatestReady(scope)).toMatchObject({ id: "ready-image" }); + }); + + it("fails closed when the authoritative session row is missing", async () => { + const scope = await readyEnvironment(TEAM_A); + const lookup = createImageBuildLookup(env.DB, "modal", () => "missing-session"); + expect(await lookup.getLatestReady(scope)).toBeNull(); + }); +}); diff --git a/packages/control-plane/test/integration/pr-autofix-feedback-store.test.ts b/packages/control-plane/test/integration/pr-autofix-feedback-store.test.ts index 2d37b3b40b..aabc635696 100644 --- a/packages/control-plane/test/integration/pr-autofix-feedback-store.test.ts +++ b/packages/control-plane/test/integration/pr-autofix-feedback-store.test.ts @@ -122,4 +122,54 @@ describe("PrAutofixFeedbackStore", () => { expect(second.records.map((record) => record.feedbackKey)).toEqual(["github:pr_comment:1234"]); expect(second.nextCursor).toBeNull(); }); + + it("filters attached private sessions before paging but retains unattached ledger rows", async () => { + const store = new PrAutofixFeedbackStore(env.DB); + const publicFeedback = await store.receive(COMMENT_ENVELOPE, 1_000); + const privateFeedback = await store.receive( + { ...COMMENT_ENVELOPE, providerObject: { kind: "pr_comment", id: "private" } }, + 2_000 + ); + const unattached = await store.receive( + { ...COMMENT_ENVELOPE, providerObject: { kind: "pr_comment", id: "unattached" } }, + 3_000 + ); + for (const [id, visibility] of [ + ["public", "workspace"], + ["private", "private"], + ] as const) { + await new SessionIndexStore(env.DB).create({ + id, + ownerTeamId: null, + visibility, + title: null, + repoOwner: "acme", + repoName: "widgets", + model: "test-model", + reasoningEffort: null, + baseBranch: "main", + status: "active", + createdAt: 1_000, + updatedAt: 1_000, + }); + } + const context = { + artifactId: "artifact", + authorId: "7", + authorLogin: "alice", + authorType: "User", + feedbackUrl: "https://example.com", + }; + await store.attachContext(publicFeedback.feedbackKey, { ...context, sessionId: "public" }); + await store.attachContext(privateFeedback.feedbackKey, { ...context, sessionId: "private" }); + + const first = await store.listActivity({ limit: 1, cursor: null }); + expect(first.records.map(({ feedbackKey }) => feedbackKey)).toEqual([unattached.feedbackKey]); + expect(first.nextCursor).not.toBeNull(); + const second = await store.listActivity({ limit: 1, cursor: first.nextCursor }); + expect(second.records.map(({ feedbackKey }) => feedbackKey)).toEqual([ + publicFeedback.feedbackKey, + ]); + expect(second.nextCursor).toBeNull(); + }); }); diff --git a/packages/control-plane/test/integration/pull-request-analytics.test.ts b/packages/control-plane/test/integration/pull-request-analytics.test.ts index 433506fcf8..5bcb489bd3 100644 --- a/packages/control-plane/test/integration/pull-request-analytics.test.ts +++ b/packages/control-plane/test/integration/pull-request-analytics.test.ts @@ -6,16 +6,17 @@ */ import { beforeEach, describe, expect, it } from "vitest"; -import { env } from "cloudflare:test"; +import { createExecutionContext, env } from "cloudflare:test"; import type { AnalyticsPullRequestsResponse } from "@open-inspect/shared/types/analytics"; import type { SpawnSource } from "@open-inspect/shared/types/sessions"; import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; import { SessionPullRequestStore, type SessionPullRequestRecord, } from "../../src/db/session-pull-request-store"; import { cleanD1Tables } from "./cleanup"; -import { serviceFetch } from "./helpers"; +import { routeRequest, serviceFetch, serviceRequestHeaders } from "./helpers"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -30,12 +31,14 @@ async function seedSession(input: { createdAt: number; model?: string; harness?: "opencode" | "claude"; + ownerTeamId?: string | null; + visibility?: "workspace" | "team" | "private"; }): Promise { const store = new SessionIndexStore(env.DB); await store.create({ id: input.id, - ownerTeamId: null, - visibility: "workspace", + ownerTeamId: input.ownerTeamId ?? null, + visibility: input.visibility ?? "workspace", title: input.id, repoOwner: "acme", repoName: "web", @@ -90,6 +93,69 @@ function makePrRecord( describe("GET /analytics/pull-requests", () => { beforeEach(cleanD1Tables); + it("filters every PR cohort, inventory, merge and dimension cost by session visibility", async () => { + const member = "33333333333333333333333333333333"; + const now = Date.now(); + await serviceRequestHeaders("https://test.local/analytics/pull-requests", { + as: { userId: member, role: "member" }, + }); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('pr-allowed', 'pr-allowed', 'Allowed', 1, 1), ('pr-denied', 'pr-denied', 'Denied', 1, 1)" + ).run(); + await new TeamMembershipStore(env.DB).add("pr-allowed", member); + const prs = new SessionPullRequestStore(env.DB); + for (const [id, ownerTeamId, visibility, cost, prNumber] of [ + ["pr-workspace", null, "workspace", 1, 1], + ["pr-allowed-session", "pr-allowed", "team", 2, 2], + ["pr-denied-session", "pr-denied", "team", 4, 3], + ["pr-private-session", null, "private", 8, 4], + ] as const) { + await seedSession({ + id, + spawnSource: "user", + totalCost: cost, + createdAt: now - DAY_MS, + ownerTeamId, + visibility, + model: "openai/gpt-5", + }); + await prs.upsert( + makePrRecord({ + artifactId: `artifact-${id}`, + sessionId: id, + prNumber, + providerCreatedAt: now - DAY_MS / 2, + mergedAt: prNumber === 1 || prNumber === 3 ? now - DAY_MS / 4 : null, + lifecycleState: prNumber === 1 || prNumber === 3 ? "merged" : "open", + }) + ); + } + const url = "https://test.local/analytics/pull-requests?days=7"; + const response = await routeRequest( + new Request(url, { + headers: await serviceRequestHeaders(url, { as: { userId: member, role: "member" } }), + }), + { ...env, TEAMS_ENFORCEMENT: "on" }, + createExecutionContext() + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.funnel).toEqual({ created: 2, open: 1, draft: 0, merged: 1, closed: 0 }); + expect(body.prSessionCost).toBe(3); + expect(body.mergedInWindow).toBe(1); + expect(body.openInventory.total).toBe(1); + expect(body.timeseries.reduce((sum, point) => sum + point.created, 0)).toBe(2); + expect(body.timeseries.reduce((sum, point) => sum + point.merged, 0)).toBe(1); + expect(body.repos).toEqual([expect.objectContaining({ created: 2, merged: 1 })]); + expect(body.sources).toEqual([expect.objectContaining({ created: 2, merged: 1 })]); + expect(body.models).toEqual([ + expect.objectContaining({ created: 2, merged: 1, sessionCost: 3 }), + ]); + expect(body.harnesses).toEqual([ + expect.objectContaining({ created: 2, merged: 1, sessionCost: 3 }), + ]); + }); + it("reports the PR value stream scoped to PRs, never sessions", async () => { const prs = new SessionPullRequestStore(env.DB); const now = Date.now(); diff --git a/packages/control-plane/test/integration/route-admission-matrix.test.ts b/packages/control-plane/test/integration/route-admission-matrix.test.ts index be2a5e35ab..58ad6f2a9b 100644 --- a/packages/control-plane/test/integration/route-admission-matrix.test.ts +++ b/packages/control-plane/test/integration/route-admission-matrix.test.ts @@ -617,6 +617,48 @@ describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { } }); + it("admits team directory reads but conceals member tabs and denies capabilities in every mode", async () => { + const observed: string[] = []; + for (const mode of ["off", "shadow", "on"] as const) { + for (const route of routes.filter( + (item) => isTeamRoute(item) && item.authorization.kind === "active-user" + )) { + if (route.authorization.kind !== "active-user") throw new Error("Missing team policy"); + const requirement = route.authorization.allOf.find((entry) => entry.kind === "team"); + if (!requirement || requirement.kind !== "team") + throw new Error("Missing team requirement"); + const url = `${BASE}${materialize(route, { id: fixtures.teamId, userId: TEAM_VIEWER })}`; + const headers = await serviceRequestHeaders(url, { + method: route.method, + as: { userId: OTHER_MEMBER, role: "member" }, + }); + const response = await handle( + new Request(url, { method: route.method, headers }), + createCloudflareEnv({ ...env, TEAMS_ENFORCEMENT: mode }), + createExecutionContext() + ); + const expected = + requirement.need === "read" || requirement.need === "canJoin" + ? 200 + : requirement.need === "member" || requirement.need === "removeMember" + ? 404 + : 403; + const identity = `${route.method} ${route.path}`; + observed.push( + `${identity} ${mode}/nonmember=${response.status} auditAllowed=${route.authorization.auditAllowed}` + ); + expect(response.status, identity).toBe(expected); + if (expected === 403) + await expect(response.json()).resolves.toMatchObject({ + reason_code: "team_capability_required", + }); + if (expected === 404) + await expect(response.json()).resolves.toEqual({ error: "Team not found" }); + } + } + expect(observed).toMatchSnapshot(); + }); + it("conceals all team item routes from another team before any handler or DO call", async () => { const get = vi.fn(() => { throw new Error("Denied route reached the Durable Object"); @@ -654,6 +696,29 @@ describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { expect(observed).toMatchSnapshot(); }); + it("denies cross-target member removal before the handler in every mode", async () => { + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(fixtures.teamId, COLLABORATOR); + try { + const url = `${BASE}/teams/${fixtures.teamId}/members/${TEAM_VIEWER}`; + for (const mode of ["off", "shadow", "on"] as const) { + const headers = await serviceRequestHeaders(url, { + method: "DELETE", + as: { userId: COLLABORATOR, role: "member" }, + }); + const response = await handle( + new Request(url, { method: "DELETE", headers }), + createCloudflareEnv({ ...env, TEAMS_ENFORCEMENT: mode }), + createExecutionContext() + ); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ reason_code: "team_capability_required" }); + } + } finally { + await memberships.remove(fixtures.teamId, COLLABORATOR); + } + }); + it("reports action denials for a same-team Viewer and admits a private collaborator", async () => { const observed: string[] = []; for (const route of routes.filter( @@ -664,7 +729,7 @@ describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { (route.path.endsWith("/children") && route.method === "POST") ) continue; - const url = `${BASE}${materialize(route, { id: teamSessionId, childId: fixtures.sandboxSessionId })}`; + const url = `${BASE}${materialize(route, { id: teamSessionId, childId: fixtures.sandboxSessionId, userId: TEAM_VIEWER })}`; const headers = await serviceRequestHeaders(url, { method: route.method, as: { userId: TEAM_VIEWER, role: "viewer" }, @@ -683,19 +748,37 @@ describe("route admission sentinel", { timeout: MATRIX_TIMEOUT_MS }, () => { : 403; observed.push(`${route.method} ${route.path} same-team-viewer=${response.status}`); expect(response.status, `${route.method} ${route.path}`).toBe(expected); - if (expected === 403) - await expect(response.json()).resolves.toMatchObject({ reason_code: "missing_permission" }); + if (expected === 403) { + const ownershipOnly = + route.authorization.kind === "active-user" && + route.authorization.allOf.some( + (entry) => + entry.kind === "session" && + (entry.action === "changeVisibility" || entry.action === "manageCollaborators") + ); + await expect(response.json()).resolves.toMatchObject({ + reason_code: ownershipOnly ? "not_owner_or_lead" : "missing_permission", + }); + } } expect(observed).toMatchSnapshot(); const url = `${BASE}/sessions/${privateSessionId}/events`; const headers = await serviceRequestHeaders(url, { as: { userId: COLLABORATOR, role: "member" }, }); - const response = await handle( - new Request(url, { headers }), - createCloudflareEnv({ ...env, TEAMS_ENFORCEMENT: "on" }), - createExecutionContext() - ); + // Team-owned collaborator grants are honored only for current team members. + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(fixtures.teamId, COLLABORATOR); + let response: Response; + try { + response = await handle( + new Request(url, { headers }), + createCloudflareEnv({ ...env, TEAMS_ENFORCEMENT: "on" }), + createExecutionContext() + ); + } finally { + await memberships.remove(fixtures.teamId, COLLABORATOR); + } expect(response.status).toBe(200); expect([`collaborator-on-private=${response.status}`]).toMatchSnapshot(); }); diff --git a/packages/control-plane/test/integration/sandbox-lifecycle-harness.ts b/packages/control-plane/test/integration/sandbox-lifecycle-harness.ts index 3c4eb45471..c52bec7486 100644 --- a/packages/control-plane/test/integration/sandbox-lifecycle-harness.ts +++ b/packages/control-plane/test/integration/sandbox-lifecycle-harness.ts @@ -3,7 +3,8 @@ import { DEFAULT_LIFECYCLE_CONFIG, SandboxLifecycleManager, } from "../../src/sandbox/lifecycle/manager"; -import type { SandboxProvider } from "../../src/sandbox/provider"; +import { SandboxAccess } from "../../src/sandbox/lifecycle/sandbox-access"; +import { providerResumesAfterStop, type SandboxProvider } from "../../src/sandbox/provider"; import { LifecycleSessionContext } from "../../src/session/sandbox-lifecycle-adapters"; import { SandboxShutdownCoordinator } from "../../src/session/sandbox-shutdown"; import { @@ -42,6 +43,7 @@ export function realLifecycleHarness( const shutdownAnnouncements: object[] = []; const lifecycleAnnouncements: object[] = []; const queueAdmissions: string[] = []; + const log = createLogger("retention-test"); const transaction = (operation: () => T) => durableState.storage.transactionSync(operation); const messages = new MessageRepository( durableState.storage.sql, @@ -55,7 +57,7 @@ export function realLifecycleHarness( void task(); }, }, - createLogger("retention-test"), + log, messages, { broadcast: () => undefined, sendToSandbox: async () => undefined }, { notifyComplete: async () => undefined } as never, @@ -66,6 +68,26 @@ export function realLifecycleHarness( queueAdmissions.push(decision); options.onQueueAdmission?.(decision); }; + const broadcaster = { + broadcast: (message: object) => { + options.onLifecycleAnnouncement?.(message); + lifecycleAnnouncements.push(message); + }, + }; + const sockets = { + getSandboxWebSocket: () => + sandbox.getSandbox()?.active_socket_id === "" ? null : (options.socket ?? null), + getConnectedClientCount: () => 0, + sendToSandbox: () => false, + detachSandboxWebSocket: () => sandbox.revokeActiveSocketId(), + }; + const access = new SandboxAccess({ + storage: sandbox, + broadcaster, + sockets, + canResumeAfterStop: () => providerResumesAfterStop(provider), + getLogger: () => log, + }); const shutdown = new SandboxShutdownCoordinator({ store: options.store ?? new SandboxShutdownRepository(durableState.storage.sql), provider, @@ -91,25 +113,14 @@ export function realLifecycleHarness( }, onLifecycleChange: processQueue, reconcileStatusFromMessages: async () => undefined, - retireAccess: () => manager.retireShutdownAccess(), + retireAccess: () => access.retireShutdownAccess(), } as never); const manager = new SandboxLifecycleManager( provider, sandbox, sessionContext, - { - broadcast: (message) => { - options.onLifecycleAnnouncement?.(message); - lifecycleAnnouncements.push(message); - }, - }, - { - getSandboxWebSocket: () => - sandbox.getSandbox()?.active_socket_id === "" ? null : (options.socket ?? null), - getConnectedClientCount: () => 0, - sendToSandbox: () => false, - detachSandboxWebSocket: () => sandbox.revokeActiveSocketId(), - }, + broadcaster, + sockets, { schedule: async () => undefined, cancel: async () => undefined, @@ -117,6 +128,7 @@ export function realLifecycleHarness( }, { generateId: () => "integration-sandbox-token" }, shutdown, + access, { ...DEFAULT_LIFECYCLE_CONFIG, controlPlaneUrl: "https://control-plane.test", diff --git a/packages/control-plane/test/integration/sandbox-shutdown.test.ts b/packages/control-plane/test/integration/sandbox-shutdown.test.ts index ad69b252c8..73ee04e0be 100644 --- a/packages/control-plane/test/integration/sandbox-shutdown.test.ts +++ b/packages/control-plane/test/integration/sandbox-shutdown.test.ts @@ -5,10 +5,14 @@ import { DEFAULT_LIFECYCLE_CONFIG, SandboxLifecycleManager, } from "../../src/sandbox/lifecycle/manager"; +import { SandboxAccess } from "../../src/sandbox/lifecycle/sandbox-access"; import type { RestoreConfig, RestoreResult, SandboxProvider } from "../../src/sandbox/provider"; +import { providerResumesAfterStop } from "../../src/sandbox/provider"; +import { createLogger } from "../../src/logger"; import { EventRepository } from "../../src/session/event-repository"; import { MessageFailureService } from "../../src/session/message-failure-service"; import { MessageRepository } from "../../src/session/message-repository"; +import { SessionMessengerImpl } from "../../src/session/messenger"; import { SandboxRuntimeEventHandler } from "../../src/session/sandbox-events/runtime.handler"; import { SandboxShutdownCoordinator } from "../../src/session/sandbox-shutdown"; import { @@ -77,6 +81,65 @@ async function readShutdown(stub: DurableObjectStub): Promise { + it("retires access before detaching through the assembled shutdown callback without manager forwarding", async () => { + const { stub } = await initNamedSession(`shutdown-access-composition-${Date.now()}`); + await seedSandboxAuth(stub, { authToken: AUTH_TOKEN, sandboxId: SANDBOX_ID, status: "ready" }); + await seedShutdown(stub, { + generationReady: true, + runtimeReady: true, + lifecyclePolicy: "confirmed", + protocolVersion: 1, + }); + + await runInSessionDO(stub, async (instance) => { + const { sandboxRepository, lifecycleManager, wsManager } = componentsOf(instance); + for (const kind of ["codeServer", "vnc", "ttyd"] as const) { + await sandboxRepository.updateSandboxAccess(kind, `https://${kind}.example`, "secret"); + } + sandboxRepository.updateSandboxTunnelUrls({ "8080": "https://port.example" }); + const pair = new WebSocketPair(); + wsManager.acceptAndSetSandboxSocket(pair[1], SANDBOX_ID); + pair[0].accept(); + const clear = vi.spyOn(sandboxRepository, "clearSandboxAccess"); + const clearTunnels = vi.spyOn(sandboxRepository, "clearSandboxTunnelUrls"); + const broadcast = vi.spyOn(SessionMessengerImpl.prototype, "broadcast"); + const detach = vi.spyOn(wsManager, "detachSandboxSocket"); + const close = vi.spyOn(wsManager, "close"); + try { + expect("retireShutdownAccess" in lifecycleManager).toBe(false); + // With no provider handle, emergency shutdown retires access without outbound provider I/O. + await lifecycleManager.terminateUnresponsiveSandbox("stop_send_failed"); + + expect(clear.mock.calls).toEqual([["codeServer"], ["vnc"], ["ttyd"]]); + expect(clearTunnels).toHaveBeenCalledOnce(); + const notificationIndex = broadcast.mock.calls.findIndex( + ([message]) => message.type === "sandbox_access_changed" + ); + expect(notificationIndex).toBeGreaterThanOrEqual(0); + const notificationOrder = broadcast.mock.invocationCallOrder[notificationIndex]; + expect( + Math.max(...clear.mock.invocationCallOrder, ...clearTunnels.mock.invocationCallOrder) + ).toBeLessThan(notificationOrder); + expect(notificationOrder).toBeLessThan(detach.mock.invocationCallOrder[0]); + expect(detach).toHaveBeenCalledExactlyOnceWith(1000, "Sandbox state preserved"); + expect(close).toHaveBeenCalledExactlyOnceWith(pair[1], 1000, "Sandbox state preserved"); + expect(sandboxRepository.getSandbox()).toMatchObject({ + code_server_url: null, + code_server_password: null, + vnc_url: null, + vnc_password: null, + ttyd_url: null, + ttyd_token: null, + tunnel_urls: null, + active_socket_id: "", + }); + } finally { + vi.restoreAllMocks(); + pair[0].close(); + } + }); + }); + it("holds an interrupted legacy VM capture without recapture or retirement", async () => { const { stub } = await initNamedSession(`vm-capture-receipt-${Date.now()}`); await seedSandboxAuth(stub, { authToken: AUTH_TOKEN, sandboxId: SANDBOX_ID, status: "ready" }); @@ -663,6 +726,21 @@ describe("sandbox graceful shutdown wiring", () => { }, }; const sandbox = componentsOf(instance).sandboxRepository; + const broadcaster = { broadcast: () => undefined }; + const sockets = { + getSandboxWebSocket: () => null, + getConnectedClientCount: () => 0, + sendToSandbox: () => false, + detachSandboxWebSocket: () => undefined, + }; + const log = createLogger("shutdown-test"); + const access = new SandboxAccess({ + storage: sandbox, + broadcaster, + sockets, + canResumeAfterStop: () => providerResumesAfterStop(provider), + getLogger: () => log, + }); const shutdown = new SandboxShutdownCoordinator({ store: new SandboxShutdownRepository(durableState.storage.sql), provider, @@ -670,14 +748,14 @@ describe("sandbox graceful shutdown wiring", () => { session: { getSession: () => ({ id: "session-1", session_name: "legacy-session" }), }, - messenger: { broadcast: () => undefined }, + messenger: broadcaster, background: { submit: (task: () => Promise) => { void task(); }, }, onLifecycleChange: async () => undefined, - retireAccess: () => undefined, + retireAccess: () => access.retireShutdownAccess(), } as never); const manager = new SandboxLifecycleManager( provider, @@ -687,15 +765,12 @@ describe("sandbox graceful shutdown wiring", () => { getSessionRepositories: () => [], getUserEnvVars: async () => undefined, } as never, - { broadcast: () => undefined }, - { - getConnectedClientCount: () => 0, - sendToSandbox: () => false, - detachSandboxWebSocket: () => undefined, - } as never, + broadcaster, + sockets, { schedule: async () => undefined, cancel: async () => undefined } as never, { generateId: () => "generated-id" }, shutdown, + access, { ...DEFAULT_LIFECYCLE_CONFIG, controlPlaneUrl: "https://control-plane.test", diff --git a/packages/control-plane/test/integration/sandbox-state-retention.test.ts b/packages/control-plane/test/integration/sandbox-state-retention.test.ts index 4ce5622e32..05e6d2411b 100644 --- a/packages/control-plane/test/integration/sandbox-state-retention.test.ts +++ b/packages/control-plane/test/integration/sandbox-state-retention.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { env } from "cloudflare:test"; import type { SandboxProvider, SnapshotResult, RestoreResult } from "../../src/sandbox/provider"; +import { ModalSandboxProvider } from "../../src/sandbox/providers/modal-provider"; +import { createCloudflareEnv, type WorkerBindings } from "../../src/cloudflare/platform"; +import { createDurableObjectSessionPlatform } from "../../src/cloudflare/session-platform"; +import { createSessionRuntime } from "../../src/session/components"; import { SandboxShutdownRepository } from "../../src/session/sandbox-shutdown-repository"; import { cleanD1Tables } from "./cleanup"; import { initNamedSession, queryDO, seedSandboxAuth, seedMessage } from "./helpers"; @@ -41,6 +46,88 @@ function snapshotProvider(overrides: Partial = {}): SandboxProv } describe("sandbox state retention", () => { + it("reconstructs and rearms persisted rejected cleanup without releasing a durable shutdown hold", async () => { + const stub = await servingSession(); + await runInSessionDO(stub, async (instance, durableState) => { + const background: Promise[] = []; + const platform = createDurableObjectSessionPlatform(durableState, env.DB); + platform.createBackgroundTasks = () => ({ + submit: (task) => { + background.push(task()); + }, + }); + const runtimeEnv = createCloudflareEnv((instance as unknown as { env: WorkerBindings }).env); + const initial = createSessionRuntime(platform, runtimeEnv); + const row = initial.internals.sandboxRepository.getSandbox()!; + const generation = { sandboxId: row.modal_sandbox_id!, createdAt: row.created_at }; + expect( + initial.internals.sandboxRepository.rejectProviderStartup(generation, "rejected-source") + ).toBe("failed"); + const shutdownStore = new SandboxShutdownRepository(durableState.storage.sql); + shutdownStore.write({ + phase: "unknown", + generation, + provider: "modal", + providerObjectId: "rejected-source", + lifetimeKind: "none", + expiresAtMs: null, + drainAtMs: null, + generationReady: false, + receipt: { + kind: "snapshot", + artifactId: "saved-filesystem", + provider: "modal", + runtimeVersion: "v67-legacy", + savedAtMs: Date.now(), + }, + }); + const held = shutdownStore.read(); + durableState.storage.sql.exec("DELETE FROM session_alarm_state"); + await durableState.storage.deleteAlarm(); + const stop = vi + .spyOn(ModalSandboxProvider.prototype, "stopSandbox") + .mockRejectedValue(new Error("provider unavailable")); + try { + const restarted = createSessionRuntime(platform, runtimeEnv); + const manager = restarted.internals.lifecycleManager; + const beforeRearm = Date.now(); + restarted.alarms.rehydrate(); + await Promise.all(background); + const [deadline] = durableState.storage.sql + .exec("SELECT pending_deadline FROM session_alarm_state") + .toArray(); + expect(deadline.pending_deadline).toBeGreaterThanOrEqual(beforeRearm + 30_000); + expect(deadline.pending_deadline).toBeLessThanOrEqual(Date.now() + 30_000); + expect(stop).not.toHaveBeenCalled(); + expect(manager.mayProcessQueuedWork()).toBe(false); + expect(await manager.handleShutdownAlarm()).toBe("hold_watchdogs"); + expect(stop).toHaveBeenCalledOnce(); + expect(restarted.internals.sandboxRepository.getSandbox()).toMatchObject({ + modal_object_id: "rejected-source", + startup_rejected: 1, + fenced: 1, + auth_token_hash: "", + active_socket_id: "", + }); + expect(shutdownStore.read()).toEqual(held); + stop.mockResolvedValue({ success: true }); + expect(await manager.handleShutdownAlarm()).toBe("hold_watchdogs"); + expect(restarted.internals.sandboxRepository.getSandbox()).toMatchObject({ + modal_object_id: null, + startup_rejected: 1, + fenced: 1, + status: "failed", + }); + expect(shutdownStore.read()).toEqual(held); + expect(await manager.handleShutdownAlarm()).toBe("hold_watchdogs"); + expect(stop).toHaveBeenCalledTimes(2); + expect(shutdownStore.read()).toEqual(held); + } finally { + stop.mockRestore(); + } + }); + }); + it.each([ ["snapshot", "access"], ["snapshot", "announcement"], @@ -51,14 +138,16 @@ describe("sandbox state retention", () => { async (kind, failure) => { const stub = await servingSession(); await runInSessionDO(stub, async (instance, durableState) => { - const startup = vi.fn(async (): Promise => ({ - success: true, - sandboxId: "recovered-sandbox", - providerObjectId: "recovered-source", - lifetime: { kind: "none", observedAtMs: Date.now() }, - codeServerUrl: "https://preview.test", - codeServerPassword: "preview-secret", - })); + const startup = vi.fn( + async (): Promise => ({ + success: true, + sandboxId: "recovered-sandbox", + providerObjectId: "recovered-source", + lifetime: { kind: "none", observedAtMs: Date.now() }, + codeServerUrl: "https://preview.test", + codeServerPassword: "preview-secret", + }) + ); const provider = snapshotProvider({ capabilities: { supportsSandboxTimeout: true, diff --git a/packages/control-plane/test/integration/scheduler-slack-events.test.ts b/packages/control-plane/test/integration/scheduler-slack-events.test.ts index cd6fee82ab..a6172fa09d 100644 --- a/packages/control-plane/test/integration/scheduler-slack-events.test.ts +++ b/packages/control-plane/test/integration/scheduler-slack-events.test.ts @@ -1,6 +1,15 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { env } from "cloudflare:test"; -import { seedActiveUser, sqlDatabase } from "./helpers"; +import { + collectMessages, + initNamedSession, + openSandboxWs, + seedSandboxAuth, + waitForSandboxStatus, + seedActiveUser, + sqlDatabase, +} from "./helpers"; +import { getUserAuth } from "../../src/auth/user/runtime"; import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; import { SlackChannelStore } from "../../src/db/slack-channel-store"; import type { SlackAutomationEvent } from "@open-inspect/shared/triggers"; @@ -217,6 +226,104 @@ describe("Scheduler slack event handling (integration)", () => { ).toBeUndefined(); }); + afterEach(() => vi.restoreAllMocks()); + + it.each(["unlinked", "ambiguous", "unavailable", "missing-login"])( + "clears attribution on successive Slack follow-ups when current GitHub attribution is %s", + async (scenario) => { + const store = new AutomationStore(env.DB); + const automationId = await seedSlackAutomation(store); + await env.DB.prepare( + `INSERT INTO user_identities + (id, user_id, provider, provider_user_id, provider_login, provider_issuer, created_at, updated_at) + VALUES ('actor-github', 'slack-actor-1', 'github', '42', 'reviewer', 'https://github.com', 1, 1)` + ).run(); + const name = `slack-attribution-${crypto.randomUUID()}`; + const { stub } = await initNamedSession(name, { + repoOwner: null, + repoName: null, + repoId: null, + userId: "user-1", + canonicalUserId: "user-1", + }); + const concurrencyKey = `slack:C1:${name}`; + await seedRun(makeRunRow(automationId, { session_id: name, status: "running" }), { + concurrencyKey, + }); + await seedSandboxAuth(stub, { authToken: "slack-sandbox-token", sandboxId: name }); + const { ws: sandbox } = await openSandboxWs(name, { + authToken: "slack-sandbox-token", + sandboxId: name, + }); + expect(sandbox).not.toBeNull(); + sandbox!.accept(); + try { + sandbox!.send( + JSON.stringify({ type: "ready", sandboxId: name, timestamp: Date.now() / 1000 }) + ); + await waitForSandboxStatus(stub, "ready"); + const firstCommands = collectMessages(sandbox!, { + until: (message) => message.type === "prompt", + }); + expect( + await sendEvent( + makeSlackEvent({ text: "first follow-up", concurrencyKey, threadTs: name }) + ) + ).toMatchObject({ steered: 1, triggered: 0 }); + const first = (await firstCommands).find((message) => message.type === "prompt"); + expect(first?.author).toEqual({ + userId: "slack:U1", + gitIdentity: { + mode: "attributed-user", + name: "Integration User", + email: "42+reviewer@users.noreply.github.com", + }, + }); + if (scenario === "unlinked") { + await env.DB.prepare("DELETE FROM user_identities WHERE id = 'actor-github'").run(); + } else if (scenario === "ambiguous") { + await env.DB.prepare( + `INSERT INTO user_identities + (id, user_id, provider, provider_user_id, provider_login, provider_issuer, created_at, updated_at) + VALUES ('actor-github-other', 'slack-actor-1', 'github', '43', 'other', 'https://github.com', 1, 1)` + ).run(); + } else { + await env.DB.prepare( + "UPDATE user_identities SET provider_login = NULL WHERE id = 'actor-github'" + ).run(); + if (scenario === "unavailable") { + vi.spyOn( + getUserAuth(createCloudflareEnv(env), env.DB).api, + "getAccessToken" + ).mockRejectedValue(new Error("Grant temporarily unavailable")); + } + } + const secondCommands = collectMessages(sandbox!, { + until: (message) => message.type === "prompt", + }); + expect( + await sendEvent( + makeSlackEvent({ text: "second follow-up", concurrencyKey, threadTs: name }) + ) + ).toMatchObject({ steered: 1, triggered: 0 }); + sandbox!.send( + JSON.stringify({ + type: "execution_complete", + messageId: first!.messageId, + success: true, + sandboxId: name, + timestamp: Date.now() / 1000, + }) + ); + const second = (await secondCommands).find((message) => message.type === "prompt"); + expect(second?.author).toEqual({ userId: "slack:U1", gitIdentity: { mode: "agent-only" } }); + expect(await fetchRuns(automationId)).toHaveLength(1); + } finally { + sandbox!.close(); + } + } + ); + it.each([ [ "suspended", diff --git a/packages/control-plane/test/integration/service-auth.test.ts b/packages/control-plane/test/integration/service-auth.test.ts index ec8b78b1db..22cad16d0d 100644 --- a/packages/control-plane/test/integration/service-auth.test.ts +++ b/packages/control-plane/test/integration/service-auth.test.ts @@ -326,6 +326,26 @@ describe("sig1 service-credential authentication", () => { }) ); + const ownList = await signedFetch({ + service: "slack-bot", + method: "GET", + url: "https://test.local/sessions?createdBy=me", + actor: "slack:U0001", + }); + expect(ownList.status).toBe(200); + await expect(ownList.json()).resolves.toMatchObject({ + sessions: [expect.objectContaining({ id: createdBody.sessionId, userId: identity!.userId })], + }); + + const otherActorList = await signedFetch({ + service: "slack-bot", + method: "GET", + url: "https://test.local/sessions?createdBy=me", + actor: "slack:U0002", + }); + expect(otherActorList.status).toBe(200); + await expect(otherActorList.json()).resolves.toMatchObject({ sessions: [] }); + const collaboratorList = await signedFetch({ service: "slack-bot", method: "GET", diff --git a/packages/control-plane/test/integration/session-access-routes.test.ts b/packages/control-plane/test/integration/session-access-routes.test.ts index d3cdc3f94b..cb39e1f222 100644 --- a/packages/control-plane/test/integration/session-access-routes.test.ts +++ b/packages/control-plane/test/integration/session-access-routes.test.ts @@ -6,6 +6,7 @@ import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; import { cleanD1Tables } from "./cleanup"; import { initSession, + queryDO, routeRequest, seedActiveUser, serviceRequestHeaders, @@ -22,7 +23,7 @@ async function fetchMode( mode: string, options: { method?: string; - as?: { userId: string; role: "owner" | "member" | "viewer" }; + as?: { userId: string; role: "owner" | "administrator" | "member" | "viewer" }; body?: string; service?: "linear-bot"; } = {} @@ -69,7 +70,7 @@ describe("HTTP session access by enforcement mode", () => { await seedActiveUser(CREATOR); }); - async function session(visibility: "team" | "private") { + async function session(visibility: "team" | "private" | "workspace") { const team = await new TeamStore(env.DB).create({ slug: `access-${crypto.randomUUID()}`, name: "Access Team", @@ -80,7 +81,7 @@ describe("HTTP session access by enforcement mode", () => { await env.DB.prepare("UPDATE sessions SET owner_team_id = ?, visibility = ? WHERE id = ?") .bind(team.id, visibility, sessionName) .run(); - return { sessionName, team }; + return { sessionName, team, stub }; } it("conceals a team session on read and token mint when enforcement is on", async () => { @@ -127,7 +128,7 @@ describe("HTTP session access by enforcement mode", () => { ).toBe(200); }); - it("writes one shadow audit row with the real status for reads and mutations", async () => { + it("audits shadow read observations and enforced team action denials with the real status", async () => { const { sessionName } = await session("team"); for (const [path, method, body] of [ [`/sessions/${sessionName}`, "GET", undefined], @@ -140,77 +141,162 @@ describe("HTTP session access by enforcement mode", () => { }); const rows = ( await env.DB.prepare( - "SELECT reason_code, metadata_json FROM authorization_audit_events WHERE request_id = ? AND action = 'authorization.request_allowed'" + "SELECT action, reason_code, metadata_json FROM authorization_audit_events WHERE request_id = ?" ) .bind(response.headers.get("x-request-id")) .all() ).results; expect(response.status).toBe(method === "GET" ? 200 : 403); expect(rows).toHaveLength(1); - expect(rows[0].reason_code).toBe("shadow_denied:not_member"); + const reason = method === "GET" ? "shadow_denied:not_member" : "not_member"; + expect(rows[0].reason_code).toBe(reason); + expect(rows[0].action).toBe( + method === "GET" ? "authorization.request_allowed" : "authorization.request_denied" + ); expect(JSON.parse(String(rows[0].metadata_json))).toMatchObject({ httpStatus: response.status, - responseCode: "shadow_denied:not_member", + responseCode: reason, }); } }); - it("records batch shadow denials in its one response-time audit row", async () => { - const { sessionName } = await session("team"); - await env.DB.batch([ - env.DB.prepare( - "INSERT INTO roles (id, key, name, normalized_name, is_system) VALUES ('role_batch_shadow', NULL, 'Batch Shadow', 'batch shadow', 0)" - ), - env.DB.prepare( - `INSERT INTO role_permissions (role_id, permission_id) - VALUES ('role_batch_shadow', 'sessions.bulk_archive'), - ('role_batch_shadow', 'sessions.read'), - ('role_batch_shadow', 'sessions.lifecycle')` - ), - env.DB.prepare( - "UPDATE user_role_assignments SET role_id = 'role_batch_shadow' WHERE user_id = ?" - ).bind(MEMBER), - ]); - const response = await fetchMode("/sessions/batch-archive", "shadow", { - method: "POST", - as: { userId: MEMBER, role: "member" }, - body: JSON.stringify({ sessionIds: [sessionName] }), - }); - const rows = ( - await env.DB.prepare( - "SELECT reason_code, metadata_json FROM authorization_audit_events WHERE request_id = ? AND action = 'authorization.request_allowed'" - ) - .bind(response.headers.get("x-request-id")) - .all() - ).results; - expect(rows).toHaveLength(1); - expect(rows[0].reason_code).toBe("shadow_denied:batch"); - expect(JSON.parse(String(rows[0].metadata_json))).toMatchObject({ - httpStatus: response.status, - responseCode: "shadow_denied:batch", - shadowDenials: [{ sessionId: sessionName, reason: "not_member" }], - }); - }); - - it("allows legacy deletion in shadow and off while shadow audits the ownership denial", async () => { + it("enforces team deletion ownership in shadow and off", async () => { const as = { userId: MEMBER, role: "member" } as const; const shadow = await session("team"); await new TeamMembershipStore(env.DB).add(shadow.team.id, MEMBER); expect( (await fetchMode(`/sessions/${shadow.sessionName}`, "shadow", { method: "DELETE", as })) .status - ).toBe(200); + ).toBe(403); expect( - (await auditRows("authorization.request_allowed")).filter( - (row) => row.reason_code === "shadow_denied:not_owner_or_lead" + (await auditRows("authorization.request_denied")).filter( + (row) => row.reason_code === "not_owner_or_lead" ) ).toHaveLength(1); const off = await session("team"); expect( (await fetchMode(`/sessions/${off.sessionName}`, "off", { method: "DELETE", as })).status - ).toBe(200); + ).toBe(403); }); + it.each(["off", "shadow", "on"] as const)( + "requires current membership for workspace-visible team interaction in %s mode", + async (mode) => { + const { sessionName, team, stub } = await session("workspace"); + await queryDO( + stub, + "UPDATE sandbox SET status = 'ready', code_server_url = ?, vnc_url = ?, ttyd_url = ?, tunnel_urls = ?, modal_object_id = ?", + "https://code.example.test", + "https://vnc.example.test", + "https://terminal.example.test", + JSON.stringify({ "3000": "https://app.example.test" }), + "team-access-sandbox" + ); + for (const as of [ + { userId: MEMBER, role: "member" }, + { userId: CREATOR, role: "member" }, + { userId: OWNER, role: "owner" }, + { userId: "44444444444444444444444444444444", role: "administrator" }, + ] as const) { + for (const [path, method] of [ + ["prompt", "POST"], + ["sandbox-access", "GET"], + ] as const) { + const response = await fetchMode(`/sessions/${sessionName}/${path}`, mode, { + as, + method, + body: method === "POST" ? JSON.stringify({ content: "Denied" }) : undefined, + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Forbidden", + code: "session_action_denied", + reason_code: "not_member", + }); + } + const snapshot = await fetchMode(`/sessions/${sessionName}`, mode, { as }); + expect(snapshot.status).toBe(200); + const body = await snapshot.json(); + expect(body).toMatchObject({ + session: { + capabilities: { + canRead: true, + canCollaborate: false, + canManageLifecycle: false, + canDelete: false, + canSandbox: false, + canManageCollaborators: false, + canChangeVisibility: false, + }, + }, + }); + for (const field of [ + "codeServerUrl", + "sandboxDashboardUrl", + "vncUrl", + "ttydUrl", + "tunnelUrls", + ]) { + expect(body).not.toHaveProperty(`session.${field}`); + } + } + await new TeamMembershipStore(env.DB).add(team.id, MEMBER); + const as = { userId: MEMBER, role: "member" } as const; + expect( + (await fetchMode(`/sessions/${sessionName}/sandbox-access`, mode, { as })).status + ).toBe(200); + const prompt = await fetchMode(`/sessions/${sessionName}/prompt`, mode, { + as, + method: "POST", + body: JSON.stringify({ content: "Allowed" }), + }); + expect(prompt.status).toBe(200); + expect( + await (await fetchMode(`/sessions/${sessionName}`, mode, { as })).json() + ).toMatchObject({ + session: { + capabilities: { canCollaborate: true, canManageLifecycle: true, canSandbox: true }, + }, + }); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "refuses a removed owner's private team prompt and honors collaborators only as members in %s mode", + async (mode) => { + const { sessionName, team } = await session("private"); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, CREATOR); + await memberships.remove(team.id, CREATOR); + const prompt = await fetchMode(`/sessions/${sessionName}/prompt`, mode, { + as: { userId: CREATOR, role: "member" }, + method: "POST", + body: JSON.stringify({ content: "Denied" }), + }); + expect(prompt.status).toBe(403); + expect(await prompt.json()).toMatchObject({ reason_code: "not_member" }); + const collaborators = new SessionCollaboratorStore(env.DB); + await collaborators.add(sessionName, MEMBER, CREATOR); + const as = { userId: MEMBER, role: "member" } as const; + const collaboratorPrompt = await fetchMode(`/sessions/${sessionName}/prompt`, mode, { + as, + method: "POST", + body: JSON.stringify({ content: "Denied" }), + }); + expect(collaboratorPrompt.status).toBe(404); + const selfRemove = () => + fetchMode(`/sessions/${sessionName}/collaborators/${MEMBER}`, mode, { + as, + method: "DELETE", + }); + expect((await selfRemove()).status).toBe(404); + expect(await collaborators.listUserIds(sessionName)).toEqual([MEMBER]); + await memberships.add(team.id, MEMBER); + expect((await selfRemove()).status).toBe(200); + expect(await collaborators.listUserIds(sessionName)).toEqual([]); + } + ); + it("conceals another team's export even when the viewer holds sessions.export", async () => { const { sessionName } = await session("team"); await env.DB.batch([ @@ -255,7 +341,23 @@ describe("HTTP session access by enforcement mode", () => { expect(await auditRows("session.private_break_glass")).toHaveLength(1); }); - it("does not query memberships in off mode", async () => { + it.each(["off", "shadow", "on"] as const)( + "refuses an Owner break-glass prompt on a private session in %s mode", + async (mode) => { + const { sessionName } = await session("private"); + const response = await fetchMode(`/sessions/${sessionName}/prompt`, mode, { + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Forbidden", + code: "session_action_denied", + reason_code: "not_member", + }); + } + ); + + it("loads memberships for snapshot capabilities even in off mode", async () => { const { sessionName } = await session("team"); const list = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); try { @@ -266,19 +368,19 @@ describe("HTTP session access by enforcement mode", () => { }) ).status ).toBe(200); - expect(list).not.toHaveBeenCalled(); + expect(list).toHaveBeenCalledOnce(); } finally { list.mockRestore(); } }); - it("loads collaborators but not memberships for an off-mode private session", async () => { + it("loads collaborators and memberships for an off-mode private team session", async () => { const { sessionName } = await session("private"); const members = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); const collaborators = vi.spyOn(SessionCollaboratorStore.prototype, "listUserIds"); try { expect((await fetchMode(`/sessions/${sessionName}`, "off")).status).toBe(200); - expect(members).not.toHaveBeenCalled(); + expect(members).toHaveBeenCalledOnce(); expect(collaborators).toHaveBeenCalledOnce(); } finally { members.mockRestore(); @@ -369,11 +471,6 @@ describe("HTTP session access by enforcement mode", () => { (mode === "on" ? [workspace.sessionName] : [workspace.sessionName, team.sessionName]).sort() ); } - expect( - (await auditRows("authorization.request_allowed")).filter( - (row) => row.reason_code === "shadow_denied:batch" - ) - ).toHaveLength(1); }); it("audits both private reads when an Owner accesses a private child", async () => { @@ -449,7 +546,7 @@ describe("HTTP session access by enforcement mode", () => { }); it.each(["off", "shadow", "on"] as const)( - "preserves bulk-only custom-role archiving in %s mode", + "refuses bulk-only custom-role team archiving without lifecycle permission in %s mode", async (mode) => { const { sessionName, team } = await session("team"); await new TeamMembershipStore(env.DB).add(team.id, MEMBER); @@ -474,18 +571,26 @@ describe("HTTP session access by enforcement mode", () => { body: JSON.stringify({ sessionIds: [sessionName] }), }); expect(response.status).toBe(200); - expect(await response.json()).toEqual( - mode === "on" - ? { results: [], skipped: [{ sessionId: sessionName, reason: "missing_permission" }] } - : { results: [{ sessionId: sessionName, outcome: "archived" }], skipped: [] } - ); - if (mode === "shadow") { - expect( - (await auditRows("authorization.request_allowed")).some( - (row) => row.reason_code === "shadow_denied:batch" - ) - ).toBe(true); - } + expect(await response.json()).toEqual({ + results: [], + skipped: [{ sessionId: sessionName, reason: "missing_permission" }], + }); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "reports team membership, not permission, when a workspace owner batch-archives a team session in %s mode", + async (mode) => { + const { sessionName } = await session("workspace"); + const response = await fetchMode("/sessions/batch-archive", mode, { + method: "POST", + body: JSON.stringify({ sessionIds: [sessionName] }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + results: [], + skipped: [{ sessionId: sessionName, reason: "not_member" }], + }); } ); diff --git a/packages/control-plane/test/integration/session-collaborators.test.ts b/packages/control-plane/test/integration/session-collaborators.test.ts new file mode 100644 index 0000000000..771020a5a9 --- /dev/null +++ b/packages/control-plane/test/integration/session-collaborators.test.ts @@ -0,0 +1,65 @@ +import { env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { MAX_D1_QUERY_PARAMETERS } from "../../src/db/query-limits"; +import { cleanD1Tables } from "./cleanup"; + +describe("SessionCollaboratorStore bulk lookups", () => { + beforeEach(async () => { + await cleanD1Tables(); + await env.DB.prepare( + "INSERT INTO users (id, created_at, updated_at) VALUES ('owner', 1, 1), ('collaborator', 1, 1)" + ).run(); + await env.DB.prepare( + `INSERT INTO sessions (id, user_id, visibility, created_at, updated_at) + VALUES ('private', 'owner', 'private', 1, 1), ('workspace', 'owner', 'workspace', 1, 1)` + ).run(); + const store = new SessionCollaboratorStore(env.DB); + await store.add("private", "collaborator", "owner"); + await store.add("workspace", "collaborator", "owner"); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("returns collaborators for every requested session by default", async () => { + expect( + await new SessionCollaboratorStore(env.DB).listForSessions(["private", "workspace"]) + ).toEqual( + new Map([ + ["private", ["collaborator"]], + ["workspace", ["collaborator"]], + ]) + ); + }); + + it("filters by persisted visibility when privateOnly is enabled", async () => { + const store = new SessionCollaboratorStore(env.DB); + expect(await store.listForSessions(["private", "workspace"], { privateOnly: true })).toEqual( + new Map([["private", ["collaborator"]]]) + ); + await env.DB.prepare("UPDATE sessions SET visibility = 'workspace' WHERE id = 'private'").run(); + expect(await store.listForSessions(["private", "workspace"], { privateOnly: true })).toEqual( + new Map() + ); + expect(await store.listUserIds("private")).toEqual(["collaborator"]); + }); + + it("preserves private-only filtering across query-sized batches", async () => { + const ids = [ + ...Array.from({ length: MAX_D1_QUERY_PARAMETERS }, (_, index) => `missing-${index}`), + "private", + "workspace", + ]; + expect( + await new SessionCollaboratorStore(env.DB).listForSessions(ids, { privateOnly: true }) + ).toEqual(new Map([["private", ["collaborator"]]])); + }); + + it("does not query the database for an empty input", async () => { + const prepare = vi.spyOn(env.DB, "prepare"); + expect( + await new SessionCollaboratorStore(env.DB).listForSessions([], { privateOnly: true }) + ).toEqual(new Map()); + expect(prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/test/integration/session-components.test.ts b/packages/control-plane/test/integration/session-components.test.ts index 325cb248e3..f389909ae4 100644 --- a/packages/control-plane/test/integration/session-components.test.ts +++ b/packages/control-plane/test/integration/session-components.test.ts @@ -1,10 +1,17 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { env } from "cloudflare:test"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { createCloudflareEnv, type WorkerBindings } from "../../src/cloudflare/platform"; import type { Env } from "../../src/types"; import { createSessionRuntime } from "../../src/session/components"; import { createDurableObjectSessionPlatform } from "../../src/cloudflare/session-platform"; +import { SandboxLifecycleManager } from "../../src/sandbox/lifecycle/manager"; +import { SandboxAccess } from "../../src/sandbox/lifecycle/sandbox-access"; +import { SessionMessageQueue } from "../../src/session/message-queue"; +import { SessionMessengerImpl } from "../../src/session/messenger"; +import { SandboxShutdownCoordinator } from "../../src/session/sandbox-shutdown"; +import { SessionStatusService } from "../../src/session/session-status-service"; +import { SessionWebSocketManagerImpl } from "../../src/session/websocket-manager"; import { componentsOf, runInSessionDO } from "./session-do-access"; /** @@ -40,6 +47,58 @@ describe("createSessionRuntime", () => { expect(await buildWithEnv({})).toBeNull(); }); + it("does not invoke runtime operations during construction", async () => { + const stub = env.SESSION.get(env.SESSION.idFromName(`components-inert-${crypto.randomUUID()}`)); + await runInSessionDO(stub, (instance, state) => { + componentsOf(instance); + const platform = createDurableObjectSessionPlatform(state, env.DB); + const runtimeOperation = vi.fn(() => { + throw new Error("Runtime operation invoked during construction"); + }); + const autoResponse = vi.spyOn(platform.sockets, "setAutoResponse"); + platform.sockets.adopt = runtimeOperation; + platform.sockets.tags = runtimeOperation; + platform.sockets.sockets = runtimeOperation; + platform.alarmStore = { + getAlarm: runtimeOperation, + setAlarm: runtimeOperation, + deleteAlarm: runtimeOperation, + }; + platform.createBackgroundTasks = () => ({ submit: runtimeOperation }); + const prototypes = [ + SandboxAccess.prototype, + SandboxLifecycleManager.prototype, + SandboxShutdownCoordinator.prototype, + SessionMessageQueue.prototype, + SessionMessengerImpl.prototype, + SessionStatusService.prototype, + SessionWebSocketManagerImpl.prototype, + ]; + try { + for (const prototype of prototypes) { + for (const [name, descriptor] of Object.entries( + Object.getOwnPropertyDescriptors(prototype) + )) { + if (name === "constructor" || typeof descriptor.value !== "function") continue; + vi.spyOn( + prototype as unknown as Record unknown>, + name + ).mockImplementation(runtimeOperation); + } + } + const runtime = createSessionRuntime( + platform, + createCloudflareEnv((instance as unknown as { env: WorkerBindings }).env) + ); + expect(runtime.internals.lifecycleManager).toBeInstanceOf(SandboxLifecycleManager); + expect(autoResponse).toHaveBeenCalledOnce(); + expect(runtimeOperation).not.toHaveBeenCalled(); + } finally { + vi.restoreAllMocks(); + } + }); + }); + it("fails at graph build on an unsupported SANDBOX_PROVIDER", async () => { const error = await buildWithEnv({ SANDBOX_PROVIDER: "not-a-real-sandbox-provider" }); expect(error).toMatch(/SANDBOX_PROVIDER/); diff --git a/packages/control-plane/test/integration/session-discovery.test.ts b/packages/control-plane/test/integration/session-discovery.test.ts index 6b8e7c14dc..386e4404a1 100644 --- a/packages/control-plane/test/integration/session-discovery.test.ts +++ b/packages/control-plane/test/integration/session-discovery.test.ts @@ -6,6 +6,8 @@ import { SessionIndexStore, type ListSessionsOptions } from "../../src/db/sessio import type { SqlDatabase } from "../../src/db/sql-database"; import { cleanD1Tables } from "./cleanup"; +type TestListOptions = Omit; + interface SeedSession { id: string; title?: string | null; @@ -22,6 +24,7 @@ interface SeedSession { const ALICE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const BOB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const internal = { kind: "internal", reason: "session discovery integration tests" } as const; async function seed(store: SessionIndexStore, session: SeedSession): Promise { const repositories = session.repositories?.map((repository) => ({ @@ -50,20 +53,20 @@ async function seed(store: SessionIndexStore, session: SeedSession): Promise { - const result = await store.list(options); +async function listIds(store: SessionIndexStore, options: TestListOptions): Promise { + const result = await store.list({ ...options, readScope: internal, mode: "on" }); return result.sessions.map((session) => session.id); } /** Walk every offset page for `options` and return the ids in page order. */ async function listAllIds( store: SessionIndexStore, - options: Omit, + options: Omit, limit: number ): Promise { const ids: string[] = []; for (let offset = 0; ; offset += limit) { - const page = await store.list({ ...options, limit, offset }); + const page = await store.list({ ...options, limit, offset, readScope: internal, mode: "on" }); ids.push(...page.sessions.map((session) => session.id)); if (!page.hasMore) return ids; } @@ -112,6 +115,8 @@ describe("session discovery search (D1)", () => { excludeStatus: "archived", search: "login redirect", limit: 2, + readScope: internal, + mode: "on", }); console.info( `[session-discovery] search over ${123} rows took ${Date.now() - startedAt}ms (page of 2)` @@ -224,7 +229,7 @@ describe("session discovery search (D1)", () => { updatedAt: 2, }); - const result = await store.list({ search: "changelog" }); + const result = await store.list({ search: "changelog", readScope: internal, mode: "on" }); expect(result.sessions.map(({ id, parentSessionId }) => ({ id, parentSessionId }))).toEqual([ { id: "child", parentSessionId: "parent" }, ]); @@ -319,7 +324,13 @@ describe("session discovery search (D1)", () => { await seed(store, { id: `s${index}`, title: "walk", updatedAt: index }); } - const first = await store.list({ search: "walk", limit: 2, offset: 0 }); + const first = await store.list({ + search: "walk", + limit: 2, + offset: 0, + readScope: internal, + mode: "on", + }); expect(first.sessions.map((session) => session.id)).toEqual(["s6", "s5"]); // Activity on a not-yet-paged session reorders the list underneath the @@ -330,9 +341,21 @@ describe("session discovery search (D1)", () => { // and the moved session leads the next first page. await store.updateTitle("s2", "walk again", 100); - const second = await store.list({ search: "walk", limit: 2, offset: 2 }); + const second = await store.list({ + search: "walk", + limit: 2, + offset: 2, + readScope: internal, + mode: "on", + }); expect(second.sessions.map((session) => session.id)).toEqual(["s5", "s4"]); - const third = await store.list({ search: "walk", limit: 2, offset: 4 }); + const third = await store.list({ + search: "walk", + limit: 2, + offset: 4, + readScope: internal, + mode: "on", + }); expect(third.sessions.map((session) => session.id)).toEqual(["s3", "s1"]); expect(third.hasMore).toBe(false); @@ -340,7 +363,13 @@ describe("session discovery search (D1)", () => { expect(new Set(walked)).toEqual(new Set(["s6", "s5", "s4", "s3", "s1"])); expect(walked.filter((id) => id === "s5")).toHaveLength(2); - const refreshed = await store.list({ search: "walk", limit: 2, offset: 0 }); + const refreshed = await store.list({ + search: "walk", + limit: 2, + offset: 0, + readScope: internal, + mode: "on", + }); expect(refreshed.sessions.map((session) => session.id)).toEqual(["s2", "s6"]); }); @@ -369,17 +398,17 @@ describe("session discovery search (D1)", () => { ).run(); const store = new SessionIndexStore(env.DB); - const median = async (options: ListSessionsOptions, runs = 5): Promise => { + const median = async (options: TestListOptions, runs = 5): Promise => { const durations: number[] = []; for (let run = 0; run < runs; run += 1) { const started = performance.now(); - await store.list({ ...options, viewerUserId: ALICE }); + await store.list({ ...options, viewerUserId: ALICE, readScope: internal, mode: "on" }); durations.push(performance.now() - started); } durations.sort((a, b) => a - b); return Math.round(durations[Math.floor(runs / 2)]); }; - const cases: Array<[string, ListSessionsOptions, number]> = [ + const cases: Array<[string, TestListOptions, number]> = [ ["default page", { excludeStatus: "archived" }, 50], [ "repository via scalar primary (500 matches)", @@ -400,7 +429,12 @@ describe("session discovery search (D1)", () => { ["search with one match", { excludeStatus: "archived", search: "work 4999" }, 1], ]; for (const [label, options, expectedRows] of cases) { - const result = await store.list({ ...options, viewerUserId: ALICE }); + const result = await store.list({ + ...options, + viewerUserId: ALICE, + readScope: internal, + mode: "on", + }); expect(result.sessions, label).toHaveLength(expectedRows); console.info(`[session-discovery] ${total} sessions, ${label}: ${await median(options)} ms`); } @@ -421,13 +455,13 @@ describe("session discovery search (D1)", () => { await seed(store, { id: "plan-target", title: "plan me", updatedAt: 1 }); const explain = async ( - options: ListSessionsOptions, + options: TestListOptions, bindings: unknown[], label: string, { sortsCandidates = false }: { sortsCandidates?: boolean } = {} ): Promise => { preparedQueries.length = 0; - await store.list(options); + await store.list({ ...options, readScope: internal, mode: "on" }); const pageQuery = preparedQueries.find((query) => query.includes("WITH paged_sessions AS")); expect(pageQuery).toBeDefined(); expect(pageQuery).not.toContain("plan'"); diff --git a/packages/control-plane/test/integration/session-do-access.ts b/packages/control-plane/test/integration/session-do-access.ts index 50b8c4a010..f43aa23d61 100644 --- a/packages/control-plane/test/integration/session-do-access.ts +++ b/packages/control-plane/test/integration/session-do-access.ts @@ -1,11 +1,12 @@ import { runInDurableObject } from "cloudflare:test"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import type { SessionRuntime } from "../../src/session/components"; +import type { Env } from "../../src/types"; /** * The DO internals integration tests are allowed to reach: the private - * `runtime` accessor (which initializes on first touch) and the component - * graph behind `SessionRuntime.internals`. + * `runtime` accessor (which initializes on first touch), the pre-init + * `appEnv`, and the component graph behind `SessionRuntime.internals`. * * NOTE: the `as unknown` cast below has no structural tie to SessionDO — its * members are private, so they cannot be `Pick`ed. Renaming the DO's @@ -16,6 +17,7 @@ import type { SessionRuntime } from "../../src/session/components"; */ export interface SessionDOInternals { runtime: SessionRuntime; + appEnv: Env; } /** @@ -39,6 +41,16 @@ export function componentsOf(instance: SessionDO): SessionRuntime["internals"] { return (instance as unknown as SessionDOInternals).runtime.internals; } +/** Set the real DO's mode before its first request initializes the runtime. */ +export function setSessionTeamsEnforcementMode( + stub: DurableObjectStub, + mode: string +): Promise { + return runInSessionDO(stub, (instance) => { + (instance as unknown as SessionDOInternals).appEnv.TEAMS_ENFORCEMENT = mode; + }); +} + /** * Invoke the DO's real user-env resolver. The single place secrets tests * reach past SessionDO's encapsulation. diff --git a/packages/control-plane/test/integration/session-export-store.test.ts b/packages/control-plane/test/integration/session-export-store.test.ts index 8459424db8..83e3f2c37e 100644 --- a/packages/control-plane/test/integration/session-export-store.test.ts +++ b/packages/control-plane/test/integration/session-export-store.test.ts @@ -1,11 +1,20 @@ import { env } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { SessionViewer } from "@open-inspect/shared"; import { SessionExportStore, type RunsPage } from "../../src/db/session-export-store"; import type { RunsExportCursor } from "../../src/db/session-export-cursor"; import type { SqlDatabase, SqlStatement } from "../../src/db/sql-database"; import { cleanD1Tables } from "./cleanup"; import { sqlDatabase } from "./helpers"; +const serviceViewer: SessionViewer = { kind: "service", teamId: null }; + +async function setVisibility(id: string, visibility: "workspace" | "private") { + await env.DB.prepare("UPDATE sessions SET visibility = ? WHERE id = ?") + .bind(visibility, id) + .run(); +} + function insertSession(id: string, createdAt: number) { return env.DB.prepare("INSERT INTO sessions (id, created_at, updated_at) VALUES (?, ?, ?)") .bind(id, createdAt, createdAt) @@ -31,6 +40,142 @@ describe("SessionExportStore integration", () => { beforeEach(cleanD1Tables); afterEach(cleanD1Tables); + it("filters sessions before paging and never fetches a hidden trace or metadata", async () => { + await insertSession("visible-old", 100); + await insertSession("hidden", 200); + await insertSession("visible-new", 300); + await setVisibility("hidden", "private"); + const store = new SessionExportStore(sqlDatabase(env.DB)); + + const first = await store.list({ + cursor: null, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + expect(first.sessions.map(({ id }) => id)).toEqual(["visible-new"]); + expect(first.hasMore).toBe(true); + const second = await store.list({ + cursor: first.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + expect(second.sessions.map(({ id }) => id)).toEqual(["visible-old"]); + expect(second.nextCursor).toBeNull(); + }); + + it("filters run roots and descendants independently, including for an unbound service", async () => { + await insertSession("private-root", 500); + await insertDescendant("public-child-of-private", "private-root", "private-root", 501, 1); + await setVisibility("private-root", "private"); + await insertSession("public-root", 400); + await insertDescendant("private-child", "public-root", "public-root", 401, 1); + await setVisibility("private-child", "private"); + await insertDescendant("public-child", "public-root", "public-root", 402, 1); + const store = new SessionExportStore(sqlDatabase(env.DB)); + + const first = await store.list({ + scope: "runs", + cursor: null, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + expect(first.sessions.map(({ id }) => id)).toEqual(["public-root"]); + const second = await store.list({ + scope: "runs", + cursor: first.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + expect(second.sessions.map(({ id }) => id)).toEqual(["public-child"]); + expect(second.nextCursor).toBeNull(); + }); + + it("omits visibility predicates for explicitly internal session and run exports", async () => { + await insertSession("private-root", 200); + await setVisibility("private-root", "private"); + await insertDescendant("private-child", "private-root", "private-root", 210, 1); + await setVisibility("private-child", "private"); + await insertSession("workspace", 100); + + const raw = sqlDatabase(env.DB); + const pageQueries: string[] = []; + const db: SqlDatabase = { + prepare(sql) { + if (sql.startsWith("SELECT sessions.*") || sql.startsWith("SELECT s.*")) { + pageQueries.push(sql); + } + return raw.prepare(sql); + }, + batch(statements) { + return raw.batch(statements); + }, + }; + const store = new SessionExportStore(db); + const options = { + readScope: { kind: "internal" as const, reason: "data export" }, + mode: "on" as const, + cursor: null, + limit: 10, + }; + expect((await store.list(options)).sessions.map(({ id }) => id)).toEqual([ + "private-child", + "private-root", + "workspace", + ]); + expect((await store.list({ ...options, scope: "runs" })).sessions.map(({ id }) => id)).toEqual([ + "private-root", + "private-child", + "workspace", + ]); + expect(pageQueries).toHaveLength(2); + for (const sql of pageQueries) { + expect(sql).not.toMatch(/\b(?:sessions|root|s)\.visibility\b/); + expect(sql).not.toContain("team_memberships"); + } + }); + + it("honors enforcement mode for users but never exposes private rows to services", async () => { + await insertSession("workspace", 100); + await insertSession("private", 200); + await setVisibility("private", "private"); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)" + ) + .bind("team-1", "team-1", "Team 1", 100, 100) + .run(); + await insertSession("team", 300); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ?, visibility = 'team' WHERE id = ?") + .bind("team-1", "team") + .run(); + const viewer: SessionViewer = { + kind: "user", + userId: "viewer", + roleKey: null, + permissions: ["sessions.read"], + suspended: false, + memberships: new Map(), + }; + const store = new SessionExportStore(sqlDatabase(env.DB)); + + const on = await store.list({ cursor: null, limit: 10, readScope: viewer, mode: "on" }); + expect(on.sessions.map(({ id }) => id)).toEqual(["workspace"]); + const shadow = await store.list({ cursor: null, limit: 10, readScope: viewer, mode: "shadow" }); + expect(shadow.sessions.map(({ id }) => id)).toEqual(["team", "workspace"]); + const off = await store.list({ cursor: null, limit: 10, readScope: viewer, mode: "off" }); + expect(off.sessions.map(({ id }) => id)).toEqual(["team", "workspace"]); + const service = await store.list({ + cursor: null, + limit: 10, + readScope: serviceViewer, + mode: "off", + }); + expect(service.sessions.map(({ id }) => id)).toEqual(["team", "workspace"]); + }); + it("looks up exactly one export row", async () => { await insertSession("other", 100); await insertSession("root", 200); @@ -51,7 +196,12 @@ describe("SessionExportStore integration", () => { await insertSession("session-newest", 300); const store = new SessionExportStore(sqlDatabase(env.DB)); - const first = await store.list({ cursor: null, limit: 2 }); + const first = await store.list({ + cursor: null, + limit: 2, + readScope: serviceViewer, + mode: "on", + }); expect(first.sessions.map(({ id }) => id)).toEqual(["session-newest", "session-c"]); expect(first.nextCursor).toEqual({ createdAt: 200, @@ -61,7 +211,12 @@ describe("SessionExportStore integration", () => { await insertSession("session-created-during-export", 400); await insertSession("session-bb-created-during-export", 200); - const second = await store.list({ cursor: first.nextCursor, limit: 2 }); + const second = await store.list({ + cursor: first.nextCursor, + limit: 2, + readScope: serviceViewer, + mode: "on", + }); expect(second.sessions.map(({ id }) => id)).toEqual(["session-b", "session-a"]); expect(second).toMatchObject({ hasMore: false, nextCursor: null }); }); @@ -75,6 +230,8 @@ describe("SessionExportStore integration", () => { const result = await new SessionExportStore(sqlDatabase(env.DB)).list({ cursor: null, limit: 10, + readScope: serviceViewer, + mode: "on", createdAfter: 200, createdBefore: 300, }); @@ -94,6 +251,8 @@ describe("SessionExportStore integration", () => { scope: "runs", cursor: null, limit: 10, + readScope: serviceViewer, + mode: "on", createdAfter: 200, createdBefore: 300, }); @@ -119,7 +278,13 @@ describe("SessionExportStore integration", () => { const pages: string[][] = []; let cursor: RunsExportCursor | null = null; do { - const page: RunsPage = await store.list({ scope: "runs", cursor, limit: 3 }); + const page: RunsPage = await store.list({ + scope: "runs", + cursor, + limit: 3, + readScope: serviceViewer, + mode: "on", + }); const pageIds = page.sessions.map(({ id }) => id); pages.push(pageIds); ids.push(...pageIds); @@ -140,10 +305,34 @@ describe("SessionExportStore integration", () => { await insertSession("root-a", 200); await insertDescendant("child-a", "root-a", "root-a", 240, 1); const store = new SessionExportStore(sqlDatabase(env.DB)); - const first = await store.list({ scope: "runs", cursor: null, limit: 1 }); - const second = await store.list({ scope: "runs", cursor: first.nextCursor, limit: 1 }); - const third = await store.list({ scope: "runs", cursor: second.nextCursor, limit: 1 }); - const fourth = await store.list({ scope: "runs", cursor: third.nextCursor, limit: 1 }); + const first = await store.list({ + scope: "runs", + cursor: null, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + const second = await store.list({ + scope: "runs", + cursor: first.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + const third = await store.list({ + scope: "runs", + cursor: second.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + const fourth = await store.list({ + scope: "runs", + cursor: third.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); expect( [first, second, third, fourth].flatMap((page) => page.sessions.map(({ id }) => id)) @@ -172,7 +361,13 @@ describe("SessionExportStore integration", () => { }, }; const store = new SessionExportStore(db); - const first = await store.list({ scope: "runs", cursor: null, limit: 1 }); + const first = await store.list({ + scope: "runs", + cursor: null, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); if (!pageSql) throw new Error("Run page query was not prepared"); const plan = await env.DB.prepare(`EXPLAIN QUERY PLAN ${pageSql}`) @@ -189,7 +384,7 @@ describe("SessionExportStore integration", () => { const cursor = first.nextCursor; if (!cursor) throw new Error("Expected a second run export page"); - await store.list({ scope: "runs", cursor, limit: 1 }); + await store.list({ scope: "runs", cursor, limit: 1, readScope: serviceViewer, mode: "on" }); if (!pageSql) throw new Error("Continuation query was not prepared"); const continuation = await env.DB.prepare(`EXPLAIN QUERY PLAN ${pageSql}`) .bind( @@ -224,7 +419,13 @@ describe("SessionExportStore integration", () => { const ids: string[] = []; let cursor: RunsExportCursor | null = null; do { - const page: RunsPage = await store.list({ scope: "runs", cursor, limit: 1 }); + const page: RunsPage = await store.list({ + scope: "runs", + cursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); ids.push(...page.sessions.map(({ id }) => id)); cursor = page.nextCursor; } while (cursor); @@ -237,10 +438,28 @@ describe("SessionExportStore integration", () => { await insertDescendant("child-1", "root", "root", 210, 1); await insertDescendant("child-2", "root", "root", 230, 1); const store = new SessionExportStore(sqlDatabase(env.DB)); - const first = await store.list({ scope: "runs", cursor: null, limit: 1 }); + const first = await store.list({ + scope: "runs", + cursor: null, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); await insertDescendant("late-child", "root", "root", 220, 1); - const second = await store.list({ scope: "runs", cursor: first.nextCursor, limit: 1 }); - const third = await store.list({ scope: "runs", cursor: second.nextCursor, limit: 1 }); + const second = await store.list({ + scope: "runs", + cursor: first.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + const third = await store.list({ + scope: "runs", + cursor: second.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); expect([first, second, third].flatMap((page) => page.sessions.map(({ id }) => id))).toEqual([ "root", @@ -255,7 +474,13 @@ describe("SessionExportStore integration", () => { await insertDescendant("child-1", "root", "root", 210, 1); await insertDescendant("child-2", "root", "root", 230, 1); const store = new SessionExportStore(sqlDatabase(env.DB)); - const first = await store.list({ scope: "runs", cursor: null, limit: 1 }); + const first = await store.list({ + scope: "runs", + cursor: null, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); const deleted = await env.DB.prepare("SELECT rowid FROM sessions WHERE id = ?") .bind("child-2") .first(); @@ -265,8 +490,20 @@ describe("SessionExportStore integration", () => { .bind("late-child") .first(); expect(inserted).toEqual(deleted); - const second = await store.list({ scope: "runs", cursor: first.nextCursor, limit: 1 }); - const third = await store.list({ scope: "runs", cursor: second.nextCursor, limit: 1 }); + const second = await store.list({ + scope: "runs", + cursor: first.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); + const third = await store.list({ + scope: "runs", + cursor: second.nextCursor, + limit: 1, + readScope: serviceViewer, + mode: "on", + }); expect([first, second, third].flatMap((page) => page.sessions.map(({ id }) => id))).toEqual([ "root", @@ -282,15 +519,31 @@ describe("SessionExportStore integration", () => { await insertDescendant("child", "root", "root", 210, 1); const store = new SessionExportStore(sqlDatabase(env.DB)); const window = { scope: "runs" as const, limit: 1, createdAfter: 100, createdBefore: 220 }; - const first = await store.list({ ...window, cursor: null }); + const first = await store.list({ + ...window, + cursor: null, + readScope: serviceViewer, + mode: "on", + }); expect(first.sessions.map(({ id }) => id)).toEqual(["root"]); await env.DB.prepare("DELETE FROM sessions WHERE id = ?").bind("root").run(); - const second = await store.list({ ...window, cursor: first.nextCursor }); + const second = await store.list({ + ...window, + cursor: first.nextCursor, + readScope: serviceViewer, + mode: "on", + }); expect(second.sessions.map(({ id }) => id)).toEqual(["old-root"]); expect(second.nextCursor).toBeNull(); - const fresh = await store.list({ ...window, cursor: null, limit: 10 }); + const fresh = await store.list({ + ...window, + cursor: null, + limit: 10, + readScope: serviceViewer, + mode: "on", + }); expect(fresh.sessions.map(({ id, rootSessionId }) => [id, rootSessionId])).toEqual([ ["child", "child"], ["old-root", "old-root"], @@ -310,6 +563,8 @@ describe("SessionExportStore integration", () => { scope: "runs", cursor: null, limit: 10, + readScope: serviceViewer, + mode: "on", }); expect(result.sessions).toEqual([]); }); @@ -385,6 +640,8 @@ describe("SessionExportStore integration", () => { const result = await new SessionExportStore(sqlDatabase(env.DB)).list({ cursor: null, limit: 1, + readScope: serviceViewer, + mode: "on", }); expect(result.sessions).toMatchObject([ @@ -445,6 +702,8 @@ describe("SessionExportStore integration", () => { const result = await new SessionExportStore(sqlDatabase(env.DB)).list({ cursor: null, limit: 10, + readScope: serviceViewer, + mode: "on", }); expect(result.sessions).toMatchObject([ @@ -466,6 +725,8 @@ describe("SessionExportStore integration", () => { const result = await new SessionExportStore(sqlDatabase(env.DB)).list({ cursor: null, limit: 10, + readScope: serviceViewer, + mode: "on", }); expect(result.sessions[0].repositories).toEqual([]); @@ -498,6 +759,8 @@ describe("SessionExportStore integration", () => { const result = await new SessionExportStore(sqlDatabase(env.DB)).list({ cursor: null, limit: 101, + readScope: serviceViewer, + mode: "on", }); expect(result.sessions).toHaveLength(101); @@ -575,7 +838,12 @@ describe("SessionExportStore integration", () => { }, }; - const result = await new SessionExportStore(db).list({ cursor: null, limit: 10 }); + const result = await new SessionExportStore(db).list({ + cursor: null, + limit: 10, + readScope: serviceViewer, + mode: "on", + }); expect(wrote).toBe(true); expect(result.sessions[0].prCount).toBe(result.sessions[0].pullRequests.length); diff --git a/packages/control-plane/test/integration/session-export.test.ts b/packages/control-plane/test/integration/session-export.test.ts index 94c6d446fd..77f3b1b747 100644 --- a/packages/control-plane/test/integration/session-export.test.ts +++ b/packages/control-plane/test/integration/session-export.test.ts @@ -55,6 +55,18 @@ describe("GET /sessions/export with include", () => { }); }); + it("filters hidden bulk rows before reading included events", async () => { + const visible = await initSession({ title: "visible" }); + const hidden = await initSession({ title: "hidden" }); + await env.DB.prepare("UPDATE sessions SET visibility = 'private' WHERE id = ?") + .bind(hidden.sessionName) + .run(); + + const lines = await exportLines("events"); + expect(lines).toMatchObject([{ type: "session", id: visible.sessionName, events: [] }]); + expect(lines.every((line) => line.id !== hidden.sessionName)).toBe(true); + }); + it("downloads only the requested session and rejects scope on the single route", async () => { const root = await initSession({ title: "root" }); const child = await initSession({ title: "child" }); diff --git a/packages/control-plane/test/integration/session-inbox-scoping.test.ts b/packages/control-plane/test/integration/session-inbox-scoping.test.ts new file mode 100644 index 0000000000..c4921142af --- /dev/null +++ b/packages/control-plane/test/integration/session-inbox-scoping.test.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { env } from "cloudflare:test"; +import type { SessionViewer } from "@open-inspect/shared"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { SessionInboxStore } from "../../src/db/session-inbox-store"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser, serviceFetch } from "./helpers"; +import { VIEWER_ID, viewer, seedTeams, session } from "./session-inbox-test-helpers"; + +describe("scoped inbox", () => { + beforeEach(async () => { + await cleanD1Tables(); + await seedTeams(); + const otherUserId = "22222222222222222222222222222222"; + await seedActiveUser(otherUserId); + const store = new SessionIndexStore(env.DB); + await store.create( + session("team-root", { + ownerTeamId: "team-a", + visibility: "team", + updatedAt: 7000, + }) + ); + await store.create(session("workspace-root", { userId: otherUserId, updatedAt: 6000 })); + await store.create( + session("owned-child", { + parentSessionId: "workspace-root", + spawnDepth: 1, + ownerTeamId: "team-a", + visibility: "team", + updatedAt: 5000, + }) + ); + await store.create( + session("collaborating", { + userId: otherUserId, + visibility: "private", + updatedAt: 4000, + }) + ); + await store.create(session("read-only", { userId: otherUserId, updatedAt: 3000 })); + await store.create( + session("unrelated", { + userId: otherUserId, + status: "active", + updatedAt: 2000, + }) + ); + await store.create( + session("hidden", { + userId: otherUserId, + visibility: "private", + status: "active", + updatedAt: 8000, + }) + ); + await store.create(session("owned-private", { visibility: "private", updatedAt: 1000 })); + await new SessionCollaboratorStore(env.DB).add("collaborating", VIEWER_ID, otherUserId); + for (const sessionId of ["read-only", "hidden"]) { + await env.DB.prepare( + "INSERT INTO session_read_states (user_id, session_id, last_read_message_id, updated_at) VALUES (?, ?, 'read-message', 1)" + ) + .bind(VIEWER_ID, sessionId) + .run(); + } + }); + + it.each([ + { ownerFilter: "started" as const, ids: ["team-root", "owned-child", "owned-private"] }, + { + ownerFilter: "participating" as const, + ids: ["team-root", "owned-child", "collaborating", "read-only", "owned-private"], + }, + { + ownerFilter: "anyone" as const, + ids: ["team-root", "workspace-root", "collaborating", "read-only", "owned-private"], + }, + ])( + "applies $ownerFilter before rerooting, classification and pagination", + async ({ ownerFilter, ids }) => { + const inbox = new SessionInboxStore(env.DB); + const options = { + readScope: viewer, + mode: "on" as const, + viewerUserId: VIEWER_ID, + limit: 1, + ownerFilter, + }; + const snapshot = await inbox.snapshot(options); + expect(snapshot.finished.items.map(({ rootSession }) => rootSession.id)).toEqual( + ids.slice(0, 1) + ); + expect(snapshot.in_progress.items.map(({ rootSession }) => rootSession.id)).toEqual( + ownerFilter === "anyone" ? ["unrelated"] : [] + ); + const allIds: string[] = []; + let cursor = null; + do { + const page = await inbox.list({ ...options, category: "finished", cursor }); + allIds.push(...page.items.map(({ rootSession }) => rootSession.id)); + cursor = page.nextCursor; + } while (cursor); + expect(allIds).toEqual(ids); + } + ); + + it.each([ + { visibility: "team" as const, scope: undefined, ids: ["team-root", "owned-child"] }, + { visibility: "workspace" as const, scope: undefined, ids: ["workspace-root", "read-only"] }, + { visibility: "private" as const, scope: undefined, ids: ["collaborating", "owned-private"] }, + { + visibility: undefined, + scope: "workspace" as const, + ids: ["workspace-root", "collaborating", "read-only", "owned-private"], + }, + { visibility: "team" as const, scope: "workspace" as const, ids: [] }, + ])( + "composes $visibility visibility and $scope scope before grouping", + async ({ visibility, scope, ids }) => { + const inbox = new SessionInboxStore(env.DB); + const options = { + readScope: viewer, + mode: "on" as const, + viewerUserId: VIEWER_ID, + limit: 20, + visibility, + scope, + }; + const snapshot = await inbox.snapshot(options); + const page = await inbox.list({ ...options, category: "finished", cursor: null }); + expect(page.items.map(({ rootSession }) => rootSession.id)).toEqual(ids); + expect(snapshot.finished).toEqual(page); + expect(page.items.every(({ descendantSessions }) => descendantSessions.length === 0)).toBe( + true + ); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "does not enumerate break-glass private rows in all scope in %s", + async (mode) => { + const inbox = new SessionInboxStore(env.DB); + const options = { + readScope: { ...viewer, roleKey: "owner" } as SessionViewer, + mode, + viewerUserId: VIEWER_ID, + limit: 20, + scope: "all" as const, + }; + expect( + (await inbox.snapshot(options)).in_progress.items.map(({ rootSession }) => rootSession.id) + ).toEqual(["unrelated"]); + expect( + (await inbox.list({ ...options, category: "in_progress", cursor: null })).items.map( + ({ rootSession }) => rootSession.id + ) + ).toEqual(["unrelated"]); + } + ); + + it("keeps Mine creator-only and excludes only directly automated rows with participation filters", async () => { + await serviceFetch("https://example.com/me/authorization"); + const store = new SessionIndexStore(env.DB); + await store.create(session("automated", { spawnSource: "automation", updatedAt: 10000 })); + await store.create(session("bot", { spawnSource: "github-bot", updatedAt: 9000 })); + await store.create( + session("automation-child", { + parentSessionId: "automated", + spawnSource: "agent", + spawnDepth: 1, + automationId: "automation-1", + updatedAt: 8000, + }) + ); + const response = await serviceFetch( + "https://example.com/sessions/inbox?mine=true&ownerFilter=participating" + ); + const body = (await response.json()) as { + categories: { finished: { items: Array<{ rootSession: { id: string } }> } }; + }; + expect(body.categories.finished.items.map(({ rootSession }) => rootSession.id)).toEqual([ + "automation-child", + "team-root", + "owned-child", + "owned-private", + ]); + }); + + it("returns persisted ownership and effective capabilities on snapshot and paged wire rows", async () => { + const snapshot = (await (await serviceFetch("https://example.com/sessions/inbox")).json()) as { + categories: { finished: unknown }; + }; + const page = await ( + await serviceFetch("https://example.com/sessions/inbox?category=finished") + ).json(); + expect(page).toEqual(snapshot.categories.finished); + expect(page).toMatchObject({ + items: expect.arrayContaining([ + { + rootSession: expect.objectContaining({ + id: "workspace-root", + ownerTeamId: null, + visibility: "workspace", + capabilities: expect.objectContaining({ canRead: true }), + }), + descendantSessions: [ + expect.objectContaining({ + id: "owned-child", + ownerTeamId: "team-a", + visibility: "team", + capabilities: expect.objectContaining({ + canRead: true, + canManageCollaborators: true, + }), + }), + ], + }, + { + rootSession: expect.objectContaining({ + id: "collaborating", + visibility: "private", + capabilities: expect.objectContaining({ + canRead: true, + canCollaborate: true, + canSandbox: true, + canManageCollaborators: false, + }), + }), + descendantSessions: [], + }, + ]), + }); + expect(JSON.stringify(page)).not.toContain("userId"); + }); +}); diff --git a/packages/control-plane/test/integration/session-inbox-test-helpers.ts b/packages/control-plane/test/integration/session-inbox-test-helpers.ts new file mode 100644 index 0000000000..9a3d460520 --- /dev/null +++ b/packages/control-plane/test/integration/session-inbox-test-helpers.ts @@ -0,0 +1,51 @@ +import { env } from "cloudflare:test"; +import type { SessionViewer } from "@open-inspect/shared"; +import type { SessionEntry } from "../../src/db/session-index"; + +export const VIEWER_ID = "11111111111111111111111111111111"; +export const viewer: SessionViewer = { + kind: "user", + userId: VIEWER_ID, + roleKey: "member", + permissions: ["sessions.read"], + suspended: false, + memberships: new Map([["team-a", "member"]]), +}; + +export async function seedTeams(): Promise { + await env.DB.prepare( + "INSERT INTO users (id, display_name, created_at, updated_at) VALUES (?, 'Viewer', 1, 1)" + ) + .bind(VIEWER_ID) + .run(); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team-a', 'a', 'A', 1, 1), ('team-b', 'b', 'B', 1, 1)" + ).run(); + await env.DB.prepare( + "INSERT INTO team_memberships (team_id, user_id, created_at) VALUES ('team-a', ?, 1)" + ) + .bind(VIEWER_ID) + .run(); +} + +export function session(id: string, overrides: Partial = {}): SessionEntry { + return { + id, + ownerTeamId: null, + visibility: "workspace", + title: id, + repoOwner: "open-inspect", + repoName: "open-inspect", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: "high", + baseBranch: "main", + status: "completed", + parentSessionId: null, + spawnSource: "user", + spawnDepth: 0, + userId: VIEWER_ID, + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} diff --git a/packages/control-plane/test/integration/session-inbox.test.ts b/packages/control-plane/test/integration/session-inbox.test.ts index f1da23c986..2f4e5503e7 100644 --- a/packages/control-plane/test/integration/session-inbox.test.ts +++ b/packages/control-plane/test/integration/session-inbox.test.ts @@ -4,34 +4,218 @@ import { SessionIndexStore, type SessionEntry } from "../../src/db/session-index import { cleanD1Tables } from "./cleanup"; import { serviceFetch } from "./helpers"; import type { SessionInboxCategory } from "@open-inspect/shared/types/session-inbox"; +import { SessionInboxStore } from "../../src/db/session-inbox-store"; -const VIEWER_ID = "11111111111111111111111111111111"; - -function session(id: string, overrides: Partial = {}): SessionEntry { - return { - id, - ownerTeamId: null, - visibility: "workspace", - title: id, - repoOwner: "open-inspect", - repoName: "open-inspect", - model: "anthropic/claude-sonnet-4-6", - reasoningEffort: "high", - baseBranch: "main", - status: "completed", - parentSessionId: null, - spawnSource: "user", - spawnDepth: 0, - userId: VIEWER_ID, - createdAt: 1000, - updatedAt: 2000, - ...overrides, - }; -} +import { VIEWER_ID, viewer, seedTeams, session } from "./session-inbox-test-helpers"; describe("session inbox", () => { beforeEach(cleanD1Tables); + it("excludes hidden roots and children before category aggregation and reroots visible descendants", async () => { + await seedTeams(); + const store = new SessionIndexStore(env.DB); + await store.create(session("visible-root", { updatedAt: 5000 })); + await store.create( + session("hidden-child", { + parentSessionId: "visible-root", + ownerTeamId: "team-b", + visibility: "team", + spawnDepth: 1, + status: "active", + updatedAt: 6000, + }) + ); + await store.create( + session("visible-leaf", { + parentSessionId: "hidden-child", + spawnDepth: 2, + updatedAt: 4000, + }) + ); + await store.create( + session("hidden-root", { + ownerTeamId: "team-b", + visibility: "team", + updatedAt: 7000, + }) + ); + await store.create( + session("visible-child", { + parentSessionId: "hidden-root", + spawnDepth: 1, + updatedAt: 3000, + }) + ); + + const inbox = new SessionInboxStore(env.DB); + const options = { readScope: viewer, mode: "on" as const, viewerUserId: VIEWER_ID, limit: 10 }; + const page = await inbox.list({ ...options, category: "finished", cursor: null }); + expect(page.items.map(({ rootSession }) => rootSession.id)).toEqual([ + "visible-root", + "visible-leaf", + "visible-child", + ]); + expect(page.items.every(({ descendantSessions }) => descendantSessions.length === 0)).toBe( + true + ); + const snapshot = await inbox.snapshot(options); + expect(snapshot.in_progress.items).toEqual([]); + expect(snapshot.finished.items.map(({ rootSession }) => rootSession.id)).toEqual([ + "visible-root", + "visible-leaf", + "visible-child", + ]); + }); + + it("applies teamIds in the eligibility CTE before rerooting and paging", async () => { + await seedTeams(); + const store = new SessionIndexStore(env.DB); + await store.create( + session("other-team", { ownerTeamId: "team-b", visibility: "team", updatedAt: 6000 }) + ); + await store.create(session("workspace-root", { updatedAt: 5000 })); + await store.create( + session("team-child", { + ownerTeamId: "team-a", + visibility: "team", + parentSessionId: "workspace-root", + spawnDepth: 1, + updatedAt: 4000, + }) + ); + const inbox = new SessionInboxStore(env.DB); + const options = { + readScope: viewer, + mode: "off" as const, + viewerUserId: VIEWER_ID, + limit: 1, + teamIds: ["team-a"], + }; + const page = await inbox.list({ ...options, category: "finished", cursor: null }); + expect(page.items.map(({ rootSession }) => rootSession.id)).toEqual(["team-child"]); + expect(page.hasMore).toBe(false); + const snapshot = await inbox.snapshot(options); + expect(snapshot.finished.items.map(({ rootSession }) => rootSession.id)).toEqual([ + "team-child", + ]); + }); + + it("rejects combined inbox filters before exceeding D1's parameter budget", async () => { + const inbox = new SessionInboxStore(env.DB); + const options = { + readScope: viewer, + mode: "on" as const, + viewerUserId: VIEWER_ID, + limit: 20, + teamIds: Array.from({ length: 50 }, (_, i) => `team_${i}`), + createdByUserIds: Array.from({ length: 45 }, (_, i) => `user_${i}`), + }; + await expect(inbox.snapshot(options)).rejects.toThrow("Too many session filters"); + await expect(inbox.list({ ...options, category: "finished", cursor: null })).rejects.toThrow( + "Too many session filters" + ); + }); + + it("excludes private children even when team enforcement is off", async () => { + const store = new SessionIndexStore(env.DB); + await store.create(session("visible-root", { updatedAt: 5000 })); + await store.create( + session("private-child", { + parentSessionId: "visible-root", + spawnDepth: 1, + visibility: "private", + userId: "another-user", + status: "active", + updatedAt: 6000, + }) + ); + const inbox = new SessionInboxStore(env.DB); + const options = { readScope: viewer, mode: "off" as const, viewerUserId: VIEWER_ID, limit: 10 }; + expect((await inbox.list({ ...options, category: "in_progress", cursor: null })).items).toEqual( + [] + ); + expect( + (await inbox.snapshot(options)).finished.items.map(({ rootSession }) => rootSession.id) + ).toEqual(["visible-root"]); + }); + + it("includes all eligibility-matching rows only with an explicit internal scope", async () => { + await seedTeams(); + const store = new SessionIndexStore(env.DB); + await store.create(session("workspace", { updatedAt: 1000 })); + await store.create( + session("hidden-team", { + visibility: "team", + ownerTeamId: "team-b", + updatedAt: 2000, + }) + ); + await store.create( + session("hidden-private", { visibility: "private", userId: "someone-else", updatedAt: 3000 }) + ); + + const inbox = new SessionInboxStore(env.DB); + const options = { mode: "on" as const, viewerUserId: VIEWER_ID, limit: 10 }; + const page = await inbox.list({ + ...options, + readScope: { kind: "internal", reason: "inbox maintenance" }, + category: "finished", + cursor: null, + }); + expect(page.items.map(({ rootSession }) => rootSession.id)).toEqual([ + "hidden-private", + "hidden-team", + "workspace", + ]); + const snapshot = await inbox.snapshot({ + ...options, + readScope: { kind: "internal", reason: "inbox maintenance" }, + }); + expect(snapshot.finished.items.map(({ rootSession }) => rootSession.id)).toEqual([ + "hidden-private", + "hidden-team", + "workspace", + ]); + expect( + ( + await inbox.list({ ...options, readScope: viewer, category: "finished", cursor: null }) + ).items.map(({ rootSession }) => rootSession.id) + ).toEqual(["workspace"]); + }); + + it("filters children independently of their visible parent", async () => { + await seedTeams(); + const store = new SessionIndexStore(env.DB); + await store.create(session("parent")); + await store.create( + session("hidden", { + parentSessionId: "parent", + ownerTeamId: "team-b", + visibility: "team", + spawnDepth: 1, + }) + ); + await store.create( + session("private", { + parentSessionId: "parent", + userId: "another-user", + visibility: "private", + spawnDepth: 1, + }) + ); + await store.create( + session("visible", { + parentSessionId: "parent", + ownerTeamId: "team-a", + visibility: "team", + spawnDepth: 1, + }) + ); + + const children = await store.listByParent("parent", viewer, "on"); + expect(children.map(({ id }) => id)).toEqual(["visible"]); + }); + it("classifies complete hierarchies on the server", async () => { await serviceFetch("https://example.com/sessions/inbox?category=finished"); const store = new SessionIndexStore(env.DB); diff --git a/packages/control-plane/test/integration/session-provider-auth.test.ts b/packages/control-plane/test/integration/session-provider-auth.test.ts index 4ef50c0cf1..dc750c7f71 100644 --- a/packages/control-plane/test/integration/session-provider-auth.test.ts +++ b/packages/control-plane/test/integration/session-provider-auth.test.ts @@ -55,6 +55,7 @@ describe("session provider auth persistence", () => { reasoningEffort: null, participantUserId: "user-1", platformUserId: null, + participantCanonicalUserId: null, managedSkillsManifest: { selection: { mode: "all" }, resolverVersion: 1, diff --git a/packages/control-plane/test/integration/session-read-state.test.ts b/packages/control-plane/test/integration/session-read-state.test.ts index 70fa4c890c..0c45801a6f 100644 --- a/packages/control-plane/test/integration/session-read-state.test.ts +++ b/packages/control-plane/test/integration/session-read-state.test.ts @@ -6,6 +6,7 @@ import { serviceFetch } from "./helpers"; import type { SqlDatabase } from "../../src/db/sql-database"; const BROWSER_USER_ID = "11111111111111111111111111111111"; +const internal = { kind: "internal", reason: "session read state integration tests" } as const; async function createUser(userId: string, createdAt: number): Promise { await env.DB.prepare( @@ -88,10 +89,9 @@ describe("session read state", () => { updated_at: 1_000, }); - expect((await store.list()).sessions.map(({ id }) => id)).toEqual([ - "newer-session", - "terminal-message-session", - ]); + expect( + (await store.list({ readScope: internal, mode: "on" })).sessions.map(({ id }) => id) + ).toEqual(["newer-session", "terminal-message-session"]); }); it("rejects partial terminal-message projections and read states", async () => { @@ -142,12 +142,18 @@ describe("session read state", () => { terminalMessageCompletedAt: 2_000, }); - expect((await store.list({ viewerUserId: "user-a" })).sessions[0].readState).toEqual({ + expect( + (await store.list({ viewerUserId: "user-a", readScope: internal, mode: "on" })).sessions[0] + .readState + ).toEqual({ unread: true, latestMessageId: "message-a", version: 1_500, }); - expect((await store.list({ viewerUserId: "user-b" })).sessions[0].readState).toEqual({ + expect( + (await store.list({ viewerUserId: "user-b", readScope: internal, mode: "on" })).sessions[0] + .readState + ).toEqual({ unread: true, latestMessageId: "message-a", version: 1_500, @@ -177,12 +183,18 @@ describe("session read state", () => { latestMessageId: "message-a", version: 1_500, }); - expect((await store.list({ viewerUserId: "user-a" })).sessions[0].readState).toEqual({ + expect( + (await store.list({ viewerUserId: "user-a", readScope: internal, mode: "on" })).sessions[0] + .readState + ).toEqual({ unread: false, latestMessageId: "message-a", version: 1_500, }); - expect((await store.list({ viewerUserId: "user-b" })).sessions[0].readState).toEqual({ + expect( + (await store.list({ viewerUserId: "user-b", readScope: internal, mode: "on" })).sessions[0] + .readState + ).toEqual({ unread: true, latestMessageId: "message-a", version: 1_500, @@ -236,7 +248,10 @@ describe("session read state", () => { terminalMessageCompletedAt: 4_999, }); - expect((await store.list({ viewerUserId: "new-user" })).sessions[0].readState).toEqual({ + expect( + (await store.list({ viewerUserId: "new-user", readScope: internal, mode: "on" })).sessions[0] + .readState + ).toEqual({ unread: false, latestMessageId: "historical-message", version: 1_000, @@ -248,7 +263,10 @@ describe("session read state", () => { await createUser("viewer", 1_000); await createSession(store, "lifecycle-session"); - expect((await store.list({ viewerUserId: "viewer" })).sessions[0].readState).toEqual({ + expect( + (await store.list({ viewerUserId: "viewer", readScope: internal, mode: "on" })).sessions[0] + .readState + ).toEqual({ unread: false, latestMessageId: null, version: 0, @@ -277,7 +295,10 @@ describe("session read state", () => { await store.updateStatus("lifecycle-session", "archived", 4_000); await store.updateStatus("lifecycle-session", "completed", 5_000); - expect((await store.list({ viewerUserId: "viewer" })).sessions[0].readState).toEqual({ + expect( + (await store.list({ viewerUserId: "viewer", readScope: internal, mode: "on" })).sessions[0] + .readState + ).toEqual({ unread: false, latestMessageId: "message-1", version: 2_000, @@ -415,7 +436,11 @@ describe("session read state", () => { return env.DB.batch(statements); }, } as SqlDatabase; - const result = await new SessionIndexStore(countedDb).list({ viewerUserId: "viewer" }); + const result = await new SessionIndexStore(countedDb).list({ + viewerUserId: "viewer", + readScope: internal, + mode: "on", + }); expect(result.sessions).toHaveLength(50); expect(result.sessions.every((session) => session.readState?.unread)).toBe(true); @@ -443,7 +468,12 @@ describe("session read state", () => { await createSession(store, `large-page-${index.toString().padStart(3, "0")}`, 10_000 - index); } - const result = await store.list({ viewerUserId: "viewer", limit: 100 }); + const result = await store.list({ + viewerUserId: "viewer", + limit: 100, + readScope: internal, + mode: "on", + }); expect(result.sessions).toHaveLength(100); expect(result.sessions.every((session) => session.readState?.unread === false)).toBe(true); diff --git a/packages/control-plane/test/integration/session-repositories.test.ts b/packages/control-plane/test/integration/session-repositories.test.ts index f2c5bd11a3..ec885331d2 100644 --- a/packages/control-plane/test/integration/session-repositories.test.ts +++ b/packages/control-plane/test/integration/session-repositories.test.ts @@ -9,6 +9,8 @@ import { SessionIndexStore } from "../../src/db/session-index"; import { cleanD1Tables } from "./cleanup"; import { initSession, queryDO, serviceFetch } from "./helpers"; +const internal = { kind: "internal", reason: "session repositories integration tests" } as const; + type MemberRow = { position: number; repo_owner: string; @@ -126,7 +128,7 @@ describe("D1 session index repositories", () => { ]) ); - const { sessions } = await store.list(); + const { sessions } = await store.list({ readScope: internal, mode: "on" }); expect(sessions).toHaveLength(1); expect(sessions[0].repositories).toEqual([ { repoOwner: "acme", repoName: "frontend", repoId: 1, baseBranch: "main" }, @@ -138,7 +140,7 @@ describe("D1 session index repositories", () => { const store = new SessionIndexStore(env.DB); await store.create(makeEntry("scalar-1")); - const { sessions } = await store.list(); + const { sessions } = await store.list({ readScope: internal, mode: "on" }); expect(sessions).toHaveLength(1); expect(sessions[0].repositories).toBeUndefined(); }); diff --git a/packages/control-plane/test/integration/session-runs.test.ts b/packages/control-plane/test/integration/session-runs.test.ts index 5023686dd3..4a28b84b1b 100644 --- a/packages/control-plane/test/integration/session-runs.test.ts +++ b/packages/control-plane/test/integration/session-runs.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { env } from "cloudflare:test"; +import { createExecutionContext, env } from "cloudflare:test"; import type { AnalyticsRunsResponse } from "@open-inspect/shared/types/analytics"; import { SessionIndexStore, type SessionEntry } from "../../src/db/session-index"; import { SessionRunStore } from "../../src/db/session-run-store"; import { cleanD1Tables } from "./cleanup"; -import { serviceFetch } from "./helpers"; +import { routeRequest, serviceFetch, serviceRequestHeaders } from "./helpers"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -22,6 +22,8 @@ async function seedSession( | "automationId" | "repoOwner" | "repoName" + | "ownerTeamId" + | "visibility" > >, cost: number, @@ -148,8 +150,70 @@ describe("session runs", () => { repoName: "project", }, ]); - expect(await new SessionRunStore(env.DB).get("root")).toEqual(body.runs[0]); - expect(await new SessionRunStore(env.DB).get("missing")).toBeNull(); + expect( + await new SessionRunStore(env.DB, { kind: "internal", reason: "verify rollup" }, "on").get( + "root" + ) + ).toEqual(body.runs[0]); + expect( + await new SessionRunStore( + env.DB, + { kind: "internal", reason: "verify missing run" }, + "on" + ).get("missing") + ).toBeNull(); + }); + + it("excludes an entire run when its root is hidden, even if a child is visible", async () => { + const store = new SessionIndexStore(env.DB); + const now = Date.now() - DAY_MS; + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('hidden-run-team', 'hidden-run-team', 'Hidden', 1, 1)" + ).run(); + await seedSession( + store, + { + id: "hidden-root", + createdAt: now, + updatedAt: now, + visibility: "team", + ownerTeamId: "hidden-run-team", + }, + 4, + 0, + 0 + ); + await seedSession( + store, + { + id: "visible-child", + parentSessionId: "hidden-root", + createdAt: now + 1, + updatedAt: now + 1, + }, + 2, + 0, + 0 + ); + const url = "https://test.local/analytics/runs?scope=all"; + const response = await routeRequest( + new Request(url, { + headers: await serviceRequestHeaders(url, { + as: { userId: "66666666666666666666666666666666", role: "member" }, + }), + }), + { ...env, TEAMS_ENFORCEMENT: "on" }, + createExecutionContext() + ); + expect(response.status).toBe(200); + expect((await response.json()).runs).toEqual([]); + expect( + await new SessionRunStore( + env.DB, + { kind: "internal", reason: "audit hidden roots" }, + "on" + ).get("hidden-root") + ).toMatchObject({ sessionCount: 2, totalCost: 6 }); }); it("windows by root creation, not child creation, and includes later children", async () => { @@ -210,7 +274,11 @@ describe("session runs", () => { expect(body.runs.map((run) => run.rootSessionId)).toEqual(["recent-root"]); expect(body.runs[0]).toMatchObject({ sessionCount: 2, totalCost: 7, totalPrs: 1 }); - const runs = await new SessionRunStore(env.DB).list({ + const runs = await new SessionRunStore( + env.DB, + { kind: "internal", reason: "verify old root window" }, + "on" + ).list({ startAt: now - 11 * DAY_MS, endAt: now - 7 * DAY_MS, limit: 10, @@ -246,7 +314,7 @@ describe("session runs", () => { 0 ); - const runs = new SessionRunStore(env.DB); + const runs = new SessionRunStore(env.DB, { kind: "internal", reason: "verify ordering" }, "on"); const window = { startAt: now - 7 * DAY_MS, endAt: now, limit: 1, scope: "all" as const }; expect( (await runs.list({ ...window, orderBy: "cost" })).map((run) => run.rootSessionId) diff --git a/packages/control-plane/test/integration/session-scope-routes.test.ts b/packages/control-plane/test/integration/session-scope-routes.test.ts new file mode 100644 index 0000000000..e4a1b3fd4f --- /dev/null +++ b/packages/control-plane/test/integration/session-scope-routes.test.ts @@ -0,0 +1,627 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamStore } from "../../src/db/teams"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { SessionScopeStore } from "../../src/db/session-scope-store"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { cleanD1Tables } from "./cleanup"; +import { + initSession, + routeRequest, + seedActiveUser, + serviceFetch, + serviceRequestHeaders, +} from "./helpers"; + +const BASE = "https://test.local"; +const OWNER = "11111111111111111111111111111111"; +const COLLABORATOR = "22222222222222222222222222222222"; + +function request(path: string, method = "GET", body?: object, as?: string) { + return serviceFetch(`${BASE}${path}`, { + method, + ...(body ? { body: JSON.stringify(body) } : {}), + ...(as ? { as: { userId: as, role: "member" } } : {}), + }); +} + +async function session(id: string, parentSessionId?: string, userId: string | null = OWNER) { + await new SessionIndexStore(env.DB).create({ + id, + ownerTeamId: null, + visibility: "workspace", + title: id, + repoOwner: "acme", + repoName: "web-app", + baseBranch: "main", + model: "anthropic/claude-haiku-4-5", + reasoningEffort: null, + status: "created", + userId, + parentSessionId, + repositories: [{ repoOwner: "acme", repoName: "web-app", repoId: 12345, baseBranch: "main" }], + createdAt: Date.now(), + updatedAt: Date.now(), + }); +} + +describe("session scope routes", () => { + beforeEach(async () => { + await cleanD1Tables(); + await seedActiveUser(COLLABORATOR); + await request("/me/authorization"); + }); + + it("does not audit allowed collaborator-candidate reads but still audits denials", async () => { + await session("root"); + const path = "/sessions/root/collaborator-candidates"; + const allowed = await request(path); + expect(allowed.status).toBe(200); + const denied = await request(path, "GET", undefined, COLLABORATOR); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ reason_code: "not_owner_or_lead" }); + for (const [response, expected] of [ + [allowed, []], + [denied, [{ action: "authorization.request_denied" }]], + ] as const) { + const events = await env.DB.prepare( + "SELECT action FROM authorization_audit_events WHERE request_id = ?" + ) + .bind(response.headers.get("x-request-id")) + .all(); + expect(events.results).toEqual(expected); + } + }); + + it("returns 404 for the removed ownership scope endpoint", async () => { + await session("root"); + const response = await request("/sessions/root/scope", "PUT", { teamId: null }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Not found" }); + }); + + it("audits only changed rows in a visibility cascade and none on a repeat request", async () => { + await session("root"); + await session("child", "root"); + await session("grandchild", "child"); + await env.DB.prepare("UPDATE sessions SET visibility = 'private' WHERE id = 'child'").run(); + + const changed = await request("/sessions/root/visibility", "PUT", { visibility: "private" }); + expect(changed.status).toBe(200); + expect(await changed.json()).toMatchObject({ + affectedSessionIds: expect.arrayContaining(["root", "child", "grandchild"]), + }); + for (const id of ["root", "child", "grandchild"]) { + expect((await new SessionIndexStore(env.DB).get(id))?.visibility).toBe("private"); + } + const audits = await env.DB.prepare( + `SELECT request_id, actor_user_id_snapshot, resource_id, team_id, metadata_json + FROM authorization_audit_events WHERE action = 'session.visibility_changed' ORDER BY resource_id` + ).all<{ + request_id: string; + actor_user_id_snapshot: string; + resource_id: string; + team_id: string | null; + metadata_json: string; + }>(); + expect( + audits.results.map((row) => ({ + requestId: row.request_id, + actor: row.actor_user_id_snapshot, + sessionId: row.resource_id, + teamId: row.team_id, + metadata: JSON.parse(row.metadata_json), + })) + ).toEqual( + ["grandchild", "root"].map((sessionId) => ({ + requestId: changed.headers.get("x-request-id"), + actor: OWNER, + sessionId, + teamId: null, + metadata: { + before: { visibility: "workspace" }, + requested: {}, + after: { visibility: "private" }, + }, + })) + ); + expect( + (await request("/sessions/root/visibility", "PUT", { visibility: "private" })).status + ).toBe(200); + const auditCount = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'session.visibility_changed'" + ).first<{ count: number }>(); + expect(auditCount?.count).toBe(2); + }); + + it("does not audit a descendant deleted after visibility preflight", async () => { + await session("root"); + await session("child", "root"); + const original = SessionScopeStore.prototype.updateVisibility; + const write = vi + .spyOn(SessionScopeStore.prototype, "updateVisibility") + .mockImplementation(async function (this: SessionScopeStore, ...args) { + await env.DB.prepare("DELETE FROM sessions WHERE id = 'child'").run(); + return original.apply(this, args); + }); + try { + expect( + (await request("/sessions/root/visibility", "PUT", { visibility: "private" })).status + ).toBe(200); + const audits = await env.DB.prepare( + "SELECT resource_id FROM authorization_audit_events WHERE action = 'session.visibility_changed'" + ).all<{ resource_id: string }>(); + expect(audits.results).toEqual([{ resource_id: "root" }]); + } finally { + write.mockRestore(); + } + }); + + it("refuses a readable but non-owned descendant without changing any visibility", async () => { + await session("root"); + await session("child", "root", COLLABORATOR); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY.member.id, OWNER) + .run(); + + const response = await request("/sessions/root/visibility", "PUT", { visibility: "private" }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ reason_code: "not_owner_or_lead" }); + expect((await new SessionIndexStore(env.DB).get("root"))?.visibility).toBe("workspace"); + expect((await new SessionIndexStore(env.DB).get("child"))?.visibility).toBe("workspace"); + const audit = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'session.visibility_changed'" + ).first<{ count: number }>(); + expect(audit?.count).toBe(0); + }); + + it("refuses private without an owner and lets a collaborator remove only themselves", async () => { + await session("unowned", undefined, null); + const denied = await request("/sessions/unowned/visibility", "PUT", { visibility: "private" }); + expect(denied.status).toBe(400); + expect(await denied.json()).toMatchObject({ code: "owner_required" }); + await initSession({ sessionName: "root", userId: OWNER }); + expect((await request(`/sessions/root/collaborators/${COLLABORATOR}`, "PUT")).status).toBe(200); + expect( + (await request("/sessions/root/visibility", "PUT", { visibility: "private" })).status + ).toBe(200); + const snapshot = await request("/sessions/root", "GET", undefined, COLLABORATOR); + expect(snapshot.status).toBe(200); + expect(await snapshot.json()).toMatchObject({ + session: { + ownerTeamId: null, + visibility: "private", + collaborators: [COLLABORATOR], + capabilities: { canRead: true, canCollaborate: true, canManageCollaborators: false }, + }, + }); + expect(await (await request("/sessions", "GET", undefined, COLLABORATOR)).json()).toMatchObject( + { + sessions: expect.arrayContaining([ + expect.objectContaining({ + id: "root", + visibility: "private", + capabilities: expect.objectContaining({ canRead: true, canCollaborate: true }), + }), + ]), + } + ); + expect( + (await request(`/sessions/root/collaborators/${OWNER}`, "DELETE", undefined, COLLABORATOR)) + .status + ).toBe(403); + expect( + ( + await request( + `/sessions/root/collaborators/${COLLABORATOR}`, + "DELETE", + undefined, + COLLABORATOR + ) + ).status + ).toBe(200); + expect((await request("/sessions/root", "GET", undefined, COLLABORATOR)).status).toBe(404); + }); + + it("reads and edits requireTeamOnCreate under workspace member management", async () => { + expect(await (await request("/settings/teams")).json()).toEqual({ requireTeamOnCreate: false }); + expect((await request("/settings/teams", "PATCH", { requireTeamOnCreate: true })).status).toBe( + 200 + ); + expect(await (await request("/settings/teams")).json()).toEqual({ requireTeamOnCreate: true }); + const stored = await env.DB.prepare( + "SELECT settings FROM integration_settings WHERE integration_id = 'teams'" + ).first<{ settings: string }>(); + expect(JSON.parse(stored!.settings)).toEqual({ defaults: { requireTeamOnCreate: true } }); + expect( + (await request("/settings/teams", "PATCH", { requireTeamOnCreate: false }, COLLABORATOR)) + .status + ).toBe(403); + }); + + it("rejects creating without a required team and as a nonmember of a selected team", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "alpha", + name: "Alpha", + joinPolicy: "invite_only", + }); + expect((await request("/settings/teams", "PATCH", { requireTeamOnCreate: true })).status).toBe( + 200 + ); + const missing = await request("/sessions", "POST", { title: "No team" }); + expect(missing.status).toBe(400); + expect(await missing.json()).toMatchObject({ code: "team_required" }); + const nonmember = await request("/sessions", "POST", { title: "Wrong team", teamId: team.id }); + expect(nonmember.status).toBe(403); + expect(await nonmember.json()).toMatchObject({ code: "not_member" }); + }); + + it("creates team-default and explicitly private sessions with persisted scope and private audit", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "creator-team", + name: "Creator team", + joinPolicy: "invite_only", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + expect((await request("/settings/teams", "PATCH", { requireTeamOnCreate: true })).status).toBe( + 200 + ); + + const defaultResponse = await request("/sessions", "POST", { + title: "Team default", + teamId: team.id, + }); + expect(defaultResponse.status).toBe(201); + const { sessionId: defaultId } = await defaultResponse.json<{ sessionId: string }>(); + expect(await new SessionIndexStore(env.DB).get(defaultId)).toMatchObject({ + ownerTeamId: team.id, + visibility: "team", + userId: OWNER, + }); + const state = await env.SESSION.get(env.SESSION.idFromName(defaultId)).fetch( + "http://internal/internal/state" + ); + expect(state.status).toBe(200); + expect(await state.json()).toMatchObject({ status: expect.any(String) }); + + const privateResponse = await request("/sessions", "POST", { + title: "Explicitly private", + teamId: team.id, + visibility: "private", + }); + expect(privateResponse.status).toBe(201); + const { sessionId: privateId } = await privateResponse.json<{ sessionId: string }>(); + expect(await new SessionIndexStore(env.DB).get(privateId)).toMatchObject({ + ownerTeamId: team.id, + visibility: "private", + userId: OWNER, + }); + const audit = await env.DB.prepare( + `SELECT request_id, actor_user_id_snapshot, resource_id, team_id, metadata_json + FROM authorization_audit_events WHERE action = 'session.created_private'` + ).first<{ + request_id: string; + actor_user_id_snapshot: string; + resource_id: string; + team_id: string; + metadata_json: string; + }>(); + expect(audit).toMatchObject({ + request_id: privateResponse.headers.get("x-request-id"), + actor_user_id_snapshot: OWNER, + resource_id: privateId, + team_id: team.id, + }); + expect(JSON.parse(audit!.metadata_json)).toEqual({ + before: {}, + requested: {}, + after: { ownerUserId: OWNER, teamId: team.id, visibility: "private" }, + }); + }); + + it("keeps a child visible as its own root when its parent becomes private", async () => { + await session("root"); + await session("child", "root"); + expect( + ( + await request("/sessions/root/visibility", "PUT", { + visibility: "private", + includeChildren: false, + }) + ).status + ).toBe(200); + const listed = await request("/sessions", "GET", undefined, COLLABORATOR); + expect( + (await listed.json<{ sessions: Array<{ id: string }> }>()).sessions.map((row) => row.id) + ).toEqual(["child"]); + const inbox = await request("/sessions/inbox", "GET", undefined, COLLABORATOR); + expect(inbox.status).toBe(200); + const body = await inbox.json<{ + categories: { finished: { items: Array<{ rootSession: { id: string } }> } }; + }>(); + expect(body.categories.finished.items.map((item) => item.rootSession.id)).toEqual(["child"]); + }); + + it("does not publish a private child during a parent visibility cascade", async () => { + await session("root"); + await session("private-child", "root", COLLABORATOR); + await env.DB.prepare( + "UPDATE sessions SET visibility = 'private' WHERE id = 'private-child'" + ).run(); + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY.member.id, OWNER) + .run(); + + const response = await request("/sessions/root/visibility", "PUT", { visibility: "workspace" }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Session not found" }); + expect((await new SessionIndexStore(env.DB).get("private-child"))?.visibility).toBe("private"); + }); + + it("removes an inactive collaborator without allowing them to be added again", async () => { + await session("root"); + expect((await request(`/sessions/root/collaborators/${COLLABORATOR}`, "PUT")).status).toBe(200); + await env.DB.prepare("UPDATE users SET suspended_at = ? WHERE id = ?") + .bind(Date.now(), COLLABORATOR) + .run(); + const removed = await request(`/sessions/root/collaborators/${COLLABORATOR}`, "DELETE"); + expect(removed.status).toBe(200); + expect(await new SessionCollaboratorStore(env.DB).listUserIds("root")).toEqual([]); + const add = await request(`/sessions/root/collaborators/${COLLABORATOR}`, "PUT"); + expect(add.status).toBe(409); + expect(await add.json()).toMatchObject({ code: "user_inactive" }); + }); + + it("enforces collaborator HTTP authorization and audits only effective changes", async () => { + await session("root"); + const path = `/sessions/root/collaborators/${COLLABORATOR}`; + const added = await request(path, "PUT"); + expect(added.status).toBe(200); + expect(await added.json()).toMatchObject({ status: "updated" }); + expect(await (await request(path, "PUT")).json()).toMatchObject({ status: "unchanged" }); + expect( + (await request(`/sessions/root/collaborators/${OWNER}`, "PUT", undefined, COLLABORATOR)) + .status + ).toBe(403); + expect( + (await request(`/sessions/root/collaborators/${OWNER}`, "DELETE", undefined, COLLABORATOR)) + .status + ).toBe(403); + const removed = await request(path, "DELETE", undefined, COLLABORATOR); + expect(removed.status).toBe(200); + expect(await removed.json()).toMatchObject({ status: "updated" }); + expect(await (await request(path, "DELETE", undefined, COLLABORATOR)).json()).toMatchObject({ + status: "unchanged", + }); + expect(await new SessionCollaboratorStore(env.DB).listUserIds("root")).toEqual([]); + const audits = await env.DB.prepare( + `SELECT action, request_id, actor_user_id_snapshot, target_user_id_snapshot, resource_id, metadata_json + FROM authorization_audit_events WHERE action IN ('session.collaborator_added', 'session.collaborator_removed') + ORDER BY action` + ).all<{ + action: string; + request_id: string; + actor_user_id_snapshot: string; + target_user_id_snapshot: string; + resource_id: string; + metadata_json: string; + }>(); + expect( + audits.results.map((row) => ({ + action: row.action, + requestId: row.request_id, + actor: row.actor_user_id_snapshot, + target: row.target_user_id_snapshot, + sessionId: row.resource_id, + metadata: JSON.parse(row.metadata_json), + })) + ).toEqual([ + { + action: "session.collaborator_added", + requestId: added.headers.get("x-request-id"), + actor: OWNER, + target: COLLABORATOR, + sessionId: "root", + metadata: { before: { collaborator: false }, requested: {}, after: { collaborator: true } }, + }, + { + action: "session.collaborator_removed", + requestId: removed.headers.get("x-request-id"), + actor: COLLABORATOR, + target: COLLABORATOR, + sessionId: "root", + metadata: { before: { collaborator: true }, requested: {}, after: { collaborator: false } }, + }, + ]); + }); + + it("audits only collaborator writes that changed a row", async () => { + await session("root"); + const store = new SessionCollaboratorStore(env.DB); + const audit = (action: "session.collaborator_added" | "session.collaborator_removed") => ({ + requestId: crypto.randomUUID(), + actorUserId: OWNER, + action, + sessionId: "root", + teamId: null, + targetUserId: COLLABORATOR, + before: {}, + after: {}, + }); + expect( + await Promise.all([ + store.add("root", COLLABORATOR, OWNER, audit("session.collaborator_added")), + store.add("root", COLLABORATOR, OWNER, audit("session.collaborator_added")), + ]) + ).toContain(false); + expect( + await Promise.all([ + store.remove("root", COLLABORATOR, audit("session.collaborator_removed")), + store.remove("root", COLLABORATOR, audit("session.collaborator_removed")), + ]) + ).toContain(false); + const rows = await env.DB.prepare( + "SELECT action FROM authorization_audit_events WHERE resource_id = 'root' ORDER BY action" + ).all(); + expect(rows.results).toEqual([ + { action: "session.collaborator_added" }, + { action: "session.collaborator_removed" }, + ]); + }); + + it("reports read-only team capabilities for a nonmember in shadow mode", async () => { + await initSession({ sessionName: "team-session", userId: OWNER }); + const team = await new TeamStore(env.DB).create({ + slug: "other", + name: "Other", + joinPolicy: "invite_only", + }); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ?, visibility = 'team' WHERE id = ?") + .bind(team.id, "team-session") + .run(); + const snapshot = await request("/sessions/team-session", "GET", undefined, COLLABORATOR); + expect(snapshot.status).toBe(200); + expect(await snapshot.json()).toMatchObject({ + session: { + capabilities: { + canRead: true, + canCollaborate: false, + canChangeVisibility: false, + }, + }, + }); + expect(await (await request("/sessions", "GET", undefined, COLLABORATOR)).json()).toMatchObject( + { + sessions: [ + expect.objectContaining({ + capabilities: expect.objectContaining({ canRead: true }), + }), + ], + } + ); + }); + + it("filters before pagination and agrees with snapshot capabilities across enforcement modes", async () => { + await initSession({ sessionName: "team-session", userId: OWNER }); + await session("private-session", undefined, COLLABORATOR); + await session("workspace-new"); + await session("workspace-old"); + const team = await new TeamStore(env.DB).create({ + slug: "pagination-team", + name: "Pagination team", + joinPolicy: "invite_only", + }); + await env.DB.prepare( + "UPDATE sessions SET owner_team_id = ?, visibility = 'team' WHERE id = 'team-session'" + ) + .bind(team.id) + .run(); + await env.DB.prepare( + "UPDATE sessions SET visibility = 'private' WHERE id = 'private-session'" + ).run(); + for (const [id, updatedAt] of [ + ["team-session", 400], + ["private-session", 300], + ["workspace-new", 200], + ["workspace-old", 100], + ] as const) { + await env.DB.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?") + .bind(updatedAt, id) + .run(); + } + const as = { userId: COLLABORATOR, role: "member" } as const; + async function fetchMode(path: string, mode: "on" | "shadow") { + const url = `${BASE}${path}`; + return routeRequest( + new Request(url, { headers: await serviceRequestHeaders(url, { as }) }), + { ...env, TEAMS_ENFORCEMENT: mode }, + createExecutionContext() + ); + } + + for (const [mode, expectedIds] of [ + ["on", ["private-session", "workspace-new", "workspace-old"]], + ["shadow", ["team-session", "private-session", "workspace-new", "workspace-old"]], + ] as const) { + for (const [offset, id] of expectedIds.entries()) { + const response = await fetchMode(`/sessions?limit=1&offset=${offset}`, mode); + expect(response.status).toBe(200); + const page = await response.json<{ + sessions: Array<{ id: string; capabilities: Record }>; + hasMore: boolean; + }>(); + expect(page.sessions.map((row) => row.id)).toEqual([id]); + expect(page.sessions[0].capabilities.canRead).toBe(true); + expect(page.hasMore).toBe(offset < expectedIds.length - 1); + if (mode === "shadow" && id === "team-session") { + const snapshot = await fetchMode(`/sessions/${id}`, mode); + expect(snapshot.status).toBe(200); + const snapshotBody = await snapshot.json<{ + session: { capabilities: Record }; + }>(); + expect(snapshotBody.session.capabilities).toEqual(page.sessions[0].capabilities); + expect(page.sessions[0].capabilities).toMatchObject({ + canRead: true, + canCollaborate: false, + canChangeVisibility: false, + }); + } + } + const beyond = await fetchMode(`/sessions?limit=1&offset=${expectedIds.length}`, mode); + expect(await beyond.json()).toMatchObject({ sessions: [], hasMore: false }); + } + expect((await fetchMode("/sessions/team-session", "on")).status).toBe(404); + }); + + it("limits team-owned collaborators and candidates to members of the owning team", async () => { + await session("root"); + const team = await new TeamStore(env.DB).create({ + slug: "owning", + name: "Owning", + joinPolicy: "invite_only", + }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, OWNER); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ? WHERE id = 'root'") + .bind(team.id) + .run(); + const candidateIds = async () => + (await (await request("/sessions/root/collaborator-candidates")).json<{ userId: string }[]>()) + .map((candidate) => candidate.userId) + .sort(); + const path = `/sessions/root/collaborators/${COLLABORATOR}`; + + expect(await candidateIds()).toEqual([OWNER]); + const rejected = await request(path, "PUT"); + expect(rejected.status).toBe(409); + expect(await rejected.json()).toMatchObject({ code: "not_team_member" }); + expect(await new SessionCollaboratorStore(env.DB).listUserIds("root")).toEqual([]); + + await memberships.add(team.id, COLLABORATOR); + expect(await candidateIds()).toEqual([OWNER, COLLABORATOR].sort()); + const added = await request(path, "PUT"); + expect(added.status).toBe(200); + expect(await added.json()).toMatchObject({ status: "updated" }); + expect( + (await request("/sessions/root/visibility", "PUT", { visibility: "private" })).status + ).toBe(200); + const listedIds = async () => + ( + await ( + await request("/sessions", "GET", undefined, COLLABORATOR) + ).json<{ + sessions: { id: string }[]; + }>() + ).sessions.map((listed) => listed.id); + expect((await request("/sessions/root", "GET", undefined, COLLABORATOR)).status).toBe(200); + expect(await listedIds()).toContain("root"); + + // The grant lapses with membership even though the collaborator row remains. + await memberships.remove(team.id, COLLABORATOR); + expect(await new SessionCollaboratorStore(env.DB).listUserIds("root")).toEqual([COLLABORATOR]); + expect((await request("/sessions/root", "GET", undefined, COLLABORATOR)).status).toBe(404); + expect(await listedIds()).not.toContain("root"); + }); +}); diff --git a/packages/control-plane/test/integration/session-snapshot.test.ts b/packages/control-plane/test/integration/session-snapshot.test.ts index 330b6b5e9c..8c2c8cd945 100644 --- a/packages/control-plane/test/integration/session-snapshot.test.ts +++ b/packages/control-plane/test/integration/session-snapshot.test.ts @@ -8,12 +8,42 @@ import { openClientWs, queryDO, seedEvents, + serviceFetch, waitForSandboxStatus, } from "./helpers"; describe("session snapshot synchronization", () => { beforeEach(cleanD1Tables); + it.each(["22222222222222222222222222222222", null])( + "returns the D1 ownerUserId for a private snapshot despite a stale DO owner (%s)", + async (ownerUserId) => { + const name = `snapshot-owner-${crypto.randomUUID()}`; + const { stub } = await initNamedSession(name, { userId: "stale-runtime-owner" }); + await waitForSandboxStatus(stub, "failed"); + await env.DB.prepare("UPDATE sessions SET user_id = ?, visibility = 'private' WHERE id = ?") + .bind(ownerUserId, name) + .run(); + + expect( + await queryDO<{ user_id: string }>( + stub, + "SELECT user_id FROM participants WHERE role = 'owner'" + ) + ).toEqual([{ user_id: "stale-runtime-owner" }]); + + const response = await serviceFetch(`https://test.local/sessions/${name}`); + + expect(response.status).toBe(200); + const snapshot = await response.json(); + expect(snapshot.session).toMatchObject({ + ownerUserId, + visibility: "private", + capabilities: { canRead: true, canSandbox: false }, + }); + } + ); + it("returns a secret-free snapshot with stable event identities", async () => { const name = `snapshot-${Date.now()}`; const { stub } = await initNamedSession(name, { title: "Snapshot session" }); diff --git a/packages/control-plane/test/integration/session-visibility-lists.test.ts b/packages/control-plane/test/integration/session-visibility-lists.test.ts new file mode 100644 index 0000000000..011e51bf3e --- /dev/null +++ b/packages/control-plane/test/integration/session-visibility-lists.test.ts @@ -0,0 +1,377 @@ +import { checkSessionAccess, type SessionViewer } from "@open-inspect/shared"; +import { applyD1Migrations, env } from "cloudflare:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { AnalyticsStore } from "../../src/db/analytics-store"; +import { PrAutofixFeedbackStore } from "../../src/db/pr-autofix-feedback-store"; +import { SessionExportStore } from "../../src/db/session-export-store"; +import { SessionIndexStore, type SessionEntry } from "../../src/db/session-index"; +import { SessionRunStore } from "../../src/db/session-run-store"; +import { cleanD1Tables } from "./cleanup"; + +const USERS = ["owner", "admin", "viewer", "collaborator", "outsider"] as const; +const PUBLIC_IDS = ["workspace-root", "alpha-root", "beta-root", "beta-child"]; +const FILTERS = { startAt: 1000, endAt: 2000, scope: "all" as const }; + +function userViewer( + userId: (typeof USERS)[number], + roleKey: "owner" | "administrator" | "member" +): Extract { + return { + kind: "user", + userId, + roleKey, + permissions: ["sessions.read"], + suspended: false, + memberships: new Map( + userId === "viewer" + ? [["team_alpha", "member"] as const] + : userId === "collaborator" + ? [["team_beta", "member"] as const] + : [] + ), + }; +} + +async function seedFixture(): Promise { + await env.DB.batch( + USERS.map((id) => + env.DB.prepare( + "INSERT INTO users (id, display_name, created_at, updated_at) VALUES (?, ?, 1, 1)" + ).bind(id, id) + ) + ); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_alpha', 'alpha', 'Alpha', 1, 1), ('team_beta', 'beta', 'Beta', 1, 1)" + ).run(); + await env.DB.prepare( + "INSERT INTO team_memberships (team_id, user_id, created_at) VALUES ('team_alpha', 'viewer', 1), ('team_beta', 'collaborator', 1)" + ).run(); + + const index = new SessionIndexStore(env.DB); + const rows: Array<{ + id: string; + visibility: SessionEntry["visibility"]; + ownerTeamId: string | null; + userId: string; + cost: number; + parentSessionId?: string; + status?: SessionEntry["status"]; + }> = [ + { + id: "workspace-root", + visibility: "workspace", + ownerTeamId: null, + userId: "outsider", + cost: 1, + }, + { id: "alpha-root", visibility: "team", ownerTeamId: "team_alpha", userId: "viewer", cost: 2 }, + { + id: "beta-root", + visibility: "team", + ownerTeamId: "team_beta", + userId: "collaborator", + cost: 4, + }, + { + id: "beta-child", + visibility: "team", + ownerTeamId: "team_beta", + userId: "collaborator", + cost: 8, + parentSessionId: "workspace-root", + status: "active", + }, + { id: "own-private", visibility: "private", ownerTeamId: null, userId: "viewer", cost: 16 }, + { + id: "shared-private", + visibility: "private", + // Team-owned collaborator grants require membership, so share within the collaborator's team. + ownerTeamId: "team_beta", + userId: "outsider", + cost: 32, + }, + { + id: "private-child", + visibility: "private", + ownerTeamId: null, + userId: "outsider", + cost: 64, + parentSessionId: "workspace-root", + status: "active", + }, + ]; + for (const [position, row] of rows.entries()) { + await index.create({ + id: row.id, + title: row.id, + ownerTeamId: row.ownerTeamId, + visibility: row.visibility, + userId: row.userId, + repoOwner: "acme", + repoName: "widgets", + baseBranch: "main", + model: "anthropic/claude-haiku-4-5", + reasoningEffort: null, + status: row.status ?? "completed", + parentSessionId: row.parentSessionId, + spawnSource: row.parentSessionId ? "agent" : "user", + spawnDepth: row.parentSessionId ? 1 : 0, + createdAt: 1000 + position, + updatedAt: 1000 + position, + }); + await index.updateMetrics(row.id, { + totalCost: row.cost, + activeDurationMs: 0, + messageCount: 0, + prCount: 0, + inputTokens: row.cost, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }); + } + await env.DB.prepare( + "INSERT INTO session_collaborators (session_id, user_id, added_by, created_at) VALUES ('shared-private', 'collaborator', 'outsider', 1)" + ).run(); + for (const id of ["beta-child", "private-child"]) { + await index.recordLatestTerminalMessage({ + sessionId: id, + messageId: `message-${id}`, + messageCreatedAt: 1100, + terminalMessageCompletedAt: 1100, + }); + } + await env.DB.batch( + [...rows.map(({ id }) => id), "unattached"].map((id, position) => + env.DB.prepare( + `INSERT INTO pr_autofix_feedback + (feedback_key, provider_object_kind, provider_object_id, delivery_id, + repository_external_id, repo_owner, repo_name, pr_number, session_id, + decision, first_received_at, last_received_at) + VALUES (?, 'pr_comment', ?, ?, '1', 'acme', 'widgets', 1, ?, 'received', ?, ?)` + ).bind( + `feedback-${id}`, + id, + `delivery-${id}`, + id === "unattached" ? null : id, + 1200 + position, + 1200 + position + ) + ) + ); +} + +describe("cross-reader D1 session visibility", () => { + beforeAll(async () => applyD1Migrations(env.DB, env.TEST_MIGRATIONS)); + beforeEach(async () => { + await cleanD1Tables(); + await seedFixture(); + }); + afterEach(cleanD1Tables); + + it("agrees across lists, inbox, both exports, and analytics for each viewer and mode", async () => { + const index = new SessionIndexStore(env.DB); + const exports = new SessionExportStore(env.DB); + expect( + checkSessionAccess( + userViewer("owner", "owner"), + { + id: "private-child", + ownerUserId: "outsider", + ownerTeamId: null, + visibility: "private", + collaboratorIds: [], + }, + "read" + ) + ).toEqual({ allowed: true, audit: "session.private_break_glass" }); + const cases = [ + { + viewer: userViewer("viewer", "member"), + mode: "on" as const, + ids: ["workspace-root", "alpha-root", "own-private"], + publicIds: ["workspace-root", "alpha-root"], + cost: 3, + attention: [], + }, + { + viewer: userViewer("viewer", "member"), + mode: "shadow" as const, + ids: [...PUBLIC_IDS, "own-private"], + publicIds: PUBLIC_IDS, + cost: 15, + attention: ["workspace-root"], + }, + { + viewer: userViewer("viewer", "member"), + mode: "off" as const, + ids: [...PUBLIC_IDS, "own-private"], + publicIds: PUBLIC_IDS, + cost: 15, + attention: ["workspace-root"], + }, + { + viewer: userViewer("collaborator", "member"), + mode: "on" as const, + ids: ["workspace-root", "beta-root", "beta-child", "shared-private"], + publicIds: ["workspace-root", "beta-root", "beta-child"], + cost: 13, + attention: ["workspace-root"], + }, + { + viewer: userViewer("collaborator", "member"), + mode: "shadow" as const, + ids: [...PUBLIC_IDS, "shared-private"], + publicIds: PUBLIC_IDS, + cost: 15, + attention: ["workspace-root"], + }, + { + viewer: userViewer("admin", "administrator"), + mode: "on" as const, + ids: PUBLIC_IDS, + publicIds: PUBLIC_IDS, + cost: 15, + attention: ["workspace-root"], + }, + { + viewer: userViewer("owner", "owner"), + mode: "on" as const, + ids: PUBLIC_IDS, + publicIds: PUBLIC_IDS, + cost: 15, + attention: ["workspace-root"], + }, + ]; + + for (const { viewer, mode, ids, publicIds, cost, attention } of cases) { + const expected = [...ids].sort(); + const label = `${viewer.kind === "user" ? viewer.userId : "service"}/${mode}`; + const listed = await index.list({ readScope: viewer, mode, limit: 20 }); + expect(listed.sessions.map(({ id }) => id).sort(), label).toEqual(expected); + expect(listed.hasMore, label).toBe(false); + + const inbox = await index.listInboxSnapshot({ + readScope: viewer, + mode, + viewerUserId: viewer.userId, + limit: 20, + }); + const inboxIds = Object.values(inbox).flatMap(({ items }) => + items.flatMap(({ rootSession, descendantSessions }) => [ + rootSession.id, + ...descendantSessions.map(({ id }) => id), + ]) + ); + expect(inboxIds.sort(), label).toEqual(expected); + expect( + inbox.needs_attention.items.map(({ rootSession }) => rootSession.id), + label + ).toEqual(attention); + expect(inbox.in_progress.items, label).toEqual([]); + expect( + inbox.finished.items + .flatMap(({ rootSession, descendantSessions }) => [ + rootSession.id, + ...descendantSessions.map(({ id }) => id), + ]) + .sort(), + label + ).toEqual(expected.filter((id) => !attention.includes(id) && id !== "beta-child").sort()); + + const sessionsPage = await exports.list({ readScope: viewer, mode, cursor: null, limit: 20 }); + const runsPage = await exports.list({ + readScope: viewer, + mode, + scope: "runs", + cursor: null, + limit: 20, + }); + expect(sessionsPage.sessions.map(({ id }) => id).sort(), label).toEqual(expected); + expect(runsPage.sessions.map(({ id }) => id).sort(), label).toEqual(expected); + expect(sessionsPage.nextCursor, label).toBeNull(); + expect(runsPage.nextCursor, label).toBeNull(); + + const analytics = new AnalyticsStore(env.DB, viewer, mode); + const summary = await analytics.getSummary(FILTERS); + const breakdown = await analytics.getBreakdown(FILTERS, "repo"); + const byUser = await analytics.getBreakdown(FILTERS, "user"); + expect(summary, label).toMatchObject({ + totalSessions: publicIds.length, + totalCost: cost, + inputTokens: cost, + privateSessionsCostUsd: + viewer.roleKey === "owner" || viewer.roleKey === "administrator" ? 112 : null, + }); + expect(breakdown.entries, label).toMatchObject([ + { key: "acme/widgets", sessions: publicIds.length, cost, inputTokens: cost }, + ]); + expect( + byUser.entries.reduce((sum, entry) => sum + entry.sessions, 0), + label + ).toBe(publicIds.length); + expect( + byUser.entries.reduce((sum, entry) => sum + entry.cost, 0), + label + ).toBe(cost); + + const runs = await new SessionRunStore(env.DB, viewer, mode).list({ + ...FILTERS, + orderBy: "cost", + limit: 20, + }); + expect( + runs.reduce((sum, run) => sum + run.sessionCount, 0), + label + ).toBe(publicIds.length); + expect( + runs.reduce((sum, run) => sum + run.totalCost, 0), + label + ).toBe(cost); + } + + expect( + ( + await index.list({ + readScope: userViewer("viewer", "member"), + mode: "on", + search: "alpha", + limit: 20, + }) + ).sessions.map(({ id }) => id) + ).toEqual(["alpha-root"]); + }); + + it("keeps private rows out of service lists and autofix activity, including before paging", async () => { + const index = new SessionIndexStore(env.DB); + const exports = new SessionExportStore(env.DB); + for (const [viewer, expected] of [ + [{ kind: "service", teamId: null }, PUBLIC_IDS], + [{ kind: "service", teamId: "team_alpha" }, ["workspace-root", "alpha-root"]], + ] as const satisfies ReadonlyArray) { + expect( + (await index.list({ readScope: viewer, mode: "on", limit: 20 })).sessions + .map(({ id }) => id) + .sort() + ).toEqual([...expected].sort()); + for (const mode of ["off", "shadow"] as const) { + expect( + (await exports.list({ readScope: viewer, mode, cursor: null, limit: 20 })).sessions + .map(({ id }) => id) + .sort() + ).toEqual([...PUBLIC_IDS].sort()); + } + } + + const activity = new PrAutofixFeedbackStore(env.DB); + const sessionIds: Array = []; + let cursor: string | null = null; + do { + const page = await activity.listActivity({ limit: 2, cursor }); + sessionIds.push(...page.records.map(({ sessionId }) => sessionId)); + cursor = page.nextCursor; + } while (cursor); + expect(sessionIds).toEqual([null, "beta-child", "beta-root", "alpha-root", "workspace-root"]); + }); +}); diff --git a/packages/control-plane/test/integration/spawn-children.test.ts b/packages/control-plane/test/integration/spawn-children.test.ts index f0cf67d096..0f5fad4807 100644 --- a/packages/control-plane/test/integration/spawn-children.test.ts +++ b/packages/control-plane/test/integration/spawn-children.test.ts @@ -4,7 +4,13 @@ import { runInSessionDO } from "./session-do-access"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { SessionIndexStore } from "../../src/db/session-index"; import { cleanD1Tables } from "./cleanup"; -import { initNamedSessionDO, queryDO, seedMessage, seedSandboxAuth } from "./helpers"; +import { + initNamedSessionDO, + queryDO, + seedActiveUser, + seedMessage, + seedSandboxAuth, +} from "./helpers"; describe("POST /sessions/:parentId/children — spawn child", () => { beforeEach(cleanD1Tables); @@ -163,6 +169,137 @@ describe("POST /sessions/:parentId/children — spawn child", () => { expect(state.status).toBe("active"); }); + it("inherits private visibility, owner and collaborators when the prompt author is not canonical", async () => { + const ownerId = "11111111111111111111111111111111"; + const collaboratorId = "22222222222222222222222222222222"; + await seedActiveUser(ownerId); + await seedActiveUser(collaboratorId); + const { parentName, sandboxToken, store } = await setupParent({ + visibility: "private", + repoId: 12345, + userId: "slack:U0123", + }); + await env.DB.prepare("UPDATE sessions SET user_id = ? WHERE id = ?") + .bind(ownerId, parentName) + .run(); + await env.DB.prepare( + "INSERT INTO session_collaborators (session_id, user_id, added_by, created_at) VALUES (?, ?, ?, ?)" + ) + .bind(parentName, collaboratorId, ownerId, Date.now()) + .run(); + + const response = await SELF.fetch(`https://test.local/sessions/${parentName}/children`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ title: "Private child", prompt: "Investigate" }), + }); + expect(response.status).toBe(201); + const { sessionId } = await response.json<{ sessionId: string }>(); + expect(await store.get(sessionId)).toMatchObject({ + visibility: "private", + userId: ownerId, + ownerTeamId: null, + }); + const collaborator = await env.DB.prepare( + "SELECT user_id FROM session_collaborators WHERE session_id = ?" + ) + .bind(sessionId) + .first<{ user_id: string }>(); + expect(collaborator?.user_id).toBe(collaboratorId); + const audit = await env.DB.prepare( + "SELECT team_id, resource_id FROM authorization_audit_events WHERE action = 'session.created_private'" + ).first<{ team_id: string | null; resource_id: string }>(); + expect(audit).toEqual({ team_id: null, resource_id: sessionId }); + }); + + it.each([null, "22"])( + "does not borrow the private owner's credential identity for an unresolved author (SCM %s)", + async (scmUserId) => { + const ownerId = "11111111111111111111111111111111"; + await seedActiveUser(ownerId); + const { parentName, stub, sandboxToken, store } = await setupParent({ + visibility: "private", + repoId: 12345, + canonicalUserId: ownerId, + }); + await runInSessionDO(stub, (_instance: SessionDO, state) => { + state.storage.sql.exec( + "INSERT INTO participants (id, user_id, scm_user_id, scm_login, role, joined_at) VALUES (?, ?, ?, ?, 'member', ?)", + "reviewer-participant", + "github:22", + scmUserId, + scmUserId ? "reviewer" : null, + Date.now() + ); + state.storage.sql.exec( + "UPDATE messages SET author_id = ? WHERE status = 'processing'", + "reviewer-participant" + ); + }); + const response = await SELF.fetch(`https://test.local/sessions/${parentName}/children`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ title: "Review follow-up", prompt: "Investigate the review" }), + }); + expect(response.status).toBe(201); + const { sessionId } = await response.json<{ sessionId: string }>(); + expect((await store.get(sessionId))?.userId).toBe(ownerId); + const childStub = env.SESSION.get(env.SESSION.idFromName(sessionId)); + await markChildPromptProcessing(childStub); + const authorResponse = await childStub.fetch("http://internal/internal/active-prompt-author"); + expect(authorResponse.status).toBe(200); + const author = await authorResponse.json<{ canonicalUserId?: string | null }>(); + expect(author).toMatchObject({ userId: "github:22", scmUserId }); + expect(author.canonicalUserId ?? null).toBeNull(); + } + ); + + it("retains the private parent's owner as a child collaborator when another user authors it", async () => { + const ownerId = "11111111111111111111111111111111"; + const authorId = "22222222222222222222222222222222"; + await seedActiveUser(ownerId); + await seedActiveUser(authorId); + const { parentName, stub, sandboxToken, store } = await setupParent({ + visibility: "private", + repoId: 12345, + userId: "slack:U1", + canonicalUserId: ownerId, + }); + await env.DB.prepare( + "INSERT INTO session_collaborators (session_id, user_id, added_by, created_at) VALUES (?, ?, ?, ?)" + ) + .bind(parentName, authorId, ownerId, Date.now()) + .run(); + await runInSessionDO(stub, (_instance: SessionDO, state) => { + state.storage.sql.exec( + `INSERT INTO participants (id, user_id, canonical_user_id, role, joined_at) + VALUES (?, ?, ?, 'member', ?)`, + "other-author", + "slack:U2", + authorId, + Date.now() + ); + state.storage.sql.exec( + "UPDATE messages SET author_id = ? WHERE status = 'processing'", + "other-author" + ); + }); + const response = await SELF.fetch(`https://test.local/sessions/${parentName}/children`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sandboxToken}` }, + body: JSON.stringify({ title: "Shared private child", prompt: "Investigate" }), + }); + expect(response.status).toBe(201); + const { sessionId } = await response.json<{ sessionId: string }>(); + expect((await store.get(sessionId))?.userId).toBe(authorId); + const collaborators = await env.DB.prepare( + "SELECT user_id FROM session_collaborators WHERE session_id = ? ORDER BY user_id" + ) + .bind(sessionId) + .all<{ user_id: string }>(); + expect(collaborators.results.map((row) => row.user_id)).toEqual([ownerId, authorId]); + }); + it("attributes a child to the active prompt author instead of the parent owner", async () => { const { parentName, stub, sandboxToken, store } = await setupParent({ repoId: 12345, diff --git a/packages/control-plane/test/integration/team-member-privacy.test.ts b/packages/control-plane/test/integration/team-member-privacy.test.ts new file mode 100644 index 0000000000..166a7d7fb8 --- /dev/null +++ b/packages/control-plane/test/integration/team-member-privacy.test.ts @@ -0,0 +1,221 @@ +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { teamMemberSchema } from "@open-inspect/shared/types/teams"; +import { auditEventListResponseSchema } from "@open-inspect/shared/types/audit-events"; +import { TeamStore } from "../../src/db/teams"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { serviceFetch } from "./helpers"; +import { + BASE, + OWNER, + MEMBER, + OTHER, + request, + setRole, + auditEvents, + requestAuditEvents, + modeRequest, + setupTeamRoutes, +} from "./team-route-helpers"; + +describe("team member privacy", () => { + beforeEach(setupTeamRoutes); + + it.each(["off", "shadow", "on"] as const)( + "does not expose team activity to members or administrators in %s mode", + async (mode) => { + const team = await new TeamStore(env.DB).create({ + slug: "no-activity", + name: "No activity", + joinPolicy: "invite_only", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + for (const role of ["member", "administrator", "owner"] as const) { + await setRole(OWNER, role); + expect((await modeRequest(`/teams/${team.id}/activity`, mode, role)).status).toBe(404); + } + } + ); + + it.each([ + ["member", "lead", false], + ["member", "lead", true], + ["administrator", null, false], + ["administrator", null, true], + ] as const)( + "admits and audits cross-target removal for %s/%s (archived: %s)", + async (role, teamRole, archived) => { + await setRole(OWNER, role); + const teams = new TeamStore(env.DB); + const team = await teams.create({ + slug: "managed-removal", + name: "Managed removal", + joinPolicy: "open", + }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, MEMBER, "lead"); + await memberships.add(team.id, OTHER); + if (teamRole) await memberships.add(team.id, OWNER, teamRole); + if (archived) await teams.archive(team.id); + + const response = await request(`/teams/${team.id}/members/${OTHER}`, "DELETE"); + expect(response.status).toBe(204); + expect((await memberships.listForUser(OTHER)).has(team.id)).toBe(false); + expect(await requestAuditEvents(response)).toEqual([ + { action: "authorization.request_allowed" }, + { action: "team.member_removed" }, + ]); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "returns directory emails only with workspace member read permission in %s mode", + async (mode) => { + const team = await new TeamStore(env.DB).create({ + slug: "email-directory", + name: "Email directory", + joinPolicy: "open", + }); + const memberships = new TeamMembershipStore(env.DB); + for (const userId of [MEMBER, OTHER]) { + await env.DB.prepare( + "UPDATE users SET display_name = ?, email = ?, avatar_url = ? WHERE id = ?" + ) + .bind("Team member", `${userId}@example.com`, "https://example.com/avatar.png", userId) + .run(); + await memberships.add(team.id, userId); + } + for (const role of ["member", "administrator"] as const) { + await setRole(OWNER, role); + const response = await modeRequest(`/teams/${team.id}/members`, mode, role); + expect(response.status).toBe(200); + const body = await response.json<{ members: unknown[] }>(); + const members = teamMemberSchema.array().parse(body.members); + expect(members).toHaveLength(2); + for (const member of members) { + expect(member).toMatchObject({ + displayName: "Team member", + email: role === "administrator" ? `${member.userId}@example.com` : null, + avatarUrl: "https://example.com/avatar.png", + }); + } + } + } + ); + + it("redacts member emails for a non-administrator lead on add, role change, and unchanged role", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "email-lead", + name: "Email lead", + joinPolicy: "invite_only", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER, "lead"); + await setRole(OWNER, "member"); + await env.DB.prepare("UPDATE users SET email = ? WHERE id = ?") + .bind("member@example.com", MEMBER) + .run(); + for (const role of ["member", "lead", "lead"] as const) { + const response = await request(`/teams/${team.id}/members/${MEMBER}`, "PUT", { role }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + member: { userId: MEMBER, role, email: null }, + }); + } + await setRole(OWNER, "administrator"); + const response = await request(`/teams/${team.id}/members/${MEMBER}`, "PUT", { + role: "member", + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + member: { userId: MEMBER, email: "member@example.com" }, + }); + }); + + it("denies member audit reads and exposes administrator membership audit metadata without profile fields", async () => { + await setRole(OWNER, "administrator"); + await setRole(OTHER, "member"); + await env.DB.prepare( + "UPDATE users SET display_name = ?, email = ?, avatar_url = ? WHERE id = ?" + ) + .bind("Ada", "ada@example.com", "https://example.com/ada.png", MEMBER) + .run(); + const team = await new TeamStore(env.DB).create({ + slug: "roles", + name: "Roles", + joinPolicy: "invite_only", + }); + const store = new TeamMembershipStore(env.DB); + await store.add(team.id, OWNER, "lead"); + await store.add(team.id, OTHER); + const addedResponse = await request(`/teams/${team.id}/members/${MEMBER}`, "PUT", { + role: "member", + }); + expect(addedResponse.status).toBe(200); + const added = teamMemberSchema.parse((await addedResponse.json<{ member: unknown }>()).member); + expect(added).toMatchObject({ + userId: MEMBER, + role: "member", + displayName: "Ada", + email: "ada@example.com", + avatarUrl: "https://example.com/ada.png", + }); + const changedResponse = await request(`/teams/${team.id}/members/${MEMBER}`, "PUT", { + role: "lead", + }); + expect(changedResponse.status).toBe(200); + expect(await changedResponse.json()).toMatchObject({ + member: { userId: MEMBER, role: "lead", email: "ada@example.com" }, + }); + expect((await request(`/teams/${team.id}/members/${MEMBER}`, "DELETE")).status).toBe(204); + const member = { + teamId: team.id, + userId: MEMBER, + role: "member", + source: "manual", + createdAt: added.createdAt, + }; + const lead = { ...member, role: "lead" }; + const expectedEvents = [ + { + action: "team.member_added", + metadata: { before: {}, requested: {}, after: member }, + }, + { + action: "team.member_role_changed", + metadata: { before: member, requested: {}, after: lead }, + }, + { + action: "team.member_removed", + metadata: { before: lead, requested: {}, after: {} }, + }, + ]; + const rows = await auditEvents(team.id); + expect( + rows.map((row) => ({ + action: row.action, + metadata: JSON.parse(String(row.metadata_json)), + })) + ).toEqual(expectedEvents); + for (const row of rows) { + expect(row.team_id).toBe(team.id); + expect(row.target_user_id_snapshot).toBe(MEMBER); + } + + const denied = await serviceFetch(`${BASE}/audit-events?teamId=${team.id}`, { + as: { userId: OTHER, role: "member" }, + }); + expect(denied.status).toBe(403); + const response = await request(`/audit-events?teamId=${team.id}`); + expect(response.status).toBe(200); + const feed = auditEventListResponseSchema.parse(await response.json()); + expect(feed.hasMore).toBe(false); + const domainEvents = feed.events.filter(({ resourceType }) => resourceType !== "http_route"); + expect(domainEvents.map(({ action, metadata }) => ({ action, metadata }))).toEqual( + [...expectedEvents].reverse() + ); + for (const event of domainEvents) { + expect(event.actorUserIdSnapshot).toBe(OWNER); + expect(event.targetUserIdSnapshot).toBe(MEMBER); + } + }); +}); diff --git a/packages/control-plane/test/integration/team-route-helpers.ts b/packages/control-plane/test/integration/team-route-helpers.ts new file mode 100644 index 0000000000..e2778ef19f --- /dev/null +++ b/packages/control-plane/test/integration/team-route-helpers.ts @@ -0,0 +1,64 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, seedActiveUser, serviceFetch, serviceRequestHeaders } from "./helpers"; + +export const BASE = "https://test.local"; +export const OWNER = "11111111111111111111111111111111"; +export const MEMBER = "22222222222222222222222222222222"; +export const OTHER = "33333333333333333333333333333333"; + +export async function request(path: string, method = "GET", body?: object) { + return serviceFetch(`${BASE}${path}`, { + method, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +export async function setRole( + userId: string, + role: "owner" | "administrator" | "member" | "viewer" +) { + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY[role].id, userId) + .run(); +} + +export async function auditEvents(teamId: string) { + const result = await env.DB.prepare( + "SELECT action, team_id, target_user_id_snapshot, metadata_json FROM authorization_audit_events WHERE resource_type = 'team' AND team_id = ? ORDER BY occurred_at, id" + ) + .bind(teamId) + .all(); + return result.results; +} + +export async function requestAuditEvents(response: Response) { + const result = await env.DB.prepare( + "SELECT action FROM authorization_audit_events WHERE request_id = ? ORDER BY action" + ) + .bind(response.headers.get("x-request-id")) + .all(); + return result.results; +} + +export async function modeRequest( + path: string, + mode: "off" | "shadow" | "on", + role: "owner" | "administrator" | "member" | "viewer" = "member" +) { + const url = `${BASE}${path}`; + const headers = await serviceRequestHeaders(url, { as: { userId: OWNER, role } }); + return routeRequest( + new Request(url, { headers }), + { ...env, TEAMS_ENFORCEMENT: mode }, + createExecutionContext() + ); +} + +export async function setupTeamRoutes() { + await cleanD1Tables(); + await seedActiveUser(MEMBER); + await seedActiveUser(OTHER); + await request("/me/authorization"); +} diff --git a/packages/control-plane/test/integration/team-secrets-routes.test.ts b/packages/control-plane/test/integration/team-secrets-routes.test.ts new file mode 100644 index 0000000000..7dce7d5bd0 --- /dev/null +++ b/packages/control-plane/test/integration/team-secrets-routes.test.ts @@ -0,0 +1,343 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ImageBuildStore } from "../../src/db/image-builds"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamSecretsStore } from "../../src/db/team-secrets"; +import { TeamStore } from "../../src/db/teams"; +import { MAX_SECRETS_PER_SCOPE, MAX_VALUE_SIZE } from "../../src/db/secrets-validation"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, serviceRequestHeaders, type ServiceRequestInit } from "./helpers"; +import { environmentScope, getRow, seedEnvironment, seedImageRow } from "./image-build-helpers"; + +const BASE = "https://test.local"; +const LEAD = "22222222222222222222222222222222"; +const MEMBER = "33333333333333333333333333333333"; +const OTHER = "44444444444444444444444444444444"; +const ADMIN = "55555555555555555555555555555555"; +const OWNER = "11111111111111111111111111111111"; +const MODES = ["off", "shadow", "on"] as const; +const METHODS = ["GET", "PUT", "DELETE"] as const; + +async function request( + path: string, + init: ServiceRequestInit = {}, + mode: (typeof MODES)[number] = "on", + overrides: Partial = {} +) { + const url = `${BASE}${path}`; + return routeRequest( + new Request(url, { + method: init.method ?? "GET", + headers: await serviceRequestHeaders(url, init), + body: init.body, + }), + { ...env, TEAMS_ENFORCEMENT: mode, ...overrides }, + createExecutionContext() + ); +} + +function mutationInit(method: (typeof METHODS)[number]): ServiceRequestInit { + return { + method, + ...(method === "PUT" ? { body: JSON.stringify({ secrets: { TOKEN: "new-value" } }) } : {}), + }; +} + +describe("team secrets routes", () => { + let teamId: string; + let store: TeamSecretsStore; + + beforeEach(async () => { + await cleanD1Tables(); + for (const [userId, role] of [ + [OWNER, "owner"], + [LEAD, "member"], + [MEMBER, "member"], + [OTHER, "member"], + [ADMIN, "administrator"], + ] as const) { + await serviceRequestHeaders(`${BASE}/me/authorization`, { as: { userId, role } }); + } + teamId = ( + await new TeamStore(env.DB).create({ + slug: "secrets", + name: "Secrets", + joinPolicy: "invite_only", + }) + ).id; + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(teamId, LEAD, "lead"); + await memberships.add(teamId, MEMBER, "member"); + store = new TeamSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); + await store.setSecrets(teamId, { TOKEN: "original-sensitive-value" }); + }); + + function path(method: (typeof METHODS)[number], id = teamId) { + return `/teams/${id}/secrets${method === "DELETE" ? "/token" : ""}`; + } + + it.each(MODES)( + "allows leads, administrators and owners to manage secrets in %s mode", + async (mode) => { + for (const [userId, role] of [ + [LEAD, "member"], + [ADMIN, "administrator"], + [OWNER, "owner"], + ] as const) { + await store.setSecrets(teamId, { TOKEN: "original-sensitive-value" }); + for (const method of METHODS) { + const response = await request( + path(method), + { ...mutationInit(method), as: { userId, role } }, + mode + ); + expect(response.status, `${role}/${method}`).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + } + } + } + ); + + it.each(MODES)( + "denies members, nonmembers, bots and suspended leads in %s mode", + async (mode) => { + for (const userId of [MEMBER, OTHER]) { + for (const method of METHODS) { + const response = await request( + path(method), + { ...mutationInit(method), as: { userId, role: "member" } }, + mode + ); + expect(response.status, `${userId}/${method}`).toBe(403); + expect(await response.json()).toMatchObject({ reason_code: "team_capability_required" }); + } + } + for (const [service, validActor] of [ + ["slack-bot", "slack:U-SECRET-ACTOR"], + ["github-bot", "github:987654321"], + ["linear-bot", "linear:U-SECRET-ACTOR"], + ] as const) { + for (const actor of [undefined, validActor]) { + for (const method of METHODS) { + const response = await request( + path(method), + { ...mutationInit(method), service, actor }, + mode + ); + expect(response.status, `${service}/${actor}/${method}`).toBe(403); + } + } + } + await env.DB.prepare("UPDATE users SET suspended_at = ? WHERE id = ?") + .bind(Date.now(), LEAD) + .run(); + for (const method of METHODS) { + expect( + ( + await request( + path(method), + { ...mutationInit(method), as: { userId: LEAD, role: "member" } }, + mode + ) + ).status + ).toBe(403); + } + expect(await store.getDecryptedSecrets(teamId)).toEqual({ + TOKEN: "original-sensitive-value", + }); + expect( + ( + await env.DB.prepare( + "SELECT action FROM authorization_audit_events WHERE action IN ('team.secret_set', 'team.secret_deleted')" + ).all() + ).results + ).toEqual([]); + } + ); + + it("returns editor-compatible metadata and mutation responses without plaintext or ciphertext", async () => { + const other = await new TeamStore(env.DB).create({ + slug: "other", + name: "Other", + joinPolicy: "invite_only", + }); + await store.setSecrets(other.id, { OTHER_TOKEN: "other-team-value" }); + const listed = await request(path("GET")); + expect(await listed.json()).toEqual({ + teamId, + secrets: [{ key: "TOKEN", createdAt: expect.any(Number), updatedAt: expect.any(Number) }], + }); + const saved = await request(path("PUT"), { + method: "PUT", + body: JSON.stringify({ + secrets: { token: "replacement-sensitive-value", NEW_KEY: "new-sensitive-value" }, + }), + }); + expect(await saved.json()).toEqual({ + status: "updated", + teamId, + keys: ["TOKEN", "NEW_KEY"], + created: 1, + updated: 1, + }); + const ciphertext = await env.DB.prepare( + "SELECT encrypted_value FROM team_secrets WHERE team_id = ? AND key = 'TOKEN'" + ) + .bind(teamId) + .first<{ encrypted_value: string }>(); + const deleted = await request(path("DELETE"), { method: "DELETE" }); + expect(await deleted.json()).toEqual({ status: "deleted", teamId, key: "TOKEN" }); + const missing = await request(path("DELETE"), { method: "DELETE" }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: "Secret not found" }); + const audits = (await env.DB.prepare("SELECT * FROM authorization_audit_events").all()).results; + const serialized = JSON.stringify({ + audits, + metadata: await (await request(path("GET"))).json(), + }); + for (const value of [ + "original-sensitive-value", + "replacement-sensitive-value", + "new-sensitive-value", + "other-team-value", + ciphertext!.encrypted_value, + ]) { + expect(serialized).not.toContain(value); + } + expect(audits.filter((row) => row.action === "team.secret_set")).toHaveLength(1); + expect(audits.filter((row) => row.action === "team.secret_deleted")).toHaveLength(1); + }); + + it("lists metadata without decrypting stored values", async () => { + await env.DB.prepare( + "UPDATE team_secrets SET encrypted_value = 'corrupt-ciphertext' WHERE team_id = ?" + ) + .bind(teamId) + .run(); + const response = await request(path("GET")); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + teamId, + secrets: [{ key: "TOKEN", createdAt: expect.any(Number), updatedAt: expect.any(Number) }], + }); + }); + + it.each(["PUT", "DELETE"] as const)( + "%s makes an owned environment's ready image unselectable and supersedes its in-flight build", + async (method) => { + const environmentId = await seedEnvironment({ prebuildEnabled: true }); + await env.DB.prepare("UPDATE environments SET owner_team_id = ? WHERE id = ?") + .bind(teamId, environmentId) + .run(); + await seedImageRow({ + id: "team-ready-image", + environmentId, + status: "ready", + providerImageId: "team-secret-artifact", + }); + await seedImageRow({ id: "team-building-image", environmentId, status: "building" }); + const images = new ImageBuildStore(env.DB); + const scope = environmentScope(environmentId); + expect(await images.getLatestReadyForSpawn(scope, "modal")).toMatchObject({ + id: "team-ready-image", + }); + + const response = await request(path(method), mutationInit(method), "on", { + SANDBOX_PROVIDER: "daytona", + DAYTONA_PREBUILDS_ENABLED: "false", + }); + + expect(response.status).toBe(200); + expect(await images.getLatestReadyForSpawn(scope, "modal")).toBeNull(); + expect((await getRow("team-ready-image"))?.status).toBe("superseded"); + expect((await getRow("team-building-image"))?.status).toBe("superseded"); + const audits = ( + await env.DB.prepare( + "SELECT action, operation_result, metadata_json FROM authorization_audit_events WHERE action IN ('team.secret_set', 'team.secret_deleted')" + ).all() + ).results; + expect(audits).toHaveLength(1); + expect(audits[0]).toMatchObject({ + action: method === "PUT" ? "team.secret_set" : "team.secret_deleted", + operation_result: "applied", + }); + expect(JSON.parse(String(audits[0].metadata_json))).toEqual({ + before: { keys: ["TOKEN"] }, + requested: {}, + after: { keys: method === "PUT" ? ["TOKEN"] : [] }, + }); + } + ); + + it("returns a generic storage error when database row validation fails", async () => { + await env.DB.prepare( + "UPDATE team_secrets SET updated_at = 'sensitive-invalid-row' WHERE team_id = ?" + ) + .bind(teamId) + .run(); + const response = await request(path("GET")); + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "Secrets storage unavailable" }); + }); + + it.each(["{}", "[]", "null", '{"secrets":[]}', '{"secrets":{"TOKEN":42}}', "not-json"])( + "rejects malformed body %s", + async (body) => { + expect((await request(path("PUT"), { method: "PUT", body })).status).toBe(400); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ + TOKEN: "original-sensitive-value", + }); + } + ); + + it.each([ + { "BAD-KEY": "value" }, + { PATH: "value" }, + { token: "one", TOKEN: "two" }, + { TOKEN: "x".repeat(MAX_VALUE_SIZE + 1) }, + ])("rejects invalid secrets without leaking values (%#)", async (secrets) => { + const response = await request(path("PUT"), { + method: "PUT", + body: JSON.stringify({ secrets }), + }); + expect(response.status).toBe(400); + expect(await response.text()).not.toContain("original-sensitive-value"); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ TOKEN: "original-sensitive-value" }); + }); + + it("returns validation errors for the key cap and invalid delete keys", async () => { + await store.setSecrets( + teamId, + Object.fromEntries( + Array.from({ length: MAX_SECRETS_PER_SCOPE - 1 }, (_, i) => [`KEY_${i}`, "value"]) + ) + ); + expect( + (await request(path("PUT"), { method: "PUT", body: '{"secrets":{"EXTRA":"value"}}' })).status + ).toBe(400); + expect((await request(`/teams/${teamId}/secrets/BAD-KEY`, { method: "DELETE" })).status).toBe( + 400 + ); + expect((await request(`/teams/${teamId}/secrets/PATH`, { method: "DELETE" })).status).toBe(400); + expect(await store.listSecretKeys(teamId)).toHaveLength(MAX_SECRETS_PER_SCOPE); + }); + + it("requires a real team and configured encryption key for every method", async () => { + for (const method of METHODS) { + expect((await request(path(method, "team_missing"), mutationInit(method))).status).toBe(404); + const response = await request(path(method), mutationInit(method), "on", { + REPO_SECRETS_ENCRYPTION_KEY: undefined, + }); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "REPO_SECRETS_ENCRYPTION_KEY not configured", + }); + } + }); + + it("supports non-GitHub SCM configurations", async () => { + for (const SCM_PROVIDER of ["gitlab", "bitbucket"]) { + expect((await request(path("GET"), {}, "on", { SCM_PROVIDER })).status).toBe(200); + } + }); +}); diff --git a/packages/control-plane/test/integration/team-secrets-store.test.ts b/packages/control-plane/test/integration/team-secrets-store.test.ts new file mode 100644 index 0000000000..eb579362a1 --- /dev/null +++ b/packages/control-plane/test/integration/team-secrets-store.test.ts @@ -0,0 +1,403 @@ +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { decryptToken } from "../../src/auth/crypto"; +import { ImageBuildStore } from "../../src/db/image-builds"; +import { + MAX_SECRETS_PER_SCOPE, + MAX_TOTAL_VALUE_SIZE, + MAX_VALUE_SIZE, + SecretsValidationError, +} from "../../src/db/secrets-validation"; +import type { SqlDatabase, SqlStatement } from "../../src/db/sql-database"; +import { TeamSecretsStore } from "../../src/db/team-secrets"; +import { TeamStore } from "../../src/db/teams"; +import { IMAGE_BUILD_PROVIDER_IDS } from "../../src/image-builds/model"; +import { cleanD1Tables } from "./cleanup"; +import { sqlDatabase } from "./helpers"; +import { + environmentScope, + getRow, + seedEnvironment, + seedImageRowForScope, +} from "./image-build-helpers"; + +const AUDIT = { requestId: "team-secret-request", actorUserId: "team-secret-actor" }; + +async function secretAudits() { + return ( + await env.DB.prepare( + "SELECT action, resource_type, resource_id, team_id, actor_user_id_snapshot, request_id, operation_result, metadata_json FROM authorization_audit_events WHERE action IN ('team.secret_set', 'team.secret_deleted') ORDER BY occurred_at, id" + ).all() + ).results; +} + +function failingAuditDatabase(): SqlDatabase { + const db = sqlDatabase(env.DB); + return { + prepare(sql) { + return db.prepare( + sql.includes("INSERT INTO authorization_audit_events") + ? sql.replace("'applied'", "'invalid-result'") + : sql + ); + }, + batch(statements: SqlStatement[]) { + return db.batch(statements); + }, + }; +} + +function failingInvalidationDatabase(): SqlDatabase { + const db = sqlDatabase(env.DB); + return { + prepare(sql) { + return db.prepare( + sql.includes("UPDATE image_builds") + ? sql.replace("status = 'superseded'", "status = NULL") + : sql + ); + }, + batch(statements: SqlStatement[]) { + return db.batch(statements); + }, + }; +} + +async function seedOwnedEnvironment(teamId: string, prebuildEnabled = false): Promise { + const id = await seedEnvironment({ prebuildEnabled }); + await env.DB.prepare("UPDATE environments SET owner_team_id = ? WHERE id = ?") + .bind(teamId, id) + .run(); + return id; +} + +describe("TeamSecretsStore", () => { + let teamId: string; + let store: TeamSecretsStore; + + beforeEach(async () => { + await cleanD1Tables(); + teamId = ( + await new TeamStore(env.DB).create({ + slug: "secret-team", + name: "Secrets", + joinPolicy: "invite_only", + }) + ).id; + store = new TeamSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); + }); + + it("normalizes keys, encrypts with the shared key, lists metadata, upserts and deletes", async () => { + expect( + await store.setSecrets(teamId, { token: "secret-value", API_URL: "https://example.test" }) + ).toEqual({ created: 2, updated: 0, keys: ["TOKEN", "API_URL"] }); + const stored = await env.DB.prepare( + "SELECT encrypted_value FROM team_secrets WHERE team_id = ? AND key = 'TOKEN'" + ) + .bind(teamId) + .first<{ encrypted_value: string }>(); + expect(stored?.encrypted_value).not.toContain("secret-value"); + expect(await decryptToken(stored!.encrypted_value, env.REPO_SECRETS_ENCRYPTION_KEY!)).toBe( + "secret-value" + ); + expect(await store.listSecretKeys(teamId)).toEqual([ + { key: "API_URL", createdAt: expect.any(Number), updatedAt: expect.any(Number) }, + { key: "TOKEN", createdAt: expect.any(Number), updatedAt: expect.any(Number) }, + ]); + await env.DB.prepare("UPDATE team_secrets SET created_at = 1, updated_at = 1 WHERE team_id = ?") + .bind(teamId) + .run(); + expect(await store.setSecrets(teamId, { TOKEN: "replacement" })).toEqual({ + created: 0, + updated: 1, + keys: ["TOKEN"], + }); + expect(await store.listSecretKeys(teamId)).toContainEqual({ + key: "TOKEN", + createdAt: 1, + updatedAt: expect.any(Number), + }); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ + API_URL: "https://example.test", + TOKEN: "replacement", + }); + expect(await store.deleteSecret(teamId, "token")).toBe(true); + expect(await store.deleteSecret(teamId, "TOKEN")).toBe(false); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ API_URL: "https://example.test" }); + }); + + it("isolates identical keys between teams", async () => { + const other = await new TeamStore(env.DB).create({ + slug: "other-team", + name: "Other", + joinPolicy: "invite_only", + }); + await store.setSecrets(teamId, { TOKEN: "first" }); + await store.setSecrets(other.id, { TOKEN: "second" }); + await store.deleteSecret(teamId, "TOKEN"); + expect(await store.listSecretKeys(teamId)).toEqual([]); + expect(await store.getDecryptedSecrets(other.id)).toEqual({ TOKEN: "second" }); + }); + + it.each(["set", "delete"] as const)( + "%s supersedes live images across providers only for the team's environments, even with prebuilds disabled", + async (mutation) => { + await store.setSecrets(teamId, { TOKEN: "original" }); + const enabled = await seedOwnedEnvironment(teamId, true); + const disabled = await seedOwnedEnvironment(teamId); + const otherTeam = await new TeamStore(env.DB).create({ + slug: "other-image-team", + name: "Other image team", + joinPolicy: "invite_only", + }); + const other = await seedOwnedEnvironment(otherTeam.id); + const workspace = await seedEnvironment(); + const expected: Record = {}; + for (const environmentId of [enabled, disabled]) { + for (const provider of IMAGE_BUILD_PROVIDER_IDS) { + for (const status of ["building", "ready"]) { + const id = `${environmentId}-${provider}-${status}`; + await seedImageRowForScope(environmentScope(environmentId), { + id, + provider, + status, + providerImageId: status === "ready" ? `image-${id}` : null, + }); + expected[id] = "superseded"; + } + } + } + for (const scope of [ + environmentScope(other), + environmentScope(workspace), + { kind: "repo" as const, id: enabled }, + { kind: "repo" as const, id: "acme/web" }, + ]) { + for (const status of ["building", "ready"]) { + const id = `${scope.kind}-${scope.id}-${status}`; + await seedImageRowForScope(scope, { id, status }); + expected[id] = status; + } + } + for (const status of ["failed", "superseded"]) { + await seedImageRowForScope(environmentScope(enabled), { id: status, status }); + expected[status] = status; + } + + if (mutation === "set") { + await store.setSecrets(teamId, { TOKEN: "rotated", NEW_KEY: "new" }, AUDIT); + } else { + expect(await store.deleteSecret(teamId, "token", AUDIT)).toBe(true); + } + + const rows = (await env.DB.prepare("SELECT id, status FROM image_builds").all()).results; + expect(Object.fromEntries(rows.map((row) => [row.id, row.status]))).toEqual(expected); + expect(await secretAudits()).toHaveLength(1); + } + ); + + it.each([true, false])( + "does not invalidate images for empty writes or missing deletes (audit: %s)", + async (withAudit) => { + const environmentId = await seedOwnedEnvironment(teamId); + await seedImageRowForScope(environmentScope(environmentId), { + id: "unchanged-image", + status: "ready", + providerImageId: "unchanged-artifact", + }); + const audit = withAudit ? AUDIT : undefined; + expect(await store.setSecrets(teamId, {}, audit)).toEqual({ + created: 0, + updated: 0, + keys: [], + }); + expect(await store.deleteSecret(teamId, "MISSING", audit)).toBe(false); + expect((await getRow("unchanged-image"))?.status).toBe("ready"); + expect(await secretAudits()).toEqual([]); + } + ); + + it.each(["set", "delete"] as const)( + "invalidates images on a successful unaudited %s", + async (mutation) => { + await store.setSecrets(teamId, { TOKEN: "original" }); + const environmentId = await seedOwnedEnvironment(teamId); + await seedImageRowForScope(environmentScope(environmentId), { + id: "unaudited-image", + status: "ready", + }); + if (mutation === "set") { + await store.setSecrets(teamId, { TOKEN: "rotated" }); + } else { + expect(await store.deleteSecret(teamId, "TOKEN")).toBe(true); + } + expect((await getRow("unaudited-image"))?.status).toBe("superseded"); + expect(await secretAudits()).toEqual([]); + } + ); + + it.each([ + { "BAD-KEY": "value" }, + { PATH: "value" }, + { "": "value" }, + { ["K".repeat(257)]: "value" }, + { token: "one", TOKEN: "two" }, + { TOKEN: 123 }, + { TOKEN: "x".repeat(MAX_VALUE_SIZE + 1) }, + Object.fromEntries( + Array.from({ length: 5 }, (_, i) => [`KEY_${i}`, "x".repeat(MAX_TOTAL_VALUE_SIZE / 4)]) + ), + ])("rejects invalid entries without writing secrets or audits (%#)", async (secrets) => { + await expect(store.setSecrets(teamId, secrets, AUDIT)).rejects.toBeInstanceOf( + SecretsValidationError + ); + expect(await store.listSecretKeys(teamId)).toEqual([]); + expect(await secretAudits()).toEqual([]); + }); + + it("enforces the existing-key union cap while allowing updates at capacity", async () => { + const secrets = Object.fromEntries( + Array.from({ length: MAX_SECRETS_PER_SCOPE }, (_, i) => [`KEY_${i}`, "value"]) + ); + await store.setSecrets(teamId, secrets); + expect(await store.setSecrets(teamId, { KEY_0: "updated" })).toMatchObject({ updated: 1 }); + await expect(store.setSecrets(teamId, { EXTRA: "value" }, AUDIT)).rejects.toThrow( + `Team would exceed ${MAX_SECRETS_PER_SCOPE} secrets limit` + ); + expect(await store.listSecretKeys(teamId)).toHaveLength(MAX_SECRETS_PER_SCOPE); + expect(await secretAudits()).toEqual([]); + }); + + it("records only affected key names with the mutation and skips empty writes and missing deletes", async () => { + await store.setSecrets(teamId, { token: "sensitive-plaintext" }, AUDIT); + await store.setSecrets(teamId, { TOKEN: "sensitive-replacement", NEW_KEY: "new-value" }, AUDIT); + await store.deleteSecret(teamId, "token", AUDIT); + const audits = await secretAudits(); + expect(audits).toHaveLength(3); + for (const row of audits) { + expect(row).toMatchObject({ + resource_type: "team", + resource_id: teamId, + team_id: teamId, + actor_user_id_snapshot: AUDIT.actorUserId, + request_id: AUDIT.requestId, + operation_result: "applied", + }); + } + const setEvents = audits.filter((row) => row.action === "team.secret_set"); + expect(setEvents.map((row) => JSON.parse(String(row.metadata_json)))).toEqual( + expect.arrayContaining([ + { before: { keys: [] }, requested: {}, after: { keys: ["TOKEN"] } }, + { before: { keys: ["TOKEN"] }, requested: {}, after: { keys: ["TOKEN", "NEW_KEY"] } }, + ]) + ); + expect( + JSON.parse(String(audits.find((row) => row.action === "team.secret_deleted")?.metadata_json)) + ).toEqual({ before: { keys: ["TOKEN"] }, requested: {}, after: { keys: [] } }); + const ciphertext = await env.DB.prepare( + "SELECT encrypted_value FROM team_secrets WHERE team_id = ?" + ) + .bind(teamId) + .first<{ encrypted_value: string }>(); + for (const value of [ + "sensitive-plaintext", + "sensitive-replacement", + "new-value", + ciphertext!.encrypted_value, + ]) { + expect(JSON.stringify(audits)).not.toContain(value); + } + expect(await store.setSecrets(teamId, {}, AUDIT)).toEqual({ created: 0, updated: 0, keys: [] }); + expect(await store.deleteSecret(teamId, "MISSING", AUDIT)).toBe(false); + expect(await secretAudits()).toHaveLength(3); + }); + + it("rolls back every upsert and deletion when the operation audit fails", async () => { + await store.setSecrets(teamId, { TOKEN: "original" }); + const environmentId = await seedOwnedEnvironment(teamId); + await seedImageRowForScope(environmentScope(environmentId), { + id: "audit-failure-image", + status: "ready", + }); + const failing = new TeamSecretsStore(failingAuditDatabase(), env.REPO_SECRETS_ENCRYPTION_KEY!); + await expect( + failing.setSecrets(teamId, { TOKEN: "changed", NEW_KEY: "new" }, AUDIT) + ).rejects.toThrow(); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ TOKEN: "original" }); + await expect(failing.deleteSecret(teamId, "TOKEN", AUDIT)).rejects.toThrow(); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ TOKEN: "original" }); + expect((await getRow("audit-failure-image"))?.status).toBe("ready"); + expect(await secretAudits()).toEqual([]); + }); + + it("rolls back secrets and key-only audits when image invalidation fails, leaving deletion retryable", async () => { + await store.setSecrets(teamId, { TOKEN: "original" }); + const environmentId = await seedOwnedEnvironment(teamId); + const scope = environmentScope(environmentId); + await seedImageRowForScope(scope, { + id: "invalidation-failure-image", + status: "ready", + providerImageId: "original-artifact", + }); + const failing = new TeamSecretsStore( + failingInvalidationDatabase(), + env.REPO_SECRETS_ENCRYPTION_KEY! + ); + await expect( + failing.setSecrets(teamId, { TOKEN: "changed", NEW_KEY: "new" }, AUDIT) + ).rejects.toThrow(); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ TOKEN: "original" }); + expect(await secretAudits()).toEqual([]); + await expect(failing.deleteSecret(teamId, "TOKEN", AUDIT)).rejects.toThrow(); + expect(await store.getDecryptedSecrets(teamId)).toEqual({ TOKEN: "original" }); + expect((await getRow("invalidation-failure-image"))?.status).toBe("ready"); + expect(await secretAudits()).toEqual([]); + + expect(await store.deleteSecret(teamId, "TOKEN", AUDIT)).toBe(true); + expect(await new ImageBuildStore(env.DB).getLatestReadyForSpawn(scope, "modal")).toBeNull(); + expect(await secretAudits()).toHaveLength(1); + }); + + it("does not record an applied audit for a failed foreign-key mutation", async () => { + await expect(store.setSecrets("team_missing", { TOKEN: "secret" }, AUDIT)).rejects.toThrow(); + expect(await secretAudits()).toEqual([]); + }); + + it.each(["bad-key", "PATH", ""])( + "validates delete key %j before mutating or auditing", + async (key) => { + await expect(store.deleteSecret(teamId, key, AUDIT)).rejects.toBeInstanceOf( + SecretsValidationError + ); + expect(await secretAudits()).toEqual([]); + } + ); + + it("validates database metadata, encrypted rows, and existing keys with Zod", async () => { + await store.setSecrets(teamId, { TOKEN: "value" }); + await env.DB.prepare("UPDATE team_secrets SET updated_at = 'invalid' WHERE team_id = ?") + .bind(teamId) + .run(); + await expect(store.listSecretKeys(teamId)).rejects.toMatchObject({ name: "ZodError" }); + await env.DB.prepare("UPDATE team_secrets SET encrypted_value = '' WHERE team_id = ?") + .bind(teamId) + .run(); + await expect(store.getDecryptedSecrets(teamId)).rejects.toMatchObject({ name: "ZodError" }); + await env.DB.prepare("UPDATE team_secrets SET key = '' WHERE team_id = ?").bind(teamId).run(); + await expect(store.setSecrets(teamId, { OTHER: "value" })).rejects.toMatchObject({ + name: "ZodError", + }); + }); + + it("fails closed on corrupt ciphertext without including it in the error", async () => { + await store.setSecrets(teamId, { TOKEN: "value" }); + await env.DB.prepare( + "UPDATE team_secrets SET encrypted_value = 'corrupt-sensitive-ciphertext' WHERE team_id = ?" + ) + .bind(teamId) + .run(); + await expect(store.getDecryptedSecrets(teamId)).rejects.toThrow( + "Failed to decrypt secret 'TOKEN'" + ); + }); +}); diff --git a/packages/control-plane/test/integration/team-secrets.test.ts b/packages/control-plane/test/integration/team-secrets.test.ts new file mode 100644 index 0000000000..34cddeef5b --- /dev/null +++ b/packages/control-plane/test/integration/team-secrets.test.ts @@ -0,0 +1,164 @@ +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { EnvironmentSecretsStore } from "../../src/db/environment-secrets"; +import { GlobalSecretsStore } from "../../src/db/global-secrets"; +import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import { TeamSecretsStore } from "../../src/db/team-secrets"; +import { loadScopeBuildSecrets } from "../../src/image-builds/scope"; +import { createCloudflareEnv } from "../../src/cloudflare/platform"; +import { cleanD1Tables } from "./cleanup"; +import { initSession } from "./helpers"; +import { getUserEnvVars } from "./session-do-access"; + +const TEAM_A = "team_a"; +const TEAM_B = "team_b"; +const ENV_ID = "env_team_secrets"; +const key = () => env.REPO_SECRETS_ENCRYPTION_KEY!; + +describe("team secret resolution", () => { + beforeEach(async () => { + await cleanD1Tables(); + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES (?, ?, ?, 1, 1), (?, ?, ?, 1, 1)" + ) + .bind(TEAM_A, "a", "A", TEAM_B, "b", "B") + .run(); + await env.DB.prepare( + "INSERT INTO environments (id, name, owner_team_id, created_at, updated_at) VALUES (?, ?, ?, 1, 1)" + ) + .bind(ENV_ID, "Team secrets environment", TEAM_A) + .run(); + await new GlobalSecretsStore(env.DB, key()).setSecrets({ + SHARED: "global", + GLOBAL_TEAM: "global", + GLOBAL_ONLY: "global", + }); + const teamStore = new TeamSecretsStore(env.DB, key()); + await teamStore.setSecrets(TEAM_A, { + SHARED: "team-a", + GLOBAL_TEAM: "team-a", + TEAM_ONLY: "a", + XAI_OAUTH_REFRESH_TOKEN: "team-a-refresh", + }); + await teamStore.setSecrets(TEAM_B, { SHARED: "team-b", TEAM_B_ONLY: "b" }); + await new EnvironmentSecretsStore(env.DB, key()).setSecrets(ENV_ID, { SHARED: "environment" }); + }); + + async function sessionEnv(teamId: string | null, environmentId: string | null = null) { + const session = await initSession({ environmentId }); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ? WHERE id = ?") + .bind(teamId, session.sessionName) + .run(); + return { ...session, vars: await getUserEnvVars(session.stub) }; + } + + it("gives environment precedence over team and global without advertising team-only OAuth", async () => { + const { vars } = await sessionEnv(TEAM_A, ENV_ID); + expect(vars).toEqual({ + SHARED: "environment", + GLOBAL_TEAM: "team-a", + GLOBAL_ONLY: "global", + TEAM_ONLY: "a", + }); + }); + + it("gives team precedence over global without letting member repositories change the broker", async () => { + const { vars } = await sessionEnv(TEAM_A); + expect(vars).toEqual({ + SHARED: "team-a", + GLOBAL_TEAM: "team-a", + GLOBAL_ONLY: "global", + TEAM_ONLY: "a", + }); + }); + + it("keeps primary repository precedence over team secrets", async () => { + await new RepoSecretsStore(env.DB, key()).setSecrets(12345, "acme", "web-app", { + SHARED: "repo", + }); + const { vars } = await sessionEnv(TEAM_A); + expect(vars?.SHARED).toBe("repo"); + expect(vars?.GLOBAL_TEAM).toBe("team-a"); + }); + + it("never injects another team's secrets or any team secrets into workspace sessions", async () => { + const { vars } = await sessionEnv(TEAM_B); + expect(vars).toEqual({ + SHARED: "team-b", + GLOBAL_TEAM: "global", + GLOBAL_ONLY: "global", + TEAM_B_ONLY: "b", + }); + expect((await sessionEnv(null)).vars).toEqual({ + SHARED: "global", + GLOBAL_TEAM: "global", + GLOBAL_ONLY: "global", + }); + }); + + it("uses current D1 ownership after a session moves teams", async () => { + const session = await sessionEnv(TEAM_A); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ? WHERE id = ?") + .bind(TEAM_B, session.sessionName) + .run(); + const vars = await getUserEnvVars(session.stub); + expect(vars?.TEAM_ONLY).toBeUndefined(); + expect(vars?.XAI_OAUTH_MANAGED).toBeUndefined(); + expect(vars?.TEAM_B_ONLY).toBe("b"); + }); + + it("rejects corrupt team ciphertext instead of building with lower-precedence secrets", async () => { + await env.DB.prepare( + "UPDATE team_secrets SET encrypted_value = ? WHERE team_id = ? AND key = ?" + ) + .bind("corrupt-team-secret", TEAM_A, "GLOBAL_TEAM") + .run(); + + await expect( + loadScopeBuildSecrets( + createCloudflareEnv(env), + env.DB, + { kind: "environment", id: ENV_ID }, + { kind: "environment", repositories: [], repositoriesFingerprint: "test-env" } + ) + ).rejects.toThrow("Failed to decrypt secret 'GLOBAL_TEAM'"); + }); + + it("adds the environment owner's team to builds but never to repository-shared builds", async () => { + const appEnv = createCloudflareEnv(env); + expect( + await loadScopeBuildSecrets( + appEnv, + env.DB, + { kind: "environment", id: ENV_ID }, + { + kind: "environment", + repositories: [], + repositoriesFingerprint: "test-env", + } + ) + ).toEqual({ + SHARED: "environment", + GLOBAL_TEAM: "team-a", + GLOBAL_ONLY: "global", + TEAM_ONLY: "a", + XAI_OAUTH_REFRESH_TOKEN: "team-a-refresh", + }); + await new RepoSecretsStore(env.DB, key()).setSecrets(12345, "acme", "web-app", { + SHARED: "repo", + }); + expect( + await loadScopeBuildSecrets( + appEnv, + env.DB, + { kind: "repo", id: "acme/web-app" }, + { + kind: "repo", + repoId: 12345, + repositories: [], + repositoriesFingerprint: "test-repo", + } + ) + ).toEqual({ SHARED: "repo", GLOBAL_TEAM: "global", GLOBAL_ONLY: "global" }); + }); +}); diff --git a/packages/control-plane/test/integration/team-stores.test.ts b/packages/control-plane/test/integration/team-stores.test.ts index e88ce7f779..7dbe0477bb 100644 --- a/packages/control-plane/test/integration/team-stores.test.ts +++ b/packages/control-plane/test/integration/team-stores.test.ts @@ -1,5 +1,5 @@ import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { EnvironmentStore, type EnvironmentRow } from "../../src/db/environments"; import { TeamSlugConflictError, TeamStore } from "../../src/db/teams"; import { @@ -8,6 +8,7 @@ import { TeamMembershipNotFoundError, } from "../../src/db/team-memberships"; import { cleanD1Tables } from "./cleanup"; +import { sqlDatabase } from "./helpers"; beforeEach(cleanD1Tables); @@ -27,6 +28,39 @@ function environmentRow(overrides: Partial): EnvironmentRow { } describe("team and membership stores", () => { + it("selects email only when explicitly included in member identities", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "identities", + name: "Identities", + joinPolicy: "open", + }); + await env.DB.prepare( + "INSERT INTO users (id, display_name, email, avatar_url, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 1)" + ) + .bind("ada", "Ada", "ada@example.com", "https://example.com/ada.png") + .run(); + await new TeamMembershipStore(env.DB).add(team.id, "ada"); + const db = sqlDatabase(env.DB); + const prepare = vi.fn((sql: string) => db.prepare(sql)); + const store = new TeamMembershipStore({ prepare, batch: db.batch.bind(db) }); + for (const includeEmail of [false, true]) { + const members = await store.listMembersWithUsers(team.id, { includeEmail }); + expect(members).toEqual([ + expect.objectContaining({ + userId: "ada", + displayName: "Ada", + email: includeEmail ? "ada@example.com" : null, + avatarUrl: "https://example.com/ada.png", + }), + ]); + const sql = prepare.mock.lastCall?.[0]; + if (!includeEmail) { + expect(sql).toContain("NULL AS email"); + expect(sql).not.toContain("u.email"); + } + } + }); + it("validates team rows and allows any team to be archived or restored", async () => { const store = new TeamStore(env.DB); expect(await store.list()).toEqual([]); diff --git a/packages/control-plane/test/integration/teams-routes.test.ts b/packages/control-plane/test/integration/teams-routes.test.ts index 3dfcb0aa17..3e5b123ae7 100644 --- a/packages/control-plane/test/integration/teams-routes.test.ts +++ b/packages/control-plane/test/integration/teams-routes.test.ts @@ -1,51 +1,136 @@ -import { env } from "cloudflare:test"; +import { createExecutionContext, env } from "cloudflare:test"; import { beforeEach, describe, expect, it } from "vitest"; import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; -import type { Team } from "@open-inspect/shared/types/teams"; +import { + meTeamsResponseSchema, + teamSessionsResponseSchema, + type Team, +} from "@open-inspect/shared/types/teams"; +import { SessionIndexStore, type SessionEntry } from "../../src/db/session-index"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; import { TeamStore } from "../../src/db/teams"; import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamSettingsStore } from "../../src/db/team-settings"; import type { SqlDatabase, SqlStatement } from "../../src/db/sql-database"; -import { cleanD1Tables } from "./cleanup"; -import { seedActiveUser, serviceFetch, sqlDatabase } from "./helpers"; - -const BASE = "https://test.local"; -const OWNER = "11111111111111111111111111111111"; -const MEMBER = "22222222222222222222222222222222"; -const OTHER = "33333333333333333333333333333333"; - -async function request(path: string, method = "GET", body?: object) { - return serviceFetch(`${BASE}${path}`, { - method, - ...(body ? { body: JSON.stringify(body) } : {}), - }); -} +import { routeRequest, serviceFetch, serviceRequestHeaders, sqlDatabase } from "./helpers"; +import { + BASE, + OWNER, + MEMBER, + OTHER, + request, + setRole, + auditEvents, + requestAuditEvents, + modeRequest, + setupTeamRoutes, +} from "./team-route-helpers"; -async function setRole(userId: string, role: "member" | "administrator") { - await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") - .bind(BUILT_IN_ROLE_REGISTRY[role].id, userId) - .run(); +async function seedSession(id: string, teamId: string, overrides: Partial = {}) { + await new SessionIndexStore(env.DB).create({ + id, + ownerTeamId: teamId, + visibility: "team", + userId: MEMBER, + title: id, + repoOwner: "acme", + repoName: "widgets", + baseBranch: "main", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + status: "completed", + createdAt: 100, + updatedAt: 100, + ...overrides, + }); } -async function auditEvents(teamId: string) { - const result = await env.DB.prepare( - "SELECT action, team_id, target_user_id_snapshot, metadata_json FROM authorization_audit_events WHERE resource_type = 'team' AND team_id = ? ORDER BY occurred_at, id" - ) - .bind(teamId) - .all(); - return result.results; +function inboxPage(value: unknown) { + const parsed = teamSessionsResponseSchema.parse(value); + if (!("items" in parsed)) throw new Error("Expected bucket page"); + return parsed; } describe("team routes", () => { - beforeEach(async () => { - await cleanD1Tables(); - await seedActiveUser(MEMBER); - await seedActiveUser(OTHER); - await request("/me/authorization"); + beforeEach(setupTeamRoutes); + + it.each(["", "/members", "/sessions"])( + "does not audit an allowed team read at /teams/:id%s", + async (suffix) => { + await setRole(OWNER, "member"); + const team = await new TeamStore(env.DB).create({ + slug: "quiet-read", + name: "Quiet read", + joinPolicy: "invite_only", + }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, MEMBER, "lead"); + if (suffix === "/sessions") await memberships.add(team.id, OWNER); + + const response = await request(`/teams/${team.id}${suffix}`); + expect(response.status).toBe(200); + expect(await requestAuditEvents(response)).toEqual([]); + } + ); + + it("audits an allowed team capability write", async () => { + await setRole(OWNER, "member"); + const team = await new TeamStore(env.DB).create({ + slug: "audited-write", + name: "Audited write", + joinPolicy: "invite_only", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER, "lead"); + + const response = await request(`/teams/${team.id}`, "PATCH", { name: "Renamed" }); + expect(response.status).toBe(200); + expect(await requestAuditEvents(response)).toEqual([ + { action: "authorization.request_allowed" }, + { action: "team.updated" }, + ]); + }); + + it("still audits a denied team capability write", async () => { + await setRole(OWNER, "member"); + const team = await new TeamStore(env.DB).create({ + slug: "denied-write", + name: "Denied write", + joinPolicy: "invite_only", + }); + const response = await request(`/teams/${team.id}`, "PATCH", { name: "Forbidden" }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ reason_code: "team_capability_required" }); + expect(await requestAuditEvents(response)).toEqual([ + { action: "authorization.request_denied" }, + ]); + }); + + it.each([false, true])("still audits leaving a team (archived: %s)", async (archived) => { + await setRole(OWNER, "member"); + const team = await new TeamStore(env.DB).create({ + slug: "audited-leave", + name: "Audited leave", + joinPolicy: "invite_only", + }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, MEMBER, "lead"); + await memberships.add(team.id, OWNER); + if (archived) await new TeamStore(env.DB).archive(team.id); + + const response = await request(`/teams/${team.id}/members/${OWNER}`, "DELETE"); + expect(response.status).toBe(204); + expect(await requestAuditEvents(response)).toEqual([ + { action: "authorization.request_allowed" }, + { action: "team.member_removed" }, + ]); }); it("starts without teams and lets an administrator create, rename, archive and restore", async () => { expect((await request("/me/teams")).status).toBe(200); - expect(await (await request("/me/teams")).json()).toEqual({ teams: [] }); + expect(await (await request("/me/teams")).json()).toEqual({ + teams: [], + requireTeamOnCreate: false, + }); await setRole(OWNER, "administrator"); const created = await request("/teams", "POST", { slug: "engineering", name: "Engineering" }); expect(created.status).toBe(201); @@ -59,7 +144,6 @@ describe("team routes", () => { expect((await request(`/teams/${team.id}`, "PATCH", { name: "Platform" })).status).toBe(200); expect((await request(`/teams/${team.id}/archive`, "POST")).status).toBe(200); expect((await new TeamStore(env.DB).getById(team.id))?.archivedAt).not.toBeNull(); - expect(await new TeamStore(env.DB).isActive(team.id)).toBe(false); expect(await (await request("/teams?membership=all")).json()).toEqual({ teams: [] }); expect( await (await request("/teams?membership=all&includeArchived=true")).json() @@ -68,7 +152,6 @@ describe("team routes", () => { }); expect((await request(`/teams/${team.id}/restore`, "POST")).status).toBe(200); expect((await new TeamStore(env.DB).getById(team.id))?.archivedAt).toBeNull(); - expect(await new TeamStore(env.DB).isActive(team.id)).toBe(true); expect((await auditEvents(team.id)).map((row) => row.action)).toEqual([ "team.created", "team.updated", @@ -77,6 +160,30 @@ describe("team routes", () => { ]); }); + it("meTeams exposes the creation setting to active users without settings permissions", async () => { + await setRole(OWNER, "member"); + const settings = new TeamSettingsStore(env.DB); + for (const requireTeamOnCreate of [false, true, false]) { + await settings.set({ requireTeamOnCreate }); + const response = await request("/me/teams"); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + expect(meTeamsResponseSchema.parse(await response.json())).toEqual({ + teams: [], + requireTeamOnCreate, + }); + } + }); + + it("meTeams still requires an active human user", async () => { + const bot = await serviceFetch(`${BASE}/me/teams`, { service: "slack-bot" }); + expect(bot.status).toBe(403); + await env.DB.prepare("UPDATE users SET suspended_at = ? WHERE id = ?") + .bind(Date.now(), OWNER) + .run(); + expect((await request("/me/teams")).status).toBe(403); + }); + it("reports member counts on list, membership and detail responses", async () => { const created = await request("/teams", "POST", { slug: "counted", name: "Counted" }); const team = (await created.json()) as Team; @@ -157,7 +264,7 @@ describe("team routes", () => { expect(await auditEvents(team.id)).toEqual([]); }); - it("lets leads manage their team without exposing another team", async () => { + it("lets leads manage their team and reads other teams without granting management", async () => { await setRole(OWNER, "member"); const teams = new TeamStore(env.DB); const memberships = new TeamMembershipStore(env.DB); @@ -166,11 +273,13 @@ describe("team routes", () => { await memberships.add(own.id, OWNER, "lead"); await memberships.add(other.id, MEMBER, "lead"); expect((await request(`/teams/${own.id}`, "PATCH", { name: "Renamed" })).status).toBe(200); - expect((await request(`/teams/${other.id}`, "PATCH", { name: "Forbidden" })).status).toBe(404); - const hidden = await request(`/teams/${other.id}`); + const denied = await request(`/teams/${other.id}`, "PATCH", { name: "Forbidden" }); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ reason_code: "team_capability_required" }); + expect((await request(`/teams/${other.id}`)).status).toBe(200); const absent = await request("/teams/team_missing"); - expect(hidden.status).toBe(404); - expect(await hidden.json()).toEqual(await absent.json()); + expect(absent.status).toBe(404); + expect(await absent.json()).toEqual({ error: "Team not found" }); expect((await teams.getById(other.id))?.name).toBe("Other"); const mine = await request("/teams?membership=all&search=own"); expect(((await mine.json()) as { teams: Team[] }).teams.map((team) => team.id)).toEqual([ @@ -178,23 +287,435 @@ describe("team routes", () => { ]); }); - it("rejects member creation, sole-lead demotion and departure without changing the membership", async () => { - await setRole(OWNER, "member"); - expect((await request("/teams", "POST", { slug: "blocked", name: "Blocked" })).status).toBe( - 403 + it.each(["member", "viewer"] as const)( + "lists all active teams and their members for a nonmember %s", + async (role) => { + await env.DB.prepare("UPDATE user_role_assignments SET role_id = ? WHERE user_id = ?") + .bind(BUILT_IN_ROLE_REGISTRY[role].id, OWNER) + .run(); + const teams = new TeamStore(env.DB); + const open = await teams.create({ slug: "directory-open", name: "Open", joinPolicy: "open" }); + const closed = await teams.create({ + slug: "directory-closed", + name: "Closed", + joinPolicy: "invite_only", + }); + const archived = await teams.create({ + slug: "directory-archived", + name: "Archived", + joinPolicy: "open", + }); + await teams.archive(archived.id); + await new TeamMembershipStore(env.DB).add(open.id, MEMBER, "lead"); + const all = await ( + await request("/teams?membership=all") + ).json<{ teams: Array<{ id: string; memberCount: number }> }>(); + expect(all.teams.map(({ id }) => id).sort()).toEqual([open.id, closed.id].sort()); + expect(all.teams.find(({ id }) => id === open.id)?.memberCount).toBe(1); + expect(await (await request("/teams?membership=mine")).json()).toEqual({ teams: [] }); + expect(await (await request(`/teams/${open.id}`)).json()).toMatchObject({ + id: open.id, + capabilities: { canJoin: true, canManageMembers: false }, + }); + expect(await (await request(`/teams/${open.id}/members`)).json()).toMatchObject({ + members: [{ userId: MEMBER, role: "lead" }], + }); + expect((await request(`/teams/${closed.id}`)).status).toBe(200); + expect((await request(`/teams/${open.id}/members/${MEMBER}`, "DELETE")).status).toBe(404); + const manage = await request(`/teams/${open.id}/members/${OTHER}`, "PUT", { role: "member" }); + expect(manage.status).toBe(403); + expect(await manage.json()).toMatchObject({ reason_code: "team_capability_required" }); + expect((await request(`/teams/${open.id}/join`, "POST")).status).toBe(200); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "always conceals team sessions from nonmembers in %s", + async (mode) => { + await setRole(OWNER, "member"); + const team = await new TeamStore(env.DB).create({ + slug: "guarded", + name: "Guarded", + joinPolicy: "open", + }); + const hidden = await modeRequest(`/teams/${team.id}/sessions`, mode); + const missing = await modeRequest("/teams/team_missing/sessions", mode); + expect(hidden.status).toBe(404); + expect(await hidden.json()).toEqual(await missing.json()); + const bot = await serviceFetch(`${BASE}/teams/${team.id}/sessions`, { + service: "slack-bot", + actor: "slack:U-TEAM", + }); + expect(bot.status).toBe(403); + } + ); + + describe("archived directory access", () => { + it.each(["off", "shadow", "on"] as const)( + "lists active teams and only the caller's archived memberships in %s", + async (mode) => { + const teams = new TeamStore(env.DB); + const active = await teams.create({ slug: "active", name: "Active", joinPolicy: "open" }); + const own = await teams.create({ slug: "own-archived", name: "Own", joinPolicy: "open" }); + const other = await teams.create({ + slug: "other-archived", + name: "Other", + joinPolicy: "open", + }); + await new TeamMembershipStore(env.DB).add(own.id, OWNER, "lead"); + await teams.archive(own.id); + await teams.archive(other.id); + + for (const role of ["member", "viewer"] as const) { + await setRole(OWNER, role); + const all = await modeRequest("/teams?membership=all&includeArchived=true", mode, role); + expect(all.status).toBe(200); + const result = await all.json<{ teams: Team[] }>(); + expect(result.teams.map(({ id }) => id).sort()).toEqual([active.id, own.id].sort()); + expect( + await ( + await modeRequest( + "/teams?membership=all&includeArchived=true&search=other", + mode, + role + ) + ).json() + ).toEqual({ teams: [] }); + expect( + await (await modeRequest("/teams?membership=all", mode, role)).json() + ).toMatchObject({ teams: [{ id: active.id }] }); + expect( + await ( + await modeRequest("/teams?membership=mine&includeArchived=true", mode, role) + ).json() + ).toMatchObject({ teams: [{ id: own.id }] }); + const mine = await ( + await modeRequest("/me/teams", mode, role) + ).json<{ teams: Array }>(); + expect(mine.teams).toHaveLength(1); + expect(mine.teams[0]).toMatchObject({ id: own.id, role: "lead" }); + expect(mine.teams[0].archivedAt).not.toBeNull(); + } + } + ); + + it.each(["off", "shadow", "on"] as const)( + "conceals archived detail and member reads with the missing-team 404 in %s", + async (mode) => { + const teams = new TeamStore(env.DB); + const archived = await teams.create({ + slug: "hidden-archived", + name: "Hidden", + joinPolicy: "open", + }); + await new TeamMembershipStore(env.DB).add(archived.id, MEMBER, "lead"); + await teams.archive(archived.id); + + for (const role of ["member", "viewer"] as const) { + await setRole(OWNER, role); + for (const suffix of ["", "/members"]) { + const hidden = await modeRequest(`/teams/${archived.id}${suffix}`, mode, role); + const missing = await modeRequest(`/teams/team_missing${suffix}`, mode, role); + expect(hidden.status, `${role}${suffix}`).toBe(404); + expect(missing.status).toBe(404); + expect(await hidden.json()).toEqual(await missing.json()); + } + } + } + ); + + it.each([ + { role: "member", teamRole: "member", canRestore: false }, + { role: "member", teamRole: "lead", canRestore: true }, + { role: "owner", teamRole: null, canRestore: true }, + { role: "administrator", teamRole: null, canRestore: true }, + ] as const)( + "preserves archived reads and restore capabilities for $role / $teamRole", + async ({ role, teamRole, canRestore }) => { + await setRole(OWNER, role); + const teams = new TeamStore(env.DB); + const archived = await teams.create({ + slug: "readable-archived", + name: "Readable", + joinPolicy: "invite_only", + }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(archived.id, MEMBER, "lead"); + if (teamRole) await memberships.add(archived.id, OWNER, teamRole); + await teams.archive(archived.id); + + const detail = await request(`/teams/${archived.id}`); + expect(detail.status).toBe(200); + expect(await detail.json()).toMatchObject({ + id: archived.id, + capabilities: { canArchive: canRestore }, + }); + const members = await request(`/teams/${archived.id}/members`); + expect(members.status).toBe(200); + expect(await members.json()).toMatchObject({ + members: expect.arrayContaining([ + expect.objectContaining({ userId: MEMBER, role: "lead" }), + ]), + }); + const all = await request("/teams?membership=all&includeArchived=true"); + expect(await all.json()).toMatchObject({ teams: [{ id: archived.id }] }); + expect((await request(`/teams/${archived.id}/restore`, "POST")).status).toBe( + canRestore ? 200 : 403 + ); + expect((await teams.getById(archived.id))?.archivedAt === null).toBe(canRestore); + } ); - const teams = new TeamStore(env.DB); - const memberships = new TeamMembershipStore(env.DB); - const team = await teams.create({ slug: "lead", name: "Lead", joinPolicy: "invite_only" }); - await memberships.add(team.id, OWNER, "lead"); - const demote = await request(`/teams/${team.id}/members/${OWNER}`, "PUT", { role: "member" }); - expect(demote.status).toBe(409); - expect(await demote.json()).toMatchObject({ code: "last_lead" }); - expect((await request(`/teams/${team.id}/members/${OWNER}`, "DELETE")).status).toBe(409); - expect((await memberships.listForUser(OWNER)).get(team.id)).toBe("lead"); - expect(await auditEvents(team.id)).toEqual([]); }); + it.each(["off", "shadow", "on"] as const)( + "returns scoped inbox buckets with effective capabilities and never lists unshared private rows in %s", + async (mode) => { + await setRole(OWNER, "member"); + const teams = new TeamStore(env.DB); + const team = await teams.create({ slug: "inbox", name: "Inbox", joinPolicy: "open" }); + const other = await teams.create({ + slug: "elsewhere", + name: "Elsewhere", + joinPolicy: "open", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + await seedSession("finished", team.id); + await seedSession("active", team.id, { status: "active" }); + await seedSession("attention", team.id); + await new SessionIndexStore(env.DB).recordLatestTerminalMessage({ + sessionId: "attention", + messageId: "reply", + messageCreatedAt: Date.now() + 1000, + terminalMessageCompletedAt: Date.now() + 1000, + }); + await seedSession("hidden-private", team.id, { visibility: "private", status: "active" }); + await seedSession("shared-private", team.id, { visibility: "private" }); + await new SessionCollaboratorStore(env.DB).add("shared-private", OWNER, MEMBER); + await seedSession("other-team", other.id, { visibility: "workspace" }); + const response = await modeRequest(`/teams/${team.id}/sessions`, mode); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + const snapshot = teamSessionsResponseSchema.parse(await response.json()); + if (!("categories" in snapshot)) throw new Error("Expected bucket snapshot"); + expect( + snapshot.categories.needs_attention.items.map(({ rootSession }) => rootSession.id) + ).toEqual(["attention"]); + expect( + snapshot.categories.in_progress.items.map(({ rootSession }) => rootSession.id) + ).toEqual(["active"]); + expect( + snapshot.categories.finished.items.map(({ rootSession }) => rootSession.id).sort() + ).toEqual(["finished", "shared-private"]); + expect( + snapshot.categories.finished.items.find(({ rootSession }) => rootSession.id === "finished") + ?.rootSession + ).toMatchObject({ + ownerTeamId: team.id, + visibility: "team", + capabilities: { canRead: true, canDelete: false }, + }); + const page = inboxPage( + await ( + await modeRequest( + `/teams/${team.id}/sessions?bucket=finished&teamIds[]=${other.id}`, + mode + ) + ).json() + ); + expect(page.items.map(({ rootSession }) => rootSession.id).sort()).toEqual([ + "finished", + "shared-private", + ]); + expect( + page.items.find(({ rootSession }) => rootSession.id === "shared-private")?.rootSession + .capabilities + ).toMatchObject({ + canRead: true, + canManageCollaborators: false, + }); + } + ); + + it("paginates session buckets, decorates descendants, and rejects malformed inbox queries", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "paged", + name: "Paged", + joinPolicy: "open", + }); + for (let index = 0; index < 21; index++) + await seedSession(`paged-${String(index).padStart(2, "0")}`, team.id); + await seedSession("child", team.id, { + parentSessionId: "paged-20", + spawnDepth: 1, + spawnSource: "agent", + }); + const firstResponse = await modeRequest( + `/teams/${team.id}/sessions?bucket=finished`, + "on", + "owner" + ); + expect(firstResponse.status).toBe(200); + const first = inboxPage(await firstResponse.json()); + expect(first.items).toHaveLength(20); + expect(first.hasMore).toBe(true); + expect(first.items[0].descendantSessions[0]).toMatchObject({ + id: "child", + capabilities: { canRead: true }, + }); + const second = inboxPage( + await ( + await modeRequest( + `/teams/${team.id}/sessions?bucket=finished&cursor=${first.nextCursor}`, + "on", + "owner" + ) + ).json() + ); + expect(second).toMatchObject({ + items: [{ rootSession: { id: "paged-00" } }], + hasMore: false, + nextCursor: null, + }); + for (const query of [ + "bucket=invalid", + "bucket=finished&bucket=in_progress", + "cursor=bad", + "bucket=finished&cursor=bad", + "bucket=finished&cursor=", + ]) { + expect( + (await modeRequest(`/teams/${team.id}/sessions?${query}`, "on", "owner")).status, + query + ).toBe(400); + } + }); + + it.each(["viewer", "administrator", "owner"] as const)( + "returns effective %s capabilities without administrative private-session list access", + async (role) => { + await setRole(OWNER, role); + const team = await new TeamStore(env.DB).create({ + slug: "role-inbox", + name: "Role Inbox", + joinPolicy: "open", + }); + if (role === "viewer") await new TeamMembershipStore(env.DB).add(team.id, OWNER); + await seedSession("role-visible", team.id); + await seedSession("role-private", team.id, { visibility: "private" }); + const response = await modeRequest(`/teams/${team.id}/sessions?bucket=finished`, "on", role); + expect(response.status).toBe(200); + const page = inboxPage(await response.json()); + expect(page.items).toHaveLength(1); + expect(page.items[0].rootSession).toMatchObject({ + id: "role-visible", + capabilities: { + canRead: true, + canCollaborate: false, + canManageLifecycle: false, + canDelete: false, + canSandbox: false, + canManageCollaborators: false, + canChangeVisibility: false, + }, + }); + expect(JSON.stringify(page)).not.toContain("role-private"); + expect(page.items[0].rootSession).not.toHaveProperty("userId"); + } + ); + + it("omits inaccessible ancestor IDs when a visible child is rerooted into the team inbox", async () => { + await setRole(OWNER, "member"); + const team = await new TeamStore(env.DB).create({ + slug: "rerooted", + name: "Rerooted", + joinPolicy: "open", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + await seedSession("private-ancestor", team.id, { visibility: "private" }); + await seedSession("visible-child", team.id, { + parentSessionId: "private-ancestor", + visibility: "workspace", + spawnDepth: 1, + spawnSource: "agent", + }); + const response = await modeRequest(`/teams/${team.id}/sessions?bucket=finished`, "on"); + const body = await response.json(); + expect(JSON.stringify(body)).not.toContain("private-ancestor"); + expect(body).toMatchObject({ + items: [{ rootSession: { id: "visible-child", parentSessionId: null } }], + }); + }); + + it("denies directory and member-only reads for suspended and role-less users", async () => { + const team = await new TeamStore(env.DB).create({ + slug: "inactive", + name: "Inactive", + joinPolicy: "open", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + await env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(OWNER).run(); + for (const path of [ + "/teams?membership=all", + `/teams/${team.id}`, + `/teams/${team.id}/members`, + `/teams/${team.id}/sessions`, + ]) { + expect((await request(path)).status, path).toBe(403); + } + await env.DB.prepare("UPDATE users SET suspended_at = NULL WHERE id = ?").bind(OWNER).run(); + await env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?").bind(OWNER).run(); + expect((await request("/teams?membership=all")).status).toBe(403); + expect((await request(`/teams/${team.id}/sessions`)).status).toBe(403); + }); + + it("allows directory reads without session or audit permissions but keeps both inaccessible", async () => { + await env.DB.prepare( + "INSERT INTO roles (id, key, name, normalized_name, is_system) VALUES ('role_directory', NULL, 'Directory', 'directory', 0)" + ).run(); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_directory' WHERE user_id = ?" + ) + .bind(OWNER) + .run(); + const team = await new TeamStore(env.DB).create({ + slug: "directory-only", + name: "Directory", + joinPolicy: "open", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + await seedSession("not-readable", team.id, { visibility: "workspace" }); + expect((await request("/teams?membership=all")).status).toBe(200); + expect((await request(`/teams/${team.id}`)).status).toBe(200); + expect((await request(`/teams/${team.id}/sessions`)).status).toBe(403); + expect((await request(`/audit-events?teamId=${team.id}`)).status).toBe(403); + }); + + it.each([false, true])( + "rejects sole-lead demotion and departure without changing membership (archived: %s)", + async (archived) => { + await setRole(OWNER, "member"); + expect((await request("/teams", "POST", { slug: "blocked", name: "Blocked" })).status).toBe( + 403 + ); + const teams = new TeamStore(env.DB); + const memberships = new TeamMembershipStore(env.DB); + const team = await teams.create({ slug: "lead", name: "Lead", joinPolicy: "invite_only" }); + await memberships.add(team.id, OWNER, "lead"); + if (archived) await teams.archive(team.id); + const demote = await request(`/teams/${team.id}/members/${OWNER}`, "PUT", { role: "member" }); + expect(demote.status).toBe(409); + expect(await demote.json()).toMatchObject({ code: "last_lead" }); + const leave = await request(`/teams/${team.id}/members/${OWNER}`, "DELETE"); + expect(leave.status).toBe(409); + expect(await leave.json()).toMatchObject({ code: "last_lead" }); + expect(await requestAuditEvents(leave)).toEqual([ + { action: "authorization.request_allowed" }, + ]); + expect((await memberships.listForUser(OWNER)).get(team.id)).toBe("lead"); + expect(await auditEvents(team.id)).toEqual([]); + } + ); + it("joins open teams, rejects invite-only joins and audits membership changes", async () => { await setRole(OWNER, "member"); const teams = new TeamStore(env.DB); @@ -222,33 +743,84 @@ describe("team routes", () => { ]); }); - it("audits adding, changing and removing members with team and target IDs", async () => { - const team = await new TeamStore(env.DB).create({ - slug: "roles", - name: "Roles", - joinPolicy: "invite_only", - }); - const store = new TeamMembershipStore(env.DB); - await store.add(team.id, OWNER, "lead"); - expect( - (await request(`/teams/${team.id}/members/${MEMBER}`, "PUT", { role: "member" })).status - ).toBe(200); - expect( - (await request(`/teams/${team.id}/members/${MEMBER}`, "PUT", { role: "lead" })).status - ).toBe(200); - expect((await request(`/teams/${team.id}/members/${MEMBER}`, "DELETE")).status).toBe(204); - const rows = await auditEvents(team.id); - expect(rows.map((row) => row.action)).toEqual([ - "team.member_added", - "team.member_role_changed", - "team.member_removed", - ]); - for (const row of rows) { - expect(row.team_id).toBe(team.id); - expect(row.target_user_id_snapshot).toBe(MEMBER); - expect(JSON.parse(String(row.metadata_json))).toMatchObject({ before: {}, after: {} }); + it.each(["off", "shadow", "on"] as const)( + "preserves the reason for every unavailable join in %s", + async (mode) => { + await setRole(OWNER, "member"); + const teams = new TeamStore(env.DB); + const memberships = new TeamMembershipStore(env.DB); + const closed = await teams.create({ + slug: "join-closed", + name: "Closed", + joinPolicy: "invite_only", + }); + const archived = await teams.create({ + slug: "join-archived", + name: "Archived", + joinPolicy: "open", + }); + await teams.archive(archived.id); + const joined = await teams.create({ + slug: "join-existing", + name: "Joined", + joinPolicy: "open", + }); + await memberships.add(joined.id, OWNER); + for (const { team, reason } of [ + { team: closed, reason: "invite_only" }, + { team: archived, reason: "team_archived" }, + { team: joined, reason: "already_member" }, + ]) { + const url = `${BASE}/teams/${team.id}/join`; + const headers = await serviceRequestHeaders(url, { + method: "POST", + as: { userId: OWNER, role: "member" }, + }); + const denied = await routeRequest( + new Request(url, { method: "POST", headers }), + { ...env, TEAMS_ENFORCEMENT: mode }, + createExecutionContext() + ); + expect(denied.status, team.slug).toBe(403); + expect(await denied.json(), team.slug).toEqual({ + error: "Forbidden", + code: reason, + reason_code: reason, + }); + expect(await auditEvents(team.id)).toEqual([]); + } + expect((await memberships.listForUser(OWNER)).get(joined.id)).toBe("member"); + expect((await memberships.listForUser(OWNER)).has(closed.id)).toBe(false); + expect((await memberships.listForUser(OWNER)).has(archived.id)).toBe(false); } - }); + ); + + it.each([false, true])( + "denies and audits ordinary-member removal of another member (archived: %s)", + async (archived) => { + await setRole(OWNER, "member"); + const team = await new TeamStore(env.DB).create({ + slug: "remove-guard", + name: "Remove Guard", + joinPolicy: "open", + }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, OWNER); + await memberships.add(team.id, MEMBER, "lead"); + if (archived) await new TeamStore(env.DB).archive(team.id); + const absent = await request(`/teams/${team.id}/members/${OTHER}`, "DELETE"); + expect(absent.status).toBe(404); + expect(await absent.json()).toEqual({ error: "Team membership not found" }); + const denied = await request(`/teams/${team.id}/members/${MEMBER}`, "DELETE"); + expect(denied.status).toBe(403); + expect(await denied.json()).toMatchObject({ reason_code: "team_capability_required" }); + expect((await memberships.listForUser(MEMBER)).get(team.id)).toBe("lead"); + expect(await auditEvents(team.id)).toEqual([]); + expect(await requestAuditEvents(denied)).toEqual([ + { action: "authorization.request_denied" }, + ]); + } + ); it("records a single addition when membership requests race", async () => { await setRole(OWNER, "member"); diff --git a/packages/control-plane/test/integration/user-store.test.ts b/packages/control-plane/test/integration/user-store.test.ts index 67d7d192c5..acedfb8aeb 100644 --- a/packages/control-plane/test/integration/user-store.test.ts +++ b/packages/control-plane/test/integration/user-store.test.ts @@ -1,7 +1,10 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { env } from "cloudflare:test"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamStore } from "../../src/db/teams"; import { UserStore } from "../../src/db/user-store"; import { cleanD1Tables } from "./cleanup"; +import { sqlDatabase } from "./helpers"; const providerIssuers = [ ["github", "https://github.com"], @@ -18,6 +21,79 @@ describe("UserStore", () => { store = new UserStore(env.DB); }); + describe("listCollaboratorCandidates", () => { + it("returns an empty list when there are no active users", async () => { + expect(await store.listCollaboratorCandidates({ includeEmail: true })).toEqual([]); + }); + + it("returns only active picker identities ordered by name with email fallback", async () => { + const zed = await store.createUser({ displayName: "zed" }); + const alice = await store.createUser({ + displayName: "ALICE", + avatarUrl: "https://example.com/alice.png", + }); + const bob = await store.createUser({ email: "bob@example.com" }); + const suspended = await store.createUser({ displayName: "Suspended" }); + const unassigned = await store.createUser({ displayName: "Unassigned" }); + await env.DB.batch([ + env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(suspended.id), + env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?").bind(unassigned.id), + ]); + + expect(await store.listCollaboratorCandidates({ includeEmail: true })).toEqual([ + { + userId: alice.id, + displayName: "ALICE", + email: null, + avatarUrl: "https://example.com/alice.png", + }, + { userId: bob.id, displayName: null, email: "bob@example.com", avatarUrl: null }, + { userId: zed.id, displayName: "zed", email: null, avatarUrl: null }, + ]); + }); + + it("does not read or sort by email when email is excluded", async () => { + const user = await store.createUser({ email: "private@example.com" }); + const db = sqlDatabase(env.DB); + const prepare = vi.fn((sql: string) => db.prepare(sql)); + const candidates = await new UserStore({ + prepare, + batch: db.batch.bind(db), + }).listCollaboratorCandidates({ includeEmail: false }); + expect(candidates).toEqual([ + { userId: user.id, displayName: null, email: null, avatarUrl: null }, + ]); + expect(prepare).toHaveBeenCalledWith(expect.stringContaining("NULL AS email")); + expect(prepare.mock.calls[0][0]).not.toContain("users.email"); + }); + }); + + describe("getCollaboratorEligibility", () => { + it("requires an active user and, for a team, current membership", async () => { + const member = await store.createUser({ displayName: "Member" }); + const outsider = await store.createUser({ displayName: "Outsider" }); + const suspended = await store.createUser({ displayName: "Suspended" }); + const unassigned = await store.createUser({ displayName: "Unassigned" }); + const team = await new TeamStore(env.DB).create({ + slug: "eligibility", + name: "Eligibility", + joinPolicy: "invite_only", + }); + await new TeamMembershipStore(env.DB).add(team.id, member.id); + await env.DB.batch([ + env.DB.prepare("UPDATE users SET suspended_at = 1 WHERE id = ?").bind(suspended.id), + env.DB.prepare("DELETE FROM user_role_assignments WHERE user_id = ?").bind(unassigned.id), + ]); + + expect(await store.getCollaboratorEligibility("missing", null)).toBe("not_found"); + expect(await store.getCollaboratorEligibility(suspended.id, null)).toBe("inactive"); + expect(await store.getCollaboratorEligibility(unassigned.id, null)).toBe("inactive"); + expect(await store.getCollaboratorEligibility(outsider.id, null)).toBe("eligible"); + expect(await store.getCollaboratorEligibility(outsider.id, team.id)).toBe("not_team_member"); + expect(await store.getCollaboratorEligibility(member.id, team.id)).toBe("eligible"); + }); + }); + // ── resolveOrCreateUser ───────────────────────────────────────── describe("resolveOrCreateUser", () => { diff --git a/packages/control-plane/test/integration/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts index 01576b5ec7..df11dc7919 100644 --- a/packages/control-plane/test/integration/websocket-client.test.ts +++ b/packages/control-plane/test/integration/websocket-client.test.ts @@ -94,6 +94,12 @@ describe("Client WebSocket (via SELF.fetch)", () => { const state = subscribed.session as Record; expect(state.id).toBe(name); expect(state.repoOwner).toBe("acme"); + expect(state.capabilities).toMatchObject({ + canRead: true, + canCollaborate: true, + canManageLifecycle: true, + canSandbox: true, + }); ws.close(); }); @@ -328,7 +334,7 @@ describe("Client WebSocket (via SELF.fetch)", () => { expect((await denied).find((message) => message.type === "error")).toMatchObject({ code: "PERMISSION_REQUIRED", - message: "Permission required: sessions.collaborate", + message: "Access denied: missing_permission", }); ws.close(); }); diff --git a/packages/control-plane/test/integration/websocket-sandbox.test.ts b/packages/control-plane/test/integration/websocket-sandbox.test.ts index 97e3f2e03d..64cbfdc867 100644 --- a/packages/control-plane/test/integration/websocket-sandbox.test.ts +++ b/packages/control-plane/test/integration/websocket-sandbox.test.ts @@ -905,7 +905,8 @@ describe("Sandbox WebSocket (via SELF.fetch)", () => { userId: "user-replay", }); const subscribed = replayMessages.find((message) => message.type === "subscribed") as - { timeline: { events: Array<{ event: unknown }> } } | undefined; + | { timeline: { events: Array<{ event: unknown }> } } + | undefined; expect(subscribed?.timeline.events.map(({ event }) => event)).toEqual([ before, compacted, diff --git a/packages/control-plane/test/integration/websocket-session-access.test.ts b/packages/control-plane/test/integration/websocket-session-access.test.ts new file mode 100644 index 0000000000..d7617237b0 --- /dev/null +++ b/packages/control-plane/test/integration/websocket-session-access.test.ts @@ -0,0 +1,557 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamStore } from "../../src/db/teams"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { SessionCollaboratorStore } from "../../src/db/session-collaborators"; +import { cleanD1Tables } from "./cleanup"; +import { setSessionTeamsEnforcementMode } from "./session-do-access"; +import { + collectMessages, + initNamedSession, + issueClientWsToken, + openClientWs, + queryDO, + routeRequest, + seedMessage, + serviceRequestHeaders, + waitForSandboxStatus, +} from "./helpers"; + +describe("session WebSocket D1 access", () => { + beforeEach(cleanD1Tables); + + async function scopedSession(visibility: "workspace" | "team" | "private", mode?: string) { + const name = `ws-access-${crypto.randomUUID()}`; + const { stub } = await initNamedSession( + name, + undefined, + mode ? (sessionStub) => setSessionTeamsEnforcementMode(sessionStub, mode) : undefined + ); + await waitForSandboxStatus(stub, "failed"); + const team = await new TeamStore(env.DB).create({ + slug: `ws-${crypto.randomUUID()}`, + name: "Socket team", + joinPolicy: "invite_only", + }); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ?, visibility = ? WHERE id = ?") + .bind(team.id, visibility, name) + .run(); + return { name, stub, team }; + } + + it("rejects a still-valid token after private access is removed and refuses re-mint", async () => { + const { name, team } = await scopedSession("private"); + const userId = crypto.randomUUID().replaceAll("-", ""); + const url = `https://test.local/sessions/${name}/ws-token`; + const headers = await serviceRequestHeaders(url, { + method: "POST", + as: { userId, role: "member" }, + }); + await new TeamMembershipStore(env.DB).add(team.id, userId); + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + const collaborators = new SessionCollaboratorStore(env.DB); + await collaborators.add(name, userId, "user-1"); + + const { ws } = await openClientWs(name); + const subscribed = collectMessages(ws, { until: (message) => message.type === "subscribed" }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "collaborator" })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + await collaborators.remove(name, userId); + + const closedActive = new Promise((resolve) => + ws.addEventListener("close", (event) => resolve(event.code)) + ); + ws.send(JSON.stringify({ type: "fetch_history", cursor: { timestamp: 0, id: "event" } })); + await expect(closedActive).resolves.toBe(4010); + + const { ws: stale } = await openClientWs(name); + const closed = new Promise((resolve) => + stale.addEventListener("close", (event) => resolve(event.code)) + ); + stale.send(JSON.stringify({ type: "subscribe", token, clientId: "stale" })); + await expect(closed).resolves.toBe(4010); + + const response = await routeRequest( + new Request(url, { + method: "POST", + headers, + }), + { ...env, TEAMS_ENFORCEMENT: "on" }, + createExecutionContext() + ); + expect(response.status).toBe(404); + }); + + it("samples team permissions in shadow and rechecks the private row on the next command", async () => { + const { name, team } = await scopedSession("team"); + const userId = `team-member-${crypto.randomUUID()}`; + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + await new TeamMembershipStore(env.DB).add(team.id, userId); + const { ws } = await openClientWs(name); + const subscribed = collectMessages(ws, { until: (message) => message.type === "subscribed" }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "member" })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + + await new TeamMembershipStore(env.DB).remove(team.id, userId); + await env.DB.prepare("UPDATE sessions SET visibility = 'private' WHERE id = ?") + .bind(name) + .run(); + const closed = new Promise((resolve) => + ws.addEventListener("close", (event) => resolve(event.code)) + ); + ws.send(JSON.stringify({ type: "presence", status: "idle" })); + await expect(closed).resolves.toBe(4010); + }); + + it("denies a member of another team at subscribe with enforcement on", async () => { + const { name } = await scopedSession("team", "on"); + const otherTeam = await new TeamStore(env.DB).create({ + slug: `other-${crypto.randomUUID()}`, + name: "Other team", + joinPolicy: "invite_only", + }); + const userId = `other-team-member-${crypto.randomUUID()}`; + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + await new TeamMembershipStore(env.DB).add(otherTeam.id, userId); + + const { ws } = await openClientWs(name); + const closed = new Promise((resolve) => + ws.addEventListener("close", (event) => resolve(event.code)) + ); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "other-team" })); + + await expect(closed).resolves.toBe(4010); + }); + + it.each(["off", "shadow", "on"] as const)( + "loads fresh memberships at subscribe and on team-owned commands in %s mode", + async (mode) => { + const { name, team } = await scopedSession("team", mode); + const userId = `query-member-${crypto.randomUUID()}`; + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + await new TeamMembershipStore(env.DB).add(team.id, userId); + const { ws } = await openClientWs(name); + const memberships = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); + const collaborators = vi.spyOn(SessionCollaboratorStore.prototype, "listUserIds"); + try { + const subscribed = collectMessages(ws, { + until: (message) => message.type === "subscribed", + }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "scope-reads" })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + expect(memberships).toHaveBeenCalledTimes(1); + expect(collaborators).toHaveBeenCalledTimes(mode === "on" ? 1 : 0); + memberships.mockClear(); + collaborators.mockClear(); + + const history = collectMessages(ws, { + until: (message) => message.type === "history_page", + }); + ws.send(JSON.stringify({ type: "fetch_history", cursor: { timestamp: 0, id: "event" } })); + expect((await history).some((message) => message.type === "history_page")).toBe(true); + expect(memberships).toHaveBeenCalledTimes(1); + expect(collaborators).toHaveBeenCalledTimes(mode === "on" ? 1 : 0); + } finally { + memberships.mockRestore(); + collaborators.mockRestore(); + ws.close(); + } + } + ); + + it.each(["off", "shadow", "on"] as const)( + "sends visibility capabilities for a non-owner team lead in %s mode", + async (mode) => { + const { name, team } = await scopedSession("team", mode); + const userId = `team-lead-${crypto.randomUUID()}`; + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + await new TeamMembershipStore(env.DB).add(team.id, userId, "lead"); + const { ws } = await openClientWs(name); + try { + const subscribed = collectMessages(ws, { + until: (message) => message.type === "subscribed", + }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "lead" })); + expect((await subscribed).find((message) => message.type === "subscribed")).toMatchObject({ + session: { + capabilities: { + canChangeVisibility: true, + canManageCollaborators: false, + }, + }, + }); + } finally { + ws.close(); + } + } + ); + + it.each(["off", "shadow", "on"] as const)( + "requires current membership to prompt or type in a workspace-visible team session in %s mode", + async (mode) => { + const { name, stub, team } = await scopedSession("workspace", mode); + const userId = `workspace-nonmember-${crypto.randomUUID()}`; + const { token, participantId } = await issueClientWsToken(name, { + userId, + canonicalUserId: userId, + }); + await queryDO( + stub, + `UPDATE sandbox SET code_server_url = ?, vnc_url = ?, ttyd_url = ?, + tunnel_urls = ?, modal_object_id = ?`, + "https://code.example.test", + "https://vnc.example.test", + "https://terminal.example.test", + JSON.stringify({ "3000": "https://app.example.test" }), + "team-access-sandbox" + ); + const memberships = new TeamMembershipStore(env.DB); + const { ws } = await openClientWs(name); + const closed = vi.fn(); + ws.addEventListener("close", closed); + try { + const subscribed = collectMessages(ws, { + until: (message) => message.type === "subscribed", + }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "nonmember" })); + const snapshot = (await subscribed).find((message) => message.type === "subscribed"); + expect(snapshot).toHaveProperty("session.capabilities", { + canRead: true, + canCollaborate: false, + canManageLifecycle: false, + canDelete: false, + canSandbox: false, + canManageCollaborators: false, + canChangeVisibility: false, + }); + for (const field of [ + "codeServerUrl", + "sandboxDashboardUrl", + "ttydUrl", + "vncUrl", + "tunnelUrls", + ]) { + expect(snapshot).not.toHaveProperty(`session.${field}`); + } + + const assertDenied = async (clientRequestId: string) => { + for (const command of [ + { type: "prompt", content: "not allowed", clientRequestId }, + { type: "typing" }, + ]) { + const denied = collectMessages(ws, { until: (message) => message.type === "error" }); + ws.send(JSON.stringify(command)); + expect((await denied).find((message) => message.type === "error")).toMatchObject({ + code: "PERMISSION_REQUIRED", + message: "Access denied: not_member", + }); + } + expect( + await queryDO( + stub, + "SELECT id FROM messages WHERE client_request_id = ?", + clientRequestId + ) + ).toEqual([]); + const presence = collectMessages(ws, { + until: (message) => message.type === "presence_update", + }); + ws.send(JSON.stringify({ type: "presence", status: "active" })); + expect((await presence).some((message) => message.type === "presence_update")).toBe(true); + expect(closed).not.toHaveBeenCalled(); + }; + + await assertDenied("before-membership"); + await memberships.add(team.id, userId); + await queryDO(stub, "UPDATE sandbox SET modal_object_id = NULL"); + + // The test provider fails to spawn, but warming proves typing was dispatched. + const typing = collectMessages(ws, { + until: (message) => message.type === "sandbox_error" || message.type === "error", + }); + ws.send(JSON.stringify({ type: "typing" })); + const typingMessages = await typing; + expect(typingMessages.some((message) => message.type === "sandbox_warming")).toBe(true); + expect(typingMessages.some((message) => message.type === "error")).toBe(false); + await waitForSandboxStatus(stub, "failed"); + + const queued = collectMessages(ws, { + until: (message) => message.type === "prompt_queued" || message.type === "error", + }); + ws.send( + JSON.stringify({ type: "prompt", content: "member prompt", clientRequestId: "member" }) + ); + const accepted = (await queued).find((message) => message.type === "prompt_queued"); + expect(accepted).toMatchObject({ + clientRequestId: "member", + messageId: expect.any(String), + }); + expect( + await queryDO( + stub, + "SELECT id, author_id, content FROM messages WHERE client_request_id = ?", + "member" + ) + ).toEqual([ + { id: accepted?.messageId, author_id: participantId, content: "member prompt" }, + ]); + + await memberships.remove(team.id, userId); + await assertDenied("after-membership-removal"); + expect( + await queryDO(stub, "SELECT id FROM messages WHERE author_id = ?", participantId) + ).toEqual([{ id: accepted?.messageId }]); + } finally { + ws.close(); + } + } + ); + + it.each(["off", "shadow", "on"] as const)( + "denies prompt and typing from a removed private team session owner in %s mode", + async (mode) => { + const { name, stub, team } = await scopedSession("private", mode); + const userId = "user-1"; + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, userId); + const { ws } = await openClientWs(name); + const closed = vi.fn(); + ws.addEventListener("close", closed); + try { + const subscribed = collectMessages(ws, { + until: (message) => message.type === "subscribed", + }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "private-owner" })); + expect((await subscribed).find((message) => message.type === "subscribed")).toMatchObject({ + session: { capabilities: { canRead: true, canCollaborate: true } }, + }); + + await memberships.remove(team.id, userId); + for (const command of [ + { type: "prompt", content: "not allowed", clientRequestId: "removed-private-owner" }, + { type: "typing" }, + ]) { + const denied = collectMessages(ws, { until: (message) => message.type === "error" }); + ws.send(JSON.stringify(command)); + expect((await denied).find((message) => message.type === "error")).toMatchObject({ + code: "PERMISSION_REQUIRED", + message: "Access denied: not_member", + }); + } + expect(await queryDO(stub, "SELECT id FROM messages")).toEqual([]); + const history = collectMessages(ws, { + until: (message) => message.type === "history_page", + }); + ws.send(JSON.stringify({ type: "fetch_history", cursor: { timestamp: 0, id: "event" } })); + expect((await history).some((message) => message.type === "history_page")).toBe(true); + expect(closed).not.toHaveBeenCalled(); + } finally { + ws.close(); + } + } + ); + + it("closes on the next prompt after membership removal and refuses the still-valid token", async () => { + const { name, team } = await scopedSession("team", "on"); + const userId = crypto.randomUUID().replaceAll("-", ""); + const url = `https://test.local/sessions/${name}/ws-token`; + const headers = await serviceRequestHeaders(url, { + method: "POST", + as: { userId, role: "member" }, + }); + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, userId); + + const { ws } = await openClientWs(name); + const subscribed = collectMessages(ws, { until: (message) => message.type === "subscribed" }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "member" })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + + await memberships.remove(team.id, userId); + const closed = new Promise((resolve) => + ws.addEventListener("close", (event) => resolve(event.code)) + ); + ws.send(JSON.stringify({ type: "prompt", content: "not allowed", clientRequestId: "removed" })); + await expect(closed).resolves.toBe(4010); + + const { ws: stale } = await openClientWs(name); + const staleClosed = new Promise((resolve) => + stale.addEventListener("close", (event) => resolve(event.code)) + ); + stale.send(JSON.stringify({ type: "subscribe", token, clientId: "stale" })); + await expect(staleClosed).resolves.toBe(4010); + const remint = await routeRequest( + new Request(url, { method: "POST", headers }), + { ...env, TEAMS_ENFORCEMENT: "on" }, + createExecutionContext() + ); + expect(remint.status).toBe(404); + }); + + it("closes on the next command after a D1 team scope change with enforcement on", async () => { + const { name, team } = await scopedSession("team", "on"); + const userId = `moved-team-member-${crypto.randomUUID()}`; + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + await new TeamMembershipStore(env.DB).add(team.id, userId); + const { ws } = await openClientWs(name); + const subscribed = collectMessages(ws, { until: (message) => message.type === "subscribed" }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "member" })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + + const newTeam = await new TeamStore(env.DB).create({ + slug: `moved-${crypto.randomUUID()}`, + name: "Destination team", + joinPolicy: "invite_only", + }); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ? WHERE id = ?") + .bind(newTeam.id, name) + .run(); + const closed = new Promise((resolve) => + ws.addEventListener("close", (event) => resolve(event.code)) + ); + ws.send(JSON.stringify({ type: "presence", status: "idle" })); + await expect(closed).resolves.toBe(4010); + }); + + it("audits each Owner break-glass subscribe but not the session owner or a collaborator", async () => { + const { name, stub, team } = await scopedSession("private", "on"); + await env.DB.prepare("UPDATE sessions SET visibility = 'workspace' WHERE id = ?") + .bind(name) + .run(); + const ownerId = crypto.randomUUID().replaceAll("-", ""); + const { token } = await issueClientWsToken(name, { userId: ownerId, canonicalUserId: ownerId }); + await env.DB.prepare( + "UPDATE user_role_assignments SET role_id = 'role_builtin_owner' WHERE user_id = ?" + ) + .bind(ownerId) + .run(); + await new TeamMembershipStore(env.DB).add(team.id, ownerId); + await env.DB.prepare("UPDATE sessions SET visibility = 'private' WHERE id = ?") + .bind(name) + .run(); + + const sockets: WebSocket[] = []; + for (let i = 0; i < 2; i++) { + const { ws } = await openClientWs(name); + sockets.push(ws); + const subscribed = collectMessages(ws, { until: (message) => message.type === "subscribed" }); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: `break-glass-${i}` })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + } + const audit = await env.DB.prepare( + "SELECT principal_kind, actor_user_id_snapshot, resource_id, team_id, reason_code, metadata_json FROM authorization_audit_events WHERE action = 'session.private_break_glass' ORDER BY occurred_at, id" + ).all(); + expect(audit.results).toHaveLength(2); + for (const row of audit.results) { + expect(row).toMatchObject({ + principal_kind: "user", + actor_user_id_snapshot: ownerId, + resource_id: name, + team_id: team.id, + reason_code: "session.private_break_glass", + }); + expect(JSON.parse(String(row.metadata_json))).toEqual({ + before: {}, + requested: {}, + after: {}, + }); + } + + const ws = sockets[1]; + const denied = collectMessages(ws, { + until: (message) => + message.type === "error" && message.message === "Access denied: not_collaborator", + }); + ws.send(JSON.stringify({ type: "prompt", content: "not allowed", clientRequestId: "private" })); + expect((await denied).find((message) => message.type === "error")).toMatchObject({ + code: "PERMISSION_REQUIRED", + message: "Access denied: not_collaborator", + }); + const deniedTyping = collectMessages(ws, { until: (message) => message.type === "error" }); + ws.send(JSON.stringify({ type: "typing" })); + expect((await deniedTyping).find((message) => message.type === "error")).toMatchObject({ + code: "PERMISSION_REQUIRED", + message: "Access denied: not_collaborator", + }); + + const [participant] = await queryDO<{ id: string }>( + stub, + "SELECT id FROM participants WHERE user_id = 'user-1'" + ); + await seedMessage(stub, { + id: "private-stop-message", + authorId: participant.id, + content: "Stop this prompt", + source: "web", + status: "processing", + createdAt: Date.now() - 1000, + startedAt: Date.now() - 500, + }); + const stopped = collectMessages(ws, { + until: (message) => message.type === "processing_status", + }); + ws.send(JSON.stringify({ type: "stop" })); + expect((await stopped).some((message) => message.type === "processing_status")).toBe(true); + expect( + await queryDO<{ status: string }>( + stub, + "SELECT status FROM messages WHERE id = 'private-stop-message'" + ) + ).toMatchObject([{ status: "failed" }]); + + for (const userId of ["user-1", crypto.randomUUID().replaceAll("-", "")]) { + const { token: ownToken } = await issueClientWsToken(name, { + userId, + canonicalUserId: userId, + }); + if (userId !== "user-1") { + await new TeamMembershipStore(env.DB).add(team.id, userId); + await new SessionCollaboratorStore(env.DB).add(name, userId, ownerId); + } + const { ws: ownSocket } = await openClientWs(name); + sockets.push(ownSocket); + const subscribed = collectMessages(ownSocket, { + until: (message) => message.type === "subscribed", + }); + ownSocket.send(JSON.stringify({ type: "subscribe", token: ownToken, clientId: userId })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + } + expect( + ( + await env.DB.prepare( + "SELECT id FROM authorization_audit_events WHERE action = 'session.private_break_glass'" + ).all() + ).results + ).toHaveLength(2); + for (const socket of sockets) socket.close(); + }); + + it("keeps an invalid enforcement mode from taking down the DO and reports authorization unavailable", async () => { + const { name, stub, team } = await scopedSession("team", "invalid"); + const userId = crypto.randomUUID().replaceAll("-", ""); + const { token } = await issueClientWsToken(name, { userId, canonicalUserId: userId }); + const { ws } = await openClientWs(name); + const closed = new Promise((resolve) => + ws.addEventListener("close", (event) => resolve(event.code)) + ); + ws.send(JSON.stringify({ type: "subscribe", token, clientId: "invalid-mode" })); + await expect(closed).resolves.toBe(1011); + expect((await stub.fetch("http://internal/internal/state")).status).toBe(200); + + await setSessionTeamsEnforcementMode(stub, "on"); + await new TeamMembershipStore(env.DB).add(team.id, userId); + const { ws: live } = await openClientWs(name); + const subscribed = collectMessages(live, { until: (message) => message.type === "subscribed" }); + live.send(JSON.stringify({ type: "subscribe", token, clientId: "valid-mode" })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + await setSessionTeamsEnforcementMode(stub, "invalid"); + const unavailable = collectMessages(live, { until: (message) => message.type === "error" }); + live.send(JSON.stringify({ type: "presence", status: "active" })); + expect((await unavailable).find((message) => message.type === "error")).toMatchObject({ + code: "AUTHORIZATION_UNAVAILABLE", + }); + live.close(); + }); +}); diff --git a/packages/control-plane/test/integration/ws-token-participants.test.ts b/packages/control-plane/test/integration/ws-token-participants.test.ts index 200c4fc16d..45ded17be6 100644 --- a/packages/control-plane/test/integration/ws-token-participants.test.ts +++ b/packages/control-plane/test/integration/ws-token-participants.test.ts @@ -1,5 +1,16 @@ import { describe, it, expect } from "vitest"; -import { initSession, queryDO } from "./helpers"; +import { env, SELF } from "cloudflare:test"; +import { + collectMessages, + initNamedSession, + initSession, + openClientWs, + openSandboxWs, + queryDO, + seedSandboxAuth, + serviceRequestHeaders, + waitForSandboxStatus, +} from "./helpers"; function wsTokenBody(body: Record): string { return JSON.stringify({ canonicalUserId: "user-1", ...body }); @@ -137,3 +148,105 @@ describe("GET /internal/participants", () => { expect(userIds).toContain("user-2"); }); }); + +describe("browser Git author attribution", () => { + it.each(["join", "reconnect", "unlink", "relink-without-login"])( + "dispatches the linked GitHub author after an empty-body browser %s", + async (scenario) => { + const userId = "11111111111111111111111111111111"; + const name = `browser-author-${crypto.randomUUID()}`; + const { stub } = await initNamedSession(name, { + userId: "linear:creator", + canonicalUserId: userId, + }); + const url = `https://test.local/sessions/${name}/ws-token`; + await serviceRequestHeaders(url, { method: "POST", body: "{}" }); + await env.DB.prepare( + "UPDATE user_identities SET provider_login = ? WHERE user_id = ? AND provider = 'github'" + ) + .bind("octocat", userId) + .run(); + if (scenario !== "join") { + const prior = await stub.fetch("http://internal/internal/ws-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId, + canonicalUserId: userId, + scmUserId: scenario === "reconnect" ? null : "583231", + scmLogin: scenario === "reconnect" ? null : "octocat", + }), + }); + expect(prior.status).toBe(200); + } + const headers = await serviceRequestHeaders(url, { method: "POST", body: "{}" }); + if (scenario === "unlink") { + await env.DB.prepare( + "DELETE FROM user_identities WHERE user_id = ? AND provider = 'github'" + ) + .bind(userId) + .run(); + } else if (scenario === "relink-without-login") { + await env.DB.prepare( + "UPDATE user_identities SET provider_user_id = ?, provider_login = NULL WHERE user_id = ? AND provider = 'github'" + ) + .bind("77", userId) + .run(); + } + const tokenResponse = await SELF.fetch(url, { method: "POST", body: "{}", headers }); + expect(tokenResponse.status).toBe(200); + const { token } = await tokenResponse.json<{ token: string }>(); + await seedSandboxAuth(stub, { + authToken: "author-sandbox-token", + sandboxId: "author-sandbox", + }); + const { ws: sandbox } = await openSandboxWs(name, { + authToken: "author-sandbox-token", + sandboxId: "author-sandbox", + }); + expect(sandbox).not.toBeNull(); + sandbox!.accept(); + const { ws: client } = await openClientWs(name); + try { + const subscribed = collectMessages(client, { + until: (message) => message.type === "subscribed", + }); + client.send(JSON.stringify({ type: "subscribe", token, clientId: `browser-${scenario}` })); + expect((await subscribed).some((message) => message.type === "subscribed")).toBe(true); + sandbox!.send( + JSON.stringify({ + type: "ready", + sandboxId: "author-sandbox", + timestamp: Date.now() / 1000, + }) + ); + await waitForSandboxStatus(stub, "ready"); + const commands = collectMessages(sandbox!, { + until: (message) => message.type === "prompt", + }); + client.send( + JSON.stringify({ + type: "prompt", + clientRequestId: crypto.randomUUID(), + content: "Commit the fix as me", + }) + ); + const command = (await commands).find((message) => message.type === "prompt"); + expect(command?.author).toEqual({ + userId, + gitIdentity: + scenario === "unlink" || scenario === "relink-without-login" + ? { mode: "agent-only" } + : { + mode: "attributed-user", + name: "Integration Browser User", + email: "583231+octocat@users.noreply.github.com", + }, + }); + } finally { + client.close(); + sandbox!.close(); + } + } + ); +}); diff --git a/packages/docs/README.md b/packages/docs/README.md index bf7b7ec2be..8d3d383e7e 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -91,6 +91,15 @@ cannot create a Vercel project. Apply, then publish the project id so the workfl gh variable set VERCEL_DOCS_PROJECT_ID --body "$(terraform output -raw docs_site_project_id)" ``` +If the `Terraform` workflow applies this environment, give it the same two settings as repository +variables. Otherwise its next apply on `main` uses the defaults and destroys the docs project and +its domain: + +```bash +gh variable set DOCS_SITE_ENABLED --body true +gh variable set DOCS_CUSTOM_DOMAIN --body docs.backgroundagents.dev +``` + The workflow also needs `VERCEL_API_TOKEN` (a secret) and `VERCEL_TEAM_ID` (a variable or secret, shared with `Deploy Web`). The project deliberately has no git integration, so nothing deploys it except this workflow — do not connect the repository to it from the Vercel dashboard, which would diff --git a/packages/docs/content/docs/models/choosing-a-model.mdx b/packages/docs/content/docs/models/choosing-a-model.mdx index ce264a973e..aa98680129 100644 --- a/packages/docs/content/docs/models/choosing-a-model.mdx +++ b/packages/docs/content/docs/models/choosing-a-model.mdx @@ -85,6 +85,7 @@ Runs on OpenCode only. Credential: a connected ChatGPT account, or `OPENAI_API_K | `openai/gpt-5.6-luna` | GPT 5.6 Luna | none, low, medium, high, xhigh, max | medium | | `openai/gpt-6-astra` | GPT-6 Astra | low, medium, high, xhigh, max | medium | | `openai/gpt-6-sol` | GPT-6 Sol | none, low, medium, high, xhigh, max | medium | +| `openai/gpt-6.1-sol` | GPT-6.1 Sol | low, medium, high, xhigh, max | medium | | `openai/gpt-6-luna` | GPT-6 Luna | none, low, medium, high, xhigh, max | medium | "Not set" means the composer shows **Default** and sends no effort unless you choose one. diff --git a/packages/docs/package.json b/packages/docs/package.json index 38c5fa2710..5a7b0b4c54 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -22,7 +22,7 @@ "fumadocs-mdx": "15.4.5", "fumadocs-ui": "16.15.14", "mermaid": "^12.0.0", - "next": "^16.3.6", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.3.0", "react-dom": "^19.3.0", diff --git a/packages/github-bot/package.json b/packages/github-bot/package.json index 72135eacd3..7577aadc44 100644 --- a/packages/github-bot/package.json +++ b/packages/github-bot/package.json @@ -14,7 +14,7 @@ "dependencies": { "@cloudflare/workers-types": "^5.20260927.1", "@open-inspect/shared": "file:../shared", - "hono": "^4.13.9", + "hono": "^4.13.12", "zod": "^4.6.5" }, "devDependencies": { diff --git a/packages/github-bot/src/github-auth.ts b/packages/github-bot/src/github-auth.ts index 475e8a0596..c73a0210a5 100644 --- a/packages/github-bot/src/github-auth.ts +++ b/packages/github-bot/src/github-auth.ts @@ -274,7 +274,8 @@ export async function postCommitStatus( } export type ReviewStatusStateResult = - { ok: true; state: string | null } | { ok: false; error: string }; + | { ok: true; state: string | null } + | { ok: false; error: string }; /** * Read the current state of the review's own status context on a commit, or null when the commit @@ -322,7 +323,8 @@ export async function getReviewStatusState( } export type PullRequestSnapshotResult = - { ok: true; headSha: string; state: string; draft: boolean } | { ok: false; error: string }; + | { ok: true; headSha: string; state: string; draft: boolean } + | { ok: false; error: string }; /** * Fetch the PR's current head sha, state, and draft flag directly from @@ -390,7 +392,8 @@ const pullRequestReviewSchema = z.object({ const pullRequestReviewsResponseSchema = z.array(pullRequestReviewSchema); export type PullRequestApprovalResult = - { ok: true; approved: boolean } | { ok: false; error: string }; + | { ok: true; approved: boolean } + | { ok: false; error: string }; /** * Whether the PR currently carries at least one standing approval. diff --git a/packages/github-bot/src/session-target.ts b/packages/github-bot/src/session-target.ts index 9d9f61452f..2a8bee87fb 100644 --- a/packages/github-bot/src/session-target.ts +++ b/packages/github-bot/src/session-target.ts @@ -24,7 +24,8 @@ import type { ResolvedGitHubConfig } from "./utils/integration-config"; * schema makes the two mutually exclusive. */ export type SessionTargetFields = - { repoOwner: string; repoName: string } | { environmentId: string }; + | { repoOwner: string; repoName: string } + | { environmentId: string }; const metadataResponseSchema = z.object({ metadata: z.object({ defaultEnvironmentId: z.string().optional() }).nullable(), diff --git a/packages/linear-bot/package.json b/packages/linear-bot/package.json index d889ae6bd8..a951e54bc4 100644 --- a/packages/linear-bot/package.json +++ b/packages/linear-bot/package.json @@ -15,7 +15,7 @@ "dependencies": { "@cloudflare/workers-types": "^5.20260927.1", "@open-inspect/shared": "file:../shared", - "hono": "^4.13.9", + "hono": "^4.13.12", "zod": "^4.6.5" }, "devDependencies": { diff --git a/packages/linear-bot/src/callbacks.complete.test.ts b/packages/linear-bot/src/callbacks.complete.test.ts new file mode 100644 index 0000000000..7a9e9d332b --- /dev/null +++ b/packages/linear-bot/src/callbacks.complete.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { computeHmacHex } from "@open-inspect/shared/auth"; +import { callbacksRouter } from "./callbacks"; +import { createFakeKV, makeExecutionContext, makeLinearBotEnv } from "./test-helpers"; + +const SECRET = "callback-secret"; +const SIZE_LIMIT_ERROR = + "The agent's response exceeded the event size limit and was not delivered in full."; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function postFailedCompletion(options: { + text?: string; + eventError?: string; + callbackError?: string; +}): Promise { + const { kv } = createFakeKV(); + const controlPlaneFetch = vi.fn(async (input: RequestInfo | URL) => + String(input).includes("/events") + ? Response.json({ + events: [ + ...(options.text + ? [ + { + id: "token-1", + type: "token", + data: { content: options.text }, + messageId: "message-1", + createdAt: 1, + }, + ] + : []), + { + id: "complete-1", + type: "execution_complete", + data: { + success: false, + ...(options.eventError ? { error: options.eventError } : {}), + }, + messageId: "message-1", + createdAt: 2, + }, + ], + hasMore: false, + }) + : Response.json({ artifacts: [] }) + ); + const linearFetch = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json({ data: { commentCreate: { success: true } } })); + const env = makeLinearBotEnv(kv, { + SERVICE_AUTH_SECRET: SECRET, + LINEAR_API_KEY: "linear-key", + CONTROL_PLANE: { fetch: controlPlaneFetch } as unknown as Fetcher, + }); + const data = { + sessionId: "session-1", + messageId: "message-1", + success: false, + ...(options.callbackError ? { error: options.callbackError } : {}), + timestamp: Date.now(), + context: { + source: "linear", + issueId: "issue-1", + issueIdentifier: "ENG-1", + issueUrl: "https://linear.app/acme/issue/ENG-1", + model: "anthropic/claude-haiku-4-5", + }, + }; + const payload = { ...data, signature: await computeHmacHex(JSON.stringify(data), SECRET) }; + const ctx = makeExecutionContext(); + const response = await callbacksRouter.fetch( + new Request("http://localhost/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }), + env, + ctx + ); + + expect(response.status).toBe(200); + await Promise.all(ctx.waitUntil.mock.calls.map(([promise]) => promise)); + expect(linearFetch).toHaveBeenCalledOnce(); + const body = JSON.parse(String(linearFetch.mock.calls[0][1]?.body)); + return body.variables.input.body; +} + +describe("POST /complete failure", () => { + it("shows the execution error before incomplete text", async () => { + const body = await postFailedCompletion({ + text: "Partial answer", + eventError: SIZE_LIMIT_ERROR, + callbackError: "Fallback error", + }); + + expect(body).toContain(`The agent encountered an error: ${SIZE_LIMIT_ERROR}\n\nPartial answer`); + expect(body).not.toContain("Fallback error"); + }); + + it("shows the callback error when no token or event error was recorded", async () => { + const body = await postFailedCompletion({ callbackError: SIZE_LIMIT_ERROR }); + + expect(body).toContain(`The agent encountered an error: ${SIZE_LIMIT_ERROR}`); + }); + + it.each(["eventError", "callbackError"] as const)( + "omits an oversized, sensitive %s while keeping partial text", + async (source) => { + const body = await postFailedCompletion({ + text: "Partial answer", + [source]: `Provider error at https://user:secret-token@example.test/ ${"x".repeat(1_000_000)}`, + }); + + expect(body).toContain( + "The agent encountered an error: Error details omitted for safety.\n\nPartial answer" + ); + expect(body).not.toContain("secret-token"); + expect(body.length).toBeLessThan(1_000); + } + ); + + it("keeps incomplete text when no error reason exists", async () => { + const body = await postFailedCompletion({ text: "Partial answer" }); + + expect(body).toContain("The agent encountered an error.\n\nPartial answer"); + }); + + it("keeps the generic failure text when no error reason exists", async () => { + const body = await postFailedCompletion({}); + + expect(body).toContain("The agent was unable to complete this task."); + }); +}); diff --git a/packages/linear-bot/src/callbacks.ts b/packages/linear-bot/src/callbacks.ts index 62a86275f2..f6bbb8cfd3 100644 --- a/packages/linear-bot/src/callbacks.ts +++ b/packages/linear-bot/src/callbacks.ts @@ -24,6 +24,8 @@ import { createStartCallbackRouter } from "./callbacks/start-callback"; import { rejectInvalidCallback } from "./callbacks/reject-invalid-callback"; const log = createLogger("callback"); +const EVENT_SIZE_ERROR = + "The agent's response exceeded the event size limit and was not delivered in full."; export function formatCompletionComment( appName: string, @@ -244,10 +246,18 @@ async function handleCompletionCallback( message = formatAgentResponse(agentResponse); } else { activityType = "error"; + const rawFailureReason = agentResponse.error || payload.error; + const failureReason = rawFailureReason + ? rawFailureReason === EVENT_SIZE_ERROR + ? rawFailureReason + : "Error details omitted for safety." + : undefined; if (agentResponse.textContent) { - message = `The agent encountered an error.\n\n${agentResponse.textContent.slice(0, 500)}`; + message = `The agent encountered an error${failureReason ? `: ${failureReason}` : "."}\n\n${agentResponse.textContent.slice(0, 500)}`; } else { - message = `The agent was unable to complete this task.`; + message = failureReason + ? `The agent encountered an error: ${failureReason}` + : "The agent was unable to complete this task."; } } diff --git a/packages/modal-infra/pyproject.toml b/packages/modal-infra/pyproject.toml index 582bae6539..d9652c4c2c 100644 --- a/packages/modal-infra/pyproject.toml +++ b/packages/modal-infra/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0", "fastapi>=0.110.0", - "PyJWT[crypto]>=2.9.0", + "PyJWT[crypto]>=2.14.0", ] [project.optional-dependencies] diff --git a/packages/modal-infra/src/sandbox/build_session.py b/packages/modal-infra/src/sandbox/build_session.py index b8a4a8e596..169a96c9a4 100644 --- a/packages/modal-infra/src/sandbox/build_session.py +++ b/packages/modal-infra/src/sandbox/build_session.py @@ -34,6 +34,7 @@ parse_launch, ) from .manager import SNAPSHOT_FILESYSTEM_TIMEOUT_SECONDS +from .termination import terminate_and_wait from .vcs_env import inject_vcs_env_vars log = get_logger("build_session") @@ -220,7 +221,7 @@ async def terminate( try: sandbox, _tags = await self._resolve(build_id, provider_session_id) termination_start = time.time() - exit_code = await sandbox.terminate.aio(wait=True) + exit_code = await terminate_and_wait(sandbox) except BuildSessionNotFoundError: log.info( "sandbox.terminate_build_not_found", diff --git a/packages/modal-infra/src/sandbox/launch.py b/packages/modal-infra/src/sandbox/launch.py index e3dde208ea..7ef0113d16 100644 --- a/packages/modal-infra/src/sandbox/launch.py +++ b/packages/modal-infra/src/sandbox/launch.py @@ -33,6 +33,7 @@ parse_launch, ) from .models import SandboxConfig, SandboxHandle +from .termination import terminate_and_wait from .tunnels import SandboxTunnels from .vcs_env import inject_vcs_env_vars from .vm_recovery import VMAllocationOutcome, VMServiceLaunch, find_owned_vm, owned_vm_tags_match @@ -355,7 +356,7 @@ async def _retire_docker_allocation(self, session_id: str, sandbox_id: str) -> N ): log.warn("sandbox.docker_allocation_retire_mismatch", sandbox_id=sandbox_id) return - await sandbox.terminate.aio(wait=True) + await terminate_and_wait(sandbox) log.info( "sandbox.docker_allocation_retired", sandbox_id=sandbox_id, diff --git a/packages/modal-infra/src/sandbox/manager.py b/packages/modal-infra/src/sandbox/manager.py index 0f1c4dd46a..7b30154a6a 100644 --- a/packages/modal-infra/src/sandbox/manager.py +++ b/packages/modal-infra/src/sandbox/manager.py @@ -45,6 +45,7 @@ parse_pending_vm_reference, ) from .models import DEFAULT_VNC_ENABLED, SandboxConfig, SandboxHandle +from .termination import terminate_and_wait from .tunnels import MAX_TUNNEL_PORTS from .vm_recovery import ( VMAllocationOutcome, @@ -190,7 +191,7 @@ async def take_snapshot( "-m", "sandbox_runtime.docker_control", "prepare", - timeout=min(snapshot_timeout_seconds, CONTROL_TIMEOUT_SECONDS), + timeout=min(snapshot_timeout_seconds, int(CONTROL_TIMEOUT_SECONDS)), ) if await probe.wait.aio() != 0: raise RuntimeError("Modal VM Docker shutdown preparation was not confirmed") @@ -225,7 +226,7 @@ async def stop_sandbox(self, sandbox_id: str) -> None: sandbox_id = handle.modal_object_id try: sandbox = await modal.Sandbox.from_id.aio(sandbox_id) - await sandbox.terminate.aio(wait=True) + await terminate_and_wait(sandbox) except modal.exception.NotFoundError: # Already absent is the terminal state requested by stop. return diff --git a/packages/modal-infra/src/sandbox/termination.py b/packages/modal-infra/src/sandbox/termination.py new file mode 100644 index 0000000000..3383dea7b9 --- /dev/null +++ b/packages/modal-infra/src/sandbox/termination.py @@ -0,0 +1,11 @@ +"""Wait for Modal sandbox retirement, including already-timed-out sandboxes.""" + +import modal + + +async def terminate_and_wait(sandbox: modal.Sandbox) -> int | None: + try: + return await sandbox.terminate.aio(wait=True) + except modal.exception.SandboxTimeoutError: + # Modal sets returncode before raising when the sandbox ended by timeout. + return sandbox.returncode diff --git a/packages/modal-infra/src/sandbox/vm_recovery.py b/packages/modal-infra/src/sandbox/vm_recovery.py index 3f6759df15..c18a2fa590 100644 --- a/packages/modal-infra/src/sandbox/vm_recovery.py +++ b/packages/modal-infra/src/sandbox/vm_recovery.py @@ -140,12 +140,8 @@ async def recover_vm_access( }, ) urls = await tunnels.resolve(sandbox, sandbox_id, write_env_file=False) - if ( - (launch.code_server_enabled and not urls.code_server_url) - or (launch.vnc_enabled and not urls.vnc_url) - or (launch.terminal_enabled and not urls.ttyd_url) - or any(not (urls.tunnel_urls or {}).get(port) for port in launch.tunnel_ports) - ): + # Launch returns whatever tunnels Modal published; only retry while none are readable. + if tunnels.exposed_ports and not any(urls): raise VMAllocationOutcome("race_pending", "VM allocation tunnels are not yet visible") return VMAccess( code_server_url=urls.code_server_url, diff --git a/packages/modal-infra/tests/modal_sdk_contract.py b/packages/modal-infra/tests/modal_sdk_contract.py new file mode 100644 index 0000000000..3ce81c1a42 --- /dev/null +++ b/packages/modal-infra/tests/modal_sdk_contract.py @@ -0,0 +1,57 @@ +"""Serialize recorded SDK arguments using the installed Modal protobuf contracts. + +These cover argument serialization, not RPCs, object hydration, or runtime behavior. +The mappings follow Modal 1.4.3's sandbox.py; resource conversion stays in the SDK. +""" + +from inspect import signature +from typing import Any + +import modal +from modal._resources import convert_fn_config_to_resources_config +from modal_proto import api_pb2, task_command_router_pb2 + +# Capture the installed signatures before tests replace SDK methods with fakes. +_EXEC_SIGNATURE = signature(modal.Sandbox.exec) +_CREATE_SIGNATURE = signature(modal.Sandbox.create) + + +def sandbox_exec_request(*args: str, **kwargs: Any) -> task_command_router_pb2.TaskExecStartRequest: + _EXEC_SIGNATURE.bind(None, *args, **kwargs) + return task_command_router_pb2.TaskExecStartRequest( + command_args=args, timeout_secs=kwargs.get("timeout") + ) + + +def sandbox_create_request(*args: str, **kwargs: Any) -> api_pb2.SandboxCreateRequest: + _CREATE_SIGNATURE.bind(*args, **kwargs) + definition = api_pb2.Sandbox( + entrypoint_args=args, + timeout_secs=kwargs["timeout"], + idle_timeout_secs=kwargs.get("idle_timeout"), + resources=convert_fn_config_to_resources_config( + cpu=kwargs.get("cpu"), memory=kwargs.get("memory"), gpu=None + ), + workdir=kwargs.get("workdir"), + open_ports=api_pb2.PortSpecs( + ports=[ + api_pb2.PortSpec(port=port, unencrypted=False) + for port in kwargs.get("encrypted_ports", []) + ] + ), + name=kwargs.get("name"), + experimental_options=kwargs.get("experimental_options"), + ) + return api_pb2.SandboxCreateRequest( + definition=definition, + tags=[ + api_pb2.SandboxTag(tag_name=key, tag_value=value) + for key, value in kwargs.get("tags", {}).items() + ], + ) + + +def snapshot_filesystem_request(*, timeout: int | float) -> api_pb2.SandboxSnapshotFsRequest: + # Check both SDK paths; the router timeout is passed separately from its request. + float(timeout) + return api_pb2.SandboxSnapshotFsRequest(timeout=timeout) diff --git a/packages/modal-infra/tests/test_build_sandbox_lifecycle.py b/packages/modal-infra/tests/test_build_sandbox_lifecycle.py index db011b58c5..9cd465d150 100644 --- a/packages/modal-infra/tests/test_build_sandbox_lifecycle.py +++ b/packages/modal-infra/tests/test_build_sandbox_lifecycle.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from modal.exception import SandboxTimeoutError from sandbox_runtime.constants import ( DOCKER_ENABLED_ENV_VAR, @@ -29,6 +30,7 @@ ) from src.sandbox.manager import SNAPSHOT_FILESYSTEM_TIMEOUT_SECONDS from src.web_api import IMAGE_BUILD_FINALIZATION_GRACE_SECONDS +from tests.modal_sdk_contract import sandbox_create_request, snapshot_filesystem_request @pytest.fixture(autouse=True) @@ -170,7 +172,13 @@ def test_reserved_user_env_scrub_matches_manifest(): @pytest.mark.asyncio -async def test_create_build_sandbox_runs_gated_entrypoint_and_scrubs_callback_env(monkeypatch): +@pytest.mark.parametrize( + "execution_timeout_seconds, provider_timeout_seconds", + [(1200, 1800), (1800, 2400), (3600, 4200)], +) +async def test_create_build_sandbox_runs_gated_entrypoint_and_scrubs_callback_env( + monkeypatch, execution_timeout_seconds, provider_timeout_seconds +): sandbox = SimpleNamespace(object_id="modal-session-1") create = _async_method(sandbox) monkeypatch.setattr("src.sandbox.build_session.modal.Sandbox.create", create) @@ -194,14 +202,15 @@ async def test_create_build_sandbox_runs_gated_entrypoint_and_scrubs_callback_en "MODAL_SANDBOX_ID": "attacker-sandbox", "OI_IMAGE_BUILD_EXECUTION_TIMEOUT_SECONDS": "99999", }, - build_execution_timeout_seconds=1200, - timeout_seconds=1800, + build_execution_timeout_seconds=execution_timeout_seconds, + timeout_seconds=provider_timeout_seconds, ) assert launch.provider_session_id == "modal-session-1" assert launch.sandbox_backend == "modal" args = create.aio.await_args.args kwargs = create.aio.await_args.kwargs + sandbox_create_request(*args, **kwargs) assert args == ( "python", "-m", @@ -228,7 +237,9 @@ async def test_create_build_sandbox_runs_gated_entrypoint_and_scrubs_callback_en kwargs["env"]["OI_REPO_IMAGE_FAILURE_CALLBACK_URL"] == "https://cp.test/image-builds/build-failed" ) - assert kwargs["env"]["OI_IMAGE_BUILD_EXECUTION_TIMEOUT_SECONDS"] == "1200" + assert kwargs["env"]["OI_IMAGE_BUILD_EXECUTION_TIMEOUT_SECONDS"] == str( + execution_timeout_seconds + ) assert kwargs["env"]["VCS_HOST"] == "gitlab.com" assert kwargs["env"]["VCS_CLONE_USERNAME"] == "oauth2" assert kwargs["env"]["VCS_CLONE_TOKEN"] == "clone-token" @@ -237,7 +248,7 @@ async def test_create_build_sandbox_runs_gated_entrypoint_and_scrubs_callback_en "repositories": [{"repo_owner": "acme", "repo_name": "repo", "branch": "main"}], } assert kwargs["secrets"] == [] - assert kwargs["timeout"] == 1800 + assert kwargs["timeout"] == provider_timeout_seconds assert kwargs["workdir"] == "/workspace" @@ -342,6 +353,7 @@ async def test_snapshot_build_awaits_async_snapshot_operation(monkeypatch): assert image_id == "im-snapshot-1" snapshot_filesystem.assert_not_called() + snapshot_filesystem_request(**snapshot_filesystem.aio.await_args.kwargs) snapshot_filesystem.aio.assert_awaited_once_with(timeout=SNAPSHOT_FILESYSTEM_TIMEOUT_SECONDS) @@ -367,6 +379,31 @@ async def test_terminate_build_sandbox_verifies_tags(monkeypatch): sandbox.terminate.aio.assert_awaited_once_with(wait=True) +@pytest.mark.asyncio +async def test_terminate_build_sandbox_logs_exit_code_when_timed_out(monkeypatch): + terminate = _async_method() + terminate.aio.side_effect = SandboxTimeoutError() + sandbox = SimpleNamespace( + returncode=124, + get_tags=_async_method( + {"openinspect_kind": "image-build", "openinspect_build_id": "build-1"} + ), + terminate=terminate, + ) + _mock_sandbox_lookup(monkeypatch, sandbox) + info = MagicMock() + monkeypatch.setattr("src.sandbox.build_session.log.info", info) + + await ModalBuildSessionService().terminate( + build_id="build-1", provider_session_id="modal-session-1", reason="image_build_complete" + ) + + terminate.aio.assert_awaited_once_with(wait=True) + info.assert_called_once() + assert info.call_args.args == ("sandbox.terminate_build",) + assert info.call_args.kwargs["exit_code"] == 124 + + @pytest.mark.asyncio async def test_terminate_build_sandbox_treats_provider_not_found_as_success(monkeypatch): from modal.exception import NotFoundError @@ -389,8 +426,17 @@ async def test_terminate_build_sandbox_treats_provider_not_found_as_success(monk @pytest.mark.asyncio @pytest.mark.parametrize("docker_enabled", [False, True]) +@pytest.mark.parametrize( + "settings, expected_cpu, expected_memory", + [ + (None, 2, 4096), + ({"cpuCores": 2, "memoryMib": 4096}, 2, 4096), + ({"cpuCores": 0.5, "memoryMib": 2048}, 0.5, 2048), + ], + ids=["defaults", "integer-cpu", "fractional-cpu"], +) async def test_create_build_sandbox_selects_the_variant_from_frozen_settings( - monkeypatch, docker_enabled + monkeypatch, docker_enabled, settings, expected_cpu, expected_memory ): sandbox = SimpleNamespace(object_id="modal-session-1") create = _async_method(sandbox) @@ -409,20 +455,23 @@ async def test_create_build_sandbox_selects_the_variant_from_frozen_settings( failure_callback_url="https://cp.test/image-builds/build-failed", user_env_vars={DOCKER_ENABLED_ENV_VAR: "true"}, sandbox_backend="modal-vm" if docker_enabled else "modal", - sandbox_settings=({"cpuCores": 2, "memoryMib": 4096} if docker_enabled else None), + sandbox_settings=settings, ) assert launch.provider_session_id == "modal-session-1" assert launch.sandbox_backend == ("modal-vm" if docker_enabled else "modal") kwargs = create.aio.await_args.kwargs + sandbox_create_request(*create.aio.await_args.args, **kwargs) assert kwargs["env"][DOCKER_ENABLED_ENV_VAR] == ("true" if docker_enabled else "false") if docker_enabled: assert kwargs["image"] is docker_image assert kwargs["experimental_options"] == {"vm_runtime": True} - assert (kwargs["cpu"], kwargs["memory"]) == ((2, 2), 4096) + assert (kwargs["cpu"], kwargs["memory"]) == ((expected_cpu, expected_cpu), expected_memory) else: assert kwargs["image"] is default_image assert "experimental_options" not in kwargs + assert "cpu" not in kwargs + assert "memory" not in kwargs @pytest.mark.asyncio diff --git a/packages/modal-infra/tests/test_modal_sdk_contract.py b/packages/modal-infra/tests/test_modal_sdk_contract.py new file mode 100644 index 0000000000..aaabd47340 --- /dev/null +++ b/packages/modal-infra/tests/test_modal_sdk_contract.py @@ -0,0 +1,109 @@ +"""Guard the test helpers against silently accepting invalid protobuf arguments.""" + +from unittest.mock import Mock + +import modal +import pytest + +from tests.modal_sdk_contract import ( + sandbox_create_request, + sandbox_exec_request, + snapshot_filesystem_request, +) + + +@pytest.mark.parametrize( + "build_request, args, kwargs", + [ + (sandbox_exec_request, ("python",), {"timeout": 165.0}), + (sandbox_create_request, ("python",), {"timeout": 1800.0}), + (sandbox_create_request, ("python",), {"timeout": 1800, "idle_timeout": 30.0}), + ], + ids=["exec", "create", "create-idle"], +) +def test_integer_timeout_fields_reject_floats(build_request, args, kwargs): + with pytest.raises(TypeError, match="'float' object cannot be interpreted as an integer"): + build_request(*args, **kwargs) + + +@pytest.mark.parametrize( + "build_request, sdk_method, kwargs, unexpected", + [ + (sandbox_exec_request, "exec", {"timeuot": 30}, "timeuot"), + ( + sandbox_create_request, + "create", + {"timeout": 30, "encrypted_port": [8080]}, + "encrypted_port", + ), + ], + ids=["exec", "create"], +) +def test_request_rejects_unknown_sdk_keywords( + monkeypatch, build_request, sdk_method, kwargs, unexpected +): + monkeypatch.setattr(modal.Sandbox, sdk_method, Mock()) + with pytest.raises(TypeError, match=f"unexpected keyword argument '{unexpected}'"): + build_request("python", **kwargs) + + +def test_exec_accepts_unmodeled_sdk_keywords(): + request = sandbox_exec_request("python", timeout=30, text=False) + assert request.timeout_secs == 30 + + +def test_exec_serializes_recorded_arguments_without_coercion(): + request = sandbox_exec_request("python", "-m", "module", timeout=165) + assert list(request.command_args) == ["python", "-m", "module"] + assert request.timeout_secs == 165 + + +@pytest.mark.parametrize( + "cpu, memory, expected_cpu, expected_cpu_max, expected_memory, expected_memory_max", + [ + (None, None, 0, 0, 0, 0), + (0.5, 2048, 500, 0, 2048, 0), + ((0.5, 1.5), (2048, 4096), 500, 1500, 2048, 4096), + ], + ids=["defaults", "scalar", "request-and-limit"], +) +def test_create_uses_sdk_resource_conversion( + cpu, memory, expected_cpu, expected_cpu_max, expected_memory, expected_memory_max +): + request = sandbox_create_request( + "python", + timeout=1800, + idle_timeout=30, + cpu=cpu, + memory=memory, + workdir="/workspace", + encrypted_ports=[8080], + experimental_options={"vm_runtime": True}, + name="test-allocation", + tags={"kind": "test"}, + ) + definition = request.definition + assert list(definition.entrypoint_args) == ["python"] + assert definition.timeout_secs == 1800 + assert definition.idle_timeout_secs == 30 + assert definition.workdir == "/workspace" + assert definition.resources.milli_cpu == expected_cpu + assert definition.resources.milli_cpu_max == expected_cpu_max + assert definition.resources.memory_mb == expected_memory + assert definition.resources.memory_mb_max == expected_memory_max + assert definition.open_ports.ports[0].port == 8080 + assert not definition.open_ports.ports[0].unencrypted + assert definition.experimental_options == {"vm_runtime": True} + assert definition.name == "test-allocation" + assert [(tag.tag_name, tag.tag_value) for tag in request.tags] == [("kind", "test")] + + +@pytest.mark.parametrize("timeout", [55, 55.0, 55.5]) +def test_snapshot_accepts_float_timeout_without_truncation(timeout): + request = snapshot_filesystem_request(timeout=timeout) + assert request.timeout == timeout + + +def test_snapshot_checks_router_timeout_conversion(): + with pytest.raises(TypeError, match=r"float\(\) argument must be"): + snapshot_filesystem_request(timeout=None) diff --git a/packages/modal-infra/tests/test_sandbox_launch.py b/packages/modal-infra/tests/test_sandbox_launch.py index ec83ce45d8..f56fadd258 100644 --- a/packages/modal-infra/tests/test_sandbox_launch.py +++ b/packages/modal-infra/tests/test_sandbox_launch.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock import pytest -from modal.exception import NotFoundError +from modal.exception import NotFoundError, SandboxTimeoutError from sandbox_runtime.constants import ( CODE_SERVER_PORT_ENV_VAR, @@ -19,7 +19,7 @@ VNC_PASSWORD_ENV_VAR, ) from sandbox_runtime.types import SandboxStatus, SessionConfig -from src.sandbox.launch import SandboxLauncher +from src.sandbox.launch import ACCESS_PASSWORD_READ_TIMEOUT_SECONDS, SandboxLauncher from src.sandbox.launch_policy import ( DockerImageUnavailableError, InvalidDockerSettingsError, @@ -33,6 +33,7 @@ ) from src.sandbox.tunnels import SandboxTunnels, TunnelUrls from src.sandbox.vm_recovery import VMAllocationOutcome, VMServiceLaunch +from tests.modal_sdk_contract import sandbox_create_request, sandbox_exec_request def _fake_create(captured: dict): @@ -58,8 +59,18 @@ async def create_aio(*args, **kwargs): @pytest.mark.asyncio @pytest.mark.parametrize("image_source", ["base", "repository", "snapshot"]) +@pytest.mark.parametrize( + "resources, expected_cpu, expected_memory, timeout_seconds", + [ + ({}, None, None, 30), + ({"cpuCores": 0.5}, 0.5, None, 300), + ({"memoryMib": 2048}, None, 2048, 1800), + ({"cpuCores": 1.5, "memoryMib": 3072}, 1.5, 3072, 4321), + ], + ids=["defaults", "cpu-only", "memory-only", "cpu-and-memory"], +) async def test_launch_matrix_preserves_common_and_source_specific_behavior( - monkeypatch, image_source + monkeypatch, image_source, resources, expected_cpu, expected_memory, timeout_seconds ): captured: dict = {} base_image = object() @@ -83,14 +94,13 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( "terminalPort": 9002, "terminalEnabled": True, "tunnelPorts": [3000], - "cpuCores": 1.5, - "memoryMib": 3072, + **resources, } common = { "sandbox_id": "sandbox-1", "control_plane_url": "https://control.example", "sandbox_auth_token": "sandbox-token", - "timeout_seconds": 4321, + "timeout_seconds": timeout_seconds, "user_env_vars": { "CONTROL_PLANE_URL": "https://user.example", "CUSTOM_ENV": "preserved", @@ -141,12 +151,13 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( expected_image = images["repo-image-1"] if image_source == "repository" else base_image kwargs = captured["kwargs"] + sandbox_create_request(*captured["command"], **kwargs) env = kwargs["env"] assert captured["command"] == ("python", "-m", "sandbox_runtime.entrypoint") assert kwargs["image"] is expected_image - assert kwargs["timeout"] == 4321 - assert kwargs["cpu"] == 1.5 - assert kwargs["memory"] == 3072 + assert kwargs["timeout"] == timeout_seconds + assert kwargs.get("cpu") == expected_cpu + assert kwargs.get("memory") == expected_memory assert kwargs["encrypted_ports"] == [9000, 9001, 9002, 3000] # The default launch never touches the VM runtime or named allocations. assert "experimental_options" not in kwargs @@ -431,7 +442,18 @@ def _not_found(*_args, **_kwargs): @pytest.mark.asyncio @pytest.mark.parametrize("image_source", ["base", "repository", "snapshot"]) -async def test_docker_launch_selects_vm_runtime_and_named_allocation(monkeypatch, image_source): +@pytest.mark.parametrize( + "settings, expected_cpu, expected_memory, timeout_seconds", + [ + (None, 2, 4096, 30), + (DOCKER_SETTINGS, 2, 4096, 4321), + ({"cpuCores": 0.5, "memoryMib": 2048}, 0.5, 2048, 600), + ], + ids=["defaults", "integer-cpu", "fractional-cpu"], +) +async def test_docker_launch_selects_vm_runtime_and_named_allocation( + monkeypatch, image_source, settings, expected_cpu, expected_memory, timeout_seconds +): manager, captured, docker_image = _docker_manager(monkeypatch) artifact = object() monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda _id: artifact) @@ -448,19 +470,26 @@ async def test_docker_launch_selects_vm_runtime_and_named_allocation(monkeypatch control_plane_url="https://control.example", sandbox_auth_token="token", user_env_vars={DOCKER_ENABLED_ENV_VAR: "false"}, - settings=dict(DOCKER_SETTINGS), + settings=settings, + timeout_seconds=timeout_seconds, sandbox_backend="modal-vm", ) else: handle = await manager.create_sandbox( - _docker_config(repo_image_id="repo-image-1" if image_source == "repository" else None) + _docker_config( + repo_image_id="repo-image-1" if image_source == "repository" else None, + settings=settings, + timeout_seconds=timeout_seconds, + ) ) kwargs = captured["kwargs"] + sandbox_create_request(*captured["command"], **kwargs) assert kwargs["image"] is (docker_image if image_source == "base" else artifact) assert kwargs["experimental_options"] == {"vm_runtime": True} - assert kwargs["cpu"] == (2.0, 2.0) - assert kwargs["memory"] == 4096 + assert kwargs["cpu"] == (expected_cpu, expected_cpu) + assert kwargs["memory"] == expected_memory + assert kwargs["timeout"] == timeout_seconds assert kwargs["name"] == docker_allocation_name("session-1") assert kwargs["tags"] == { **docker_allocation_tags("session-1", "sandbox-acme-repo-1700000000000"), @@ -652,6 +681,43 @@ async def launch(): assert adopted.vnc_password == original.vnc_password == "old-vnc" assert create.await_count == int(create_race) assert existing.exec.aio.call_args.args[-2:] == ("CODE_SERVER_PASSWORD", VNC_PASSWORD_ENV_VAR) + sandbox_exec_request(*existing.exec.aio.await_args.args, **existing.exec.aio.await_args.kwargs) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("code_server_enabled", [False, True]) +@pytest.mark.parametrize("vnc_enabled", [False, True]) +async def test_access_password_read_arguments_match_enabled_services( + code_server_enabled, vnc_enabled +): + passwords = {"CODE_SERVER_PASSWORD": "code-password", VNC_PASSWORD_ENV_VAR: "vnc-pass"} + process = SimpleNamespace( + stdout=SimpleNamespace( + read=SimpleNamespace(aio=AsyncMock(return_value=json.dumps(passwords))) + ), + wait=SimpleNamespace(aio=AsyncMock(return_value=0)), + ) + execute = AsyncMock(return_value=process) + recovered = await SandboxLauncher._read_access_passwords( + SimpleNamespace(exec=SimpleNamespace(aio=execute)), + code_server_enabled=code_server_enabled, + vnc_enabled=vnc_enabled, + ) + + keys = [] + if code_server_enabled: + keys.append("CODE_SERVER_PASSWORD") + if vnc_enabled: + keys.append(VNC_PASSWORD_ENV_VAR) + assert recovered == {key: passwords[key] for key in keys} + if not keys: + execute.assert_not_awaited() + else: + execute.assert_awaited_once() + request = sandbox_exec_request(*execute.await_args.args, **execute.await_args.kwargs) + assert list(request.command_args[:3]) == ["python", "-I", "-c"] + assert list(request.command_args[4:]) == keys + assert request.timeout_secs == ACCESS_PASSWORD_READ_TIMEOUT_SECONDS @pytest.mark.asyncio @@ -750,6 +816,27 @@ async def from_name(_app, name): prior.terminate.assert_not_awaited() +@pytest.mark.asyncio +async def test_retire_docker_allocation_succeeds_when_owned_vm_timed_out(monkeypatch): + terminate = AsyncMock(side_effect=SandboxTimeoutError()) + sandbox = SimpleNamespace( + object_id="modal-prior", + returncode=124, + get_tags=SimpleNamespace( + aio=AsyncMock(return_value=docker_allocation_tags("session-1", "sandbox-prior")) + ), + terminate=SimpleNamespace(aio=terminate), + ) + monkeypatch.setattr( + "src.sandbox.launch.modal.Sandbox.from_name", + SimpleNamespace(aio=AsyncMock(return_value=sandbox)), + ) + + await SandboxLauncher()._retire_docker_allocation("session-1", "sandbox-prior") + + terminate.assert_awaited_once_with(wait=True) + + @pytest.mark.asyncio async def test_late_predecessor_cannot_materialize_beside_successor(monkeypatch): from modal.exception import AlreadyExistsError @@ -870,6 +957,7 @@ async def test_launch_rejects_boolean_tunnel_ports(monkeypatch, restore, ports, ) kwargs = create.call_args.kwargs + sandbox_create_request(*create.call_args.args, **kwargs) assert kwargs.get("encrypted_ports", []) == expected assert all(type(port) is int for port in kwargs.get("encrypted_ports", [])) assert kwargs["env"].get(EXPECTED_TUNNEL_PORTS_ENV_VAR) == ( diff --git a/packages/modal-infra/tests/test_snapshot_timeout.py b/packages/modal-infra/tests/test_snapshot_timeout.py index a57d3fa0a5..f9bf10780e 100644 --- a/packages/modal-infra/tests/test_snapshot_timeout.py +++ b/packages/modal-infra/tests/test_snapshot_timeout.py @@ -6,8 +6,10 @@ import pytest from modal.exception import NotFoundError as ModalNotFoundError +from modal.exception import SandboxTimeoutError +from modal.exception import TimeoutError as ModalTimeoutError -from sandbox_runtime.docker_control import CONTROL_TIMEOUT_SECONDS +from sandbox_runtime.docker_control import CONTROL_TIMEOUT_SECONDS, PREPARATION_TIMEOUT_SECONDS from sandbox_runtime.types import SandboxStatus from src.sandbox.launch_policy import docker_allocation_tags from src.sandbox.manager import ( @@ -15,6 +17,7 @@ SandboxHandle, SandboxManager, ) +from tests.modal_sdk_contract import sandbox_exec_request, snapshot_filesystem_request @pytest.mark.asyncio @@ -104,6 +107,7 @@ async def test_take_snapshot_passes_explicit_timeout(): assert image_id == "im-session" snapshot_filesystem.assert_not_called() + snapshot_filesystem_request(**snapshot_filesystem.aio.await_args.kwargs) snapshot_filesystem.aio.assert_awaited_once_with(timeout=SNAPSHOT_FILESYSTEM_TIMEOUT_SECONDS) @@ -149,7 +153,9 @@ async def test_unknown_backend_tag_is_explicitly_rejected(monkeypatch): @pytest.mark.asyncio -@pytest.mark.parametrize("budget, expected", [(179.8, 179), (500, 300)]) +@pytest.mark.parametrize( + "budget, expected", [(1, 1), (1.9, 1), (179.8, 179), (300, 300), (500, 300)] +) async def test_take_snapshot_bounds_whole_second_timeout(budget, expected): snapshot_filesystem = _async_method(SimpleNamespace(object_id="im-session")) handle = SandboxHandle( @@ -161,6 +167,7 @@ async def test_take_snapshot_bounds_whole_second_timeout(budget, expected): await SandboxManager().take_snapshot(handle, timeout_seconds=budget) + snapshot_filesystem_request(**snapshot_filesystem.aio.await_args.kwargs) snapshot_filesystem.aio.assert_awaited_once_with(timeout=expected) @@ -193,6 +200,33 @@ async def test_stop_sandbox_waits_for_provider_termination(monkeypatch): terminate.aio.assert_awaited_once_with(wait=True) +@pytest.mark.asyncio +async def test_stop_sandbox_succeeds_when_sandbox_already_timed_out(monkeypatch): + terminate = _async_method() + terminate.aio.side_effect = SandboxTimeoutError() + monkeypatch.setattr( + "src.sandbox.manager.modal.Sandbox.from_id", + _async_method(SimpleNamespace(terminate=terminate, returncode=124)), + ) + + await SandboxManager().stop_sandbox("sandbox-1") + + terminate.aio.assert_awaited_once_with(wait=True) + + +@pytest.mark.asyncio +async def test_stop_sandbox_propagates_other_modal_timeout(monkeypatch): + terminate = _async_method() + terminate.aio.side_effect = ModalTimeoutError() + monkeypatch.setattr( + "src.sandbox.manager.modal.Sandbox.from_id", + _async_method(SimpleNamespace(terminate=terminate)), + ) + + with pytest.raises(ModalTimeoutError): + await SandboxManager().stop_sandbox("sandbox-1") + + @pytest.mark.asyncio async def test_stop_sandbox_succeeds_when_provider_object_is_already_absent(monkeypatch): from_id = _async_method() @@ -264,12 +298,54 @@ async def test_vm_capture_requires_docker_preparation(exit_code): "sandbox_runtime.docker_control", "prepare", ) - assert execute.aio.call_args.kwargs["timeout"] == CONTROL_TIMEOUT_SECONDS + assert type(execute.aio.call_args.kwargs["timeout"]) is int + assert execute.aio.call_args.kwargs["timeout"] == int(CONTROL_TIMEOUT_SECONDS) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("budget", [500, 300, 166, 165.5, 10, 1.5]) +async def test_vm_capture_exec_timeout_is_sdk_compatible_and_within_budget(monkeypatch, budget): + monkeypatch.setattr( + "src.sandbox.manager.time", SimpleNamespace(time=lambda: 1000, monotonic=lambda: 0) + ) + execute = _async_method(SimpleNamespace(wait=_async_method(0))) + snapshot = _async_method(SimpleNamespace(object_id="im-vm")) + handle = SandboxHandle( + sandbox_id="sb-vm", + sandbox_backend="modal-vm", + status=SandboxStatus.READY, + created_at=0, + modal_sandbox=SimpleNamespace(exec=execute, snapshot_filesystem=snapshot), + ) + + await SandboxManager().take_snapshot(handle, timeout_seconds=budget) + + sandbox_exec_request(*execute.aio.await_args.args, **execute.aio.await_args.kwargs) + timeout = execute.aio.await_args.kwargs["timeout"] + assert type(timeout) is int + assert timeout == min(int(budget), int(CONTROL_TIMEOUT_SECONDS)) + snapshot_filesystem_request(**snapshot.aio.await_args.kwargs) + snapshot.aio.assert_awaited_once_with( + timeout=min(int(budget), SNAPSHOT_FILESYSTEM_TIMEOUT_SECONDS) + ) + + +def test_vm_preparation_deadline_fits_capture_budget(): + from sandbox_runtime.docker_service import ( + DOCKER_START_TIMEOUT_SECONDS, + DOCKER_STOP_TIMEOUT_SECONDS, + ) + + assert PREPARATION_TIMEOUT_SECONDS > 2 * DOCKER_STOP_TIMEOUT_SECONDS + assert CONTROL_TIMEOUT_SECONDS > ( + PREPARATION_TIMEOUT_SECONDS + DOCKER_STOP_TIMEOUT_SECONDS + DOCKER_START_TIMEOUT_SECONDS + ) + assert SNAPSHOT_FILESYSTEM_TIMEOUT_SECONDS > CONTROL_TIMEOUT_SECONDS @pytest.mark.asyncio -@pytest.mark.parametrize("elapsed", [9, 10]) -async def test_vm_preparation_consumes_capture_budget(monkeypatch, elapsed): +@pytest.mark.parametrize("elapsed, expected", [(0.5, 9), (8.9, 1), (9, 1), (9.1, None), (10, None)]) +async def test_vm_preparation_consumes_capture_budget(monkeypatch, elapsed, expected): clock = SimpleNamespace(time=lambda: 1000, monotonic=lambda: 0) monkeypatch.setattr("src.sandbox.manager.time", clock) @@ -289,10 +365,11 @@ async def prepare(): exec=_async_method(SimpleNamespace(wait=wait)), snapshot_filesystem=snapshot ), ) - if elapsed == 10: + if expected is None: with pytest.raises(TimeoutError): await SandboxManager().take_snapshot(handle, timeout_seconds=10) snapshot.aio.assert_not_awaited() else: await SandboxManager().take_snapshot(handle, timeout_seconds=10) - snapshot.aio.assert_awaited_once_with(timeout=1) + snapshot_filesystem_request(**snapshot.aio.await_args.kwargs) + snapshot.aio.assert_awaited_once_with(timeout=expected) diff --git a/packages/modal-infra/tests/test_vm_resolve.py b/packages/modal-infra/tests/test_vm_resolve.py index a689d7fb28..4513793da0 100644 --- a/packages/modal-infra/tests/test_vm_resolve.py +++ b/packages/modal-infra/tests/test_vm_resolve.py @@ -112,20 +112,13 @@ async def test_resolve_returns_owned_vm_id_access_and_tunnels_without_mutation(m tunnels.assert_awaited_once_with(sandbox, GENERATION, write_env_file=False) -@pytest.mark.asyncio -@pytest.mark.parametrize("missing_port", [9000, 9001, 9002, 3001]) -async def test_resolve_retries_when_enabled_tunnel_is_missing(monkeypatch, missing_port): +def _vm_publishing(monkeypatch, published): monkeypatch.setattr(web_api, "require_auth", lambda _token: None) sandbox = _sandbox( _tags(), { "CODE_SERVER_PASSWORD": "original-code-password", VNC_PASSWORD_ENV_VAR: "original-vnc-password", - CODE_SERVER_PORT_ENV_VAR: "9000", - NOVNC_PORT_ENV_VAR: "9001", - TTYD_PROXY_PORT_ENV_VAR: "9002", - EXPECTED_TUNNEL_PORTS_ENV_VAR: "3000,3001", - "TERMINAL_ENABLED": "true", }, ) monkeypatch.setattr( @@ -135,19 +128,40 @@ async def test_resolve_retries_when_enabled_tunnel_is_missing(monkeypatch, missi ) create = AsyncMock(side_effect=AssertionError("resolve must not create")) monkeypatch.setattr(manager_module.modal.Sandbox, "create", SimpleNamespace(aio=create)) - monkeypatch.setattr( - SandboxTunnels, - "_resolve_tunnels", - AsyncMock( - return_value={ - port: f"https://port-{port}.example" - for port in [9000, 9001, 9002, 3000, 3001] - if port != missing_port - } - ), - ) + monkeypatch.setattr(SandboxTunnels, "_resolve_tunnels", AsyncMock(return_value=dict(published))) write_env = AsyncMock(side_effect=AssertionError("resolve must not write")) monkeypatch.setattr(SandboxTunnels, "_write_tunnel_env_file", write_env) + return sandbox, create, write_env + + +@pytest.mark.asyncio +@pytest.mark.parametrize("missing_port", [9000, 9001, 9002, 3001]) +async def test_resolve_returns_the_partial_tunnels_launch_would_return(monkeypatch, missing_port): + published = { + port: f"https://port-{port}.example" + for port in [9000, 9001, 9002, 3000, 3001] + if port != missing_port + } + sandbox, create, write_env = _vm_publishing(monkeypatch, published) + + result = await _call(web_api.api_resolve_vm_sandbox, RESOLVE_REQUEST) + + data = result["data"] + assert data["modal_object_id"] == "sb-real-id" + assert [data["code_server_url"], data["vnc_url"], data["ttyd_url"]] == [ + published.get(port) for port in (9000, 9001, 9002) + ] + assert data["tunnel_urls"] == { + port: url for port, url in published.items() if port in (3000, 3001) + } + create.assert_not_awaited() + sandbox.terminate.aio.assert_not_awaited() + write_env.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_retries_while_no_tunnel_is_readable(monkeypatch): + sandbox, create, write_env = _vm_publishing(monkeypatch, {}) with pytest.raises(HTTPException) as exc: await _call(web_api.api_resolve_vm_sandbox, RESOLVE_REQUEST) diff --git a/packages/modal-infra/tests/test_web_api_build_sandbox.py b/packages/modal-infra/tests/test_web_api_build_sandbox.py index ad754ba5dc..362eeb5a3e 100644 --- a/packages/modal-infra/tests/test_web_api_build_sandbox.py +++ b/packages/modal-infra/tests/test_web_api_build_sandbox.py @@ -5,6 +5,7 @@ import pytest from modal.exception import NotFoundError as ModalNotFoundError +from modal.exception import SandboxTimeoutError from modal.exception import TimeoutError as ModalTimeoutError from sandbox_runtime.types import SandboxStatus @@ -854,6 +855,22 @@ async def test_generic_stop_succeeds_when_provider_object_is_already_absent(monk from_id.aio.assert_awaited_once_with("modal-session-1") +@pytest.mark.asyncio +async def test_generic_stop_succeeds_when_provider_sandbox_timed_out(monkeypatch): + terminate = AsyncMock(side_effect=SandboxTimeoutError()) + from_id = MagicMock() + from_id.aio = AsyncMock( + return_value=SimpleNamespace(terminate=SimpleNamespace(aio=terminate), returncode=124) + ) + monkeypatch.setattr("src.sandbox.manager.modal.Sandbox.from_id", from_id) + monkeypatch.setattr(web_api, "require_auth", lambda _authorization: None) + + result = await _call_generic_stop({"sandbox_id": "modal-session-1"}) + + assert result == {"success": True, "data": {"terminated": True}} + terminate.assert_awaited_once_with(wait=True) + + @pytest.mark.asyncio async def test_generic_snapshot_rejects_expired_deadline_without_provider_call(monkeypatch): snapshot_filesystem, get_sandbox_by_id = _patch_real_snapshot_manager(monkeypatch) diff --git a/packages/modal-infra/uv.lock b/packages/modal-infra/uv.lock index ad7b37a6ec..48f4e02546 100644 --- a/packages/modal-infra/uv.lock +++ b/packages/modal-infra/uv.lock @@ -1063,7 +1063,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, { name = "open-inspect-sandbox-runtime", editable = "../sandbox-runtime" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.14.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, @@ -1090,7 +1090,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.14.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, @@ -1346,11 +1346,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.14.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] diff --git a/packages/sandbox-images/locks/runtime.txt b/packages/sandbox-images/locks/runtime.txt index fe0cc5c1be..34a44c30b7 100644 --- a/packages/sandbox-images/locks/runtime.txt +++ b/packages/sandbox-images/locks/runtime.txt @@ -244,9 +244,9 @@ pydantic-core==2.46.5 \ --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 -pyjwt==2.14.0 \ - --hash=sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86 \ - --hash=sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc +pyjwt==2.15.1 \ + --hash=sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193 \ + --hash=sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8 python-multipart==0.0.32 \ --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 diff --git a/packages/sandbox-runtime/pyproject.toml b/packages/sandbox-runtime/pyproject.toml index 6bf50edd44..c7936ac5bf 100644 --- a/packages/sandbox-runtime/pyproject.toml +++ b/packages/sandbox-runtime/pyproject.toml @@ -8,7 +8,7 @@ dependencies = [ "httpx>=0.27.0", "websockets>=13.0", "pydantic>=2.0", - "PyJWT[crypto]>=2.9.0", + "PyJWT[crypto]>=2.14.0", # Exact pin: the wheel bundles the `claude` binary and its message shapes # are what harness/claude.py translates. "claude-agent-sdk==0.2.161", diff --git a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py index 80693380d3..4e5cfa7162 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/bridge.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/bridge.py @@ -46,11 +46,9 @@ from .boot_attach import RECONNECT_BACKOFF_BASE, RECONNECT_MAX_DELAY_SECONDS, BootAttach from .constants import ( BRIDGE_FATAL_ERROR_FILE_PATH, - DOCKER_ENABLED_ENV_VAR, REPO_MANIFEST_FILE_PATH, ) from .diff_capture import ControlPlaneDiffClient, SessionDiffRefreshWorker -from .docker_control import request as request_docker_preparation from .event_forwarder import BufferedEventForwarder from .git_signing import GitSigningError, GitSigningRuntime from .harness import ( @@ -883,9 +881,8 @@ async def contain_activity(deadline: float) -> bool: ) async def persist_session() -> None: + # The provider prepares Docker during capture, after the stop deadline. await self._persist_rotated_session_id(self._require_harness(), strict=True) - if os.environ.get(DOCKER_ENABLED_ENV_VAR) == "true": - await request_docker_preparation("prepare") result = await self.shutdown_preparation.prepare( cmd, diff --git a/packages/sandbox-runtime/src/sandbox_runtime/docker_control.py b/packages/sandbox-runtime/src/sandbox_runtime/docker_control.py index 6c1294759a..8519587de8 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/docker_control.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/docker_control.py @@ -1,7 +1,7 @@ -"""Local, terminal Docker preparation owned by the runtime supervisor. +"""Local Docker snapshot preparation owned by the runtime supervisor. -VM snapshots are terminal: only acknowledged preparation permits capture. -The provider retires the VM after capture; there is no implicit container restart. +Only acknowledged preparation permits capture. Failed preparation restores Docker +in an interactive VM so a later capture can be retried. """ from __future__ import annotations @@ -11,43 +11,89 @@ from pathlib import Path from typing import TYPE_CHECKING +from .docker_service import DOCKER_START_TIMEOUT_SECONDS, DOCKER_STOP_TIMEOUT_SECONDS + if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + from .docker_service import DockerService SOCKET_PATH = "/tmp/openinspect-docker-control.sock" -CONTROL_TIMEOUT_SECONDS = 45 +PREPARATION_TIMEOUT_SECONDS = 2 * DOCKER_STOP_TIMEOUT_SECONDS + 5 +CONTROL_TIMEOUT_SECONDS = ( + PREPARATION_TIMEOUT_SECONDS + DOCKER_STOP_TIMEOUT_SECONDS + DOCKER_START_TIMEOUT_SECONDS + 10 +) class DockerControl: - def __init__(self, service: DockerService, path: str = SOCKET_PATH) -> None: + def __init__( + self, + service: DockerService, + path: str = SOCKET_PATH, + recover: Callable[[], Awaitable[None]] | None = None, + ) -> None: self.service = service self.path = path + self.recover = recover self.prepared = False + self.stopping = False self._lock = asyncio.Lock() self._server: asyncio.Server | None = None + self._handlers: set[asyncio.Task[None]] = set() async def start(self) -> None: + if ( + self.service.stop_timeout_seconds > DOCKER_STOP_TIMEOUT_SECONDS + or self.service.start_timeout_seconds > DOCKER_START_TIMEOUT_SECONDS + ): + raise ValueError("Docker control deadline cannot cover configured daemon timeouts") Path(self.path).unlink(missing_ok=True) self._server = await asyncio.start_unix_server(self._handle, path=self.path) Path(self.path).chmod(0o600) async def stop(self) -> None: + self.stopping = True if self._server: self._server.close() + handlers = tuple(self._handlers) + for task in handlers: + task.cancel() + await asyncio.gather(*handlers, return_exceptions=True) + # No supervisor teardown may stop Docker until an active prepare or + # recovery has left the same lock used to serialize control requests. + async with self._lock: + pass + if self._server: await self._server.wait_closed() + self._server = None Path(self.path).unlink(missing_ok=True) async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + task = asyncio.current_task() + assert task is not None + self._handlers.add(task) try: + if self.stopping: + return async with asyncio.timeout(CONTROL_TIMEOUT_SECONDS): command = await reader.readline() async with self._lock: - if command == b"prepare\n" and not self.prepared: - await self.service.prepare_for_snapshot() - self.prepared = True + if command == b"prepare\n" and not self.prepared and not self.stopping: + try: + async with asyncio.timeout(PREPARATION_TIMEOUT_SECONDS): + await self.service.prepare_for_snapshot() + except (Exception, asyncio.CancelledError) as error: + if self.recover is not None and not self.stopping: + self.service.log.error("docker.prepare_failed", exc=error) + await self.recover() + self.prepared = False + else: + self.prepared = True result = ( b"prepared\n" - if self.prepared and command in (b"prepare\n", b"status\n") + if self.prepared + and not self.stopping + and command in (b"prepare\n", b"status\n") else b"not_prepared\n" ) writer.write(result) @@ -56,8 +102,11 @@ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWrit # No acknowledgement is a failure, never permission to capture. pass finally: - writer.close() - await writer.wait_closed() + try: + writer.close() + await writer.wait_closed() + finally: + self._handlers.discard(task) async def request(command: str, path: str = SOCKET_PATH) -> None: diff --git a/packages/sandbox-runtime/src/sandbox_runtime/docker_service.py b/packages/sandbox-runtime/src/sandbox_runtime/docker_service.py index c603f6e789..47498ee6ba 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/docker_service.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/docker_service.py @@ -57,7 +57,7 @@ def __init__( @property def exit_expected(self) -> bool: - """Whether the exit is confirmed preparation or deliberate supervisor teardown.""" + """Whether the daemon exit was requested by preparation or supervisor teardown.""" return self._exit_expected async def start(self) -> None: @@ -158,6 +158,7 @@ async def prepare_for_snapshot(self) -> None: self._preparation_finished.clear() try: process.send_signal(signal.SIGTERM) + self._exit_expected = True try: async with asyncio.timeout(self.stop_timeout_seconds): if await wait_for_process_exit(process) != 0: @@ -175,10 +176,6 @@ async def prepare_for_snapshot(self) -> None: # A reusable image must never include secret-bearing daemon diagnostics. Path(self.log_path).write_bytes(b"") self.log.info("docker.prepared") - self._exit_expected = True - except BaseException: - self._exit_expected = False - raise finally: self._preparation_finished.set() diff --git a/packages/sandbox-runtime/src/sandbox_runtime/plugins/codex-auth-plugin.js b/packages/sandbox-runtime/src/sandbox_runtime/plugins/codex-auth-plugin.js index ba9c24877b..242d7ea80b 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/plugins/codex-auth-plugin.js +++ b/packages/sandbox-runtime/src/sandbox_runtime/plugins/codex-auth-plugin.js @@ -137,6 +137,7 @@ const ALLOWED_MODELS = new Set([ "gpt-5.6-luna", "gpt-6-astra", "gpt-6-sol", + "gpt-6.1-sol", "gpt-6-luna", "gpt-5.1-codex", ]); diff --git a/packages/sandbox-runtime/src/sandbox_runtime/supervisor.py b/packages/sandbox-runtime/src/sandbox_runtime/supervisor.py index fdf2f9c0f1..ca1e6253d8 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/supervisor.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/supervisor.py @@ -80,7 +80,11 @@ def __init__( # Present only for Docker-enabled sandboxes: started before repository # hooks, watched for the whole session, stopped last. self.docker_service = docker_service - self.docker_control = DockerControl(docker_service) if docker_service is not None else None + self.docker_control = ( + DockerControl(docker_service, recover=self._recover_docker_after_prepare) + if docker_service is not None + else None + ) self._docker_watch_task: asyncio.Task[None] | None = None self._docker_watch_failure: BaseException | None = None # The boot-events channel the bridge relays; the repository boot @@ -418,6 +422,37 @@ async def _watch_docker(self) -> None: # by ``run`` rather than treated as a requested shutdown. self.shutdown_event.set() + async def _recover_docker_after_prepare(self) -> None: + """Replace a daemon after a failed snapshot attempt without losing session supervision.""" + service = self.docker_service + assert service is not None + control = self.docker_control + if control is not None and control.stopping: + return + if not service.exit_expected: + # Preparation never signalled this daemon; its exit belongs to + # ordinary crash supervision, not failed-save recovery. + return + await self._stop_docker_watch() + try: + await service.stop() + if control is not None and control.stopping: + return + await service.start() + except BaseException as error: + if control is not None and control.stopping: + raise + self.log.error("docker.exited_unexpectedly", exc=error) + self._docker_watch_failure = RuntimeError( + "Required Docker daemon exited unexpectedly after failed preparation" + ) + self.shutdown_event.set() + raise + if control is not None and control.stopping: + return + self._docker_watch_task = asyncio.create_task(self._watch_docker()) + self.log.info("docker.restarted_after_prepare") + async def _stop_docker_watch(self) -> None: task = self._docker_watch_task if task is None: @@ -770,6 +805,8 @@ async def run(self, repo_image_callback: RepoImageBuildCallback | None = None) - ) return False finally: + if self.docker_control is not None: + await self.docker_control.stop() await self._stop_docker_watch() await self._stop_bridge_watch() await self.shutdown() diff --git a/packages/sandbox-runtime/tests/codex-auth-plugin.test.mjs b/packages/sandbox-runtime/tests/codex-auth-plugin.test.mjs index aa72f984f3..c7bba6ff41 100644 --- a/packages/sandbox-runtime/tests/codex-auth-plugin.test.mjs +++ b/packages/sandbox-runtime/tests/codex-auth-plugin.test.mjs @@ -1108,7 +1108,7 @@ test("preserves API-key requests if OpenAI authentication switches away from OAu }); test("restores known prices and filters retired Codex models after the built-in OAuth hook", async () => { - const gpt6Ids = ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"]; + const gpt6Ids = ["gpt-6-astra", "gpt-6-sol", "gpt-6.1-sol", "gpt-6-luna"]; const gpt6Models = gpt6Ids.map((id) => [id, { name: id, cost: { input: 0, output: 0 } }]); // OpenCode's built-in hook has zeroed OAuth prices; it may still expose retired models. const provider = { diff --git a/packages/sandbox-runtime/tests/fixtures/reasoning-models.json b/packages/sandbox-runtime/tests/fixtures/reasoning-models.json index 706458fcbf..50e6a9dff1 100644 --- a/packages/sandbox-runtime/tests/fixtures/reasoning-models.json +++ b/packages/sandbox-runtime/tests/fixtures/reasoning-models.json @@ -706,6 +706,55 @@ } } }, + "gpt-6.1-sol": { + "id": "gpt-6.1-sol", + "name": "GPT-6.1 Sol", + "family": "gpt-sol", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": ["low", "medium", "high", "xhigh", "max"] + } + ], + "tool_call": true, + "temperature": false, + "release_date": "2026-09-29", + "modalities": { + "input": ["text", "image", "pdf"], + "output": ["text"] + }, + "limit": { + "context": 1050000, + "input": 922000, + "output": 128000 + }, + "cost": { + "input": 2, + "output": 10, + "cache_read": 0.1, + "cache_write": 2.5, + "tiers": [ + { + "input": 4, + "output": 15, + "cache_read": 0.2, + "cache_write": 5, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "context_over_200k": { + "input": 4, + "output": 15, + "cache_read": 0.2, + "cache_write": 5 + } + } + }, "gpt-6-luna": { "id": "gpt-6-luna", "name": "GPT-6 Luna", diff --git a/packages/sandbox-runtime/tests/test_bridge_shutdown_preparation.py b/packages/sandbox-runtime/tests/test_bridge_shutdown_preparation.py index 4ff9d6f4ae..fb919079d2 100644 --- a/packages/sandbox-runtime/tests/test_bridge_shutdown_preparation.py +++ b/packages/sandbox-runtime/tests/test_bridge_shutdown_preparation.py @@ -10,7 +10,9 @@ import pytest from websockets import State +from sandbox_runtime import bridge as bridge_module from sandbox_runtime.bridge import AgentBridge +from sandbox_runtime.constants import DOCKER_ENABLED_ENV_VAR from sandbox_runtime.harness.base import TurnOutcome from sandbox_runtime.push_operation import PushRequest, PushResult from tests.conftest import ScriptedHarness @@ -312,6 +314,24 @@ async def test_prepare_deadline_reports_unconfirmed_and_keeps_fence() -> None: assert bridge.shutdown_preparation.state.operation_id == "operation-1" +@pytest.mark.asyncio +async def test_vm_bridge_drain_does_not_wait_for_docker_preparation(monkeypatch) -> None: + monkeypatch.setenv(DOCKER_ENABLED_ENV_VAR, "true") + prepare_docker = AsyncMock() + monkeypatch.setattr(bridge_module, "request_docker_preparation", prepare_docker, raising=False) + bridge = make_bridge(ShutdownPreparationHarness()) + bridge._persist_rotated_session_id = AsyncMock() + await establish_generation(bridge) + + await bridge._handle_command(prepare_command(stopByMs=time.time() * 1000 + 500)) + + result = bridge._send_event.await_args_list[-1].args[0] + assert result["executionStopped"] is True + assert "error" not in result + bridge._persist_rotated_session_id.assert_awaited_once() + prepare_docker.assert_not_called() + + @pytest.mark.asyncio async def test_prepare_deadline_is_not_swallowed_by_prompt_cancellation_cleanup() -> None: prompt_entered = asyncio.Event() diff --git a/packages/sandbox-runtime/tests/test_docker_control.py b/packages/sandbox-runtime/tests/test_docker_control.py index 8dca7ea1f4..c673afde67 100644 --- a/packages/sandbox-runtime/tests/test_docker_control.py +++ b/packages/sandbox-runtime/tests/test_docker_control.py @@ -5,6 +5,7 @@ import pytest from sandbox_runtime.docker_control import DockerControl, request +from sandbox_runtime.docker_service import DOCKER_START_TIMEOUT_SECONDS, DOCKER_STOP_TIMEOUT_SECONDS @pytest.fixture @@ -15,7 +16,11 @@ def socket_path(): @pytest.mark.asyncio async def test_preparation_is_acknowledged_only_after_clean_stop(socket_path): - service = Mock(prepare_for_snapshot=AsyncMock()) + service = Mock( + prepare_for_snapshot=AsyncMock(), + start_timeout_seconds=DOCKER_START_TIMEOUT_SECONDS, + stop_timeout_seconds=DOCKER_STOP_TIMEOUT_SECONDS, + ) # Short paths also work on macOS's small Unix-domain path limit. path = socket_path control = DockerControl(service, path) @@ -34,7 +39,11 @@ async def test_preparation_is_acknowledged_only_after_clean_stop(socket_path): @pytest.mark.asyncio async def test_failed_preparation_never_acknowledges_capture(socket_path): - service = Mock(prepare_for_snapshot=AsyncMock(side_effect=RuntimeError("stop failed"))) + service = Mock( + prepare_for_snapshot=AsyncMock(side_effect=RuntimeError("stop failed")), + start_timeout_seconds=DOCKER_START_TIMEOUT_SECONDS, + stop_timeout_seconds=DOCKER_STOP_TIMEOUT_SECONDS, + ) path = socket_path control = DockerControl(service, path) await control.start() @@ -46,3 +55,22 @@ async def test_failed_preparation_never_acknowledges_capture(socket_path): await request("status", path) finally: await control.stop() + + +async def test_stop_closes_idle_control_connections(socket_path): + service = Mock( + start_timeout_seconds=DOCKER_START_TIMEOUT_SECONDS, + stop_timeout_seconds=DOCKER_STOP_TIMEOUT_SECONDS, + ) + control = DockerControl(service, socket_path) + await control.start() + reader, writer = await asyncio.open_unix_connection(socket_path) + try: + async with asyncio.timeout(1): + while not control._handlers: + await asyncio.sleep(0) + await asyncio.wait_for(control.stop(), timeout=1) + assert await asyncio.wait_for(reader.readline(), timeout=1) == b"" + finally: + writer.close() + await writer.wait_closed() diff --git a/packages/sandbox-runtime/tests/test_docker_service.py b/packages/sandbox-runtime/tests/test_docker_service.py index 5d3d26df3f..689f122bb9 100644 --- a/packages/sandbox-runtime/tests/test_docker_service.py +++ b/packages/sandbox-runtime/tests/test_docker_service.py @@ -14,11 +14,15 @@ import os import sys from dataclasses import dataclass, field +from unittest.mock import AsyncMock import pytest from sandbox_runtime import docker_service as docker_module +from sandbox_runtime.docker_control import DockerControl, request from sandbox_runtime.docker_service import DockerService +from sandbox_runtime.runtime_config import BootMode +from tests.test_supervisor_lifecycle import _supervisor @dataclass @@ -28,12 +32,16 @@ class FakeProcesses: probe_delay: float = 0.0 fail_probe: bool = False ignore_sigterm: bool = False + exit_delay: float = 0.0 children: list[asyncio.subprocess.Process] = field(default_factory=list) spawns: list[tuple[str, ...]] = field(default_factory=list) def daemon_program(self) -> str: handler = ( - "signal.SIG_IGN" if self.ignore_sigterm else f"lambda *_: sys.exit({self.daemon_exit})" + "signal.SIG_IGN" + if self.ignore_sigterm + else "lambda *_: (signal.signal(signal.SIGTERM, signal.SIG_IGN), " + f"time.sleep({self.exit_delay}), sys.exit({self.daemon_exit}))" ) return ( "import signal, sys, time, pathlib; " @@ -95,6 +103,15 @@ def error(self, event, **_fields): return service +def _session_supervisor(tmp_path, service): + supervisor, *_ = _supervisor(tmp_path, []) + supervisor.docker_service = service + supervisor.docker_control = DockerControl( + service, str(tmp_path / "control.sock"), supervisor._recover_docker_after_prepare + ) + return supervisor + + def _group_gone(process: asyncio.subprocess.Process) -> bool: try: os.killpg(process.pid, 0) @@ -175,7 +192,7 @@ async def test_nonzero_daemon_exit_cannot_be_a_prepared_build(processes, tmp_pat await service.prepare_for_snapshot() assert await watcher == 1 - assert service.exit_expected is False + assert service.exit_expected is True await service.stop() assert all(child.returncode is not None for child in processes.children) @@ -204,7 +221,7 @@ async def test_daemon_that_ignores_sigterm_is_killed_and_never_a_prepared_build( with pytest.raises(RuntimeError, match="clean shutdown deadline"): await service.prepare_for_snapshot() - assert service.exit_expected is False + assert service.exit_expected is True assert daemon.returncode is not None assert _group_gone(daemon) @@ -220,3 +237,212 @@ async def test_preparation_requires_a_running_daemon(processes, tmp_path): with pytest.raises(RuntimeError, match="exited before build preparation"): await service.prepare_for_snapshot() await service.stop() + + +@pytest.mark.parametrize("failure", ["ignored", "late", "nonzero", "cancelled"]) +async def test_failed_session_preparation_recovers_and_can_retry( + processes, tmp_path, monkeypatch, failure +): + from sandbox_runtime import docker_control + + processes.ignore_sigterm = failure in ("ignored", "cancelled") + processes.exit_delay = 0.15 if failure == "late" else 0 + processes.daemon_exit = 2 if failure == "nonzero" else 0 + service = _service(tmp_path, stop_timeout_seconds=0.1, start_timeout_seconds=0.5) + supervisor = _session_supervisor(tmp_path, service) + supervisor._report_fatal_error = AsyncMock() + preparation_timeout = docker_control.PREPARATION_TIMEOUT_SECONDS + if failure == "cancelled": + monkeypatch.setattr(docker_control, "PREPARATION_TIMEOUT_SECONDS", 0.02) + try: + await supervisor._start_docker() + first_daemon = processes.children[0] + processes.ignore_sigterm = False + processes.exit_delay = 0 + processes.daemon_exit = 0 + with pytest.raises(RuntimeError, match="confirmed shutdown"): + await request("prepare", supervisor.docker_control.path) + assert first_daemon.returncode is not None + if failure == "late": + assert first_daemon.returncode == 0 + assert not supervisor.docker_control.prepared + with pytest.raises(RuntimeError): + await request("status", supervisor.docker_control.path) + assert not supervisor.shutdown_event.is_set() + assert supervisor._docker_watch_failure is None + supervisor._report_fatal_error.assert_not_awaited() + assert service._process is not None and service._process.returncode is None + assert not any( + call.args == ("docker.exited_unexpectedly",) + for call in supervisor.log.error.call_args_list + ) + + if failure == "cancelled": + monkeypatch.setattr(docker_control, "PREPARATION_TIMEOUT_SECONDS", preparation_timeout) + await request("prepare", supervisor.docker_control.path) + assert supervisor.docker_control.prepared + assert not supervisor.shutdown_event.is_set() + assert supervisor._docker_watch_failure is None + assert len([spawn for spawn in processes.spawns if spawn[0] == "dockerd"]) == 2 + finally: + await supervisor._stop_docker_watch() + await supervisor.shutdown() + + +async def test_failed_preparation_restart_failure_is_reported(processes, tmp_path): + processes.daemon_exit = 1 + service = _service(tmp_path, stop_timeout_seconds=0.1, start_timeout_seconds=0.2) + supervisor = _session_supervisor(tmp_path, service) + try: + await supervisor._start_docker() + processes.fail_probe = True + with pytest.raises((RuntimeError, TimeoutError)): + await request("prepare", supervisor.docker_control.path) + await _until(supervisor.shutdown_event.is_set, timeout=2) + assert any( + call.args == ("docker.exited_unexpectedly",) + for call in supervisor.log.error.call_args_list + ) + assert supervisor._docker_watch_failure is not None + finally: + await supervisor._stop_docker_watch() + await supervisor.shutdown() + + +async def test_clean_session_preparation_does_not_restart(processes, tmp_path): + service = _service(tmp_path) + supervisor = _session_supervisor(tmp_path, service) + supervisor.boot_mode = BootMode.SNAPSHOT_RESTORE + try: + await supervisor._start_docker() + await request("prepare", supervisor.docker_control.path) + await request("status", supervisor.docker_control.path) + assert supervisor.docker_control.prepared + assert not supervisor.shutdown_event.is_set() + assert len([spawn for spawn in processes.spawns if spawn[0] == "dockerd"]) == 1 + finally: + await supervisor._stop_docker_watch() + await supervisor.shutdown() + + +async def test_unrequested_exit_before_preparation_remains_fatal(processes, tmp_path): + service = _service(tmp_path) + supervisor = _session_supervisor(tmp_path, service) + try: + await supervisor._start_docker() + processes.children[0].kill() + await processes.children[0].wait() + with pytest.raises(RuntimeError): + await request("prepare", supervisor.docker_control.path) + await _until(supervisor.shutdown_event.is_set) + assert supervisor._docker_watch_failure is not None + assert len([spawn for spawn in processes.spawns if spawn[0] == "dockerd"]) == 1 + finally: + await supervisor._stop_docker_watch() + await supervisor.shutdown() + + +async def test_restarted_daemon_is_watched_for_crashes(processes, tmp_path): + processes.daemon_exit = 1 + service = _service(tmp_path, stop_timeout_seconds=0.1) + supervisor = _session_supervisor(tmp_path, service) + try: + await supervisor._start_docker() + with pytest.raises(RuntimeError): + await request("prepare", supervisor.docker_control.path) + assert not supervisor.shutdown_event.is_set() + assert service._process is not None + service._process.kill() + await _until(supervisor.shutdown_event.is_set) + assert "exited unexpectedly" in str(supervisor._docker_watch_failure) + finally: + await supervisor._stop_docker_watch() + await supervisor.shutdown() + + +async def test_requested_stop_is_not_reported_as_a_crash(processes, tmp_path): + service = _service(tmp_path, stop_timeout_seconds=0.1) + supervisor = _session_supervisor(tmp_path, service) + try: + await supervisor._start_docker() + await service.stop() + await _until(supervisor._docker_watch_task.done) + assert not supervisor.shutdown_event.is_set() + assert supervisor._docker_watch_failure is None + finally: + await supervisor._stop_docker_watch() + await supervisor.shutdown() + + +@pytest.mark.parametrize( + "budgets", + [ + {"stop_timeout_seconds": 70}, + {"start_timeout_seconds": 61}, + ], +) +async def test_control_rejects_service_budgets_beyond_capture_deadline(tmp_path, budgets): + service = _service(tmp_path, **budgets) + control = DockerControl(service, str(tmp_path / "control.sock")) + + with pytest.raises(ValueError, match="Docker control deadline"): + await control.start() + + assert not (tmp_path / "control.sock").exists() + + +async def test_shutdown_during_preparation_does_not_restart_docker(processes, tmp_path): + processes.ignore_sigterm = True + service = _service(tmp_path, stop_timeout_seconds=0.1) + supervisor = _session_supervisor(tmp_path, service) + try: + await supervisor._start_docker() + preparing = asyncio.create_task(request("prepare", supervisor.docker_control.path)) + await _until(lambda: service.exit_expected) + + await asyncio.wait_for(supervisor.shutdown(), timeout=3) + with pytest.raises(RuntimeError): + await preparing + + assert len([spawn for spawn in processes.spawns if spawn[0] == "dockerd"]) == 1 + assert all(process.returncode is not None for process in processes.children) + assert supervisor._docker_watch_failure is None + finally: + await supervisor._stop_docker_watch() + await supervisor.shutdown() + + +async def test_shutdown_cancels_inflight_recovery_without_watcher(processes, tmp_path): + processes.daemon_exit = 1 + service = _service(tmp_path, stop_timeout_seconds=0.1) + supervisor = _session_supervisor(tmp_path, service) + restart_entered = asyncio.Event() + finish_restart = asyncio.Event() + try: + await supervisor._start_docker() + original_start = service.start + + async def delayed_restart(): + restart_entered.set() + await finish_restart.wait() + await original_start() + + service.start = delayed_restart + preparing = asyncio.create_task(request("prepare", supervisor.docker_control.path)) + await asyncio.wait_for(restart_entered.wait(), timeout=2) + + shutdown = asyncio.create_task(supervisor.shutdown()) + await _until(lambda: supervisor.docker_control.stopping) + finish_restart.set() + await asyncio.wait_for(shutdown, timeout=2) + with pytest.raises(RuntimeError): + await preparing + + assert supervisor._docker_watch_task is None + assert supervisor._docker_watch_failure is None + assert len([spawn for spawn in processes.spawns if spawn[0] == "dockerd"]) == 1 + assert all(process.returncode is not None for process in processes.children) + finally: + finish_restart.set() + await supervisor._stop_docker_watch() + await supervisor.shutdown() diff --git a/packages/sandbox-runtime/tests/test_opencode_reasoning_contract.py b/packages/sandbox-runtime/tests/test_opencode_reasoning_contract.py index 8fda795110..869b492389 100644 --- a/packages/sandbox-runtime/tests/test_opencode_reasoning_contract.py +++ b/packages/sandbox-runtime/tests/test_opencode_reasoning_contract.py @@ -11,7 +11,9 @@ Source SHA-256: e20acec396a73dc3db45d0eca7f0ede5bff28f09f002ba96ce7b1b566de7b6d0 Claude Sonnet 5.5 added from the 2026-09-28 retrieval. Source SHA-256: 06e0071dd4ae9c9da2db1fabf28eb4994914fefdc5dd10270a5b340c88a49aec -Subset SHA-256: e9c9cc6f90fa9afbc75a2f18bf564398594d3f51693667cf1efd229617aaab0b +GPT-6.1 Sol added from the 2026-09-29 retrieval. +Source SHA-256: e4677e698a53d3b8b11c569c0ecc2f5722cd8126677bf5f7dbff23daf942a17b +Subset SHA-256: 121c3cf245d515b9862685a084ca8fdb1b9200ab672cca1658949b1dd0de6265 Reconcile this frozen fixture with shared model/effort definitions when changing models or the binary. Mocks verify serialization, not live provider acceptance. """ diff --git a/packages/sandbox-runtime/uv.lock b/packages/sandbox-runtime/uv.lock index 2e8ca9497b..a9f168026e 100644 --- a/packages/sandbox-runtime/uv.lock +++ b/packages/sandbox-runtime/uv.lock @@ -571,7 +571,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14.0" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.14.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, @@ -728,11 +728,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.14.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] diff --git a/packages/shared/src/models.test.ts b/packages/shared/src/models.test.ts index 390bd3ef45..c88973d423 100644 --- a/packages/shared/src/models.test.ts +++ b/packages/shared/src/models.test.ts @@ -45,6 +45,7 @@ const OPENAI_MODELS = [ "openai/gpt-5.6-luna", "openai/gpt-6-astra", "openai/gpt-6-sol", + "openai/gpt-6.1-sol", "openai/gpt-6-luna", ] as const; @@ -453,6 +454,10 @@ describe("model utilities", () => { efforts: ["none", "low", "medium", "high", "xhigh", "max"], default: "medium", }); + expect(getReasoningConfig("openai/gpt-6.1-sol")).toEqual({ + efforts: ["low", "medium", "high", "xhigh", "max"], + default: "medium", + }); expect(getReasoningConfig("openai/gpt-5.6-sol")).toEqual({ efforts: ["none", "low", "medium", "high", "xhigh"], default: "medium", @@ -503,6 +508,8 @@ describe("model utilities", () => { expect(isValidReasoningEffort("openai/gpt-6-astra", "none")).toBe(false); expect(isValidReasoningEffort("openai/gpt-6-sol", "none")).toBe(true); expect(isValidReasoningEffort("openai/gpt-6-sol", "max")).toBe(true); + expect(isValidReasoningEffort("openai/gpt-6.1-sol", "max")).toBe(true); + expect(isValidReasoningEffort("openai/gpt-6.1-sol", "none")).toBe(false); expect(isValidReasoningEffort("openai/gpt-6-luna", "none")).toBe(true); expect(isValidReasoningEffort("openai/gpt-6-luna", "max")).toBe(true); expect(isValidReasoningEffort("openai/gpt-5.6-sol", "xhigh")).toBe(true); diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index d06e76b856..1ae9303351 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -220,6 +220,15 @@ export const MODEL_CATALOG = [ default: "medium", }, }, + { + id: "openai/gpt-6.1-sol", + name: "GPT-6.1 Sol", + description: "Complex coding, computer use, and professional work", + reasoning: { + efforts: ["low", "medium", "high", "xhigh", "max"], + default: "medium", + }, + }, { id: "openai/gpt-6-luna", name: "GPT-6 Luna", diff --git a/packages/shared/src/service-auth.ts b/packages/shared/src/service-auth.ts index ecf99b8c10..fe225c2e0d 100644 --- a/packages/shared/src/service-auth.ts +++ b/packages/shared/src/service-auth.ts @@ -40,7 +40,8 @@ export type ServiceSignatureFailure = "format" | "expired" | "mismatch"; * re-split the header (this module is the sole owner of the sig1 grammar). */ export type ServiceSignatureResult = - { ok: true; timestampMs: number; nonce: string } | { ok: false; reason: ServiceSignatureFailure }; + | { ok: true; timestampMs: number; nonce: string } + | { ok: false; reason: ServiceSignatureFailure }; const NONCE_PATTERN = /^[0-9a-f]{1,64}$/; // Strict ASCII decimal. Number()'s wider grammar ("1e3", "0x10", padding) diff --git a/packages/shared/src/session-list-query.test.ts b/packages/shared/src/session-list-query.test.ts index de086f331c..a479047b46 100644 --- a/packages/shared/src/session-list-query.test.ts +++ b/packages/shared/src/session-list-query.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + MAX_SESSION_LIST_FILTER_IDS, MAX_SESSION_LIST_SEARCH_LENGTH, normalizeSessionListSearch, parseSessionListQuery, @@ -8,6 +9,53 @@ import { } from "./session-list-query"; describe("session list query codec", () => { + it("round-trips team, participation, visibility, and workspace scope filters", () => { + const query = { + teamIds: ["team_a", "team_b"], + ownerFilter: "participating" as const, + visibility: "private" as const, + scope: "workspace" as const, + }; + expect(parseSessionListQuery(serializeSessionListQuery(query))).toMatchObject({ + success: true, + data: query, + }); + }); + + it("deduplicates team IDs and rejects a filter larger than the query budget", () => { + expect( + parseSessionListQuery(new URLSearchParams("teamIds[]=team_a&teamIds[]=team_a")) + ).toMatchObject({ + success: true, + data: { teamIds: ["team_a"] }, + }); + const params = new URLSearchParams(); + for (let i = 0; i <= MAX_SESSION_LIST_FILTER_IDS; i++) { + params.append("teamIds[]", `team_${i}`); + } + expect(parseSessionListQuery(params)).toEqual({ success: false, invalidParam: "teamIds[]" }); + }); + + it("rejects more than the shared ID cap in createdBy", () => { + const params = new URLSearchParams(); + for (let i = 0; i <= MAX_SESSION_LIST_FILTER_IDS; i++) { + params.append("createdBy", "a".repeat(32)); + } + expect(parseSessionListQuery(params)).toEqual({ success: false, invalidParam: "createdBy" }); + }); + + it.each([ + ["teamIds[]=", "teamIds[]"], + ["ownerFilter=mine", "ownerFilter"], + ["visibility=unknown", "visibility"], + ["scope=team", "scope"], + ] as const)("rejects an invalid list filter %s", (query, invalidParam) => { + expect(parseSessionListQuery(new URLSearchParams(query))).toEqual({ + success: false, + invalidParam, + }); + }); + it("serializes the typed query in stable cache-key order", () => { expect( serializeSessionListQuery({ diff --git a/packages/shared/src/session-list-query.ts b/packages/shared/src/session-list-query.ts index ed9c7635a3..afb93e83b7 100644 --- a/packages/shared/src/session-list-query.ts +++ b/packages/shared/src/session-list-query.ts @@ -5,11 +5,14 @@ import { type SpawnSource, } from "./types/sessions"; import { isCanonicalUserId } from "./user-id"; +import { sessionVisibilitySchema, type SessionVisibility } from "./types/teams"; export const SESSION_LIST_CURRENT_USER = "me"; export const DEFAULT_SESSION_LIST_LIMIT = 50; export const DEFAULT_SESSION_LIST_OFFSET = 0; export const MAX_SESSION_LIST_LIMIT = 100; +/** Maximum IDs accepted for each repeated session-list filter. */ +export const MAX_SESSION_LIST_FILTER_IDS = 50; /** Longest accepted `q` after trimming; longer input is rejected, not truncated. */ export const MAX_SESSION_LIST_SEARCH_LENGTH = 200; /** Longest accepted repository owner/name or environment id filter value. */ @@ -28,7 +31,11 @@ export const SESSION_LIST_QUERY_PARAMS = [ "repoName", "environmentId", "origin", -] as const satisfies readonly (keyof SessionListQuery)[]; + "teamIds[]", + "ownerFilter", + "visibility", + "scope", +] as const satisfies readonly (keyof SessionListQuery | "teamIds[]")[]; export type SessionListQueryParam = (typeof SESSION_LIST_QUERY_PARAMS)[number]; @@ -60,10 +67,14 @@ export interface SessionListQuery { * run is `agent`. */ origin?: SpawnSource; + teamIds?: readonly string[]; + ownerFilter?: "started" | "participating" | "anyone"; + visibility?: SessionVisibility; + scope?: "workspace" | "all"; } type SessionListQueryParamsAreExhaustive = - Exclude extends never ? true : never; + Exclude extends never ? true : never; const _sessionListQueryParamsAreExhaustive: SessionListQueryParamsAreExhaustive = true; void _sessionListQueryParamsAreExhaustive; @@ -113,6 +124,17 @@ function parseIdentifier(value: string | null): string | null | undefined { return trimmed; } +export function parseSessionListTeamIds(searchParams: URLSearchParams): string[] | null { + const teamIds = [...new Set(searchParams.getAll("teamIds[]"))]; + if ( + teamIds.length > MAX_SESSION_LIST_FILTER_IDS || + teamIds.some((id) => !/^team_[a-zA-Z0-9_-]{1,256}$/.test(id)) + ) { + return null; + } + return teamIds; +} + export function parseSessionListQuery(searchParams: URLSearchParams): SessionListQueryParseResult { const statusParam = searchParams.get("status"); const excludeStatusParam = searchParams.get("excludeStatus"); @@ -133,6 +155,9 @@ export function parseSessionListQuery(searchParams: URLSearchParams): SessionLis } const createdBy = searchParams.getAll("createdBy"); + if (createdBy.length > MAX_SESSION_LIST_FILTER_IDS) { + return { success: false, invalidParam: "createdBy" }; + } if (createdBy.some((value) => value !== SESSION_LIST_CURRENT_USER && !isCanonicalUserId(value))) { return { success: false, invalidParam: "createdBy" }; } @@ -155,6 +180,30 @@ export function parseSessionListQuery(searchParams: URLSearchParams): SessionLis const origin = originParam ? spawnSourceSchema.safeParse(originParam) : undefined; if (origin && !origin.success) return { success: false, invalidParam: "origin" }; + const teamIds = parseSessionListTeamIds(searchParams); + if (teamIds === null) { + return { success: false, invalidParam: "teamIds[]" }; + } + const ownerFilter = searchParams.get("ownerFilter"); + if ( + ownerFilter !== null && + ownerFilter !== "started" && + ownerFilter !== "participating" && + ownerFilter !== "anyone" + ) { + return { success: false, invalidParam: "ownerFilter" }; + } + const visibilityParam = searchParams.get("visibility"); + const visibility = visibilityParam + ? sessionVisibilitySchema.safeParse(visibilityParam) + : undefined; + if (visibilityParam !== null && (!visibility || !visibility.success)) + return { success: false, invalidParam: "visibility" }; + const scope = searchParams.get("scope"); + if (scope !== null && scope !== "workspace" && scope !== "all") { + return { success: false, invalidParam: "scope" }; + } + return { success: true, data: { @@ -168,6 +217,10 @@ export function parseSessionListQuery(searchParams: URLSearchParams): SessionLis ...(repoOwner !== undefined && repoName !== undefined ? { repoOwner, repoName } : {}), ...(environmentId !== undefined ? { environmentId } : {}), ...(origin ? { origin: origin.data } : {}), + ...(teamIds.length ? { teamIds } : {}), + ...(ownerFilter !== null ? { ownerFilter } : {}), + ...(visibility ? { visibility: visibility.data } : {}), + ...(scope ? { scope } : {}), }, }; } @@ -191,6 +244,10 @@ export function serializeSessionListQuery(query: SessionListQuery): URLSearchPar } if (query.environmentId) searchParams.set("environmentId", query.environmentId); if (query.origin) searchParams.set("origin", query.origin); + for (const teamId of query.teamIds ?? []) searchParams.append("teamIds[]", teamId); + if (query.ownerFilter) searchParams.set("ownerFilter", query.ownerFilter); + if (query.visibility) searchParams.set("visibility", query.visibility); + if (query.scope) searchParams.set("scope", query.scope); return searchParams; } diff --git a/packages/shared/src/slack/client.ts b/packages/shared/src/slack/client.ts index a2d6a0bbaa..8d4fa62e82 100644 --- a/packages/shared/src/slack/client.ts +++ b/packages/shared/src/slack/client.ts @@ -29,7 +29,8 @@ export const SLACK_PAGINATION_TIMEOUT_MS = 30_000; * validated at the boundary cannot drift apart. */ export type SlackEnvelope = - ({ ok: true } & T) | { ok: false; error: string; retryAfter?: number }; + | ({ ok: true } & T) + | { ok: false; error: string; retryAfter?: number }; const slackFailureSchema = z.object({ ok: z.literal(false), diff --git a/packages/shared/src/triggers/slack/conditions.ts b/packages/shared/src/triggers/slack/conditions.ts index 425c782d5b..e7bfec221e 100644 --- a/packages/shared/src/triggers/slack/conditions.ts +++ b/packages/shared/src/triggers/slack/conditions.ts @@ -14,7 +14,8 @@ import { SLACK_TEXT_MAX_LENGTH } from "./normalizer"; type SlackChannelCondition = Extract; export type SlackChannelConditionParseResult = - { success: true; condition: SlackChannelCondition } | { success: false; error: string }; + | { success: true; condition: SlackChannelCondition } + | { success: false; error: string }; /** Max length of a user-supplied `text_match` regex pattern (characters). */ export const REGEX_PATTERN_MAX_LENGTH = 200; diff --git a/packages/shared/src/types/analytics.ts b/packages/shared/src/types/analytics.ts index 57bf96e922..c8258fbb75 100644 --- a/packages/shared/src/types/analytics.ts +++ b/packages/shared/src/types/analytics.ts @@ -70,6 +70,8 @@ export interface AnalyticsSummaryResponse extends AnalyticsTokenTotals { totalSessions: number; activeUsers: number; totalCost: number; + /** Private-session cost in the same window and scope; only owner/administrator viewers receive it. */ + privateSessionsCostUsd: number | null; avgCost: number; totalPrs: number; cacheHitRatio: number | null; @@ -105,7 +107,7 @@ export interface AnalyticsBreakdownResponse { entries: AnalyticsBreakdownEntry[]; } -/** All sessions in one root_session_id family, attributed to the root. */ +/** Sessions in one root_session_id family, attributed to its visible root. */ export interface SessionRun { rootSessionId: string; title: string | null; diff --git a/packages/shared/src/types/audit-events.test.ts b/packages/shared/src/types/audit-events.test.ts index 9f1ca0c704..7b27fb5f82 100644 --- a/packages/shared/src/types/audit-events.test.ts +++ b/packages/shared/src/types/audit-events.test.ts @@ -133,6 +133,8 @@ describe("interpretAuditEvent", () => { "team.member_role_changed", "team.member_removed", "team.member_joined", + "team.secret_set", + "team.secret_deleted", ])("recognizes %s as a domain operation", (action) => { expect(interpretAuditEvent({ action, operationResult: "applied", metadata: {} })).toEqual({ kind: "operation", diff --git a/packages/shared/src/types/audit-events.ts b/packages/shared/src/types/audit-events.ts index 5e313f4afa..87a152ad33 100644 --- a/packages/shared/src/types/audit-events.ts +++ b/packages/shared/src/types/audit-events.ts @@ -72,6 +72,11 @@ export const AUTHORIZATION_DECISION_ACTIONS = { /** Actions written by the operation owner alongside the change; their result is the domain outcome. */ export const AUDIT_OPERATION_ACTIONS = [ "session.private_break_glass", + "session.visibility_changed", + "session.moved", + "session.collaborator_added", + "session.collaborator_removed", + "session.created_private", "workspace.member_role_updated", "workspace.member_status_updated", "workspace.default_role_assigned", @@ -85,6 +90,8 @@ export const AUDIT_OPERATION_ACTIONS = [ "team.member_role_changed", "team.member_removed", "team.member_joined", + "team.secret_set", + "team.secret_deleted", ] as const; export const AUTHORIZATION_DECISION_METADATA_SCHEMA = "authorization_decision.v1"; diff --git a/packages/shared/src/types/integrations.ts b/packages/shared/src/types/integrations.ts index 81a1e98430..42c32e9f0f 100644 --- a/packages/shared/src/types/integrations.ts +++ b/packages/shared/src/types/integrations.ts @@ -2,6 +2,7 @@ import { escapeRegExp } from "../regex"; import { z } from "zod"; +import { teamSettingsSchema } from "./teams"; export type IntegrationId = "github" | "linear" | "code-server" | "vnc" | "sandbox" | "slack"; @@ -558,6 +559,13 @@ export const integrationSettingsSchemas = { global: scmGlobalConfigSchema, repo: scmSettingsSchema, }, + teams: { + global: z.strictObject({ + enabledRepos: z.never().optional(), + defaults: teamSettingsSchema.optional(), + }), + repo: z.strictObject({}), + }, } as const; export type IntegrationGlobalSettings = z.output< diff --git a/packages/shared/src/types/keyboard-shortcuts.ts b/packages/shared/src/types/keyboard-shortcuts.ts index cb91d2669c..bb2596241e 100644 --- a/packages/shared/src/types/keyboard-shortcuts.ts +++ b/packages/shared/src/types/keyboard-shortcuts.ts @@ -54,9 +54,9 @@ export const KEYBOARD_SHORTCUT_DEFINITIONS = { export type KeyboardShortcutAction = keyof typeof KEYBOARD_SHORTCUT_DEFINITIONS; export type GlobalKeyboardShortcutAction = { - [ - Action in KeyboardShortcutAction - ]: (typeof KEYBOARD_SHORTCUT_DEFINITIONS)[Action]["global"] extends true ? Action : never; + [Action in KeyboardShortcutAction]: (typeof KEYBOARD_SHORTCUT_DEFINITIONS)[Action]["global"] extends true + ? Action + : never; }[KeyboardShortcutAction]; export type KeyboardShortcutPreferences = Record; diff --git a/packages/shared/src/types/provider-accounts.ts b/packages/shared/src/types/provider-accounts.ts index f188ed15ca..41bd82b9ad 100644 --- a/packages/shared/src/types/provider-accounts.ts +++ b/packages/shared/src/types/provider-accounts.ts @@ -95,7 +95,9 @@ export const modelProviderAccountSchema = z.strictObject({ export type ModelProviderAccount = z.infer; export type ModelProviderAccountReconnectMethod = - "device_authorization" | "authorization_code" | "refresh_token"; + | "device_authorization" + | "authorization_code" + | "refresh_token"; /** * Canonical reconnect capability for provider accounts. diff --git a/packages/shared/src/types/server-messages.test.ts b/packages/shared/src/types/server-messages.test.ts index d770e60bbc..e32c1e5260 100644 --- a/packages/shared/src/types/server-messages.test.ts +++ b/packages/shared/src/types/server-messages.test.ts @@ -3,6 +3,7 @@ import { redactSessionSnapshotSandboxAccess, serverMessageSchema, sessionSnapshotSchema, + sessionSnapshotStateSchema, } from "./server-messages"; describe("artifact_updated server message", () => { @@ -87,6 +88,27 @@ const snapshotState = { }; describe("session view contracts", () => { + it.each(["owner-1", null])("preserves snapshot ownerUserId (%s)", (ownerUserId) => { + const parsed = sessionSnapshotSchema.parse({ + session: { ...snapshotState, ownerUserId }, + artifacts: [], + promptQueue: [], + timeline: { events: [], hasMore: false, cursor: null }, + }); + + expect(parsed.session).toHaveProperty("ownerUserId", ownerUserId); + }); + + it("accepts snapshot state without ownerUserId", () => { + expect(sessionSnapshotStateSchema.parse(snapshotState)).not.toHaveProperty("ownerUserId"); + }); + + it("rejects non-string snapshot ownerUserId", () => { + expect( + sessionSnapshotStateSchema.safeParse({ ...snapshotState, ownerUserId: 123 }).success + ).toBe(false); + }); + it("parses a snapshot and removes access credentials", () => { const parsed = sessionSnapshotSchema.parse({ session: { diff --git a/packages/shared/src/types/server-messages.ts b/packages/shared/src/types/server-messages.ts index 6993186915..5ce58865c3 100644 --- a/packages/shared/src/types/server-messages.ts +++ b/packages/shared/src/types/server-messages.ts @@ -3,9 +3,15 @@ import { z } from "zod"; import { sessionArtifactSchema } from "./artifacts"; import { sessionRepositoryStateSchema } from "./repositories"; import { sandboxBootPhaseSchema, sandboxEventSchema } from "./sandbox-events"; -import { sandboxStatusSchema, sessionStatusSchema, timelineSequenceSchema } from "./sessions"; +import { + sandboxStatusSchema, + sessionCapabilitiesSchema, + sessionStatusSchema, + timelineSequenceSchema, +} from "./sessions"; import { sandboxShutdownSchema, shutdownRecoveryActionSchema } from "./sandbox-shutdown"; import { clientRequestIdSchema } from "./prompts"; +import { sessionVisibilitySchema } from "./teams"; export const promptQueueItemSchema = z.object({ messageId: z.string(), @@ -16,6 +22,11 @@ export type PromptQueueItem = z.infer; const sessionStateSchema = z.object({ id: z.string(), + ownerUserId: z.string().nullable().optional(), + ownerTeamId: z.string().nullable().optional(), + visibility: sessionVisibilitySchema.optional(), + collaborators: z.array(z.string()).optional(), + capabilities: sessionCapabilitiesSchema.optional(), title: z.string().nullable(), repoOwner: z.string().nullable(), repoName: z.string().nullable(), diff --git a/packages/shared/src/types/session-access.test.ts b/packages/shared/src/types/session-access.test.ts index 53c026df9a..2b4ee1f336 100644 --- a/packages/shared/src/types/session-access.test.ts +++ b/packages/shared/src/types/session-access.test.ts @@ -79,8 +79,8 @@ describe("checkSessionAccess", () => { const actor = viewer(relation, roleKey, suspended); const target = { ...row, visibility }; const isOwner = relation === "owner"; - const isCollaborator = relation === "collaborator"; const teamRole = actor.memberships.get("team_one"); + const isCollaborator = relation === "collaborator" && teamRole !== undefined; const isAdmin = roleKey === "owner" || roleKey === "administrator"; const visible = visibility === "workspace" || @@ -90,17 +90,17 @@ describe("checkSessionAccess", () => { const read = !suspended && visible && has("sessions.read"); const privileged = isOwner || teamRole === "lead" || isAdmin; const privateActor = visibility !== "private" || isOwner || isCollaborator; - const canManageCollaborators = read && (isOwner || roleKey === "owner"); + const canAct = read && teamRole !== undefined; + const canManageCollaborators = canAct && (isOwner || roleKey === "owner"); const permits = { read, - collaborate: read && has("sessions.collaborate") && privateActor, - lifecycle: read && has("sessions.lifecycle"), - delete: read && has("sessions.delete") && privileged, - sandbox: read && has("sessions.sandbox_access") && privateActor, - move: read && has("sessions.lifecycle") && privileged, + collaborate: canAct && has("sessions.collaborate") && privateActor, + lifecycle: canAct && has("sessions.lifecycle"), + delete: canAct && has("sessions.delete") && privileged, + sandbox: canAct && has("sessions.sandbox_access") && privateActor, manageCollaborators: canManageCollaborators, changeVisibility: - visibility === "private" ? canManageCollaborators : read && privileged, + visibility === "private" ? canManageCollaborators : canAct && privileged, }; const readReason: AccessDenialReason = suspended ? "suspended" @@ -128,11 +128,13 @@ describe("checkSessionAccess", () => { let reason: AccessDenialReason = readReason; if (read) { - if (action === "collaborate" || action === "sandbox") { + if (teamRole === undefined) { + reason = "not_member"; + } else if (action === "collaborate" || action === "sandbox") { const grant = action === "collaborate" ? "sessions.collaborate" : "sessions.sandbox_access"; reason = has(grant) ? "not_collaborator" : "missing_permission"; - } else if (action === "lifecycle" || action === "delete" || action === "move") { + } else if (action === "lifecycle" || action === "delete") { const grant = action === "delete" ? "sessions.delete" : "sessions.lifecycle"; reason = has(grant) ? "not_owner_or_lead" : "missing_permission"; } else { @@ -146,7 +148,6 @@ describe("checkSessionAccess", () => { canCollaborate: permits.collaborate, canManageLifecycle: permits.lifecycle, canDelete: permits.delete, - canMove: permits.move, canSandbox: permits.sandbox, canManageCollaborators: permits.manageCollaborators, canChangeVisibility: permits.changeVisibility, @@ -163,7 +164,8 @@ describe("checkSessionAccess", () => { reason: "not_owner_or_lead", }); expect(checkSessionAccess(viewer("non-member", "administrator"), row, "delete")).toEqual({ - allowed: true, + allowed: false, + reason: "not_member", }); expect( checkSessionAccess(viewer("non-member", null, false, ["sessions.read"]), row, "read") @@ -179,7 +181,7 @@ describe("checkSessionAccess", () => { allowed: false, reason: "missing_permission", }); - const breakGlass = viewer("non-member", "owner"); + const breakGlass = viewer("team member", "owner"); const privateRow = { ...row, visibility: "private" } satisfies SessionAccessRow; expect(checkSessionAccess(breakGlass, privateRow, "read")).toEqual({ allowed: true, @@ -204,7 +206,7 @@ describe("checkSessionAccess", () => { ownerTeamId: null, visibility: "workspace", } satisfies SessionAccessRow; - expect(checkSessionAccess(actor, workspaceRow, "move")).toEqual({ + expect(checkSessionAccess(actor, workspaceRow, "delete")).toEqual({ allowed: false, reason: "not_owner_or_lead", }); @@ -218,6 +220,66 @@ describe("checkSessionAccess", () => { ).toEqual({ allowed: true }); }); + it.each(visibilities)("keeps removed owners read-only at %s visibility", (visibility) => { + const actor = { ...viewer("owner", "member"), memberships: new Map() }; + const target = { ...row, visibility }; + expect(checkSessionAccess(actor, target, "read")).toEqual( + visibility === "team" ? { allowed: false, reason: "not_member" } : { allowed: true } + ); + for (const action of SESSION_ACTIONS) { + if (action === "read") continue; + expect(checkSessionAccess(actor, target, action)).toEqual({ + allowed: false, + reason: "not_member", + }); + } + }); + + it("honors a team-owned collaborator grant only while the collaborator is a team member", () => { + const privateRow = { ...row, visibility: "private" } satisfies SessionAccessRow; + const member = { + ...viewer("collaborator", "member"), + memberships: new Map([["team_one", "member"]]), + }; + expect(checkSessionAccess(member, privateRow, "read")).toEqual({ allowed: true }); + expect(checkSessionAccess(member, privateRow, "collaborate")).toEqual({ allowed: true }); + const removed = viewer("collaborator", "member"); + for (const action of SESSION_ACTIONS) { + expect(checkSessionAccess(removed, privateRow, action)).toEqual({ + allowed: false, + reason: "private", + }); + } + expect(checkSessionAccess(viewer("collaborator", "owner"), privateRow, "read")).toEqual({ + allowed: true, + audit: "session.private_break_glass", + }); + }); + + it.each(["workspace", "private"] as const)( + "preserves workspace-owned %s actions without team membership", + (visibility) => { + const target = { ...row, ownerTeamId: null, visibility }; + for (const relation of relations) { + for (const role of roles) { + const actor = viewer(relation, role); + const withoutMembership = { ...actor, memberships: new Map() }; + for (const action of SESSION_ACTIONS) { + expect(checkSessionAccess(withoutMembership, target, action)).toEqual( + checkSessionAccess(actor, target, action) + ); + } + } + } + expect(checkSessionAccess(viewer("collaborator", "member"), target, "collaborate")).toEqual({ + allowed: true, + }); + expect(checkSessionAccess(viewer("non-member", "owner"), target, "lifecycle")).toEqual({ + allowed: true, + }); + } + ); + it("denies every action to a suspended owner before checking visibility or grants", () => { const actor = viewer("owner", "owner", true); for (const action of SESSION_ACTIONS) { @@ -283,12 +345,10 @@ describe("checkAutomationAccess", () => { }); expect(checkAutomationAccess(actor, target, "manage")).toEqual({ allowed: true }); expect(checkAutomationAccess(actor, target, "trigger")).toEqual({ allowed: true }); - expect(checkAutomationAccess(actor, target, "move")).toEqual({ allowed: true }); expect(automationCapabilities(actor, target)).toEqual({ canRead: false, canManage: true, canTrigger: true, - canMove: true, }); }); @@ -323,7 +383,6 @@ describe("checkAutomationAccess", () => { canRead: false, canManage: false, canTrigger: false, - canMove: false, }); }); @@ -334,7 +393,7 @@ describe("checkAutomationAccess", () => { reason: "not_owner_or_lead", }); const admin = viewer("non-member", "administrator", false, ["automations.manage.any"]); - expect(checkAutomationAccess(admin, target, "move")).toEqual({ allowed: true }); + expect(checkAutomationAccess(admin, target, "manage")).toEqual({ allowed: true }); expect(checkAutomationAccess(admin, target, "read")).toEqual({ allowed: false, reason: "missing_permission", @@ -370,7 +429,7 @@ describe("checkAutomationAccess", () => { describe("checkEnvironmentAccess", () => { const target = { ownerTeamId: "team_one" }; - it("checks distinct read, use, manage, and move decisions", () => { + it("checks distinct read, use, and manage decisions", () => { const actor = viewer("team lead", "member", false, ["environments.use", "environments.manage"]); expect(checkEnvironmentAccess(actor, target, "read")).toEqual({ allowed: false, @@ -378,12 +437,10 @@ describe("checkEnvironmentAccess", () => { }); expect(checkEnvironmentAccess(actor, target, "use")).toEqual({ allowed: true }); expect(checkEnvironmentAccess(actor, target, "manage")).toEqual({ allowed: true }); - expect(checkEnvironmentAccess(actor, target, "move")).toEqual({ allowed: true }); expect(environmentCapabilities(actor, target)).toEqual({ canRead: false, canManage: true, canUse: true, - canMove: true, }); }); @@ -402,14 +459,16 @@ describe("checkEnvironmentAccess", () => { "manage" ) ).toEqual({ allowed: false, reason: "not_owner_or_lead" }); - expect(checkEnvironmentAccess(viewer("non-member", "administrator"), target, "move")).toEqual({ - allowed: true, - }); + expect(checkEnvironmentAccess(viewer("non-member", "administrator"), target, "manage")).toEqual( + { + allowed: true, + } + ); expect( checkEnvironmentAccess( viewer("team lead", "member", false, ["environments.manage"]), { ownerTeamId: null }, - "move" + "manage" ) ).toEqual({ allowed: false, reason: "not_owner_or_lead" }); }); diff --git a/packages/shared/src/types/session-access.ts b/packages/shared/src/types/session-access.ts index 06c4cea960..9ba7e124a1 100644 --- a/packages/shared/src/types/session-access.ts +++ b/packages/shared/src/types/session-access.ts @@ -12,16 +12,15 @@ export const SESSION_ACTIONS = [ "lifecycle", "delete", "sandbox", - "move", "manageCollaborators", "changeVisibility", ] as const; export type SessionAction = (typeof SESSION_ACTIONS)[number]; -export const AUTOMATION_ACTIONS = ["read", "manage", "trigger", "move"] as const; +export const AUTOMATION_ACTIONS = ["read", "manage", "trigger"] as const; export type AutomationAction = (typeof AUTOMATION_ACTIONS)[number]; -export const ENVIRONMENT_ACTIONS = ["read", "manage", "use", "move"] as const; +export const ENVIRONMENT_ACTIONS = ["read", "manage", "use"] as const; export type EnvironmentAction = (typeof ENVIRONMENT_ACTIONS)[number]; export type AccessDenialReason = @@ -33,7 +32,8 @@ export type AccessDenialReason = | "not_collaborator"; export type AuditObligation = "session.private_break_glass"; export type AccessDecision = - { allowed: true; audit?: AuditObligation } | { allowed: false; reason: AccessDenialReason }; + | { allowed: true; audit?: AuditObligation } + | { allowed: false; reason: AccessDenialReason }; export type SessionViewer = | { @@ -60,7 +60,6 @@ export interface SessionCapabilities { canCollaborate: boolean; canManageLifecycle: boolean; canDelete: boolean; - canMove: boolean; canSandbox: boolean; canManageCollaborators: boolean; canChangeVisibility: boolean; @@ -125,8 +124,11 @@ interface SessionFacts extends Facts { function sessionFacts(viewer: UserViewer, row: SessionAccessRow): SessionFacts { const isOwner = row.ownerUserId !== null && row.ownerUserId === viewer.userId; - const isCollaborator = row.collaboratorIds.includes(viewer.userId); const teamRole = row.ownerTeamId === null ? undefined : viewer.memberships.get(row.ownerTeamId); + // A team-owned grant lapses with team membership, so stale collaborator rows grant nothing. + const isCollaborator = + row.collaboratorIds.includes(viewer.userId) && + (row.ownerTeamId === null || teamRole !== undefined); const isWsOwner = viewer.roleKey === "owner"; const isAdmin = isWsOwner || viewer.roleKey === "administrator"; const isPrivate = row.visibility === "private"; @@ -179,7 +181,6 @@ const SESSION_RULES = { reason: "not_collaborator", }, delete: { permission: "sessions.delete", when: privileged, reason: "not_owner_or_lead" }, - move: { permission: "sessions.lifecycle", when: privileged, reason: "not_owner_or_lead" }, manageCollaborators: { when: ownerOrWsOwner, reason: "not_owner_or_lead" }, changeVisibility: { when: (facts: SessionFacts) => (facts.isPrivate ? ownerOrWsOwner(facts) : privileged(facts)), @@ -207,12 +208,12 @@ export function checkSessionAccess( ): AccessDecision { if (viewer.kind === "service") return checkServiceSessionAccess(viewer, row, action); const facts = sessionFacts(viewer, row); - return ( - readGate(facts) ?? - (action === "read" - ? permit(facts.breakGlass ? "session.private_break_glass" : undefined) - : decide(SESSION_RULES[action], facts)) - ); + const read = readGate(facts); + if (read) return read; + if (action === "read") + return permit(facts.breakGlass ? "session.private_break_glass" : undefined); + if (row.ownerTeamId !== null && facts.teamRole === undefined) return deny("not_member"); + return decide(SESSION_RULES[action], facts); } export function sessionCapabilities( @@ -227,7 +228,6 @@ export function sessionCapabilities( canCollaborate: permits.get("collaborate") === true, canManageLifecycle: permits.get("lifecycle") === true, canDelete: permits.get("delete") === true, - canMove: permits.get("move") === true, canSandbox: permits.get("sandbox") === true, canManageCollaborators: permits.get("manageCollaborators") === true, canChangeVisibility: permits.get("changeVisibility") === true, @@ -274,7 +274,6 @@ const AUTOMATION_RULES = { read: { permission: "automations.read" }, manage: manageAutomation, trigger: { scoped: "automations.trigger", owns: ownsAutomation }, - move: manageAutomation, } as const satisfies Record>; function checkServiceAutomationAccess( @@ -306,7 +305,6 @@ export function automationCapabilities( canRead: checkAutomationAccess(viewer, row, "read").allowed, canManage: checkAutomationAccess(viewer, row, "manage").allowed, canTrigger: checkAutomationAccess(viewer, row, "trigger").allowed, - canMove: checkAutomationAccess(viewer, row, "move").allowed, }; } @@ -332,7 +330,6 @@ const ENVIRONMENT_RULES = { read: { permission: "environments.read" }, use: { permission: "environments.use" }, manage: manageEnvironment, - move: manageEnvironment, } as const satisfies Record>; function checkServiceEnvironmentAccess( @@ -364,6 +361,5 @@ export function environmentCapabilities( canRead: checkEnvironmentAccess(viewer, row, "read").allowed, canManage: checkEnvironmentAccess(viewer, row, "manage").allowed, canUse: checkEnvironmentAccess(viewer, row, "use").allowed, - canMove: checkEnvironmentAccess(viewer, row, "move").allowed, }; } diff --git a/packages/shared/src/types/session-api.ts b/packages/shared/src/types/session-api.ts index d92da4c6a6..99275c0b0f 100644 --- a/packages/shared/src/types/session-api.ts +++ b/packages/shared/src/types/session-api.ts @@ -6,6 +6,7 @@ import { sessionRepositoriesInputSchema } from "./repositories"; import type { EventResponse } from "./sandbox-events"; import { MAX_WEB_PROMPT_CHARS, promptContentSchema } from "./prompts"; import { modelProviderSelectionsSchema } from "./provider-accounts"; +import { sessionVisibilitySchema } from "./teams"; import { messageSourceSchema, sessionStatusSchema, @@ -268,6 +269,8 @@ function hasExclusiveSessionTarget( } const createSessionRequestBaseSchema = z.object({ + teamId: z.string().min(1).nullable().optional(), + visibility: sessionVisibilitySchema.optional(), repoOwner: z.string().trim().min(1).nullish(), repoName: z.string().trim().min(1).nullish(), title: z.string().optional(), diff --git a/packages/shared/src/types/session-archive.ts b/packages/shared/src/types/session-archive.ts index a368126757..94c09e235e 100644 --- a/packages/shared/src/types/session-archive.ts +++ b/packages/shared/src/types/session-archive.ts @@ -45,7 +45,7 @@ export const sessionBatchArchiveResponseSchema = z.strictObject({ skipped: z.array( z.strictObject({ sessionId: z.string(), - reason: z.enum(["not_found", "missing_permission"]), + reason: z.enum(["not_found", "missing_permission", "not_member"]), }) ), }); diff --git a/packages/shared/src/types/session-inbox.ts b/packages/shared/src/types/session-inbox.ts index d113616256..e3b99fd69d 100644 --- a/packages/shared/src/types/session-inbox.ts +++ b/packages/shared/src/types/session-inbox.ts @@ -1,9 +1,14 @@ import { z } from "zod"; -import { sessionReadStateSchema, sessionSummaryBaseSchema } from "./sessions"; +import { + sessionCapabilitiesSchema, + sessionReadStateSchema, + sessionSummaryBaseSchema, +} from "./sessions"; /** Viewer-specific session row in session inbox page and snapshot payloads. */ export const sessionInboxSessionSchema = sessionSummaryBaseSchema.extend({ readState: sessionReadStateSchema, + capabilities: sessionCapabilitiesSchema.optional(), }); export type SessionInboxSession = z.infer; /** @deprecated Use SessionInboxSession for this inbox-specific projection. */ diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts index c459bf412a..924a82ae93 100644 --- a/packages/shared/src/types/sessions.ts +++ b/packages/shared/src/types/sessions.ts @@ -4,6 +4,7 @@ import { resolvedSessionAttachmentsSchema } from "./session-attachments"; import { eventResponseSchema } from "./sandbox-events"; import { sessionListRepositorySchema, type SessionListRepository } from "./repositories"; import type { PullRequestLifecycleState } from "./artifacts"; +import type { SessionCapabilities } from "./session-access"; /** * A session's conversation lifecycle: durable, user-visible, and independent @@ -131,6 +132,8 @@ export type SessionReadState = z.infer; /** Fields shared only by the list, inbox, and direct-child response projections. */ export const sessionSummaryBaseSchema = z.object({ + ownerTeamId: z.string().nullable().optional(), + visibility: z.enum(["team", "workspace", "private"]).optional(), id: z.string(), title: z.string().nullable(), repoOwner: z.string().nullable(), @@ -170,7 +173,18 @@ export const childSessionListResponseSchema = z.object({ export type ChildSessionListResponse = z.infer; /** Flat session-list item. Read state is absent for callers without a viewer identity. */ +export const sessionCapabilitiesSchema = z.object({ + canRead: z.boolean(), + canCollaborate: z.boolean(), + canManageLifecycle: z.boolean(), + canDelete: z.boolean(), + canSandbox: z.boolean(), + canManageCollaborators: z.boolean(), + canChangeVisibility: z.boolean(), +}); + export const sessionListSummarySchema = childSessionSummarySchema.extend({ + capabilities: sessionCapabilitiesSchema.optional(), readState: sessionReadStateSchema.optional(), }); export type SessionListSummary = z.infer; @@ -215,6 +229,10 @@ export type SessionReadResult = z.infer; export interface Session { id: string; + ownerTeamId?: string | null; + visibility?: "team" | "workspace" | "private"; + collaborators?: string[]; + capabilities?: SessionCapabilities; title: string | null; repoOwner: string | null; repoName: string | null; @@ -291,3 +309,15 @@ export const sessionParticipantProfilesResponseSchema = z.object({ export type SessionParticipantProfilesResponse = z.infer< typeof sessionParticipantProfilesResponseSchema >; + +export const sessionCollaboratorCandidateSchema = z.object({ + userId: z.string(), + displayName: z.string().nullable(), + email: z.string().nullable(), + avatarUrl: z.string().nullable(), +}); +export type SessionCollaboratorCandidate = z.infer; + +export const sessionCollaboratorCandidatesResponseSchema = z.array( + sessionCollaboratorCandidateSchema +); diff --git a/packages/shared/src/types/teams.test.ts b/packages/shared/src/types/teams.test.ts index b994df33b5..c6cca86399 100644 --- a/packages/shared/src/types/teams.test.ts +++ b/packages/shared/src/types/teams.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it } from "vitest"; -import { updateTeamRequestSchema } from "./teams"; +import { meTeamsResponseSchema, updateTeamRequestSchema } from "./teams"; + +describe("meTeamsResponseSchema", () => { + it("defaults the omitted creation setting in legacy membership responses", () => { + expect(meTeamsResponseSchema.parse({ teams: [] })).toEqual({ + teams: [], + requireTeamOnCreate: false, + }); + }); + + it.each([false, true])("preserves the explicit creation setting %s", (requireTeamOnCreate) => { + expect(meTeamsResponseSchema.parse({ teams: [], requireTeamOnCreate })).toEqual({ + teams: [], + requireTeamOnCreate, + }); + }); + + it.each([null, "false", "true", 0, 1])( + "rejects an invalid creation setting %j", + (requireTeamOnCreate) => { + expect(meTeamsResponseSchema.safeParse({ teams: [], requireTeamOnCreate }).success).toBe( + false + ); + } + ); +}); describe("updateTeamRequestSchema", () => { it("accepts a canonical environment ID or null, but not an empty or malformed ID", () => { diff --git a/packages/shared/src/types/teams.ts b/packages/shared/src/types/teams.ts index 61f4a06c82..b9cc169426 100644 --- a/packages/shared/src/types/teams.ts +++ b/packages/shared/src/types/teams.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { isEnvironmentId } from "./environments"; +import { sessionListRepositorySchema } from "./repositories"; export const teamRoleSchema = z.enum(["lead", "member"]); export type TeamRole = z.infer; @@ -10,6 +11,9 @@ export type TeamJoinPolicy = z.infer; export const sessionVisibilitySchema = z.enum(["team", "workspace", "private"]); export type SessionVisibility = z.infer; +export const teamSettingsSchema = z.strictObject({ requireTeamOnCreate: z.boolean() }); +export type TeamSettings = z.infer; + export const teamRowSchema = z.object({ id: z.string(), slug: z.string(), @@ -103,4 +107,74 @@ export const teamMemberSchema = teamMembershipSchema.extend({ export const meTeamsResponseSchema = z.object({ teams: z.array(teamResponseSchema.extend({ role: teamRoleSchema })), + // Older control-plane responses omit the setting during independent rollouts. + requireTeamOnCreate: z.boolean().default(false), }); + +// Session modules depend on team settings; keep this wire schema cycle-free. +const teamInboxSessionSchema = z.object({ + ownerTeamId: z.string().nullable(), + visibility: sessionVisibilitySchema, + id: z.string(), + title: z.string().nullable(), + repoOwner: z.string().nullable(), + repoName: z.string().nullable(), + baseBranch: z.string().nullable(), + status: z.enum(["created", "active", "completed", "failed", "archived", "cancelled"]), + parentSessionId: z.string().nullable(), + spawnSource: z.enum(["user", "agent", "automation", "github-bot", "linear-bot", "slack-bot"]), + environmentId: z.string().nullable(), + createdAt: z.number(), + updatedAt: z.number(), + repositories: z.array(sessionListRepositorySchema).optional(), + pullRequestSummary: z + .object({ + total: z.number(), + open: z.number(), + draft: z.number(), + merged: z.number(), + closed: z.number(), + }) + .optional(), + readState: z.union([ + z.object({ latestMessageId: z.null(), unread: z.literal(false), version: z.number() }), + z.object({ latestMessageId: z.string(), unread: z.boolean(), version: z.number() }), + ]), + capabilities: z.object({ + canRead: z.boolean(), + canCollaborate: z.boolean(), + canManageLifecycle: z.boolean(), + canDelete: z.boolean(), + canSandbox: z.boolean(), + canManageCollaborators: z.boolean(), + canChangeVisibility: z.boolean(), + }), +}); +const teamInboxItemSchema = z.object({ + rootSession: teamInboxSessionSchema, + descendantSessions: z.array(teamInboxSessionSchema), +}); +const teamInboxPageSchema = z.discriminatedUnion("hasMore", [ + z.object({ + items: z.array(teamInboxItemSchema), + hasMore: z.literal(true), + nextCursor: z.string().min(1), + }), + z.object({ + items: z.array(teamInboxItemSchema), + hasMore: z.literal(false), + nextCursor: z.null(), + }), +]); + +/** A bucket page when requested, otherwise the first page of all inbox buckets. */ +export const teamSessionsResponseSchema = z.union([ + teamInboxPageSchema, + z.object({ + categories: z.record( + z.enum(["needs_attention", "in_progress", "finished"]), + teamInboxPageSchema + ), + }), +]); +export type TeamSessionsResponse = z.infer; diff --git a/packages/slack-bot/package.json b/packages/slack-bot/package.json index fc4eaa81bb..42dde6233c 100644 --- a/packages/slack-bot/package.json +++ b/packages/slack-bot/package.json @@ -16,7 +16,7 @@ "@anthropic-ai/sdk": "^0.128.0", "@cloudflare/workers-types": "^5.20260927.1", "@open-inspect/shared": "file:../shared", - "hono": "^4.13.9", + "hono": "^4.13.12", "zod": "^4.6.5" }, "devDependencies": { diff --git a/packages/slack-bot/src/app-home/slack-types.ts b/packages/slack-bot/src/app-home/slack-types.ts index 3f2f4a6147..741ce00c43 100644 --- a/packages/slack-bot/src/app-home/slack-types.ts +++ b/packages/slack-bot/src/app-home/slack-types.ts @@ -15,7 +15,11 @@ export interface ModelOption { } export type AppHomeBlock = - SlackHeaderBlock | SlackSectionBlock | SlackActionsBlock | SlackContextBlock | SlackDividerBlock; + | SlackHeaderBlock + | SlackSectionBlock + | SlackActionsBlock + | SlackContextBlock + | SlackDividerBlock; export type AppHomeModalBlock = SlackSectionBlock | SlackInputBlock; export type AppHomeView = { type: "home"; blocks: AppHomeBlock[] }; diff --git a/packages/slack-bot/src/attachments.ts b/packages/slack-bot/src/attachments.ts index 316917ae84..e7d8a8520c 100644 --- a/packages/slack-bot/src/attachments.ts +++ b/packages/slack-bot/src/attachments.ts @@ -56,7 +56,10 @@ export interface SlackImageAttachment { /** Why an attached image did not make it to the session. */ export type SlackAttachmentDropReason = - "download_failed" | "too_large" | "over_cap" | "upload_rejected"; + | "download_failed" + | "too_large" + | "over_cap" + | "upload_rejected"; /** Downloaded image bytes plus a record of every image that was lost. */ export interface PreparedImageAttachments { diff --git a/packages/slack-bot/src/inline-flags.ts b/packages/slack-bot/src/inline-flags.ts index d73c1f96ed..2c319218c3 100644 --- a/packages/slack-bot/src/inline-flags.ts +++ b/packages/slack-bot/src/inline-flags.ts @@ -81,10 +81,12 @@ export const sessionLaunchPlanSchema = z.object({ export type SessionLaunchPlan = z.infer; export type ParseInlinePromptFlagsResult = - { ok: true; text: string; options: InlinePromptOptions } | { ok: false; error: string }; + | { ok: true; text: string; options: InlinePromptOptions } + | { ok: false; error: string }; export type ResolveInlinePromptOptionsResult = - { ok: true; turnPlan: ResolvedTurnPlan } | { ok: false; error: string }; + | { ok: true; turnPlan: ResolvedTurnPlan } + | { ok: false; error: string }; const FLAG_NAMES = ["model", "reasoning"] as const; type FlagName = (typeof FLAG_NAMES)[number]; diff --git a/packages/slack-bot/src/sessions/control-plane-client.ts b/packages/slack-bot/src/sessions/control-plane-client.ts index 1569354f54..e7745fa3b0 100644 --- a/packages/slack-bot/src/sessions/control-plane-client.ts +++ b/packages/slack-bot/src/sessions/control-plane-client.ts @@ -25,7 +25,8 @@ interface CreateSessionOptions { } export type SendPromptResult = - { ok: true; data: SendPromptResponse } | { ok: false; reason: "stale" | "transient" }; + | { ok: true; data: SendPromptResponse } + | { ok: false; reason: "stale" | "transient" }; export async function createSession( env: ControlPlaneEnv, diff --git a/packages/slack-bot/src/target-clarification.ts b/packages/slack-bot/src/target-clarification.ts index 4f6164a2f5..e45d99f9f3 100644 --- a/packages/slack-bot/src/target-clarification.ts +++ b/packages/slack-bot/src/target-clarification.ts @@ -185,7 +185,8 @@ export async function resolveTargetValue( * exactly one of the two shapes). */ export type TargetClarificationOptions = - { options: SlackSelectOption[] } | { option_groups: SlackSelectOptionGroup[] }; + | { options: SlackSelectOption[] } + | { option_groups: SlackSelectOptionGroup[] }; /** Number of options in either response shape (for logging). */ export function countClarificationOptions(response: TargetClarificationOptions): number { diff --git a/packages/web/package.json b/packages/web/package.json index 3a56d570c0..c99d25f115 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", "@radix-ui/react-toggle-group": "^1.1.19", "@radix-ui/react-tooltip": "^1.2.16", "@tailwindcss/typography": "^0.5.19", @@ -34,7 +35,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "next": "^16.3.6", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.3.0", "react-dom": "^19.3.0", diff --git a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx index aa9ddd4ba2..c582caa8c8 100644 --- a/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/analytics/page.test.tsx @@ -112,6 +112,7 @@ const summary: AnalyticsSummaryResponse = { totalSessions: 13, activeUsers: 3, totalCost: 12.5, + privateSessionsCostUsd: 0, avgCost: 0.96, totalPrs: 4, statusBreakdown: { diff --git a/packages/web/src/app/(app)/(sidebar)/layout.tsx b/packages/web/src/app/(app)/(sidebar)/layout.tsx index 64720bb706..37fea7e527 100644 --- a/packages/web/src/app/(app)/(sidebar)/layout.tsx +++ b/packages/web/src/app/(app)/(sidebar)/layout.tsx @@ -1,5 +1,10 @@ import { SidebarLayout } from "@/components/sidebar-layout"; +import { ActiveTeamProvider } from "@/hooks/use-active-team"; export default function SidebarAppLayout({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx b/packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx new file mode 100644 index 0000000000..d7da57da59 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx @@ -0,0 +1,560 @@ +// @vitest-environment jsdom +/// + +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { TeamResponse } from "@/hooks/use-teams"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; +import { environment, mocks, repo, sessionCreateBody } from "./page.test-fixture"; +import Home from "./page"; + +// Radix Select uses pointer-capture APIs that jsdom doesn't implement. +beforeAll(() => { + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; +}); + +async function selectTeam(user: ReturnType, name: string) { + await user.click(screen.getByRole("combobox", { name: "Team context" })); + await user.click(await screen.findByRole("option", { name })); +} + +async function selectAudience(user: ReturnType, label: string) { + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await user.click(screen.getByRole("radio", { name: label })); +} + +function team(overrides: Partial = {}): TeamResponse & { role: TeamRole } { + return { + id: "team-1", + slug: "engineering", + name: "Engineering", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + memberCount: 1, + role: "member", + ...overrides, + }; +} + +describe("Home team context", () => { + it("preselects a required team when the setting changes without widening draft visibility", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + const view = render(); + await selectAudience(user, "Private"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "private" }) + ); + mocks.requireTeamOnCreate = true; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Private; team context: Engineering" + ); + expect(screen.getByRole("button", { name: /send/i })).not.toBeDisabled(); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: "team-1", + visibility: "private", + }); + }); + + it("cancels deferred warming when the prompt is cleared before readiness", async () => { + const user = userEvent.setup(); + mocks.teamsLoading = true; + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await user.clear(input); + mocks.teamsLoading = false; + view.rerender(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it.each(["models", "provider accounts"])( + "consumes deferred input after %s become ready", + async (resource) => { + const user = userEvent.setup(); + mocks.enabledModelsLoadingValue = resource === "models"; + mocks.providerAccountsLoadingValue = resource === "provider accounts"; + const view = render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + expect(fetch).not.toHaveBeenCalled(); + mocks.enabledModelsLoadingValue = false; + mocks.providerAccountsLoadingValue = false; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + } + ); + + it.each([ + [undefined, "workspace"], + [undefined, "all"], + ["workspace", undefined], + ["workspace", "all"], + ["all", undefined], + ["all", "workspace"], + ] as const)( + "reconciles composer access when aggregate scope changes from %s to %s", + async (from, to) => { + const user = userEvent.setup(); + mocks.teams = [team()]; + mocks.scope = from; + const view = render(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Engineering"); + await user.keyboard("{Escape}"); + await selectAudience(user, "Private"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "private" }) + ); + + mocks.scope = to; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: No team" + ); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + mocks.scope = from; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: No team" + ); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: null, + visibility: "workspace", + }); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + } + ); + + it("re-warms a retired draft on continued input without duplicating session creation", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await waitFor(() => expect(sessionCreateBody()).toBeDefined()); + await selectAudience(user, "Private"); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.type(input, " again"); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ visibility: "private" }); + }); + }); + + it("consumes deferred warming once memberships become ready without further input", async () => { + const user = userEvent.setup(); + mocks.teamsLoading = true; + mocks.teams = [team()]; + mocks.requireTeamOnCreate = true; + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + expect(fetch).not.toHaveBeenCalled(); + mocks.teamsLoading = false; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + }); + + it("preselects the first required team locally without changing All my teams", async () => { + mocks.teams = [team()]; + mocks.requireTeamOnCreate = true; + const user = userEvent.setup(); + render(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Engineering team; team context: Engineering" + ); + expect(mocks.activeTeamId).toBeNull(); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + }); + + it("archives a draft on model change without creating its replacement until submit", async () => { + mocks.enabledModelsValue.push("openai/gpt-5.4"); + mocks.enabledModelOptionsValue.push({ + category: "OpenAI", + models: [ + { + id: "openai/gpt-5.4", + name: "GPT-5.4", + description: "", + }, + ], + }); + const user = userEvent.setup(); + render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => expect(sessionCreateBody()).toBeDefined()); + await user.click(screen.getByRole("button", { name: "Switch model to GPT-5.4" })); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith("/session/session-1")); + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ model: "openai/gpt-5.4" }); + }); + + it("falls back locally when the composer's team is no longer an active membership", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + const view = render(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Engineering"); + await user.keyboard("{Escape}"); + await selectAudience(user, "Engineering team"); + mocks.teams = []; + view.rerender(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toHaveAccessibleName( + "Session access: Workspace; team context: No team" + ); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + }); + + it("separates icon-led session access from the agent controls", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + render(); + const trigger = screen.getByRole("button", { + name: "Session access: Workspace; team context: No team", + }); + expect(trigger).toHaveTextContent("Workspace"); + expect(trigger.querySelector('svg[aria-hidden="true"]')).toBeInTheDocument(); + await user.click(trigger); + const context = screen.getByRole("dialog", { name: "Session access" }); + expect(within(context).getByRole("combobox", { name: "Team context" })).toHaveTextContent( + "No team" + ); + expect( + within(context).getByRole("radiogroup", { name: "Session audience" }) + ).toBeInTheDocument(); + expect( + within(context).queryByRole("button", { name: /model and effort/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Manage secrets and settings" }) + ).not.toBeInTheDocument(); + }); + + it("defaults workspace drafts to workspace visibility with team visibility disabled", async () => { + const user = userEvent.setup(); + render(); + const visibility = screen.getByRole("button", { name: /^Session access: Workspace;/ }); + expect(visibility.tagName).toBe("BUTTON"); + expect(visibility).toHaveTextContent("Workspace"); + await user.click(visibility); + const menu = screen.getByRole("radiogroup", { name: "Session audience" }); + expect(within(menu).getByRole("radio", { name: "Workspace" })).toBeChecked(); + expect(within(menu).getByRole("radio", { name: "Team" })).toBeDisabled(); + await user.keyboard("{Escape}"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + }); + + it("uses the active team's defaults and archives the warm draft on visibility and team changes", async () => { + const user = userEvent.setup(); + mocks.teams = [team(), team({ id: "team-2", name: "Design", defaultVisibility: "private" })]; + mocks.activeTeamId = "team-1"; + const view = render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + await selectAudience(user, "Workspace"); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.clear(screen.getByPlaceholderText("What do you want to build?")); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(2) + ); + const workspaceCall = vi + .mocked(fetch) + .mock.calls.filter(([url]) => String(url) === "/api/sessions")[1]; + expect(JSON.parse(String(workspaceCall[1]?.body))).toMatchObject({ + teamId: "team-1", + visibility: "workspace", + }); + mocks.activeTeamId = "team-2"; + view.rerender(); + expect( + screen.getByRole("button", { name: "Session access: Private; team context: Design" }) + ).toHaveTextContent("Private"); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(2); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(3); + expect(JSON.parse(String(calls[2][1]?.body))).toMatchObject({ + teamId: "team-2", + visibility: "private", + }); + }); + expect( + vi + .mocked(fetch) + .mock.calls.filter(([url]) => String(url) === "/api/sessions/session-1/archive") + ).toHaveLength(2); + }); + + it("offers a composer team choice, preserves Workspace access, and maps No team to null", async () => { + const user = userEvent.setup(); + mocks.teams = [team()]; + render(); + const trigger = screen.getByRole("button", { name: /^Session access:/ }); + expect(trigger.tagName).toBe("BUTTON"); + expect(trigger).toHaveTextContent("Workspace"); + await user.click(trigger); + await selectTeam(user, "Engineering"); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect(mocks.activeTeamId).toBeNull(); + expect(trigger).toHaveAccessibleName("Session access: Workspace; team context: Engineering"); + expect(screen.getByRole("combobox", { name: "Team context" })).toHaveTextContent("Engineering"); + await selectTeam(user, "No team"); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect(trigger).toHaveAccessibleName("Session access: Workspace; team context: No team"); + await user.keyboard("{Escape}"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: null, visibility: "workspace" }) + ); + }); + + it("preserves Private access when the composer team context changes and warms the final pair", async () => { + const user = userEvent.setup(); + mocks.teams = [team(), team({ id: "team-2", name: "Design", defaultVisibility: "workspace" })]; + mocks.activeTeamId = "team-1"; + render(); + await selectAudience(user, "Private"); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "private" }) + ); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + await selectTeam(user, "Design"); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + expect(mocks.activeTeamId).toBe("team-1"); + expect( + screen.getByRole("button", { name: "Session access: Private; team context: Design" }) + ).toHaveTextContent("Private"); + expect(screen.getByRole("radio", { name: "Private" })).toBeChecked(); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: "team-2", + visibility: "private", + }); + }); + expect( + vi + .mocked(fetch) + .mock.calls.filter(([url]) => String(url) === "/api/sessions/session-1/archive") + ).toHaveLength(1); + }); + + it("selects visibility with the keyboard and restores focus to its trigger", async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole("button", { name: /^Session access:/ }); + trigger.focus(); + expect(trigger).toHaveFocus(); + await user.keyboard("[Space]"); + await waitFor(() => expect(screen.getByRole("radio", { name: "Workspace" })).toHaveFocus()); + await user.keyboard("{ArrowUp}"); + expect(screen.getByRole("radio", { name: "Private" })).toHaveFocus(); + expect(screen.getByRole("radio", { name: "Workspace" })).toBeChecked(); + await user.keyboard("{Enter}"); + expect(trigger).toHaveTextContent("Private"); + expect(screen.queryByRole("dialog", { name: "Session access" })).not.toBeInTheDocument(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it("waits for membership and settings reconciliation before warming or sending", async () => { + const user = userEvent.setup(); + mocks.teamsLoading = true; + mocks.teams = [team()]; + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + mocks.teamsLoading = false; + mocks.requireTeamOnCreate = true; + mocks.teams = [team()]; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + expect(mocks.setActiveTeam).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /^Session access:/ })); + expect(screen.getByRole("combobox", { name: "Team context" })).not.toBeDisabled(); + for (const radio of screen.getAllByRole("radio")) { + expect(radio).not.toBeDisabled(); + } + await user.click(screen.getByRole("combobox", { name: "Team context" })); + expect(screen.queryByRole("option", { name: "No team" })).not.toBeInTheDocument(); + await user.keyboard("{Escape}{Escape}"); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + }); + + it("blocks warming and shortcut submission with an inline notice when a team is required but none are available", async () => { + const user = userEvent.setup(); + mocks.requireTeamOnCreate = true; + render(); + expect(screen.getByText("Join a team to create a session.")).toBeInTheDocument(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not create workspace drafts when the team context failed to load", async () => { + const user = userEvent.setup(); + mocks.teamsError = new Error("Settings unavailable"); + render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(screen.getByRole("button", { name: /^Session access:/ })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("surfaces terminal creation codes on first typing and does not retry the denied draft", async () => { + const user = userEvent.setup(); + vi.mocked(fetch).mockResolvedValue( + Response.json({ error: "Team archived", code: "team_archived" }, { status: 409 }) + ); + render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + await user.type(input, "Ship it"); + await screen.findByText("Team archived (team_archived)"); + await user.clear(input); + await user.type(input, "Try again"); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + }); + + it("can correct visibility after a terminal denial and recreate the draft", async () => { + const user = userEvent.setup(); + vi.mocked(fetch) + .mockResolvedValueOnce( + Response.json({ error: "Visibility denied", code: "visibility_denied" }, { status: 403 }) + ) + .mockResolvedValueOnce(Response.json({ sessionId: "session-1", status: "created" })); + render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + await screen.findByText("Visibility denied (visibility_denied)"); + const visibility = screen.getByRole("button", { name: /^Session access:/ }); + expect(visibility).not.toBeDisabled(); + await selectAudience(user, "Private"); + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(1); + await user.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => { + const calls = vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions"); + expect(calls).toHaveLength(2); + expect(JSON.parse(String(calls[1][1]?.body))).toMatchObject({ + teamId: null, + visibility: "private", + }); + }); + }); + + it("consumes deferred warming after a team's default environment becomes ready", async () => { + const user = userEvent.setup(); + mocks.teams = [team({ defaultEnvironmentId: "env-1" })]; + mocks.activeTeamId = "team-1"; + mocks.environmentsLoadingValue = true; + localStorage.setItem("open-inspect-last-selected-repo", repo.fullName); + const view = render(); + await user.type(screen.getByPlaceholderText("What do you want to build?"), "Ship it"); + expect(fetch).not.toHaveBeenCalled(); + mocks.environmentsLoadingValue = false; + mocks.environmentsValue = [environment]; + view.rerender(); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ + environmentId: "env-1", + teamId: "team-1", + visibility: "team", + }) + ); + expect(sessionCreateBody()).not.toHaveProperty("repoOwner"); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx b/packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx new file mode 100644 index 0000000000..de1ef7fe84 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx @@ -0,0 +1,320 @@ +import { afterEach, beforeAll, beforeEach, expect, vi } from "vitest"; +import { cleanup } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { DEFAULT_MODEL } from "@open-inspect/shared/models"; +import { + DEFAULT_KEYBOARD_SHORTCUTS, + type KeyboardShortcutPreferences, +} from "@open-inspect/shared/types/keyboard-shortcuts"; +import type { TeamResponse } from "@/hooks/use-teams"; +import type { TeamRole } from "@open-inspect/shared/types/teams"; + +expect.extend(matchers); + +const mocks = vi.hoisted(() => { + const teamContext: { + activeTeamId: string | null; + scope: "workspace" | "all" | undefined; + teams: (TeamResponse & { role: TeamRole })[]; + teamsLoading: boolean; + teamsError: unknown; + requireTeamOnCreate: boolean; + } = { + activeTeamId: null, + scope: undefined, + teams: [], + teamsLoading: false, + teamsError: undefined, + requireTeamOnCreate: false, + }; + return { + routerPush: vi.fn(), + toastError: vi.fn(), + mutateMock: vi.fn(), + reposValue: [] as Array<{ + id: number; + fullName: string; + owner: string; + name: string; + description: string | null; + private: boolean; + defaultBranch: string; + }>, + loadingReposValue: false, + environmentsLoadingValue: false, + environmentsValue: [] as Array<{ + id: string; + name: string; + description: string | null; + prebuildEnabled: boolean; + createdAt: number; + updatedAt: number; + repositories: Array<{ + repoOwner: string; + repoName: string; + repoId: number | null; + baseBranch: string; + }>; + }>, + enabledModelsValue: [] as string[], + enabledModelsLoadingValue: false, + enabledModelOptionsValue: [] as Array<{ + category: string; + models: Array<{ id: string; name: string; description: string }>; + }>, + providerAccountsValue: [] as Array<{ + id: string; + provider: "openai" | "xai" | "anthropic"; + displayName: string; + externalAccountId: string | null; + status: "active"; + createdBy: null; + updatedBy: null; + lastVerifiedAt: null; + lastUsedAt: null; + createdAt: number; + updatedAt: number; + archivedAt: null; + }>, + providerAccountsLoadingValue: false, + skillPreview: { + skills: [ + { + skillId: "skill-1", + revisionId: "revision-1", + name: "review-pr", + description: "Review a pull request", + revisionNumber: 1, + revisionSha256: "abc", + totalBytes: 10, + assignmentSources: [], + }, + ], + totalBytes: 10, + ignoredProfileSkillIds: [], + }, + keyboardShortcuts: null as unknown as KeyboardShortcutPreferences, + canCreateSession: true, + ...teamContext, + setActiveTeam: vi.fn(), + }; +}); + +export { mocks }; + +export const repo = { + id: 1, + fullName: "open-inspect/background-agents", + owner: "open-inspect", + name: "background-agents", + description: null, + private: true, + defaultBranch: "main", +}; + +export const environment = { + id: "env-1", + name: "full-stack", + description: null, + prebuildEnabled: false, + createdAt: 1, + updatedAt: 1, + repositories: [{ repoOwner: "acme", repoName: "backend", repoId: 1, baseBranch: "main" }], +}; + +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }), +})); + +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ + hasPermission: (permission: string) => + permission === "sessions.create" && mocks.canCreateSession, + }), +})); + +vi.mock("@/hooks/use-active-team", () => ({ + useActiveTeam: () => ({ + activeTeamId: mocks.activeTeamId, + setActiveTeam: mocks.setActiveTeam, + teams: mocks.teams, + scope: mocks.scope, + requireTeamOnCreate: mocks.requireTeamOnCreate, + loading: mocks.teamsLoading, + error: mocks.teamsError, + }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mocks.routerPush }), +})); +vi.mock("sonner", () => ({ toast: { error: mocks.toastError } })); + +vi.mock("swr", () => ({ + // Home uses the default export only for the picker's prebuild-status text. + default: () => ({ data: undefined, isLoading: false }), + mutate: mocks.mutateMock, +})); + +vi.mock("@/hooks/use-environments", () => ({ + ENVIRONMENTS_KEY: "/api/environments", + useEnvironments: () => ({ + environments: mocks.environmentsValue, + loading: mocks.environmentsLoadingValue, + }), +})); + +vi.mock("@/components/sidebar-layout", () => ({ + useSidebarContext: () => ({ isOpen: true, toggle: vi.fn() }), +})); + +vi.mock("@/components/model-reasoning-selector", () => ({ + ModelReasoningSelector: ({ + disabled, + harness, + onHarnessChange, + onModelChange, + }: { + disabled?: boolean; + harness?: string | null; + onHarnessChange?: (harness: "opencode" | "claude") => void; + onModelChange: (model: string) => void; + }) => ( + <> + + + {onHarnessChange && ( + <> + + + + )} + + ), +})); + +vi.mock("@/hooks/use-repos", () => ({ + useRepos: () => ({ repos: mocks.reposValue, loading: mocks.loadingReposValue }), +})); + +vi.mock("@/hooks/use-branches", () => ({ + useBranches: () => ({ branches: [{ name: "main" }], loading: false }), +})); + +vi.mock("@/hooks/use-enabled-models", () => ({ + useEnabledModels: () => ({ + enabledModels: mocks.enabledModelsValue, + enabledModelOptions: mocks.enabledModelOptionsValue, + loading: mocks.enabledModelsLoadingValue, + }), +})); + +vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ + useKeyboardShortcuts: () => ({ + shortcuts: mocks.keyboardShortcuts, + labels: { + "send-prompt": + mocks.keyboardShortcuts["send-prompt"].code === "KeyJ" ? "Alt+J" : "Cmd/Ctrl+Enter", + "open-command-menu": "Cmd/Ctrl+K", + "new-session": "Cmd/Ctrl+Shift+O", + "toggle-sidebar": "Cmd/Ctrl+/", + }, + }), +})); + +vi.mock("@/hooks/use-provider-accounts", () => ({ + useProviderAccounts: () => ({ + providers: [], + accounts: mocks.providerAccountsValue, + defaults: [], + loading: mocks.providerAccountsLoadingValue, + error: undefined, + refresh: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-managed-skills", () => ({ + useSkillProfiles: () => ({ profiles: [], loading: false }), + useSkillResolutionPreview: () => ({ + preview: mocks.skillPreview, + loading: false, + error: undefined, + suggestions: { status: "ready", skills: mocks.skillPreview.skills }, + }), +})); + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); + +beforeEach(() => { + mocks.reposValue = [repo]; + mocks.loadingReposValue = false; + mocks.environmentsLoadingValue = false; + mocks.environmentsValue = []; + mocks.enabledModelsValue = [DEFAULT_MODEL]; + mocks.enabledModelsLoadingValue = false; + mocks.enabledModelOptionsValue = [ + { + category: "Anthropic", + models: [{ id: DEFAULT_MODEL, name: "Claude Sonnet 4.6", description: "" }], + }, + ]; + mocks.providerAccountsValue = []; + mocks.providerAccountsLoadingValue = false; + mocks.keyboardShortcuts = DEFAULT_KEYBOARD_SHORTCUTS; + mocks.canCreateSession = true; + mocks.activeTeamId = null; + mocks.scope = undefined; + mocks.teams = []; + mocks.teamsLoading = false; + mocks.teamsError = undefined; + mocks.requireTeamOnCreate = false; + mocks.setActiveTeam.mockReset(); + mocks.routerPush.mockReset(); + mocks.toastError.mockReset(); + mocks.mutateMock.mockReset(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/sessions") { + return Response.json({ sessionId: "session-1", status: "created" }); + } + if (url === "/api/sessions/session-1/prompt") { + return Response.json({ ok: true }); + } + if (url === "/api/sessions/session-1/archive") { + return Response.json({ ok: true }); + } + return Response.json({ error: "unexpected request" }, { status: 500 }); + }) + ); +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +export function sessionCreateBody(): Record { + const calls = vi.mocked(globalThis.fetch).mock.calls; + const createCall = calls.find(([input]) => String(input) === "/api/sessions"); + expect(createCall).toBeDefined(); + return JSON.parse(String(createCall?.[1]?.body)) as Record; +} diff --git a/packages/web/src/app/(app)/(sidebar)/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/page.test.tsx index 7306ba44c6..d4864d07d8 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.test.tsx @@ -1,265 +1,15 @@ // @vitest-environment jsdom /// -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import * as matchers from "@testing-library/jest-dom/matchers"; import { DEFAULT_MODEL } from "@open-inspect/shared/models"; -import { - DEFAULT_KEYBOARD_SHORTCUTS, - type KeyboardShortcutPreferences, -} from "@open-inspect/shared/types/keyboard-shortcuts"; -import Home from "./page"; +import { DEFAULT_KEYBOARD_SHORTCUTS } from "@open-inspect/shared/types/keyboard-shortcuts"; import { isSessionInboxKey } from "@/lib/session-inbox-api"; import { isUnarchivedSessionListKey } from "@/lib/session-list"; - -expect.extend(matchers); - -const mocks = vi.hoisted(() => ({ - routerPush: vi.fn(), - mutateMock: vi.fn(), - reposValue: [] as Array<{ - id: number; - fullName: string; - owner: string; - name: string; - description: string | null; - private: boolean; - defaultBranch: string; - }>, - loadingReposValue: false, - environmentsLoadingValue: false, - environmentsValue: [] as Array<{ - id: string; - name: string; - description: string | null; - prebuildEnabled: boolean; - createdAt: number; - updatedAt: number; - repositories: Array<{ - repoOwner: string; - repoName: string; - repoId: number | null; - baseBranch: string; - }>; - }>, - enabledModelsValue: [] as string[], - enabledModelOptionsValue: [] as Array<{ - category: string; - models: Array<{ id: string; name: string; description: string }>; - }>, - providerAccountsValue: [] as Array<{ - id: string; - provider: "openai" | "xai" | "anthropic"; - displayName: string; - externalAccountId: string | null; - status: "active"; - createdBy: null; - updatedBy: null; - lastVerifiedAt: null; - lastUsedAt: null; - createdAt: number; - updatedAt: number; - archivedAt: null; - }>, - providerAccountsLoadingValue: false, - skillPreview: { - skills: [ - { - skillId: "skill-1", - revisionId: "revision-1", - name: "review-pr", - description: "Review a pull request", - revisionNumber: 1, - revisionSha256: "abc", - totalBytes: 10, - assignmentSources: [], - }, - ], - totalBytes: 10, - ignoredProfileSkillIds: [], - }, - keyboardShortcuts: null as unknown as KeyboardShortcutPreferences, - canCreateSession: true, -})); - -const repo = { - id: 1, - fullName: "open-inspect/background-agents", - owner: "open-inspect", - name: "background-agents", - description: null, - private: true, - defaultBranch: "main", -}; - -vi.mock("@/lib/auth-session", () => ({ - useAuthSession: () => ({ data: { user: { id: "user-1" } }, status: "authenticated" }), -})); - -vi.mock("@/hooks/use-current-user-authorization", () => ({ - useCurrentUserAuthorization: () => ({ - hasPermission: (permission: string) => - permission === "sessions.create" && mocks.canCreateSession, - }), -})); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mocks.routerPush }), -})); - -vi.mock("swr", () => ({ - // Home uses the default export only for the picker's prebuild-status text. - default: () => ({ data: undefined, isLoading: false }), - mutate: mocks.mutateMock, -})); - -vi.mock("@/hooks/use-environments", () => ({ - ENVIRONMENTS_KEY: "/api/environments", - useEnvironments: () => ({ - environments: mocks.environmentsValue, - loading: mocks.environmentsLoadingValue, - }), -})); - -vi.mock("@/components/sidebar-layout", () => ({ - useSidebarContext: () => ({ isOpen: true, toggle: vi.fn() }), -})); - -vi.mock("@/components/model-reasoning-selector", () => ({ - ModelReasoningSelector: ({ - disabled, - harness, - onHarnessChange, - }: { - disabled?: boolean; - harness?: string | null; - onHarnessChange?: (harness: "opencode" | "claude") => void; - }) => ( - <> - - {onHarnessChange && ( - <> - - - - )} - - ), -})); - -vi.mock("@/hooks/use-repos", () => ({ - useRepos: () => ({ repos: mocks.reposValue, loading: mocks.loadingReposValue }), -})); - -vi.mock("@/hooks/use-branches", () => ({ - useBranches: () => ({ branches: [{ name: "main" }], loading: false }), -})); - -vi.mock("@/hooks/use-enabled-models", () => ({ - useEnabledModels: () => ({ - enabledModels: mocks.enabledModelsValue, - enabledModelOptions: mocks.enabledModelOptionsValue, - loading: false, - }), -})); - -vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ - useKeyboardShortcuts: () => ({ - shortcuts: mocks.keyboardShortcuts, - labels: { - "send-prompt": - mocks.keyboardShortcuts["send-prompt"].code === "KeyJ" ? "Alt+J" : "Cmd/Ctrl+Enter", - "open-command-menu": "Cmd/Ctrl+K", - "new-session": "Cmd/Ctrl+Shift+O", - "toggle-sidebar": "Cmd/Ctrl+/", - }, - }), -})); - -vi.mock("@/hooks/use-provider-accounts", () => ({ - useProviderAccounts: () => ({ - providers: [], - accounts: mocks.providerAccountsValue, - defaults: [], - loading: mocks.providerAccountsLoadingValue, - error: undefined, - refresh: vi.fn(), - }), -})); - -vi.mock("@/hooks/use-managed-skills", () => ({ - useSkillProfiles: () => ({ profiles: [], loading: false }), - useSkillResolutionPreview: () => ({ - preview: mocks.skillPreview, - loading: false, - error: undefined, - suggestions: { status: "ready", skills: mocks.skillPreview.skills }, - }), -})); - -beforeAll(() => { - Element.prototype.scrollIntoView = vi.fn(); -}); - -beforeEach(() => { - mocks.reposValue = [repo]; - mocks.loadingReposValue = false; - mocks.environmentsLoadingValue = false; - mocks.environmentsValue = []; - mocks.enabledModelsValue = [DEFAULT_MODEL]; - mocks.enabledModelOptionsValue = [ - { - category: "Anthropic", - models: [{ id: DEFAULT_MODEL, name: "Claude Sonnet 4.6", description: "" }], - }, - ]; - mocks.providerAccountsValue = []; - mocks.providerAccountsLoadingValue = false; - mocks.keyboardShortcuts = DEFAULT_KEYBOARD_SHORTCUTS; - mocks.canCreateSession = true; - mocks.routerPush.mockReset(); - mocks.mutateMock.mockReset(); - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/sessions") { - return Response.json({ sessionId: "session-1", status: "created" }); - } - if (url === "/api/sessions/session-1/prompt") { - return Response.json({ ok: true }); - } - return Response.json({ error: "unexpected request" }, { status: 500 }); - }) - ); -}); - -afterEach(() => { - cleanup(); - localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -function sessionCreateBody(): Record { - const calls = vi.mocked(fetch).mock.calls; - const createCall = calls.find(([input]) => String(input) === "/api/sessions"); - expect(createCall).toBeDefined(); - return JSON.parse(String(createCall?.[1]?.body)) as Record; -} +import { environment, mocks, repo, sessionCreateBody } from "./page.test-fixture"; +import Home from "./page"; function activeAnthropicAccount(id: string): (typeof mocks.providerAccountsValue)[number] { return { ...activeOpenAiAccount(id), provider: "anthropic", displayName: "Owner Claude" }; @@ -283,6 +33,31 @@ function activeOpenAiAccount(id: string): (typeof mocks.providerAccountsValue)[n } describe("Home", () => { + it("shows the first prompt's server denial reason in a toast without navigating", async () => { + vi.mocked(fetch).mockImplementation(async (input) => + String(input).endsWith("/prompt") + ? Response.json( + { + error: "Forbidden", + code: "session_action_denied", + reason_code: "missing_permission", + }, + { status: 403 } + ) + : Response.json({ sessionId: "session-1", status: "created" }) + ); + render(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Ship it" }, + }); + await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/sessions", expect.anything())); + fireEvent.click(screen.getByRole("button", { name: /send/i })); + await waitFor(() => + expect(mocks.toastError).toHaveBeenCalledWith("Failed to send prompt (missing_permission)") + ); + expect(mocks.routerPush).not.toHaveBeenCalled(); + }); + it("does not render session creation UI without session creation permission", () => { mocks.canCreateSession = false; @@ -526,16 +301,6 @@ describe("Home", () => { expect(body).not.toHaveProperty("branch"); }); - const environment = { - id: "env-1", - name: "full-stack", - description: null, - prebuildEnabled: false, - createdAt: 1, - updatedAt: 1, - repositories: [{ repoOwner: "acme", repoName: "backend", repoId: 1, baseBranch: "main" }], - }; - it("persists an environment selection and restores it on the next visit", async () => { mocks.environmentsValue = [environment]; const user = userEvent.setup(); diff --git a/packages/web/src/app/(app)/(sidebar)/page.tsx b/packages/web/src/app/(app)/(sidebar)/page.tsx index fe6f3de02c..684003a337 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.tsx @@ -2,12 +2,15 @@ import { useAuthSession } from "@/lib/auth-session"; import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { toast } from "sonner"; +import { sessionActionErrorMessage } from "@/lib/session-action-error"; import { useRouter } from "next/navigation"; import { mutate } from "swr"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import Link from "next/link"; import { CollapsedSidebarControls, useSidebarContext } from "@/components/sidebar-layout"; import { ErrorBanner } from "@/components/ui/error-banner"; +import { SessionAccessSelector } from "@/components/session-access-selector"; import { matchesShortcut } from "@/lib/keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { isUnarchivedSessionListKey } from "@/lib/session-list"; @@ -65,6 +68,9 @@ import { ProviderAuthControls } from "@/components/provider-auth-controls"; import { useProviderAccounts } from "@/hooks/use-provider-accounts"; import { useWarmDraftSession, type WarmDraftSessionRequest } from "@/hooks/use-warm-draft-session"; import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useActiveTeam } from "@/hooks/use-active-team"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import { resolveComposerAccess, type ComposerAccessDraft } from "@/lib/composer-access"; import { buildInteractiveProviderRoutingIdentity, parseStoredProviderSelections, @@ -101,8 +107,24 @@ export default function Home() { const { hasPermission } = useCurrentUserAuthorization(); const canCreateSession = hasPermission("sessions.create"); const router = useRouter(); - const picker = useSessionTargetPicker(); + const teamContext = useActiveTeam(); + const { teams, requireTeamOnCreate, loading: loadingTeams, error: teamError } = teamContext; + const [accessDraft, setAccessDraft] = useState(null); + const { contextKey, teamId, visibility } = resolveComposerAccess(teamContext, accessDraft); + const selectedTeam = teams.find((team) => team.id === teamId); + const teamCreationReady = + !loadingTeams && !teamError && (teamId === null ? !requireTeamOnCreate : !!selectedTeam); + const picker = useSessionTargetPicker({ + teamId, + defaultEnvironmentId: selectedTeam?.defaultEnvironmentId, + }); const { sessionTarget, buildRequestFields, isLaunchable } = picker; + + // Composer context changes preserve the audience; sidebar changes use team defaults. + useEffect(() => { + if (teamContext.loading || teamContext.error) return; + setAccessDraft((draft) => (draft ? resolveComposerAccess(teamContext, draft) : null)); + }, [teamContext]); const [storedPreference, setStoredPreference] = useState({ model: DEFAULT_MODEL, reasoningEffort: getDefaultReasoningEffort(DEFAULT_MODEL), @@ -110,6 +132,7 @@ export default function Home() { const [modelPreferenceDraft, setModelPreferenceDraft] = useState(null); const [harness, setHarness] = useState(DEFAULT_HARNESS); const [prompt, setPrompt] = useState(""); + const [warmRequested, setWarmRequested] = useState(false); const [skillSelection, setSkillSelection] = useState({ mode: "all" }); const [providerSelections, setProviderSelections] = useState({}); const [providerSelectionsHydrated, setProviderSelectionsHydrated] = useState(false); @@ -221,6 +244,7 @@ export default function Home() { const warmRequest: WarmDraftSessionRequest | null = canCreateSession && + teamCreationReady && session && providerSelectionsHydrated && !providerAccounts.loading && @@ -234,6 +258,8 @@ export default function Home() { reasoningEffort, skillSelection, providerSelections: availableProviderSelections, + teamId, + visibility, } : null; const warmRoutingIdentity = buildInteractiveProviderRoutingIdentity( @@ -242,12 +268,20 @@ export default function Home() { providerAccounts.accounts ); const { + identity: warmIdentity, sessionId: pendingSessionId, isWarming: isCreatingSession, warm: createSessionForWarming, consume: consumeWarmSession, + error: creationError, } = useWarmDraftSession(warmRequest, warmRoutingIdentity); + useEffect(() => { + if (!warmRequested || !warmIdentity) return; + setWarmRequested(false); + void createSessionForWarming(); + }, [warmRequested, warmIdentity, createSessionForWarming]); + const saveModelPreferenceDraft = useCallback((preference: ModelPreference) => { setModelPreferenceDraft(preference); localStorage.setItem(LAST_SELECTED_MODEL_STORAGE_KEY, preference.model); @@ -286,32 +320,12 @@ export default function Home() { [availableProviderSelections] ); - const handlePromptChange = (value: string) => { - const wasEmpty = prompt.length === 0; - setPrompt(value); - if ( - wasEmpty && - value.length > 0 && - !pendingSessionId && - !isCreatingSession && - !loadingEnabledModels && - isLaunchable - ) { - createSessionForWarming(); - } - }; - - const handleAddFiles = (files: Iterable) => { - sessionAttachments.addFiles(files); - if (!pendingSessionId && !isCreatingSession && isLaunchable) { - createSessionForWarming(); - } - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if ( !canCreateSession || + !teamCreationReady || + creationError?.terminal || submitInFlightRef.current || sessionAttachments.isUploading || !providerSelectionsHydrated || @@ -377,8 +391,14 @@ export default function Home() { mutate(isSessionInboxKey); router.push(`/session/${sessionId}`); } else { - const data = await res.json(); - setError(data.error || "Failed to send prompt"); + if (res.status === 403) { + const message = await sessionActionErrorMessage(res, "Failed to send prompt"); + toast.error(message); + setError(message); + } else { + const data = await res.json(); + setError(data.error || "Failed to send prompt"); + } setCreating(false); } } catch (_error) { @@ -394,6 +414,21 @@ export default function Home() { isAuthenticated={!!session} canCreateSession={canCreateSession} picker={picker} + teamContext={teamContext} + teamId={teamId} + teamCreationReady={teamCreationReady} + visibility={visibility} + onTeamChange={(teamId) => { + setAccessDraft({ contextKey, teamId, visibility }); + }} + onVisibilityChange={(value) => { + if (teamId !== null || value !== "team") + setAccessDraft({ + contextKey, + teamId, + visibility: value, + }); + }} selectedModel={selectedModel} setSelectedModel={handleModelChange} reasoningEffort={reasoningEffort} @@ -401,18 +436,21 @@ export default function Home() { harness={harness} setHarness={handleHarnessChange} prompt={prompt} - handlePromptChange={handlePromptChange} + handlePromptChange={(value) => { + setPrompt(value); + setWarmRequested(value.length > 0); + }} attachments={{ items: sessionAttachments.attachments, error: sessionAttachments.attachmentError, isUploading: sessionAttachments.isUploading, - onAdd: handleAddFiles, + onAdd: sessionAttachments.addFiles, onRemove: sessionAttachments.removeAttachment, }} creating={creating} isCreatingSession={isCreatingSession} providerSelectionsHydrated={providerSelectionsHydrated} - error={error} + error={creationError?.message ?? error} handleSubmit={handleSubmit} modelOptions={modelSelection.options} skillSelection={skillSelection} @@ -432,6 +470,12 @@ function HomeContent({ isAuthenticated, canCreateSession, picker, + teamContext, + teamId, + teamCreationReady, + visibility, + onTeamChange, + onVisibilityChange, selectedModel, setSelectedModel, reasoningEffort, @@ -460,6 +504,12 @@ function HomeContent({ isAuthenticated: boolean; canCreateSession: boolean; picker: SessionTargetSelection; + teamContext: ReturnType; + teamId: string | null; + teamCreationReady: boolean; + visibility: SessionVisibility; + onTeamChange: (teamId: string | null) => void; + onVisibilityChange: (value: SessionVisibility) => void; selectedModel: ValidModel; setSelectedModel: (value: ValidModel) => void; reasoningEffort: ReasoningEffort | undefined; @@ -507,7 +557,7 @@ function HomeContent({ handleDragOver, handleDragLeave, } = useAttachmentDropZone({ locked: attachmentsLocked, onAdd: attachments.onAdd }); - const { sessionTarget, selectedRepo, repos, loadingRepos, isLaunchable } = picker; + const { sessionTarget, repos, loadingRepos, isLaunchable } = picker; const selectedProvider = getSubscriptionProviderForModel(selectedModel); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -552,6 +602,17 @@ function HomeContent({ {isAuthenticated && canCreateSession && (
{error && {error}} + {teamContext.error ? ( + + Unable to load team memberships and settings. + + ) : !teamContext.loading && + teamContext.requireTeamOnCreate && + teamContext.teams.length === 0 ? ( +

+ Join a team to create a session. +

+ ) : null}
@@ -618,7 +679,8 @@ function HomeContent({ attachmentsLocked || !providerSelectionsHydrated || providerAccounts.loading || - !isLaunchable + !isLaunchable || + !teamCreationReady } className="p-2 text-secondary-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition" title={`Send (${labels["send-prompt"]})`} @@ -633,7 +695,7 @@ function HomeContent({
- {/* Footer row with session controls */} + {/* Agent configuration stays inside the composer. */}
+ + {/* Secrets disclosure per session target (design §7.4) */} {sessionTarget?.kind === "environment" && (

@@ -694,17 +767,6 @@ function HomeContent({

)} - {selectedRepo && ( -
- - Manage secrets and settings - -
- )} - {repos.length === 0 && !loadingRepos && (

No repositories found. You can start without a repository or grant repository diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx new file mode 100644 index 0000000000..6b095c037f --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom + +import { Component, type PropsWithChildren, type ReactNode } from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; +import { resolveSessionCapabilities } from "@/lib/session-capabilities"; +import SessionPage from "./page"; + +const mocks = vi.hoisted(() => ({ + socket: vi.fn(), + prompt: vi.fn(), + refreshSnapshot: vi.fn(), + actionBar: vi.fn(), + header: vi.fn(), + sidebar: vi.fn(), + overlay: vi.fn(), + composer: vi.fn(), + snapshot: null as SessionSnapshot | null, + mobile: false, +})); +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + }, + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("./session-snapshot-provider", () => ({ + useSessionSnapshot: () => mocks.snapshot, + useRefreshSessionSnapshot: () => mocks.refreshSnapshot, +})); +vi.mock("@/hooks/use-session-socket", () => ({ useSessionSocket: mocks.socket })); +vi.mock("@/hooks/use-prompt-input", () => ({ usePromptInput: mocks.prompt })); +vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ + useKeyboardShortcuts: () => ({ shortcuts: {} }), +})); +vi.mock("@/hooks/use-current-user-authorization", () => ({ + useCurrentUserAuthorization: () => ({ hasPermission: () => true }), +})); +vi.mock("@/hooks/use-mark-session-read", () => ({ useMarkSessionRead: vi.fn() })); +vi.mock("@/hooks/use-session-participant-profiles", () => ({ + useSessionParticipantProfiles: () => ({ profiles: new Map(), participants: [] }), +})); +vi.mock("@/hooks/use-session-skills", () => ({ + useSessionSkills: () => ({ suggestions: [] }), +})); +vi.mock("@/hooks/use-session-rename", () => ({ + useSessionRename: () => ({ optimisticTitle: null, renameSession: vi.fn() }), +})); +vi.mock("@/hooks/use-enabled-models", () => ({ + useEnabledModels: () => ({ enabledModels: [], enabledModelOptions: [], loading: false }), +})); +vi.mock("@/hooks/use-session-diffs", () => ({ + useSessionDiffs: () => ({ state: null, isLoading: false }), +})); +vi.mock("@/hooks/use-media-query", () => ({ useMediaQuery: () => mocks.mobile })); +vi.mock("@/hooks/use-session-details-sidebar", () => ({ + useSessionDetailsSidebar: () => ({ isOpen: true, toggle: vi.fn() }), +})); +vi.mock("react-resizable-panels", () => ({ + Group: ({ children }: PropsWithChildren) =>

{children}
, + Panel: ({ children }: PropsWithChildren) =>
{children}
, + Separator: () => null, +})); +vi.mock("@/components/action-bar", () => ({ ActionBar: mocks.actionBar })); +vi.mock("@/components/session-header", () => ({ SessionHeader: mocks.header })); +vi.mock("@/components/session-right-sidebar", () => ({ SessionRightSidebar: mocks.sidebar })); +vi.mock("@/components/session-details-overlay", () => ({ SessionDetailsOverlay: mocks.overlay })); +vi.mock("@/components/session-prompt-composer", () => ({ SessionPromptComposer: mocks.composer })); +vi.mock("@/components/session-timeline", () => ({ SessionTimeline: () => null })); +vi.mock("@/components/media-lightbox", () => ({ MediaLightbox: () => null })); +vi.mock("@/components/queued-prompt-stack", () => ({ QueuedPromptStack: () => null })); +vi.mock("@/components/session-desktop-layout", () => ({ + SessionDesktopLayout: ({ workspace, sidebar }: { workspace: ReactNode; sidebar: ReactNode }) => ( + <> + {workspace} + {sidebar} + + ), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.mobile = false; + mocks.snapshot = { + session: { + id: "session-1", + title: "Cached secret", + harness: "opencode", + repoOwner: null, + repoName: null, + baseBranch: null, + branchName: null, + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + ownerTeamId: "team_design", + ownerUserId: "user_owner", + visibility: "private", + collaborators: ["user_collaborator"], + capabilities: { + canRead: true, + canCollaborate: false, + canManageLifecycle: false, + canDelete: false, + canManageCollaborators: true, + canChangeVisibility: true, + canSandbox: false, + }, + }, + artifacts: [], + timeline: { events: [], hasMore: false, cursor: null }, + promptQueue: [], + }; + mocks.socket.mockReturnValue({ + sessionGone: false, + sessionState: mocks.snapshot.session, + capabilities: resolveSessionCapabilities(mocks.snapshot.session.capabilities, true), + events: [], + participants: [], + artifacts: [], + promptQueue: [], + canManageBudget: false, + }); + mocks.prompt.mockReturnValue({ + sessionAttachments: { isUploading: false, attachments: [] }, + inputRef: { current: null }, + }); + mocks.refreshSnapshot.mockResolvedValue(undefined); + for (const component of [ + mocks.actionBar, + mocks.header, + mocks.sidebar, + mocks.overlay, + mocks.composer, + ]) { + component.mockReturnValue(null); + } +}); + +class NotFoundBoundary extends Component { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + if (this.state.error?.message === "NEXT_NOT_FOUND") return

404 Not Found

; + if (this.state.error) return

Generic unavailable

; + return this.props.children; + } +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("renders the existing not-found path before cached session content or action hooks", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.socket.mockReturnValue({ sessionGone: true }); + render( + + + + ); + expect(screen.queryByText("404 Not Found")).not.toBeNull(); + expect(screen.queryByText("Cached secret")).toBeNull(); + expect(screen.queryByText("Generic unavailable")).toBeNull(); + expect(screen.queryByRole("button", { name: "Reconnect" })).toBeNull(); + expect(mocks.prompt).not.toHaveBeenCalled(); +}); + +it("keeps desktop actions available without collaboration and refreshes sidebar and overlay scope", () => { + const { rerender } = render(); + expect(mocks.composer).not.toHaveBeenCalled(); + expect(mocks.actionBar.mock.lastCall?.[0]).toMatchObject({ + capabilities: { collaborate: false, changeVisibility: true, manageCollaborators: true }, + }); + expect(mocks.sidebar.mock.lastCall?.[0].scope.ownerTeamId).toBe("team_design"); + + mocks.snapshot = { + ...mocks.snapshot!, + session: { + ...mocks.snapshot!.session, + visibility: "team", + collaborators: [], + }, + }; + mocks.mobile = true; + rerender(); + for (const scope of [ + mocks.sidebar.mock.lastCall?.[0].scope, + mocks.overlay.mock.lastCall?.[0].scope, + ]) { + expect(scope).toMatchObject({ + ownerTeamId: "team_design", + visibility: "team", + collaborators: [], + onUpdated: mocks.refreshSnapshot, + }); + } +}); diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx index a6e9574582..a9e6b68ccc 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter } from "next/navigation"; +import { notFound, useRouter } from "next/navigation"; import { mutate } from "swr"; import useSWRMutation from "swr/mutation"; import { useState, useRef, useEffect, useCallback, useMemo } from "react"; @@ -11,17 +11,15 @@ import { MediaLightbox } from "@/components/media-lightbox"; import { SessionHeader } from "@/components/session-header"; import { SessionDetailsOverlay } from "@/components/session-details-overlay"; import { SessionPromptComposer } from "@/components/session-prompt-composer"; +import { ActionBar } from "@/components/action-bar"; import { QueuedPromptStack } from "@/components/queued-prompt-stack"; import { SessionRightSidebar } from "@/components/session-right-sidebar"; -import { - Group as PanelGroup, - Panel, - Separator as PanelResizeHandle, - useDefaultLayout, -} from "react-resizable-panels"; +import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels"; import { TerminalPanel } from "@/components/terminal-panel"; import { archiveSession } from "@/lib/archive-session"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; +import { sessionActionErrorMessage } from "@/lib/session-action-error"; +import { toast } from "sonner"; import { isArchivedSessionListKey, isUnarchivedSessionListKey, @@ -40,31 +38,27 @@ import type { HarnessId } from "@open-inspect/shared/harnesses"; import { resolveHarnessModelSelection } from "@/lib/session-harness"; import { useEnabledModels } from "@/hooks/use-enabled-models"; import { useSessionDiffs } from "@/hooks/use-session-diffs"; -import { resolveDiffSelection, type DiffSelection } from "@/lib/session-diffs"; +import { resolveDiffSelection } from "@/lib/session-diffs"; import { SessionFileLinksProvider } from "@/lib/session-file-links"; import type { SessionDiffFile, SessionDiffRepository, } from "@open-inspect/shared/types/session-diffs"; import { SessionChangesPanel } from "@/components/session-changes-panel"; -import { - SESSION_CHANGES_LAYOUT_ID, - SessionDesktopLayout, -} from "@/components/session-desktop-layout"; +import { SessionDesktopLayout } from "@/components/session-desktop-layout"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; -import { useBrowserLayoutStorage } from "@/hooks/use-browser-layout-storage"; import { focusSessionDetailsTrigger } from "@/lib/session-details-focus"; import { useSessionParticipantProfiles } from "@/hooks/use-session-participant-profiles"; import { useSessionDetailsSidebar } from "@/hooks/use-session-details-sidebar"; +import { useSessionDiffSelection } from "@/hooks/use-session-diff-selection"; +import { useSessionInspectorTab } from "@/hooks/use-session-inspector-tab"; import { findLatestTerminalMessageId } from "@/lib/session-read-state"; import { useMarkSessionRead } from "@/hooks/use-mark-session-read"; import { usePromptInput } from "@/hooks/use-prompt-input"; import { formatSessionCost } from "@/lib/session-cost"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; -import { useSessionSnapshot } from "./session-snapshot-provider"; +import { useSessionSnapshot, useRefreshSessionSnapshot } from "./session-snapshot-provider"; import { useSessionRename } from "@/hooks/use-session-rename"; -import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; -import { resolveSessionCapabilities } from "@/lib/session-capabilities"; import { SandboxShutdownBanner } from "@/components/sandbox-shutdown-banner"; import { sandboxPromptBlockReason } from "@open-inspect/shared/types/sandbox-shutdown"; @@ -74,12 +68,34 @@ const TERMINAL_VISIBLE_STORAGE_KEY = "terminal-visible"; const DEFAULT_SESSION_STATUS = "created" as const; export default function SessionPage() { - const { shortcuts } = useKeyboardShortcuts(); - const { hasPermission } = useCurrentUserAuthorization(); - const capabilities = useMemo(() => resolveSessionCapabilities(hasPermission), [hasPermission]); const initialSnapshot = useSessionSnapshot(); + const socket = useSessionSocket(initialSnapshot.session.id, initialSnapshot); + if (socket.sessionGone) notFound(); + return ; +} + +function SessionContent({ + initialSnapshot, + socket, +}: { + initialSnapshot: ReturnType; + socket: ReturnType; +}) { + const { shortcuts } = useKeyboardShortcuts(); + const refreshSnapshot = useRefreshSessionSnapshot(); const sessionId = initialSnapshot.session.id; + const scope = + initialSnapshot.session.visibility === undefined + ? undefined + : { + ownerTeamId: initialSnapshot.session.ownerTeamId ?? null, + ownerUserId: initialSnapshot.session.ownerUserId ?? null, + visibility: initialSnapshot.session.visibility, + collaborators: initialSnapshot.session.collaborators ?? [], + onUpdated: refreshSnapshot, + }; const { + capabilities, connected, connecting, reconnecting, @@ -104,7 +120,7 @@ export default function SessionPage() { sendTyping, reconnect, loadOlderEvents, - } = useSessionSocket(sessionId, initialSnapshot, capabilities); + } = socket; const latestTerminalMessageId = useMemo(() => findLatestTerminalMessageId(events), [events]); useMarkSessionRead(sessionId, latestTerminalMessageId); const { profiles, participants: profiledParticipants } = useSessionParticipantProfiles( @@ -195,8 +211,6 @@ export default function SessionPage() { ); const [selectedMediaArtifactId, setSelectedMediaArtifactId] = useState(null); - const [selectedDiff, setSelectedDiff] = useState(null); - const diffReturnFocusRef = useRef(null); const { state: diffState, isLoading: diffLoading } = useSessionDiffs(sessionId); const isBelowLg = useMediaQuery("(max-width: 1023px)"); @@ -206,6 +220,12 @@ export default function SessionPage() { const { isOpen: isDesktopDetailsOpen, toggle: toggleDesktopDetails } = useSessionDetailsSidebar(); const detailsButtonRef = useRef(null); const actionsButtonRef = useRef(null); + const desktopDetailsButtonRef = useRef(null); + const { + tab: inspectorTab, + selectTab: selectInspectorTab, + showTab: showInspectorTab, + } = useSessionInspectorTab(); // Terminal panel state. Starts closed so the server and the client render the // same markup, then adopts the stored preference after hydration. @@ -251,6 +271,22 @@ export default function SessionPage() { () => focusSessionDetailsTrigger(isPhone, actionsButtonRef.current, detailsButtonRef.current), [isPhone] ); + const handleDiffOpen = useCallback(() => { + setIsDetailsOpen(false); + // The diff is navigated from the Changes list. Showing it is navigation, so the + // viewer's remembered tab stays as it was. + showInspectorTab("changes"); + }, [showInspectorTab]); + const focusAfterDiff = useCallback(() => { + if (isBelowLg) focusDetailsTrigger(); + else desktopDetailsButtonRef.current?.focus(); + }, [focusDetailsTrigger, isBelowLg]); + const { + selectedDiff, + openDiff: openDiffSelection, + selectDiff, + closeDiff, + } = useSessionDiffSelection({ onOpen: handleDiffOpen, focusFallback: focusAfterDiff }); useEffect(() => { if (isBelowLg) return; @@ -279,46 +315,11 @@ export default function SessionPage() { : null, [diffState, selectedDiff] ); - const changesLayoutStorage = useBrowserLayoutStorage(); - const changesLayout = useDefaultLayout({ - id: SESSION_CHANGES_LAYOUT_ID, - panelIds: - resolvedDiff && diffState && !isBelowLg - ? ["session-main", "session-changes"] - : ["session-main"], - storage: changesLayoutStorage, - }); - const openDiffSelection = useCallback((selection: DiffSelection) => { - diffReturnFocusRef.current = selection; - setSelectedDiff(selection); - setIsDetailsOpen(false); - }, []); const openDiff = useCallback( (repository: SessionDiffRepository, file: SessionDiffFile) => openDiffSelection({ repositoryPosition: repository.position, path: file.path }), [openDiffSelection] ); - const closeDiff = useCallback(() => { - const returnSelection = diffReturnFocusRef.current; - setSelectedDiff(null); - requestAnimationFrame(() => { - if (!isBelowLg && returnSelection) { - const row = Array.from( - document.querySelectorAll("button[data-diff-path]") - ).find( - (candidate) => - candidate.dataset.diffRepositoryPosition === - String(returnSelection.repositoryPosition) && - candidate.dataset.diffPath === returnSelection.path - ); - if (row) { - row.focus(); - return; - } - } - focusDetailsTrigger(); - }); - }, [focusDetailsTrigger, isBelowLg]); const sessionWorkspace = (
@@ -362,6 +363,19 @@ export default function SessionPage() { onRemove={handleRemoveQueuedPrompt} capabilities={capabilities} /> + {!capabilities.collaborate && capabilities.read && ( +
+ +
+ )} {capabilities.collaborate && ( } changes={ @@ -504,13 +521,12 @@ export default function SessionPage() { state={diffState} resolved={resolvedDiff} onClose={closeDiff} - onSelect={setSelectedDiff} + onSelect={selectDiff} + sidebarShowsFileList={isDesktopDetailsOpen && inspectorTab === "changes"} capabilities={capabilities} /> ) : null } - defaultLayout={changesLayout.defaultLayout} - onLayoutChanged={changesLayout.onLayoutChanged} /> ) : ( <> @@ -530,7 +546,10 @@ export default function SessionPage() { selectedDiff={selectedDiff} onOpenDiff={openDiff} canManageBudget={canManageBudget} + activeTab={inspectorTab} + onTabChange={selectInspectorTab} capabilities={capabilities} + scope={scope} /> )} @@ -556,7 +575,10 @@ export default function SessionPage() { selectedDiff={selectedDiff} onOpenDiff={openDiff} canManageBudget={canManageBudget} + activeTab={inspectorTab} + onTabChange={selectInspectorTab} capabilities={capabilities} + scope={scope} /> )} @@ -574,7 +596,7 @@ export default function SessionPage() { state={diffState} resolved={resolvedDiff} onClose={closeDiff} - onSelect={setSelectedDiff} + onSelect={selectDiff} capabilities={capabilities} /> )} @@ -632,10 +654,10 @@ function useSessionListActions(sessionId: string) { ); mutate(isUnarchivedSessionListKey); } else { - console.error("Failed to unarchive session"); + toast.error(await sessionActionErrorMessage(r, "Failed to unarchive session")); } }), - { throwOnError: false } + { throwOnError: false, onError: () => toast.error("Failed to unarchive session") } ); return { handleArchive, handleUnarchive }; diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.test.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.test.tsx new file mode 100644 index 0000000000..6c68eec3c4 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.test.tsx @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { SWRConfig } from "swr"; +import { Component, type ReactNode } from "react"; +import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { + SessionSnapshotProvider, + useSessionSnapshot, + useRefreshSessionSnapshot, +} from "./session-snapshot-provider"; + +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ data: { user: { id: "user_actor" } } }), +})); +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + }, + redirect: () => { + throw new Error("NEXT_REDIRECT"); + }, +})); + +const snapshot: SessionSnapshot = { + session: { + id: "session-1", + title: null, + repoOwner: null, + repoName: null, + baseBranch: null, + branchName: null, + status: "active", + sandboxStatus: "ready", + messageCount: 0, + createdAt: 1, + harness: "opencode", + ownerTeamId: "team_old", + visibility: "private", + collaborators: [], + capabilities: { + canRead: true, + canCollaborate: true, + canManageLifecycle: true, + canDelete: true, + canSandbox: true, + canManageCollaborators: true, + canChangeVisibility: true, + }, + }, + artifacts: [], + timeline: { events: [], hasMore: false, cursor: null }, + promptQueue: [], +}; + +function Probe() { + const current = useSessionSnapshot(); + const refresh = useRefreshSessionSnapshot(); + return ( + <> +

+ {current.session.ownerTeamId} / {current.session.visibility} +

+

{current.session.collaborators?.join(",")}

+ + + + ); +} + +class Boundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + render() { + return this.state.error ?

{this.state.error.message}

: this.props.children; + } +} + +beforeEach(() => vi.resetAllMocks()); +afterEach(() => cleanup()); + +describe("SessionSnapshotProvider", () => { + it("renders not-found instead of session content when a refreshed deep link is invisible", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ error: "Session not found" }, { status: 404 }) + ); + render( + + + + + + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.getByText("NEXT_NOT_FOUND")).toBeInTheDocument()); + expect(screen.queryByText("team_old / private")).not.toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); + it("prefers a new SSR snapshot to previously refreshed data", async () => { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ + ...snapshot, + session: { ...snapshot.session, visibility: "team" }, + }) + ); + const { rerender } = render( + + + , + { + wrapper: ({ children }) => ( + new Map() }}>{children} + ), + } + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.getByText("team_old / team")).toBeInTheDocument()); + const incoming = { + ...snapshot, + session: { ...snapshot.session, visibility: "workspace" as const }, + }; + rerender( + + + + ); + expect(screen.getByText("team_old / workspace")).toBeInTheDocument(); + }); + it("renders the SSR snapshot without an extra initial request", () => { + render( + new Map() }}> + + + + + ); + expect(screen.getByText("team_old / private")).toBeInTheDocument(); + expect(browserApiFetch).not.toHaveBeenCalled(); + }); + + it("refreshes scope, collaborators and capabilities even when the timestamp is unchanged", async () => { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ + ...snapshot, + session: { + ...snapshot.session, + visibility: "team", + collaborators: ["user_added"], + capabilities: { ...snapshot.session.capabilities, canChangeVisibility: false }, + }, + }) + ); + render( + new Map(), dedupingInterval: 0 }}> + + + + + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await waitFor(() => expect(screen.getByText("team_old / team")).toBeInTheDocument()); + expect(screen.getByText("user_added")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Change visibility" })).toBeDisabled(); + expect(browserApiFetch).toHaveBeenCalledWith("/api/sessions/session-1"); + }); +}); diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx index f2f23f6779..bde73aa8ff 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/session-snapshot-provider.tsx @@ -1,9 +1,23 @@ "use client"; -import { createContext, useContext } from "react"; -import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; +import { createContext, useContext, useState } from "react"; +import { notFound, redirect } from "next/navigation"; +import { + sessionSnapshotSchema, + type SessionSnapshot, +} from "@open-inspect/shared/types/server-messages"; +import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; -const SessionSnapshotContext = createContext(null); +const SessionSnapshotContext = createContext<{ + snapshot: SessionSnapshot; + refresh: () => Promise; +} | null>(null); + +class SnapshotRequestError extends Error { + constructor(readonly status: number) { + super(`Unable to refresh session (${status})`); + } +} export function SessionSnapshotProvider({ snapshot, @@ -12,13 +26,49 @@ export function SessionSnapshotProvider({ snapshot: SessionSnapshot; children: React.ReactNode; }) { + const [state, setState] = useState<{ + source: SessionSnapshot; + current: SessionSnapshot; + error: unknown; + }>({ source: snapshot, current: snapshot, error: null }); + if (state.source !== snapshot) setState({ source: snapshot, current: snapshot, error: null }); + const path: BrowserApiPath = `/api/sessions/${encodeURIComponent(snapshot.session.id)}`; + + async function refresh() { + try { + const response = await browserApiFetch(path); + if (!response.ok) throw new SnapshotRequestError(response.status); + const current = sessionSnapshotSchema.parse(await response.json()); + setState({ source: snapshot, current, error: null }); + } catch (error) { + setState((current) => ({ ...current, error })); + throw error; + } + } + + if (state.error instanceof SnapshotRequestError) { + if (state.error.status === 404) notFound(); + if (state.error.status === 401) redirect("/login"); + } + const current = state.source === snapshot ? state.current : snapshot; + const value = state.error + ? { ...current, session: { ...current.session, capabilities: undefined } } + : current; return ( - {children} + + {children} + ); } export function useSessionSnapshot(): SessionSnapshot { - const snapshot = useContext(SessionSnapshotContext); - if (!snapshot) throw new Error("Session snapshot provider is missing"); - return snapshot; + const context = useContext(SessionSnapshotContext); + if (!context) throw new Error("Session snapshot provider is missing"); + return context.snapshot; +} + +export function useRefreshSessionSnapshot(): () => Promise { + const context = useContext(SessionSnapshotContext); + if (!context) throw new Error("Session snapshot provider is missing"); + return context.refresh; } diff --git a/packages/web/src/app/(app)/(sidebar)/sessions/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/sessions/page.test.tsx index 5b95bbf2c8..5302d66ea9 100644 --- a/packages/web/src/app/(app)/(sidebar)/sessions/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/sessions/page.test.tsx @@ -5,6 +5,9 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionListItem } from "@/lib/session-list"; +import type * as ActiveTeam from "@/hooks/use-active-team"; +import { ActiveTeamProvider } from "@/hooks/use-active-team"; +import { DEFAULT_SESSION_DISCOVERY_QUERY } from "@/lib/session-discovery"; import SessionsPage from "./page"; expect.extend(matchers); @@ -18,6 +21,54 @@ const { mockReplace, mockUseSessionDiscovery, mockSearchParamsState, mockPermiss }) ); +const teamMocks = vi.hoisted(() => ({ + activeTeamId: "all-my-teams" as string | null, + loading: false, + error: undefined as unknown, + setActiveTeam: vi.fn(), + roleKey: "member", + realContext: false, +})); + +vi.mock("@/hooks/use-teams", () => ({ + useMeTeams: () => ({ + teams: [ + { id: "team_alpha", name: "Alpha", role: "member", archivedAt: null }, + { id: "team_beta", name: "Beta", role: "lead", archivedAt: null }, + ], + loading: teamMocks.loading, + error: teamMocks.error, + requireTeamOnCreate: false, + }), +})); + +vi.mock("@/hooks/use-active-team", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useActiveTeam: () => + teamMocks.realContext + ? original.useActiveTeam() + : { + ...teamMocks, + activeTeamId: teamMocks.activeTeamId?.startsWith("team_") + ? teamMocks.activeTeamId + : null, + scope: + teamMocks.activeTeamId === null + ? "workspace" + : teamMocks.activeTeamId === "all-teams" + ? "all" + : undefined, + teams: [ + { id: "team_alpha", name: "Alpha", role: "member" }, + { id: "team_beta", name: "Beta", role: "lead" }, + ], + requireTeamOnCreate: false, + }, + }; +}); + vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mockReplace }), useSearchParams: () => mockSearchParamsState.value, @@ -59,6 +110,7 @@ vi.mock("@/hooks/use-session-discovery", () => ({ vi.mock("@/hooks/use-current-user-authorization", () => ({ useCurrentUserAuthorization: () => ({ loading: false, + authorization: { role: { key: teamMocks.roleKey } }, hasPermission: (permission: string) => mockPermissions.has(permission), }), })); @@ -170,6 +222,13 @@ describe("SessionsPage", () => { mockPermissions.add("repositories.read"); mockUseSessionDiscovery.mockReset(); mockUseSessionDiscovery.mockReturnValue(defaultHookResult); + teamMocks.activeTeamId = "all-my-teams"; + teamMocks.loading = false; + teamMocks.error = undefined; + teamMocks.roleKey = "member"; + teamMocks.setActiveTeam.mockReset(); + teamMocks.realContext = false; + localStorage.clear(); }); afterEach(() => { @@ -244,6 +303,7 @@ describe("SessionsPage", () => { expect(screen.getByRole("button", { name: "Load more sessions" })).toBeInTheDocument(); expect(lastQuery()).toEqual({ + ...DEFAULT_SESSION_DISCOVERY_QUERY, q: "", creator: "all", repository: null, @@ -278,6 +338,7 @@ describe("SessionsPage", () => { const rows = within(screen.getByRole("list", { name: "Sessions" })).getAllByRole("link"); expect(rows[0]).toHaveTextContent("Archived"); expect(lastQuery()).toEqual({ + ...DEFAULT_SESSION_DISCOVERY_QUERY, q: "login", creator: "mine", repository: { repoOwner: "partner", repoName: "sdk" }, @@ -496,4 +557,283 @@ describe("SessionsPage", () => { expect(mockReplace).toHaveBeenCalledTimes(1); expect(mockReplace).toHaveBeenLastCalledWith("/sessions", { scroll: false }); }); + + it.each([ + [null, undefined, "workspace", "Workspace"], + ["team_alpha", ["team_alpha"], undefined, "Alpha"], + ["all-my-teams", undefined, undefined, "All my teams"], + ["all-teams", undefined, "all", "All teams"], + ])( + "inherits active context %s when the URL has no team filter", + (activeTeamId, teamIds, scope, label) => { + teamMocks.activeTeamId = activeTeamId; + teamMocks.roleKey = "administrator"; + mockSearchParamsState.value = new URLSearchParams( + "q=login&ownerFilter=started&visibility=team" + ); + render(); + + expect(lastQuery()).toMatchObject({ + q: "login", + teamIds, + scope, + ownerFilter: "started", + visibility: "team", + }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent(label!); + expect(mockReplace).not.toHaveBeenCalled(); + } + ); + + it("keeps an explicit unknown team URL authoritative without widening", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams( + "teamIds[]=team_missing&ownerFilter=participating&visibility=private&createdBy=me" + ); + render(); + + expect(lastQuery()).toMatchObject({ + teamIds: ["team_missing"], + scope: undefined, + creator: "mine", + ownerFilter: "participating", + visibility: "private", + }); + expect(lastOptions()).toEqual({ enabled: true }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("team_missing"); + expect(screen.getByRole("combobox", { name: "Owner" })).toHaveTextContent("Participating"); + expect(screen.getByRole("combobox", { name: "Visibility" })).toHaveTextContent("Private"); + expect(teamMocks.setActiveTeam).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("preserves repeated selected teams from a link", () => { + mockSearchParamsState.value = new URLSearchParams( + "teamIds[]=team_alpha&teamIds[]=team_missing" + ); + render(); + expect(lastQuery()).toMatchObject({ teamIds: ["team_alpha", "team_missing"] }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Alpha, team_missing"); + expect(lastOptions()).toEqual({ enabled: true }); + }); + + it.each(["workspace", "all"])("honors explicit scope=%s over the active team", (scope) => { + teamMocks.activeTeamId = "team_alpha"; + teamMocks.roleKey = "administrator"; + mockSearchParamsState.value = new URLSearchParams({ scope }); + render(); + expect(lastQuery()).toMatchObject({ scope, teamIds: undefined }); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("updates team context on switcher changes while preserving filters and pending search", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams( + "teamIds[]=team_missing&createdBy=me&lifecycle=archived&origin=automation&ownerFilter=participating&visibility=private" + ); + const { rerender } = render(); + typeSearch("pending login"); + teamMocks.activeTeamId = "team_beta"; + rerender(); + + const href = mockReplace.mock.calls.at(-1)?.[0]; + expect(href).toBeDefined(); + const params = new URL(href, "https://example.com").searchParams; + expect(params.getAll("teamIds[]")).toEqual(["team_beta"]); + expect(params.get("scope")).toBeNull(); + expect(params.get("q")).toBe("pending login"); + expect(params.get("createdBy")).toBe("me"); + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("visibility")).toBe("private"); + expect(params.get("lifecycle")).toBe("archived"); + expect(params.get("origin")).toBe("automation"); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Beta"); + act(() => vi.runOnlyPendingTimers()); + expect( + new URL(mockReplace.mock.calls.at(-1)![0], "https://example.com").searchParams.getAll( + "teamIds[]" + ) + ).toEqual(["team_beta"]); + }); + + it("preserves in-flight filter changes when switching an inherited team context", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams("q=login"); + const { rerender } = render(); + chooseOption(screen.getByRole("combobox", { name: "Owner" }), "Participating"); + teamMocks.activeTeamId = "team_beta"; + rerender(); + + const params = new URL(mockReplace.mock.calls.at(-1)![0], "https://example.com").searchParams; + expect(params.getAll("teamIds[]")).toEqual(["team_beta"]); + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("q")).toBe("login"); + expect(screen.getByRole("combobox", { name: "Owner" })).toHaveTextContent("Participating"); + }); + + it("does not overwrite an explicit link when initial active-team loading settles", () => { + teamMocks.activeTeamId = null; + teamMocks.loading = true; + mockSearchParamsState.value = new URLSearchParams("teamIds[]=team_missing"); + const { rerender } = render(); + teamMocks.activeTeamId = "team_alpha"; + teamMocks.loading = false; + rerender(); + + expect(lastQuery()).toMatchObject({ teamIds: ["team_missing"] }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("team_missing"); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("does not treat the inherited context alone as a clearable filter", () => { + teamMocks.activeTeamId = "team_alpha"; + render(); + expect(screen.queryByRole("button", { name: "Clear filters" })).not.toBeInTheDocument(); + }); + + it("writes independent Owner and Visibility controls while retaining Creator Mine", () => { + mockSearchParamsState.value = new URLSearchParams("createdBy=me&teamIds[]=team_alpha"); + render(); + chooseOption(screen.getByRole("combobox", { name: "Owner" }), "Participating"); + chooseOption(screen.getByRole("combobox", { name: "Visibility" }), "Private"); + const params = new URL(mockReplace.mock.calls.at(-1)![0], "https://example.com").searchParams; + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("visibility")).toBe("private"); + expect(params.get("createdBy")).toBe("me"); + expect(params.getAll("teamIds[]")).toEqual(["team_alpha"]); + }); + + it("selects All my teams without falling back to the previously active team", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams("q=login&teamIds[]=team_alpha"); + const { rerender } = render(); + chooseOption(screen.getByRole("combobox", { name: "Team" }), "All my teams"); + expect(teamMocks.setActiveTeam).toHaveBeenCalledWith("all-my-teams"); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=login", { scroll: false }); + teamMocks.activeTeamId = "all-my-teams"; + mockSearchParamsState.value = new URLSearchParams("q=login"); + rerender(); + expect(lastQuery()).toMatchObject({ q: "login", teamIds: undefined, scope: undefined }); + }); + + it("uses the real provider's All my teams scope and preserves page filters after navigation", () => { + teamMocks.realContext = true; + localStorage.setItem("open-inspect-active-team", "team_alpha"); + mockSearchParamsState.value = new URLSearchParams( + "q=login&teamIds[]=team_alpha&createdBy=me&ownerFilter=participating&visibility=private" + ); + const { rerender } = render( + + + + ); + + chooseOption(screen.getByRole("combobox", { name: "Team" }), "All my teams"); + const href = mockReplace.mock.calls.at(-1)![0]; + const params = new URL(href, "https://example.com").searchParams; + expect(new URL(href, "https://example.com").pathname).toBe("/sessions"); + expect(params.getAll("teamIds[]")).toEqual([]); + expect(params.has("scope")).toBe(false); + expect(params.get("q")).toBe("login"); + expect(params.get("createdBy")).toBe("me"); + expect(params.get("ownerFilter")).toBe("participating"); + expect(params.get("visibility")).toBe("private"); + mockSearchParamsState.value = params; + rerender( + + + + ); + + expect(lastQuery()).toMatchObject({ + q: "login", + creator: "mine", + ownerFilter: "participating", + visibility: "private", + teamIds: undefined, + scope: undefined, + }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("All my teams"); + expect(localStorage.getItem("open-inspect-active-team")).toBe("all-my-teams"); + }); + + it("protects explicit links through first-run hydration with the real provider", () => { + teamMocks.realContext = true; + teamMocks.loading = true; + localStorage.setItem("open-inspect-active-team", "team_alpha"); + mockSearchParamsState.value = new URLSearchParams("teamIds[]=team_missing&ownerFilter=started"); + const { rerender } = render( + + + + ); + expect(lastOptions()).toEqual({ enabled: false }); + teamMocks.loading = false; + rerender( + + + + ); + + expect(lastQuery()).toMatchObject({ + teamIds: ["team_missing"], + scope: undefined, + ownerFilter: "started", + }); + expect(lastOptions()).toEqual({ enabled: true }); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("team_missing"); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("updates between aggregate scopes even though the real hook's activeTeamId stays null", () => { + teamMocks.activeTeamId = null; + mockSearchParamsState.value = new URLSearchParams("q=login&scope=workspace"); + const { rerender } = render(); + teamMocks.activeTeamId = "all-my-teams"; + rerender(); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=login", { scroll: false }); + teamMocks.roleKey = "administrator"; + teamMocks.activeTeamId = "all-teams"; + rerender(); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?q=login&scope=all", { scroll: false }); + }); + + it.each(["owner", "administrator", "member"])( + "gates All teams by server role %s, not session permissions", + (roleKey) => { + teamMocks.roleKey = roleKey; + mockPermissions.add("sessions.manage"); + render(); + fireEvent.keyDown(screen.getByRole("combobox", { name: "Team" }), { key: "Enter" }); + expect(screen.queryByRole("option", { name: "All teams" }) !== null).toBe( + roleKey !== "member" + ); + expect(screen.getByRole("option", { name: "Workspace" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "All my teams" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Alpha" })).toBeInTheDocument(); + } + ); + + it("does not fetch a fallback context before active teams load or when they fail", () => { + teamMocks.loading = true; + const { rerender } = render(); + expect(lastOptions()).toEqual({ enabled: false }); + teamMocks.loading = false; + teamMocks.error = new Error("teams failed"); + rerender(); + expect(lastOptions()).toEqual({ enabled: false }); + expect(screen.getByRole("alert")).toHaveTextContent("Couldn't load teams."); + }); + + it("clears other filters without widening the active team context", () => { + teamMocks.activeTeamId = "team_alpha"; + mockSearchParamsState.value = new URLSearchParams( + "q=login&ownerFilter=participating&visibility=private" + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + expect(mockReplace).toHaveBeenLastCalledWith("/sessions?teamIds%5B%5D=team_alpha", { + scroll: false, + }); + }); }); diff --git a/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx index fc2c3d6e0c..fd8543225e 100644 --- a/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/sessions/page.tsx @@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"; import { PlusIcon, SearchIcon, XIcon } from "@/components/ui/icons"; import { useAuthSession } from "@/lib/auth-session"; import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; +import { useActiveTeam } from "@/hooks/use-active-team"; import { useEnvironments } from "@/hooks/use-environments"; import { useRepos } from "@/hooks/use-repos"; import { useSessionDiscovery } from "@/hooks/use-session-discovery"; @@ -39,14 +40,44 @@ function SessionsContent() { const { isOpen } = useSidebarContext(); const router = useRouter(); const searchParams = useSearchParams(); + const { + activeTeamId, + scope: activeTeamScope, + setActiveTeam, + teams, + loading: teamLoading, + error: teamError, + } = useActiveTeam(); + const activeTeamSelection = + activeTeamId ?? + (activeTeamScope === "workspace" + ? null + : activeTeamScope === "all" + ? "all-teams" + : "all-my-teams"); + const teamContext = useMemo( + () => ({ teamIds: activeTeamId ? [activeTeamId] : undefined, scope: activeTeamScope }), + [activeTeamId, activeTeamScope] + ); const parsed = useMemo( () => parseSessionDiscoveryQuery(new URLSearchParams(searchParams.toString())), [searchParams] ); - const query = parsed.success ? parsed.data : DEFAULT_SESSION_DISCOVERY_QUERY; + const query = useMemo(() => { + if (!parsed.success) return DEFAULT_SESSION_DISCOVERY_QUERY; + return parsed.data.teamIds || parsed.data.scope + ? parsed.data + : { ...parsed.data, ...teamContext }; + }, [parsed, teamContext]); const invalidParams = parsed.success ? [] : parsed.invalidParams; - const hasFilters = hasSessionDiscoveryFilters(query); - const { hasPermission, loading: authorizationLoading } = useCurrentUserAuthorization(); + const hasFilters = hasSessionDiscoveryFilters(query, teamContext); + const { + authorization, + hasPermission, + loading: authorizationLoading, + } = useCurrentUserAuthorization(); + const canViewAllTeams = + authorization?.role.key === "owner" || authorization?.role.key === "administrator"; const canReadSessions = hasPermission("sessions.read"); const canCreateSession = hasPermission("sessions.create"); const { data: authSession } = useAuthSession(); @@ -60,10 +91,13 @@ function SessionsContent() { // catches up. Controlled selects ignore picking the value they already // show, so rendering the URL here would swallow a quick reversal. const [controlsQuery, setControlsQuery] = useState(query); + const previousParsedQuery = useRef(parsed); useEffect(() => { + if (previousParsedQuery.current === parsed) return; + previousParsedQuery.current = parsed; latestQuery.current = query; setControlsQuery(query); - }, [query]); + }, [parsed, query]); // Search text the user has typed but the URL does not show yet. Null means // the box mirrors the URL and may be overwritten by back/forward navigation. @@ -99,13 +133,55 @@ function SessionsContent() { }, [router] ); + // An explicit shared link wins on entry; a later switcher action changes + // only its team predicate, including search text still waiting to debounce. + const previousActiveTeam = useRef(teamLoading ? undefined : activeTeamSelection); + useEffect(() => { + if (teamLoading || teamError) return; + if (previousActiveTeam.current === undefined) { + previousActiveTeam.current = activeTeamSelection; + latestQuery.current = query; + setControlsQuery(query); + return; + } + if (previousActiveTeam.current === activeTeamSelection) return; + previousActiveTeam.current = activeTeamSelection; + if (parsed.success) updateQuery(teamContext); + }, [ + activeTeamSelection, + teamContext, + teamLoading, + teamError, + parsed.success, + query, + updateQuery, + ]); + + const changeFilters = useCallback( + (patch: Partial) => { + updateQuery(patch); + if ("teamIds" in patch || "scope" in patch) { + setActiveTeam( + patch.teamIds?.[0] ?? + (patch.scope === "workspace" + ? null + : patch.scope === "all" + ? "all-teams" + : "all-my-teams") + ); + } + }, + [setActiveTeam, updateQuery] + ); + const clearFilters = useCallback(() => { pendingSearch.current = null; setSearchText(""); - latestQuery.current = DEFAULT_SESSION_DISCOVERY_QUERY; - setControlsQuery(DEFAULT_SESSION_DISCOVERY_QUERY); - router.replace(buildSessionsHref(), { scroll: false }); - }, [router]); + const next = { ...DEFAULT_SESSION_DISCOVERY_QUERY, ...teamContext }; + latestQuery.current = next; + setControlsQuery(next); + router.replace(buildSessionsHref(next), { scroll: false }); + }, [router, teamContext]); const resetSearch = useCallback(() => { setSearchText(""); updateQuery({ q: "" }); @@ -130,7 +206,7 @@ function SessionsContent() { return () => window.clearTimeout(timeoutId); }, [router, searchText]); - const canQuery = canReadSessions && parsed.success; + const canQuery = canReadSessions && parsed.success && !teamLoading && !teamError; const { sessions, loading, loadingMore, error, hasMore, loadMore, retry } = useSessionDiscovery( query, { enabled: canQuery } @@ -148,9 +224,9 @@ function SessionsContent() { const showEmptyState = !loading && !error && sessions.length === 0; const statusText = - invalidParams.length > 0 + invalidParams.length > 0 || teamError ? "No sessions shown" - : loading + : loading || teamLoading ? "Loading sessions" : showEmptyState ? hasFilters @@ -230,8 +306,10 @@ function SessionsContent() { query={controlsQuery} repositories={repositoryOptions} environments={environments} - hasFilters={hasSessionDiscoveryFilters(controlsQuery)} - onChange={updateQuery} + teams={teams} + canViewAllTeams={canViewAllTeams} + hasFilters={hasSessionDiscoveryFilters(controlsQuery, teamContext)} + onChange={changeFilters} onClear={clearFilters} />
@@ -287,7 +365,13 @@ function SessionsContent() { )} - {invalidParams.length > 0 ? null : loading ? ( + {teamError ? ( + + Couldn't load teams. + + ) : null} + + {invalidParams.length > 0 || teamError ? null : loading || teamLoading ? (