From eef911f36e704fc49104546a9cd52b584d8b9223 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Mon, 28 Sep 2026 23:01:32 -0700 Subject: [PATCH 01/44] feat(control-plane): enforce session access in WebSocket DO (#2132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Resolve the current D1 session row, workspace authorization, team memberships, and collaborators at WebSocket subscribe and on every gated command. The row, not the URL or DO participants, determines scope and visibility. - Require `read` for subscribe, history, and presence; `collaborate` for prompt and typing; `lifecycle` for cancel, stop, and recovery. Use the resolver in `on`; preserve legacy team-rule permissions in `off`/`shadow`, while applying private-session rules in every mode. Redact sandbox URLs when the sandbox decision denies access. The five-minute lease and 4010 reconnect behavior are unchanged. - Cover mode differences, Owner break-glass (redacted read, denied collaboration, permitted lifecycle), changed membership and scope, revoked private collaborators, stale tokens, and HTTP re-mint refusal. Update the dated changelog. Issue: [COL-200](https://linear.app/colemurray/issue/COL-200/teams-pr-7-control-plane-do-subscribe-and-per-command-checks-through) ## Checkpoint **Validation commands and results** - `npm run build -w @open-inspect/shared`: passed. - `npm run typecheck`: passed across workspaces after building shared. - `npm run lint:fix`: passed. - `npm run lint:sql-portability`: passed (`SQL portability: clean (24 baselined occurrence(s) across 4 file(s)).`). - `npm test -w @open-inspect/control-plane`: passed, 331 files and 5,356 tests. - `npm run test:integration -w @open-inspect/control-plane`: passed, 122 files and 1,457 tests (1 skipped). The runner emitted forced-eviction/workerd and Miniflare warnings, but no test failures. - Targeted auth/router tests after the final test edit: passed, 73 tests. Targeted new workerd tests: passed, 2 tests. `git diff --check`: passed. **Failures encountered during red/green and fixture correction** (exact failure output excerpts, followed by passing reruns): ```text TypeError: this.deps.resolveAuthorization is not a function AssertionError: expected "vi.fn()" to be called with arguments: [ { …(2) }, 'collaborate' ] Number of calls: 0 Test Files 2 failed (2) Tests 14 failed | 56 passed (70) ``` ```text Error: "./types/session-access" is not exported under the conditions ["node", "development", "import"] from package /workspace/background-agents/node_modules/@open-inspect/shared (see exports field in /workspace/background-agents/node_modules/@open-inspect/shared/package.json) Test Files 1 failed | 1 passed (2) Tests 35 passed (35) ``` ```text src/session/connection-authenticator.ts(516,25): error TS2345: Argument of type 'SessionViewerResolution' is not assignable to parameter of type '{ kind: "valid"; authorization: { userId: string; suspendedAt: number | null; role: { id: string; key: "owner" | "member" | "administrator" | "viewer" | null; name: string; }; permissions: ("analytics.read" | ... 43 more ... | "workspace.transfer_ownership")[]; }; viewer: SessionViewer; row: SessionAccessRow; }'. ``` ```text src/session/connection-authenticator.test.ts(679,49): error TS2493: Tuple type '[]' of length '0' has no element at index '1'. src/session/connection-authenticator.test.ts(679,62): error TS18048: 'message' is possibly 'undefined'. src/session/connection-authenticator.test.ts(679,95): error TS2493: Tuple type '[]' of length '0' has no element at index '1'. src/session/connection-authenticator.test.ts(680,24): error TS2339: Property 'session' does not exist on type 'never'. src/session/connection-authenticator.test.ts(681,24): error TS2339: Property 'session' does not exist on type 'never'. src/session/connection-authenticator.test.ts(682,24): error TS2339: Property 'session' does not exist on type 'never'. src/session/connection-authenticator.test.ts(683,24): error TS2339: Property 'session' does not exist on type 'never'. src/session/connection-authenticator.test.ts(684,24): error TS2339: Property 'session' does not exist on type 'never'. ``` ```text AssertionError: expected 500 to be 404 // Object.is equality - Expected + Received - 404 + 500 ``` The last failure was a test fixture using a non-canonical browser user ID; it was corrected to a 32-character canonical ID. The same 500 assertion appeared on the first retry before that correction. **Plan drift and scope** - Base is `main` at `0530683`. The spec's `components.ts:808-836` is now `components.ts:814-867`; subscribe and command checks shifted to `connection-authenticator.ts:379-524` and `message-router.ts:143-221`. They have the same described behavior, so the edits followed their current locations. Latest DO migration is 56 rather than the plan-wide snapshot's 55; D1 migration 0083 is present. Neither needs a new migration. - The requested Owner `stop` denial in the original issue conflicted with the shared resolver. Per clarification, lifecycle remains permitted during break-glass; prompt/typing and sandbox access do not. - No schema, session-creation, HTTP token-mint, or web changes. PR 8's `PUT /sessions/:id/scope` is not on this branch; the unit test changes the authoritative row to exercise the next-command recheck. PR 9 owns the browser reconnect/not-found test. No DO audit rows are written. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/72cdbc9744d881b50924e0ee223a20ed)* ## Summary by CodeRabbit * **Access and Permissions** * WebSocket subscriptions and session commands check access against the current session and team membership. * Private sessions remain restricted to authorized collaborators in every enforcement mode. * When team enforcement is enabled, team access follows current membership and session scope. * Access changes apply to subsequent commands without requiring an active connection to close. * Owners can read private sessions with sandbox URLs hidden; collaboration actions remain restricted. * **Bug Fixes** * Invalid or rate-limited history requests are rejected before authorization and history retrieval. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- CHANGELOG.md | 8 +- .../src/authorization/session-admission.ts | 5 +- .../src/authorization/session-socket-audit.ts | 29 ++ .../src/session/client-command-facade.ts | 10 +- .../control-plane/src/session/components.ts | 50 ++- .../session/connection-authenticator.test.ts | 224 +++++++++++ .../src/session/connection-authenticator.ts | 101 +++-- .../src/session/message-router.ts | 65 ++-- .../control-plane/src/session/server.test.ts | 84 ++++- .../control-plane/test/integration/helpers.ts | 4 +- .../integration/session-access-routes.test.ts | 16 + .../test/integration/session-do-access.ts | 16 +- .../test/integration/websocket-client.test.ts | 2 +- .../websocket-session-access.test.ts | 354 ++++++++++++++++++ 14 files changed, 883 insertions(+), 85 deletions(-) create mode 100644 packages/control-plane/src/authorization/session-socket-audit.ts create mode 100644 packages/control-plane/test/integration/websocket-session-access.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 875c8d5b21..2a06a5108f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,12 @@ New features, integrations, and notable improvements to Open-Inspect — newest `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. + +WebSocket subscribe and per-command session checks now follow the current session row. Private +sessions stay restricted in every enforcement mode; team access follows the resolver when +`TEAMS_ENFORCEMENT=on`, including lifecycle access after membership or scope changes. Workspace-wide +session lists and bulk export follow in subsequent changes; routes to change a session's team or +visibility are not yet available. ## September 28, 2026 diff --git a/packages/control-plane/src/authorization/session-admission.ts b/packages/control-plane/src/authorization/session-admission.ts index 022c20f4ff..684ec19fca 100644 --- a/packages/control-plane/src/authorization/session-admission.ts +++ b/packages/control-plane/src/authorization/session-admission.ts @@ -86,7 +86,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 ((mode === "on" || row.visibility === "private") && decision && !decision.allowed) { return { kind: "action_denied", reason: decision.reason }; } if (mode === "shadow") { @@ -102,6 +102,7 @@ export async function evaluateSessionAdmission( } return { kind: "allowed", - legacyPermission: mode === "on" ? null : legacyPermissionForAction(action), + legacyPermission: + mode === "on" || row.visibility === "private" ? 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/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..f41a6a4691 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -52,6 +52,8 @@ 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"; @@ -154,6 +156,9 @@ import { createSessionRuntimeClientForTrace } from "./runtime-client"; import { SessionTitleService } from "./title-service"; import { parseArtifactMetadata } from "./artifact-metadata"; import { AuthorizationError, AuthorizationService } from "../authorization/service"; +import { parseTeamsEnforcementMode } 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"; /** @@ -317,6 +322,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); @@ -810,8 +817,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 +829,42 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi snapshotReader, schedulePullRequestRefresh, scmProviderName, - resolveAuthorization: async (userId) => { + resolveSessionViewer: async (userId) => { 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 [memberships, collaboratorIds] = + mode === "on" || session.visibility === "private" + ? await Promise.all([ + teamMembershipStore.listForUser(userId), + sessionCollaboratorStore.listUserIds(session.id), + ]) + : [new Map(), []]; + 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 +874,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi return { kind: "unavailable" }; } }, + auditPrivateBreakGlass: (userId, row) => auditSocketPrivateBreakGlass(db, userId, row), log, }); diff --git a/packages/control-plane/src/session/connection-authenticator.test.ts b/packages/control-plane/src/session/connection-authenticator.test.ts index 784e407212..b1a394889d 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 } 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,225 @@ 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 } = 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: "allowed" }); + } + ); + + 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("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)( + "refuses private non-collaborators in %s mode", + async (mode) => { + const { authenticator, close } = accessHarness(mode, member, { + ...teamRow, + visibility: "private", + }); + 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 sandbox URLs for an Owner break-glass read in %s, permits lifecycle but not collaboration", + async (mode) => { + const { authenticator, send, close, auditPrivateBreakGlass } = accessHarness(mode, owner, { + ...teamRow, + 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(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 a93c45e342..309bf6290d 100644 --- a/packages/control-plane/src/session/connection-authenticator.ts +++ b/packages/control-plane/src/session/connection-authenticator.ts @@ -1,5 +1,12 @@ 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 SessionViewer, +} from "@open-inspect/shared"; import { redactSessionSnapshotSandboxAccess, type ServerMessage, @@ -30,6 +37,11 @@ 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, + type TeamsEnforcementMode, +} from "../authorization/teams-enforcement"; +import type { ClientCommandAuthorization } from "./message-router"; /** * Maximum age of a WebSocket authentication token (in milliseconds). @@ -52,19 +64,23 @@ 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) => 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 } +type SessionViewerResolution = + | { + kind: "valid"; + mode: TeamsEnforcementMode; + authorization: EffectiveAuthorization; + viewer: SessionViewer; + row: SessionAccessRow; + } | { kind: "rejected" | "unavailable" }; -export type ClientCommandAuthorization = "allowed" | "denied" | "unavailable"; - interface SandboxAdmission { sandboxId: string | null; createdAt: number; @@ -364,23 +380,21 @@ 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); + 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); @@ -406,10 +420,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, @@ -427,8 +451,8 @@ export class SessionConnectionAuthenticator implements SessionUpgradeAdmission { ws, clientInfo, enrichment, - authorization.authorization.permissions.includes("sessions.sandbox_access"), - canManageSessionBudget(ownerUserId, authorization.authorization) + this.decide(resolution, "sandbox").allowed, + canManageSessionBudget(resolution.row.ownerUserId, resolution.authorization) ) ); if (!activated) { @@ -499,16 +523,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 (resolution.mode === "on" || resolution.row.visibility === "private") { + 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/message-router.ts b/packages/control-plane/src/session/message-router.ts index 454aa7c5f5..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,8 +16,15 @@ 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 }; }; @@ -39,9 +47,10 @@ export interface SessionClientCommands SessionHistoryPage; authorize: ( + connection: Connection, client: Client, - permission: PermissionId - ) => Promise<"allowed" | "denied" | "unavailable">; + action: SessionAction + ) => Promise; } export interface SessionMessageRouterDeps { @@ -142,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) @@ -172,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: @@ -207,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" || @@ -237,7 +247,7 @@ export class SessionMessageRouter { code: "INVALID_CURSOR", message: "Invalid cursor", }); - return; + return false; } const now = this.deps.clock.nowMs(); @@ -250,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/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/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 7c78066828..0bd9eda9d1 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -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/session-access-routes.test.ts b/packages/control-plane/test/integration/session-access-routes.test.ts index d3cdc3f94b..1af9178b1a 100644 --- a/packages/control-plane/test/integration/session-access-routes.test.ts +++ b/packages/control-plane/test/integration/session-access-routes.test.ts @@ -255,6 +255,22 @@ describe("HTTP session access by enforcement mode", () => { expect(await auditRows("session.private_break_glass")).toHaveLength(1); }); + 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_collaborator", + }); + } + ); + it("does not query memberships in off mode", async () => { const { sessionName } = await session("team"); const list = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); 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/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts index 01576b5ec7..e58f379d2c 100644 --- a/packages/control-plane/test/integration/websocket-client.test.ts +++ b/packages/control-plane/test/integration/websocket-client.test.ts @@ -328,7 +328,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-session-access.test.ts b/packages/control-plane/test/integration/websocket-session-access.test.ts new file mode 100644 index 0000000000..db8e2a0f41 --- /dev/null +++ b/packages/control-plane/test/integration/websocket-session-access.test.ts @@ -0,0 +1,354 @@ +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: "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 } = 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" }, + }); + 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)( + "skips unused scope reads on a team session 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 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); + + const memberships = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); + const collaborators = vi.spyOn(SessionCollaboratorStore.prototype, "listUserIds"); + try { + 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(mode === "on" ? 1 : 0); + expect(collaborators).toHaveBeenCalledTimes(mode === "on" ? 1 : 0); + } finally { + memberships.mockRestore(); + collaborators.mockRestore(); + 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 team move 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 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 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(); + }); +}); From 671661efc9306ffaa1ac73a7866598e134943b13 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Mon, 28 Sep 2026 23:44:59 -0700 Subject: [PATCH 02/44] feat(control-plane): filter session readers by row visibility (#2133) ## Summary - Apply row-based visibility before pagination and aggregation across session lists, inbox, children, bulk export, analytics, runs and autofix activity. Every visibility-aware reader now requires a `readScope` and enforcement mode; only a parent-bound sandbox uses an explicit internal scope for its children. A hidden export row is filtered before any included trace fetch. - Bound services see all non-private work in `off`/`shadow`, and their team scope applies in `on`. `teamIds[]` and `createdBy` share one filter-ID cap, with actual D1 bind counts checked before execution. - Return scoped unattributed private-session cost only to Owners and administrators (`null` otherwise). PR funnel queries retain deleted-session rows as workspace-level via a left join. Autofix activity continues to show unattached feedback after `ON DELETE SET NULL`; the PR metadata limitation is documented in the store. Implements [COL-199](https://linear.app/colemurray/issue/COL-199/teams-pr-6-control-plane-shared-visiblesessionspredicate-in-every-list). ## Checkpoint **Validation** - `npm run build -w @open-inspect/shared`: passed (via `npm run typecheck`). - `npm run typecheck`: passed across all workspaces. - `npm run lint:fix`: passed. - `npm run lint:sql-portability`: `SQL portability: clean (24 baselined occurrence(s) across 4 file(s)).` - `npm test -w @open-inspect/control-plane`: 332 files, 5,383 tests passed. - `npm run test:integration -w @open-inspect/control-plane`: 122 files, 1,477 passed, 1 skipped. The workerd force-eviction and NDJSON warnings appear in this passing run. - `npm test -w @open-inspect/web`: 226 files, 1,985 tests passed. - Prettier check and `git diff --check`: passed. **Red-phase failure fixed before the final green run** ```text FAIL test/integration/session-access-routes.test.ts > HTTP session access by enforcement mode > lists only children visible in the selected enforcement mode AssertionError: expected [] to have a length of 1 but got +0 ``` The list seam does not write batch shadow audit rows; the assertion introduced in PR 2131 was removed while retaining its visible-parent/hidden-child coverage. **Baseline drift and resolution** - Rebasing onto `main` at `e471cff` brought PR 2131 changes to the children route and the September 29 changelog entry. The conflict was resolved with the predicate-based children list, no per-child item admission, and a separate changelog paragraph. D1 migration `0083` and DO migration `56` were already present; no migration was needed. The older `listRun()` export wrapper was already gone. **Deliberately left out** - Team-dimension analytics and per-team cost lines, WebSocket authorization, and session ownership/visibility write routes belong to later work. No schema or session write path changed. ## Summary by CodeRabbit * **New Features** * Session lists and inboxes support filtering by team, ownership, visibility, and workspace scope. Owners and administrators can request sessions across all scopes. * Session lists, inboxes, child sessions, exports, analytics, and run views respect session visibility and team access. Private sessions remain restricted. * Analytics summaries show owners and administrators an unattributed, scope-filtered total for private-session costs. * **Bug Fixes** * Inaccessible sessions and runs no longer appear in exports or inbox results. * Autofix activity excludes feedback associated with private sessions. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- CHANGELOG.md | 5 + .../src/db/analytics-dashboard-store.test.ts | 6 +- .../src/db/analytics-dashboard-store.ts | 14 +- .../src/db/analytics-store.test.ts | 91 ++++- .../control-plane/src/db/analytics-store.ts | 67 +++- .../src/db/pr-autofix-feedback-store.ts | 38 +- .../db/pull-request-analytics-store.test.ts | 67 +++- .../src/db/pull-request-analytics-store.ts | 117 +++--- packages/control-plane/src/db/query-limits.ts | 10 + .../src/db/session-export-store.ts | 27 +- .../src/db/session-inbox-store.ts | 56 ++- .../src/db/session-index.test.ts | 43 +- .../control-plane/src/db/session-index.ts | 22 +- .../src/db/session-list-predicates.test.ts | 36 +- .../src/db/session-list-predicates.ts | 44 ++ .../src/db/session-run-store.test.ts | 32 +- .../control-plane/src/db/session-run-store.ts | 28 +- .../src/db/session-visibility.test.ts | 63 +++ .../src/db/session-visibility.ts | 46 +++ .../src/routes/analytics.test.ts | 36 ++ .../control-plane/src/routes/analytics.ts | 45 ++- .../src/routes/session-children.test.ts | 104 ++++- .../src/routes/session-children.ts | 24 +- .../src/routes/session-export.test.ts | 85 +++- .../src/routes/session-export.ts | 13 +- .../src/routes/session-index.test.ts | 207 +++++++--- .../control-plane/src/routes/session-index.ts | 111 +++++- .../test/integration/analytics.test.ts | 163 +++++++- .../autofix-activity-route.test.ts | 36 ++ .../test/integration/d1-session-index.test.ts | 26 +- .../pr-autofix-feedback-store.test.ts | 50 +++ .../pull-request-analytics.test.ts | 74 +++- .../test/integration/service-auth.test.ts | 20 + .../integration/session-access-routes.test.ts | 42 -- .../integration/session-discovery.test.ts | 64 ++- .../integration/session-export-store.test.ts | 308 +++++++++++++- .../test/integration/session-export.test.ts | 12 + .../test/integration/session-inbox.test.ts | 231 +++++++++++ .../integration/session-read-state.test.ts | 56 ++- .../integration/session-repositories.test.ts | 6 +- .../test/integration/session-runs.test.ts | 80 +++- .../session-visibility-lists.test.ts | 376 ++++++++++++++++++ .../shared/src/session-list-query.test.ts | 48 +++ packages/shared/src/session-list-query.ts | 61 ++- packages/shared/src/types/analytics.ts | 4 +- .../(app)/(sidebar)/analytics/page.test.tsx | 1 + .../src/app/api/sessions/inbox/route.test.ts | 8 + .../web/src/app/api/sessions/inbox/route.ts | 2 +- .../web/src/app/api/sessions/route.test.ts | 14 + .../analytics/summary-cards.test.tsx | 1 + .../components/analytics/token-cards.test.tsx | 1 + packages/web/src/hooks/use-analytics.test.tsx | 1 + 52 files changed, 2734 insertions(+), 388 deletions(-) create mode 100644 packages/control-plane/src/db/session-visibility.test.ts create mode 100644 packages/control-plane/src/db/session-visibility.ts create mode 100644 packages/control-plane/test/integration/session-visibility-lists.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a06a5108f..4ec9bc8702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ sessions stay restricted in every enforcement mode; team access follows the reso session lists and bulk export follow in subsequent changes; routes to change a session's team or visibility are not yet available. +Session lists, the inbox, children lists, bulk export, and analytics now filter by persisted row +visibility. Private sessions remain restricted in every mode; only Owners and administrators receive +their unattributed, scope-filtered cost total in analytics. The WebSocket path follows in a later +change; no route can yet make a session private or team-owned. + ## September 28, 2026 ### Added 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/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/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-export-store.ts b/packages/control-plane/src/db/session-export-store.ts index 541858cd9a..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). */ @@ -144,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; @@ -195,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; @@ -278,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..a4ebd5ac54 100644 --- a/packages/control-plane/src/db/session-inbox-store.ts +++ b/packages/control-plane/src/db/session-inbox-store.ts @@ -5,9 +5,12 @@ import { type SessionInboxSession, } from "@open-inspect/shared/types/session-inbox"; import type { SessionStatus, SpawnSource } from "@open-inspect/shared/types/sessions"; +import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; +import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; 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. */ @@ -15,6 +18,9 @@ export interface ListSessionInboxOptions { category: SessionInboxCategory; createdByUserIds?: readonly string[]; excludeAutomatedSessions?: boolean; + teamIds?: readonly string[]; + readScope: SessionReadScope; + mode: TeamsEnforcementMode; viewerUserId: string; limit: number; cursor: SessionInboxCursor | null; @@ -115,6 +121,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 +158,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 +166,7 @@ export class SessionInboxStore { options: Omit ): SqlStatement { const { sql, params } = this.inboxCtes(options); + assertD1QueryParameterLimit(params.length + 1); return this.db .prepare( `${sql}, @@ -193,7 +202,12 @@ export class SessionInboxStore { private inboxCtes( options: Pick< ListSessionInboxOptions, - "createdByUserIds" | "excludeAutomatedSessions" | "viewerUserId" + | "createdByUserIds" + | "excludeAutomatedSessions" + | "teamIds" + | "readScope" + | "mode" + | "viewerUserId" > ): { sql: string; params: unknown[] } { const { conditions, params } = this.eligibility(options); @@ -250,7 +264,10 @@ export class SessionInboxStore { } private eligibility( - options: Pick + options: Pick< + ListSessionInboxOptions, + "createdByUserIds" | "excludeAutomatedSessions" | "teamIds" | "readScope" | "mode" + > ): { conditions: string[]; params: unknown[] } { const conditions = ["sessions.status != 'archived'", "sessions.root_session_id IS NOT NULL"]; const params: unknown[] = []; @@ -263,6 +280,17 @@ export class SessionInboxStore { ); params.push(...options.createdByUserIds); } + if (options.teamIds?.length) { + conditions.push(`sessions.owner_team_id IN (${options.teamIds.map(() => "?").join(", ")})`); + params.push(...options.teamIds); + } + if (options.readScope.kind !== "internal") { + const visibility = visibleSessionsPredicate("sessions", options.readScope, { + mode: options.mode, + }); + conditions.push(visibility.sql); + params.push(...visibility.params); + } return { conditions, params }; } diff --git a/packages/control-plane/src/db/session-index.test.ts b/packages/control-plane/src/db/session-index.test.ts index c269744ff9..5d694af16e 100644 --- a/packages/control-plane/src/db/session-index.test.ts +++ b/packages/control-plane/src/db/session-index.test.ts @@ -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..e67fc61ecb 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, @@ -468,7 +471,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 +482,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 +763,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-visibility.test.ts b/packages/control-plane/src/db/session-visibility.test.ts new file mode 100644 index 0000000000..47edd62fd1 --- /dev/null +++ b/packages/control-plane/src/db/session-visibility.test.ts @@ -0,0 +1,63 @@ +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 }); + expect(sql).not.toContain("team_memberships"); + 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..7f7453afa8 --- /dev/null +++ b/packages/control-plane/src/db/session-visibility.ts @@ -0,0 +1,46 @@ +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, + }; + } + 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 = ?)))`; + 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/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/session-children.test.ts b/packages/control-plane/src/routes/session-children.test.ts index 8038d73c47..04fc095214 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,86 @@ 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, "listByParent").mockResolvedValue([ { id: "child", @@ -76,7 +144,7 @@ describe("handleListChildren", () => { baseBranch: "main", status: "active", ownerTeamId: null, - visibility: "workspace", + visibility: "private", parentSessionId: "parent", spawnSource: "agent", spawnDepth: 1, @@ -99,7 +167,10 @@ 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: [ @@ -130,13 +201,6 @@ describe("handleListChildren", () => { }, ], }); - expect(evaluateSessionAdmission).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - "child", - "read", - null - ); }); }); diff --git a/packages/control-plane/src/routes/session-children.ts b/packages/control-plane/src/routes/session-children.ts index 276332e416..dfab8bdb92 100644 --- a/packages/control-plane/src/routes/session-children.ts +++ b/packages/control-plane/src/routes/session-children.ts @@ -11,7 +11,7 @@ import { 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 { teamsEnforcementMode, viewerFromContext } from "../authorization/session-admission"; import { createLogger } from "../logger"; import { SessionInternalPaths } from "../session/contracts"; import { resolveSandboxSettings } from "../session/integration-settings-resolution"; @@ -41,18 +41,18 @@ export async function handleListChildren( 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) + ); - return json(childSessionListResponseSchema.parse({ children: visible })); + return json(childSessionListResponseSchema.parse({ children })); } export async function handleGetChild( 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..bf3b013abc 100644 --- a/packages/control-plane/src/routes/session-index.test.ts +++ b/packages/control-plane/src/routes/session-index.test.ts @@ -1,7 +1,9 @@ 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"; @@ -66,7 +68,7 @@ async function listSessions(query = "", principal?: Principal): Promise { 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 +218,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 +238,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 () => { @@ -465,19 +577,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 a8bbe4f4ce..035c7c45c5 100644 --- a/packages/control-plane/src/routes/session-index.ts +++ b/packages/control-plane/src/routes/session-index.ts @@ -5,6 +5,7 @@ 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 { @@ -33,6 +34,9 @@ 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 { teamsEnforcementMode, viewerFromContext } from "../authorization/session-admission"; const sessionInboxQuerySchema = z.object({ category: z @@ -54,6 +58,15 @@ const sessionInboxQuerySchema = z.object({ const log = createLogger("session-read-state"); 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[], currentUserId: string | null @@ -99,6 +112,10 @@ export async function handleListSessions( origin, limit, offset, + teamIds, + ownerFilter, + visibility, + scope, } = parsedQuery.data; const viewerUserId = ctx.principal?.kind === "user" @@ -106,27 +123,62 @@ export async function handleListSessions( : ctx.principal?.kind === "service" ? (ctx.principal.actor?.canonicalUserId ?? 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; if (viewerUserId) { log.info("session_read_state.decorated", { event: "session_read_state.decorated", @@ -151,7 +203,7 @@ export async function handleListSessions( export async function handleListSessionInbox( request: Request, - _env: Env, + env: Env, _params: object, ctx: UserRouteContext ): Promise { @@ -163,6 +215,16 @@ export async function handleListSessionInbox( } 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 + )) + ); const startedAt = Date.now(); const store = new SessionIndexStore(ctx.db); @@ -171,10 +233,14 @@ export async function handleListSessionInbox( createdByUserIds: mine === "true" ? [ctx.principal.userId] : [], excludeAutomatedSessions: mine === "true", viewerUserId: ctx.principal.userId, + readScope: viewer, + mode: teamsEnforcementMode(ctx, env), + teamIds, }; if (category === null) { - const snapshot = await store.listInboxSnapshot(commonOptions); + const snapshot = await readSessionList(() => store.listInboxSnapshot(commonOptions)); + if (snapshot instanceof Response) return snapshot; const body = sessionInboxSnapshotSchema.parse({ categories: Object.fromEntries( SESSION_INBOX_CATEGORIES.map((inboxCategory) => [ @@ -188,11 +254,14 @@ export async function handleListSessionInbox( return response; } - const result = await store.listInbox({ - ...commonOptions, - category, - cursor: parsedCursor.cursor, - }); + const result = await readSessionList(() => + store.listInbox({ + ...commonOptions, + category, + cursor: parsedCursor.cursor, + }) + ); + if (result instanceof Response) return result; const nextCursor = result.nextCursor ? encodeSessionInboxCursor(result.nextCursor) : null; const response = json( sessionInboxPageSchema.parse({ 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/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/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/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/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 1af9178b1a..fdfa5143cb 100644 --- a/packages/control-plane/test/integration/session-access-routes.test.ts +++ b/packages/control-plane/test/integration/session-access-routes.test.ts @@ -155,43 +155,6 @@ describe("HTTP session access by enforcement mode", () => { } }); - 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 () => { const as = { userId: MEMBER, role: "member" } as const; const shadow = await session("team"); @@ -385,11 +348,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 () => { 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-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.test.ts b/packages/control-plane/test/integration/session-inbox.test.ts index f1da23c986..dd2af4ed50 100644 --- a/packages/control-plane/test/integration/session-inbox.test.ts +++ b/packages/control-plane/test/integration/session-inbox.test.ts @@ -4,8 +4,34 @@ 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 type { SessionViewer } from "@open-inspect/shared"; +import { SessionInboxStore } from "../../src/db/session-inbox-store"; const VIEWER_ID = "11111111111111111111111111111111"; +const viewer: SessionViewer = { + kind: "user", + userId: VIEWER_ID, + roleKey: "member", + permissions: ["sessions.read"], + suspended: false, + memberships: new Map([["team-a", "member"]]), +}; + +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(); +} function session(id: string, overrides: Partial = {}): SessionEntry { return { @@ -32,6 +58,211 @@ function session(id: string, overrides: Partial = {}): SessionEntr 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-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-visibility-lists.test.ts b/packages/control-plane/test/integration/session-visibility-lists.test.ts new file mode 100644 index 0000000000..c98bd12248 --- /dev/null +++ b/packages/control-plane/test/integration/session-visibility-lists.test.ts @@ -0,0 +1,376 @@ +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", + ownerTeamId: "team_alpha", + 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/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/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/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/api/sessions/inbox/route.test.ts b/packages/web/src/app/api/sessions/inbox/route.test.ts index 0d41bf6985..835f40c453 100644 --- a/packages/web/src/app/api/sessions/inbox/route.test.ts +++ b/packages/web/src/app/api/sessions/inbox/route.test.ts @@ -31,4 +31,12 @@ describe("session inbox API route", () => { ); expect(snapshotResponse.headers.get("Cache-Control")).toBe("private, no-store"); }); + + it("forwards repeated teamIds[] filters", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ categories: {} })); + await GET(request("/api/sessions/inbox?teamIds%5B%5D=team_a&teamIds%5B%5D=team_b")); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/sessions/inbox?teamIds%5B%5D=team_a&teamIds%5B%5D=team_b" + ); + }); }); diff --git a/packages/web/src/app/api/sessions/inbox/route.ts b/packages/web/src/app/api/sessions/inbox/route.ts index 4831cb5d99..4af30a1829 100644 --- a/packages/web/src/app/api/sessions/inbox/route.ts +++ b/packages/web/src/app/api/sessions/inbox/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from "next/server"; import { controlPlaneUserFetch } from "@/lib/control-plane"; import { buildControlPlanePath } from "@/lib/control-plane-query"; -const SESSION_INBOX_QUERY_PARAMS = ["category", "cursor", "mine"] as const; +const SESSION_INBOX_QUERY_PARAMS = ["category", "cursor", "mine", "teamIds[]"] as const; export async function GET(request: NextRequest) { try { diff --git a/packages/web/src/app/api/sessions/route.test.ts b/packages/web/src/app/api/sessions/route.test.ts index 8c02a4e2f0..d62526fbc0 100644 --- a/packages/web/src/app/api/sessions/route.test.ts +++ b/packages/web/src/app/api/sessions/route.test.ts @@ -305,6 +305,20 @@ describe("sessions API route (discovery params)", () => { expect(response.headers.get("Cache-Control")).toBe("private, no-store"); }); + it("forwards repeated team filters and owner, visibility, and scope filters", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( + Response.json({ sessions: [], hasMore: false }) + ); + await GET( + request( + "/api/sessions?teamIds%5B%5D=team_a&teamIds%5B%5D=team_b&ownerFilter=participating&visibility=team&scope=all&ignored=true" + ) + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/sessions?teamIds%5B%5D=team_a&teamIds%5B%5D=team_b&ownerFilter=participating&visibility=team&scope=all" + ); + }); + it("propagates the control plane's rejection of an oversized search", async () => { vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( Response.json({ error: "Invalid q" }, { status: 400 }) diff --git a/packages/web/src/components/analytics/summary-cards.test.tsx b/packages/web/src/components/analytics/summary-cards.test.tsx index 1f9ee435f0..a9115192f7 100644 --- a/packages/web/src/components/analytics/summary-cards.test.tsx +++ b/packages/web/src/components/analytics/summary-cards.test.tsx @@ -23,6 +23,7 @@ it("leaves the dedicated PR funnel as the only top-level PR-created metric", () totalSessions: 10, activeUsers: 2, totalCost: 5, + privateSessionsCostUsd: 0, avgCost: 0.5, totalPrs: 99, statusBreakdown: { diff --git a/packages/web/src/components/analytics/token-cards.test.tsx b/packages/web/src/components/analytics/token-cards.test.tsx index b105a5e101..34677f96cf 100644 --- a/packages/web/src/components/analytics/token-cards.test.tsx +++ b/packages/web/src/components/analytics/token-cards.test.tsx @@ -14,6 +14,7 @@ const summary: AnalyticsSummaryResponse = { totalSessions: 2, activeUsers: 1, totalCost: 1, + privateSessionsCostUsd: 0, avgCost: 0.5, totalPrs: 0, statusBreakdown: { created: 0, active: 0, completed: 2, failed: 0, archived: 0, cancelled: 0 }, diff --git a/packages/web/src/hooks/use-analytics.test.tsx b/packages/web/src/hooks/use-analytics.test.tsx index bee5b01c09..b755bb4e3d 100644 --- a/packages/web/src/hooks/use-analytics.test.tsx +++ b/packages/web/src/hooks/use-analytics.test.tsx @@ -29,6 +29,7 @@ const snapshot = { totalSessions: 1, activeUsers: 1, totalCost: 1, + privateSessionsCostUsd: 0, avgCost: 1, totalPrs: 9, statusBreakdown: { From a41abe001cbbe465b403d5629c90077134567a5c Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Mon, 28 Sep 2026 23:54:35 -0700 Subject: [PATCH 03/44] fix: keep modal-vm sessions alive after failed Docker preparation (#2136) ## Summary - Classify daemon exits requested by snapshot preparation as expected, even when the stop times out or exits non-zero. - On failed interactive preparation, stop any remaining daemon, restart it to readiness, and rearm the supervisor watcher. Keep unrelated crashes fatal and never acknowledge a failed save as prepared. - Derive preparation and control deadlines from Docker stop/start budgets so a failed stop and restart fit within the Modal VM capture budget. ## Verification - Added real daemon/control/supervisor regressions for ignored SIGTERM, late clean exit, non-zero exit, preparation cancellation, retry, restart failure, clean preparation, requested stop, and unrelated crashes. - `packages/sandbox-runtime`: 1399 passed, 3 skipped; Ruff check/format and mypy passed. - `packages/modal-infra`: 432 passed with the local sandbox-runtime checkout; Ruff check/format passed. Closes COL-221. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/006a6e47ab65d935f87d3badcd479d6b)* ## Summary by CodeRabbit * **Bug Fixes** * Improved sandbox preparation reliability: after a preparation failure or timeout, Docker can recover so preparation can be retried. * Improved handling of Docker restarts: monitoring resumes after recovery, and recovery failures are reported. * Corrected crash reporting so requested Docker stops are not mistaken for unexpected crashes. * Improved timeout handling to keep preparation, Docker operations, and snapshot capture within supported time limits. * Improved shutdown responsiveness: shutdown preparation persists the session and stops execution without waiting for Docker preparation to finish. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../tests/test_snapshot_timeout.py | 15 +- .../src/sandbox_runtime/bridge.py | 5 +- .../src/sandbox_runtime/docker_control.py | 71 +++++- .../src/sandbox_runtime/docker_service.py | 7 +- .../src/sandbox_runtime/supervisor.py | 39 ++- .../tests/test_bridge_shutdown_preparation.py | 20 ++ .../tests/test_docker_control.py | 32 ++- .../tests/test_docker_service.py | 232 +++++++++++++++++- 8 files changed, 394 insertions(+), 27 deletions(-) diff --git a/packages/modal-infra/tests/test_snapshot_timeout.py b/packages/modal-infra/tests/test_snapshot_timeout.py index a57d3fa0a5..c04c253c81 100644 --- a/packages/modal-infra/tests/test_snapshot_timeout.py +++ b/packages/modal-infra/tests/test_snapshot_timeout.py @@ -7,7 +7,7 @@ import pytest from modal.exception import NotFoundError as ModalNotFoundError -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 ( @@ -267,6 +267,19 @@ async def test_vm_capture_requires_docker_preparation(exit_code): assert execute.aio.call_args.kwargs["timeout"] == CONTROL_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): 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/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/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() From f3b397a28bdf3184187aaafc1a3923d2fb410e22 Mon Sep 17 00:00:00 2001 From: Rahul Sethuram Date: Tue, 29 Sep 2026 11:18:31 +0400 Subject: [PATCH 04/44] fix(modal-infra): resolve VMs with partially published tunnels (#2139) ## Summary When a `modal-vm` VM is created or restored, `SandboxTunnels.resolve` returns the tunnel URLs Modal has published. A missing port is not an error, and create/restore still return the handle with a partial map. `test_launch_returns_handle_despite_tunnel_failures` covers that case. The lookup-only `POST /api-resolve-vm-sandbox` added in #2115 rebuilds the same tunnel set from the VM's launch tags. However, `recover_vm_access` returns `409 race_pending` unless every enabled service (code-server, desktop, terminal) and every extra tunnel port has a URL. Suppose a create/restore response is lost and one exposed port has no tunnel. Every lookup of that VM then returns `race_pending`, even though the original request would have returned the handle. Each lookup repeats the same check against the same provider state, so if the port is never published, the lookup can never succeed. On the launch side, Modal's SDK (1.4.3) caches the first non-empty `Sandbox.tunnels()` result on the sandbox object. The launch path's own retries therefore get that same map back and return it as final. I reproduced this on `main` (eef911f3) using the mocks from the existing tests. The VM had code-server, desktop and terminal enabled and `tunnelPorts: [3000, 3001]`, and `Sandbox.tunnels()` published every port except 3001: - Create returned the real VM ID, the three service URLs and `{3000: ...}`. - Five resolve calls against the same VM state then all returned `409 race_pending`. Each call used a fresh `from_name` object. On the control-plane side, `race_pending` is treated as an unknown startup outcome. I checked the effect with a throwaway lifecycle test (not included) that used the `vm-resolve.test.ts` fixture and returned `race_pending` on every lookup: - The spawn loop and the bridge-attach loop each retried until their bounded window ran out (44 lookups in total), and then stopped. - The sandbox row kept the pending `modal-vm-session:` reference. No code-server, desktop or tunnel URLs were stored, including the ones Modal did publish. - For a restore, the shutdown record also stayed in `restoring` with `restoreInvoked: true`, and work admission reported `held`. Stopping the VM still works through the pending reference, because the Modal stop endpoint resolves it by name and checks ownership. ## Changes - `recover_vm_access` now returns the URLs `SandboxTunnels.resolve` produced, as create/restore do. It returns `race_pending` only when the VM exposes ports but none of their URLs could be read. That happens when every `tunnels()` attempt failed or returned none of the exposed ports. This keeps the retry #2115 added for tunnels that are not yet visible. - The ownership and launch-metadata checks are unchanged. `find_owned_vm` and `parse_vm_service_launch` still run first, and legacy allocations still resolve only the VM ID. - `docs/MODAL_DOCKER.md` now describes the narrower `race_pending` condition. One trade-off: if Modal publishes a VM's tunnels incrementally, a lookup made between publications now returns the partial map instead of retrying. The launch path already returns the partial map in that situation. ## Tests - `test_resolve_returns_the_partial_tunnels_launch_would_return` replaces `test_resolve_retries_when_enabled_tunnel_is_missing`, which asserted the old behaviour. It is in `tests/test_vm_resolve.py` and runs once each with the code-server, desktop, terminal or an extra port missing. It checks that resolve returns the real VM ID and exactly the published URLs, and does not create, terminate or write to the VM. All 4 cases fail on `main` with `409 race_pending` and pass with this change. - `test_resolve_retries_while_no_tunnel_is_readable` checks that resolve still returns `race_pending` when no tunnel URL can be read. It passes on `main` and with this change. It fails if the check is removed entirely. - In `packages/modal-infra`, `uv run pytest tests/ -q` passes (432 tests). `ruff check` and `ruff format --check` pass on the touched files, and Prettier passes on the doc. Not tested: - Nothing here ran against live Modal. I have not observed Modal publishing only some of a sandbox's `encrypted_ports`; the reproduction uses the same `Sandbox.tunnels()` subset that the existing launch tests model. - No control-plane code changed, and I did not rerun the control-plane suites. ## Summary by CodeRabbit * **Bug Fixes** * VM resolution now returns available tunnel URLs even when some service or app tunnel URLs are unavailable. * The `race_pending` error is returned only when none of the exposed tunnel URLs are readable; creation and restoration behavior is unchanged. * **Documentation** * Updated the error description to clarify that resolution can return a partial tunnel map. --- docs/MODAL_DOCKER.md | 3 +- .../modal-infra/src/sandbox/vm_recovery.py | 8 +-- packages/modal-infra/tests/test_vm_resolve.py | 52 ++++++++++++------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/docs/MODAL_DOCKER.md b/docs/MODAL_DOCKER.md index 56af3182d7..4ff2897539 100644 --- a/docs/MODAL_DOCKER.md +++ b/docs/MODAL_DOCKER.md @@ -106,7 +106,8 @@ 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. 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/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) From 4e5c1f3bce26974890a822c894651688ae6e4045 Mon Sep 17 00:00:00 2001 From: Rahul Sethuram Date: Tue, 29 Sep 2026 11:21:21 +0400 Subject: [PATCH 05/44] fix(control-plane): fail modal-vm launch on docker_not_available (#2138) ## Summary When a deployment has no verified Docker image, `api_create_sandbox` returns HTTP 501 with detail `docker_not_available`. `SandboxLauncher.launch` raises it while choosing the base image. That happens before `_launch_docker_sandbox` retires the predecessor, looks up the named allocation or calls `Sandbox.create`, so the request definitely created nothing. The existing `test_docker_launch_without_a_provisioned_image_never_uses_the_default` confirms that no create call is made. The modal-vm startup path still treats this response as an unknown outcome. There are three places that do this: - `ModalClient.postJson` wraps every VM-startup 5xx in `ModalVmStartupError("unknown")`. - `ModalSandboxProvider.isUnknownStartupError` treats any `ModalApiError` with status >= 500 as unknown. - `ModalSandboxProvider.classifyError` makes every modal-vm 5xx transient. Reproduced on `main` in a lifecycle test. The create returns 501 `docker_not_available` and resolve reports `not_visible`, as it does when nothing was allocated. The manager polls `resolveVmSandbox` 22 times and fails the attempt at 210 s (`PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS`) with "The VM allocation did not appear for this attempt. Please retry." Because that error is transient, the circuit breaker is not incremented. On a deployment missing the image, each spawn therefore waits about 3.5 minutes before failing, tells the user to retry, and never trips the breaker. ## Changes - `client.ts`: add `isAmbiguousModalVmLaunchError`. It returns true for 5xx, except for the `docker_not_available` detail. `postJson` now uses it, so this response reaches callers as the original `ModalApiError`. - `modal-provider.ts`: `isUnknownStartupError` and the modal-vm branch of `classifyError` use the same predicate. The 501 falls through to `classifyErrorWithStatus` and becomes permanent, which is how the standard `modal` backend already classifies it. Other 5xx responses, including plain 500s, remain unknown/transient. - `docs/MODAL_DOCKER.md`: document the 501 detail next to the typed 409 details. The classification uses the typed `detail`, following the existing 409 vocabulary. It is not a bare 501 status carve-out. ## Tests - `vm-resolve.test.ts`: new test "fails a create rejected before allocation without resolving, counting the failure". It checks that resolve is never called, the sandbox is `failed`, and `spawn_failure_count` is 1. It fails on `main`, where the breaker count stays 0 after 22 resolve calls. - `client.test.ts`: new test "keeps a VM create rejected before allocation as its HTTP error". It fails on `main`, where the client returns `ModalVmStartupError`. - `modal-provider.test.ts`: added a 501 / `docker_not_available` row to the VM launch classification table (not unknown, permanent). The row fails on `main`. The expected error type is now an explicit column. The existing rows keep their previous expectations. - Reverting either provider call site on its own makes the provider row and the lifecycle test fail again. - `packages/control-plane`: unit suite (331 files / 5376 tests), `npm run typecheck` and eslint on the touched files pass. Integration files `sandbox-shutdown.test.ts` and `modal-backend-builds.test.ts` pass (15 tests). The full integration suite was not run. - `packages/modal-infra` is unchanged. `test_sandbox_launch.py`, `test_web_api_create_sandbox.py` and `test_docker_launch.py` pass (142 tests). Not tested against a real Modal deployment without a Docker image. The failure message is now "Failed to create sandbox with HTTP 501", which does not include the detail text; this PR does not change that wording. The auth-misconfiguration 503 from `require_auth` is also returned before any allocation. It is left ambiguous because it has no typed detail and 503 is also a gateway status. ## Summary by CodeRabbit * **Bug Fixes** * Sandbox launches now treat the `docker_not_available` response as a permanent failure rather than attempting to resolve the launch. The request fails before existing resources are retired or new ones are allocated. * Other server-side errors continue to be classified according to their status. * **Documentation** * Updated Docker deployment guidance to describe the unavailable-image response and its effect on sandbox creation. --- docs/MODAL_DOCKER.md | 6 ++++-- .../control-plane/src/sandbox/client.test.ts | 19 +++++++++++++++++++ packages/control-plane/src/sandbox/client.ts | 10 +++++++++- .../src/sandbox/lifecycle/vm-resolve.test.ts | 17 +++++++++++++++++ .../sandbox/providers/modal-provider.test.ts | 17 ++++++++--------- .../src/sandbox/providers/modal-provider.ts | 7 ++++--- 6 files changed, 61 insertions(+), 15 deletions(-) diff --git a/docs/MODAL_DOCKER.md b/docs/MODAL_DOCKER.md index 4ff2897539..7c21617f42 100644 --- a/docs/MODAL_DOCKER.md +++ b/docs/MODAL_DOCKER.md @@ -109,8 +109,10 @@ Create, restore, and resolve report typed HTTP 409 error `detail` values: 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/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 e552187299..e450bbe83b 100644 --- a/packages/control-plane/src/sandbox/client.ts +++ b/packages/control-plane/src/sandbox/client.ts @@ -330,6 +330,14 @@ 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" @@ -427,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/lifecycle/vm-resolve.test.ts b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts index b972627940..cd578ba862 100644 --- a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts @@ -201,6 +201,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 () => ({ 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 83ec94cb20..1c0aa5b1cc 100644 --- a/packages/control-plane/src/sandbox/providers/modal-provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/modal-provider.test.ts @@ -95,14 +95,15 @@ const testConfig = { describe("ModalSandboxProvider", () => { 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({ @@ -121,9 +122,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..0a113a1a99 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, @@ -123,7 +123,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 +572,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); } From 0ce9e9d53f6656d7296a26006e5118c1bd20a479 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Tue, 29 Sep 2026 09:36:34 -0700 Subject: [PATCH 06/44] test: establish sandbox lifecycle refactor baseline (COL-241) (#2144) ## Summary - Record the actual checkout versus the researched baseline, current state/method ownership, and a 15-invariant compatibility evidence matrix in `docs/plans/sandbox-lifecycle-refactor-baseline.md`. - Add deterministic characterizations for encrypted resume/bridge commits across row changes, VM foreground-to-bridge token handoff, rejected-allocation retry and handle retention, and boot-budget termination-guard ordering. - Separate existing behavior gaps (unscoped fresh/restore artifact writes, attach-time status recheck, prior-generation handle clearing, and VM finalizer overlap) from this behavior-preserving refactor. No production code or schema changes. ## Verification - Pre-edit: shared build; 600 focused unit tests; 55 Workerd integration tests; control-plane typecheck; boundary lint; `git diff --check` passed. - Post-edit: 608 focused unit tests; 55 Workerd integration tests; 278 related session tests; control-plane typecheck; boundary lint; targeted ESLint and Prettier; `git diff --check` passed. - The first targeted test run expected two broadcasts in the new guard test; the existing path emits three. The characterization was corrected before the passing re-run. No live provider or full suite/bundle validation is claimed. COL-241 / prerequisite to COL-242. No deployment. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/7a16f985831db82710ec590beeeda6e3)* ## Summary by CodeRabbit - **Tests** - Added coverage for sandbox lifecycle alarms, rejected-allocation cleanup, and VM resolution when lookups are inconclusive. - Added tests for provider resume behavior when sandbox records or bridge references change during encryption. - **Documentation** - Added a verification baseline outlining lifecycle compatibility checks, coverage limits, and command results. Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> --- .../sandbox-lifecycle-refactor-baseline.md | 116 ++++++++++++++++++ .../alarm-boot-budget-effects.test.ts | 51 ++++++++ .../lifecycle/rejected-allocation.test.ts | 45 +++++++ .../src/sandbox/lifecycle/vm-resolve.test.ts | 30 +++++ .../src/session/sandbox-repository.test.ts | 69 +++++++++++ 5 files changed, 311 insertions(+) create mode 100644 docs/plans/sandbox-lifecycle-refactor-baseline.md diff --git a/docs/plans/sandbox-lifecycle-refactor-baseline.md b/docs/plans/sandbox-lifecycle-refactor-baseline.md new file mode 100644 index 0000000000..62a78141a4 --- /dev/null +++ b/docs/plans/sandbox-lifecycle-refactor-baseline.md @@ -0,0 +1,116 @@ +# 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. +- `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. + +## 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/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/rejected-allocation.test.ts b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts index aefda4cc9a..9641248d6a 100644 --- a/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts @@ -144,6 +144,51 @@ describe("rejected provider allocation", () => { expect(sandbox.fenced).toBe(1); expect(provider.createSandbox).toHaveBeenCalledTimes(1); }); + it.each(["new generation", "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(); + 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] + ); + sandbox.modal_object_id = "sb-newer"; + if (replacement === "new generation") { + sandbox.modal_sandbox_id = "newer-generation"; + sandbox.created_at += 1; + } + } finally { + releaseStop(); + await spawning; + } + expect(sandbox.modal_object_id).toBe("sb-newer"); + } + ); it.each(["create", "restore"] as const)( "rearms %s cleanup through the assembled alarm handler even under a shutdown hold", async (launch) => { 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 cd578ba862..6005c81643 100644 --- a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts @@ -265,6 +265,36 @@ 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("completes a restore after bounded transient errors when its bridge later resolves", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); diff --git a/packages/control-plane/src/session/sandbox-repository.test.ts b/packages/control-plane/src/session/sandbox-repository.test.ts index cf8cb05704..48da858eeb 100644 --- a/packages/control-plane/src/session/sandbox-repository.test.ts +++ b/packages/control-plane/src/session/sandbox-repository.test.ts @@ -727,6 +727,75 @@ describe("SandboxRepository boot state (SQLite)", () => { tunnel_urls: null, }); }); + + it.each([ + ["replaced", "modal_sandbox_id = 'sb-2', created_at = 3000"], + ["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) => { + beginEncryption(); + await gate; + return encrypt(...args); + }); + try { + const completion = repository.completeProviderResume(generation, access, "pending"); + await encrypting; + set(change); + releaseEncryption(); + await expect(completion).resolves.toBe(false); + expect(repository.getSandbox()).toMatchObject({ + modal_object_id: change.includes("modal_object_id") ? "another-pending" : "pending", + code_server_url: null, + vnc_url: null, + ttyd_url: 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", () => { From bdf314c91c3576ce442487c75114935734610ae0 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Tue, 29 Sep 2026 12:49:04 -0700 Subject: [PATCH 07/44] fix(modal-infra): restore VM saves and timed-out sandbox stops (COL-249) (#2146) ## Summary - Pass a whole-second integer timeout to the Modal VM Docker preparation `exec` call without extending the capture budget. - Treat only `modal.exception.SandboxTimeoutError` from `terminate(wait=True)` as a completed stop in session stop, prior VM retirement, and image-build termination. Preserve other errors and the build termination exit-code log. - Add regression tests using Modal's protobuf request validation and cover timed-out stops through all three paths and the stop endpoint. ## Verification - Confirmed the new regressions fail before the fix with the protobuf float `TypeError` and unhandled sandbox timeout. - `uv run pytest tests/ -q` (442 passed) - `uv run ruff check` - `uv run ruff format --check` Only modal-infra web-function code changes; no VM image rebuild or control-plane changes are required. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/701cf22bead64ab78622ded0d5d3ddff)* ## Summary by CodeRabbit * **Bug Fixes** * Sandbox shutdown now completes gracefully when the hosting service reports a termination timeout, while other timeout errors continue to propagate. * Docker preparation now uses an integer execution timeout capped by the remaining snapshot time and control timeout. Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> --- .../modal-infra/src/sandbox/build_session.py | 3 +- packages/modal-infra/src/sandbox/launch.py | 3 +- packages/modal-infra/src/sandbox/manager.py | 5 +- .../modal-infra/src/sandbox/termination.py | 11 ++++ .../tests/test_build_sandbox_lifecycle.py | 26 +++++++++ .../modal-infra/tests/test_sandbox_launch.py | 23 +++++++- .../tests/test_snapshot_timeout.py | 57 ++++++++++++++++++- .../tests/test_web_api_build_sandbox.py | 17 ++++++ 8 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 packages/modal-infra/src/sandbox/termination.py 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/tests/test_build_sandbox_lifecycle.py b/packages/modal-infra/tests/test_build_sandbox_lifecycle.py index db011b58c5..7b9b38d71e 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, @@ -367,6 +368,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 diff --git a/packages/modal-infra/tests/test_sandbox_launch.py b/packages/modal-infra/tests/test_sandbox_launch.py index ec83ce45d8..81c33edff2 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, @@ -750,6 +750,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 diff --git a/packages/modal-infra/tests/test_snapshot_timeout.py b/packages/modal-infra/tests/test_snapshot_timeout.py index c04c253c81..98f40756a1 100644 --- a/packages/modal-infra/tests/test_snapshot_timeout.py +++ b/packages/modal-infra/tests/test_snapshot_timeout.py @@ -6,6 +6,9 @@ import pytest from modal.exception import NotFoundError as ModalNotFoundError +from modal.exception import SandboxTimeoutError +from modal.exception import TimeoutError as ModalTimeoutError +from modal_proto.task_command_router_pb2 import TaskExecStartRequest from sandbox_runtime.docker_control import CONTROL_TIMEOUT_SECONDS, PREPARATION_TIMEOUT_SECONDS from sandbox_runtime.types import SandboxStatus @@ -193,6 +196,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,7 +294,32 @@ 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", [300, 166, 165.5, 10]) +async def test_vm_capture_exec_timeout_is_sdk_compatible_and_within_budget(budget): + execute = _async_method(SimpleNamespace(wait=_async_method(0))) + handle = SandboxHandle( + sandbox_id="sb-vm", + sandbox_backend="modal-vm", + status=SandboxStatus.READY, + created_at=0, + modal_sandbox=SimpleNamespace( + exec=execute, + snapshot_filesystem=_async_method(SimpleNamespace(object_id="im-vm")), + ), + ) + + await SandboxManager().take_snapshot(handle, timeout_seconds=budget) + + args = execute.aio.call_args.args + timeout = execute.aio.call_args.kwargs["timeout"] + TaskExecStartRequest(command_args=list(args), timeout_secs=timeout) + assert type(timeout) is int + assert timeout == min(int(budget), int(CONTROL_TIMEOUT_SECONDS)) def test_vm_preparation_deadline_fits_capture_budget(): 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) From 77168380a0eddb8e93d6c76782dc8367f05ae76d Mon Sep 17 00:00:00 2001 From: AjanRaj <55903526+ajanraj@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:38:43 +0100 Subject: [PATCH 08/44] feat: add GPT-6.1 Sol model support (#2147) ## Summary Adds `openai/gpt-6.1-sol` to the shared model catalog and the ChatGPT subscription allowlist, following #2028. It appears in the existing model selectors and bot model overrides, with `medium` as the default reasoning effort. The published OpenCode catalog lists `low`, `medium`, `high`, `xhigh`, and `max` for this model. `none` is not supported. Existing models and deployment defaults are unchanged. ## Changes - Add the catalog entry and subscription allowlist ID. - Extend the existing model and subscription-plugin tests. - Add the published model metadata to the frozen wire-test fixture and record its source and subset hashes. - Update the available-models reference and the public docs table required by the current catalog-consistency test. ## OpenCode compatibility The pinned OpenCode 1.18.29 passes the existing wire tests with GPT-6.1 Sol and all five reasoning efforts. These tests use a local mock endpoint, not live OpenAI or ChatGPT requests. No OpenCode upgrade is needed for the tested request format. Sandbox images and existing repository/environment prebuilds need rebuilding to include the current OpenCode model catalog, as with #2028. ## Validation - Typecheck, ESLint, changed-file Prettier checks, and Ruff passed. - All TypeScript workspace test suites passed, including 1,062 shared and 1,985 web tests. - Codex plugin tests: 5 passed. - Pinned OpenCode wire tests: 3 passed. - Sandbox runtime tests: 1,405 passed, 3 skipped. Local test runs required disabling Node 26's experimental web storage and using short Python temporary paths with isolated Git configuration. No repository changes were made for these environment settings. ## Summary by CodeRabbit * **New Features** * Added GPT-6.1 Sol to the available OpenAI models. It supports low, medium, high, extra-high, and maximum reasoning effort, with medium as the default. * GPT-6.1 Sol is available for use with Codex OAuth. --- docs/AVAILABLE_MODELS.md | 21 ++++---- .../content/docs/models/choosing-a-model.mdx | 1 + .../plugins/codex-auth-plugin.js | 1 + .../tests/codex-auth-plugin.test.mjs | 2 +- .../tests/fixtures/reasoning-models.json | 49 +++++++++++++++++++ .../tests/test_opencode_reasoning_contract.py | 4 +- packages/shared/src/models.test.ts | 7 +++ packages/shared/src/models.ts | 9 ++++ 8 files changed, 82 insertions(+), 12 deletions(-) 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/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/sandbox-runtime/src/sandbox_runtime/plugins/codex-auth-plugin.js b/packages/sandbox-runtime/src/sandbox_runtime/plugins/codex-auth-plugin.js index 505ed34e85..11c7a608f3 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 @@ -93,6 +93,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/tests/codex-auth-plugin.test.mjs b/packages/sandbox-runtime/tests/codex-auth-plugin.test.mjs index 64c9b68f93..9af676ba19 100644 --- a/packages/sandbox-runtime/tests/codex-auth-plugin.test.mjs +++ b/packages/sandbox-runtime/tests/codex-auth-plugin.test.mjs @@ -84,7 +84,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_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/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", From c9a649b5c878507db748e7495e5563f3d0be4323 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Tue, 29 Sep 2026 20:58:12 -0700 Subject: [PATCH 09/44] fix(linear-bot): surface completion failure reasons (#2143) ## Summary - Include the extracted `execution_complete` error in failed Linear completion messages, falling back to the signed callback's error when event data lacks one. - Preserve partial agent text on failures and retain the existing generic copy when no reason is available. - Add signed callback tests for partial-text, no-text, and no-reason failures through the Linear comment fallback. ## Verification - `npm test -w @open-inspect/linear-bot` (264 tests passed) - `npm run lint -w @open-inspect/linear-bot` - `npm run typecheck -w @open-inspect/linear-bot` - `npx prettier --check packages/linear-bot/src/callbacks.ts packages/linear-bot/src/callbacks.complete.test.ts` - `git diff --check origin/main...HEAD` --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/571d5c83b93e21c79434656a4d236624)* ## Summary by CodeRabbit * **Bug Fixes** * Failed task completions now provide clearer error messages, including available partial results. * When multiple error details are available, the most relevant one is shown. Sensitive or oversized details are omitted for safety. * If no useful error details or partial results are available, a general failure message is displayed. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../linear-bot/src/callbacks.complete.test.ts | 136 ++++++++++++++++++ packages/linear-bot/src/callbacks.ts | 14 +- 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 packages/linear-bot/src/callbacks.complete.test.ts 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."; } } From 44ca1a9b0bd2b4aa8f7703e07504c50f9f70eecb Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Tue, 29 Sep 2026 22:37:27 -0700 Subject: [PATCH 10/44] refactor: extract sandbox launch context (COL-242) (#2148) ## Summary Extract stateless sandbox launch-input and image mechanics from `SandboxLifecycleManager` into `sandbox/lifecycle/launch-context.ts`, the second increment of COL-240. COL-241 characterization is already integrated in the base branch. - Give launch context narrow environment/repository readers, default model and MCP/Slack lookup config, provider metadata, image lookup, and a lazy logger. It has no lifecycle storage, shutdown, admission, reservation, or provider-operation authority. - Move model/harness defaults, ordered repository fields/base SHAs, MCP/Slack resolution, persisted-setting normalization, timeout conversion, and image lookup/best-effort explicit invalidation. Reuse the existing image evaluator. - Keep manager-owned mode selection, generation/token reservation, provider dispatch, pending-reference/recovery recording, failure accounting, and confirmed-unavailable base-image retry/identity rotation. - Preserve the existing await points and differing fresh/restore integration lookup order. Resume gains no env/repository/integration/image reads; bridge timeout resolution remains behind pending-reference eligibility. - Add 44 direct narrow-dependency cases, three exact-payload/effect-order manager cases, and a lazy bridge-settings regression. Retain existing image fallback, identity rotation, VM lifetime, early-connect, and shutdown coverage. - Add the previously absent repository design document with actual ownership, related-work status, validation evidence, and limitations. COL-161 feature work and COL-156 broader construction changes are not included. ## Validation Node v24.20.0, npm 11.19.0; required package checks run sequentially from repository root: - `npm run build -w @open-inspect/shared` passed. - `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle` passed: 17 files / 547 tests (48 new cases). - `npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown` passed: 2 files / 20 tests. - `npm run typecheck -w @open-inspect/control-plane` passed all four configurations. - `npm run lint -w @open-inspect/control-plane` passed. - `npm run test:lint-sandbox-boundaries` passed: 2 tests. - Targeted `npx prettier --check` on all nine touched files passed. - `git diff --check` and staged/base diff whitespace checks passed. Initial validation caught two test-only errors: the new bridge fixture used an ineligible pending status, and a resume mock widened its success literal to boolean. Both were corrected; the lifecycle suite and typecheck passed on rerun. Commit hooks also passed ESLint and Prettier. ## Scope And Handoff No schema, wire protocol, runtime, provider backend, timeout/retry policy, deployment, or live-provider verification changes. Workerd checks use provider substitutes; full-story/bundle verification remains COL-247. Existing T1 behavioral gaps are not fixed or hidden. Integrate this PR before COL-243 starts. Issue: https://linear.app/colemurray/issue/COL-242 --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/c881621062e6bf0a04c60f35312dc095)* ## Summary by CodeRabbit * **Bug Fixes** * Sandbox launches continue with the base image when a prebuilt image is unavailable or its lookup fails, rather than blocking sessions. * Pending sandbox connections are resolved only when session and sandbox references match, preventing resolution for unrelated or ineligible sandboxes. * Configured timeouts return a clear error when the selected provider does not support them. * **Reliability** * Launch and restore flows handle missing or unavailable settings and integrations more consistently, including fallback behavior for image, notification, and server lookups. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../sandbox-lifecycle-manager-refactor.md | 97 +++ .../src/sandbox/lifecycle/image-selection.ts | 4 +- .../sandbox/lifecycle/launch-context.test.ts | 711 ++++++++++++++++++ .../src/sandbox/lifecycle/launch-context.ts | 270 +++++++ .../lifecycle/launch-orchestration.test.ts | 491 ++++++++++++ .../src/sandbox/lifecycle/manager.test.ts | 6 +- .../src/sandbox/lifecycle/manager.ts | 346 ++------- .../src/sandbox/lifecycle/vm-resolve.test.ts | 40 +- .../control-plane/src/session/components.ts | 5 +- 9 files changed, 1675 insertions(+), 295 deletions(-) create mode 100644 docs/plans/sandbox-lifecycle-manager-refactor.md create mode 100644 packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts create mode 100644 packages/control-plane/src/sandbox/lifecycle/launch-context.ts create mode 100644 packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts diff --git a/docs/plans/sandbox-lifecycle-manager-refactor.md b/docs/plans/sandbox-lifecycle-manager-refactor.md new file mode 100644 index 0000000000..08be410317 --- /dev/null +++ b/docs/plans/sandbox-lifecycle-manager-refactor.md @@ -0,0 +1,97 @@ +# 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, 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. +- `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 or shutdown internals. + +Access, VM reconciliation, allocation cleanup and watchdog effects remain manager responsibilities +until their serial extraction increments land. 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 an explicit internal interface. `createSandboxLaunchContext` is retained +only to bind shared, narrow dependencies without introducing a stateful class: a two-method +environment/repository reader, default model and MCP/Slack ports, provider metadata, optional image +lookup and lazy logger. It has 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. + +## 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. + +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 +npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown +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/packages/control-plane/src/sandbox/lifecycle/image-selection.ts b/packages/control-plane/src/sandbox/lifecycle/image-selection.ts index d3b5b8269e..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"; 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..05f21868db --- /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 { + createSandboxLaunchContext, + 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 = createSandboxLaunchContext({ + 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("createSandboxLaunchContext", () => { + 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..ccfcfeead1 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/launch-context.ts @@ -0,0 +1,270 @@ +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; +} + +/** Input resolution only; startup mode, reservations and provider operations belong to the manager. */ +export interface SandboxLaunchContext { + getUserEnvVars(): Promise | undefined>; + resolveAgent(session: SessionRow): AgentLaunchFields; + resolveRepositories(session: SessionRow): RepositoryLaunchInputs; + lookupImageBuildForSpawn( + scope: ImageBuildScope, + repositories: SessionRepositoryInfo[], + harness: HarnessId + ): Promise; + markImageBuildRestoreFailed(image: SelectedImageBuild, error: unknown): Promise; + resolveAgentSlackNotifyEnabled(session: SessionRow): Promise; + loadMcpServers(repositories: SessionRepositoryInfo[]): Promise; + resolveSandboxSettings(session: SessionRow): ResolvedSandboxSettings; +} + +/** 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 function createSandboxLaunchContext({ + sessionContext, + provider, + config, + imageBuildLookup, + getLogger, +}: SandboxLaunchContextDependencies): SandboxLaunchContext { + return { + getUserEnvVars: () => sessionContext.getUserEnvVars(), + + resolveAgent(session: SessionRow): AgentLaunchFields { + return { + ...extractProviderAndModel(getValidModelOrDefault(session.model || config.model)), + harness: getValidHarnessOrDefault(session.harness), + }; + }, + + resolveRepositories(session: SessionRow): RepositoryLaunchInputs { + const repositories = 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 (!imageBuildLookup || repositories.length === 0) return null; + try { + const image = await imageBuildLookup.getLatestReady(scope); + const result = await evaluateImageBuildForSpawn(image, repositories, harness); + if (result.outcome === "selected") { + 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; + } + 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) { + 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 (!imageBuildLookup) return; + try { + await imageBuildLookup.markRestoreFailed( + image.imageBuildId, + `restore failed at spawn: ${error instanceof Error ? error.message : String(error)}` + ); + } catch (e) { + 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 (!config.slackAgentNotifyLookup) return false; + try { + return await config.slackAgentNotifyLookup.isEnabledForRepo( + sessionHasRepository(session) ? session.repo_owner : null, + sessionHasRepository(session) ? session.repo_name : null + ); + } catch (err) { + 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 (!config.mcpServerLookup) return undefined; + const servers = await config.mcpServerLookup.getDecryptedForSession( + repositories.map(({ repoOwner, repoName }) => ({ repoOwner, repoName })) + ); + 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) { + 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, provider.name); + if (unsupported.length > 0) { + getLogger().warn("Ignoring persisted sandbox settings unsupported by the provider", { + event: "sandbox.settings_unsupported", + provider: provider.name, + settings: unsupported, + }); + } + sandboxSettings = omitUnsupportedSandboxSettings(settings, provider.name); + } catch { + getLogger().warn("Failed to parse sandbox_settings, using defaults"); + sandboxSettings = {}; + } + if (!provider.capabilities.supportsSandboxTimeout) { + if (sandboxSettings.sandboxTimeoutMs !== undefined) { + throw new SandboxProviderError( + `${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..4af30c454e --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts @@ -0,0 +1,491 @@ +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 { SandboxLifecycleManager } from "./manager"; +import { + createMockAlarmScheduler, + createMockBroadcaster, + createMockIdGenerator, + createMockProvider, + createMockSandbox, + createMockSession, + createMockStorage, + createMockWebSocketManager, + createTestConfig, + 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 = new SandboxLifecycleManager( + 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(["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 = new SandboxLifecycleManager( + 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 = new SandboxLifecycleManager( + 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.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts index e09203ce83..69607c2d8d 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts @@ -10,11 +10,9 @@ 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 { computeRepositoriesFingerprint } from "../../image-builds/fingerprint"; import { COMPATIBLE_RUNTIME_VERSION } from "../../image-builds/test-helpers"; import { MIN_SHUTDOWN_PROTOCOL_RUNTIME_GENERATION } from "../runtime-manifest"; diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index 285f7c30b7..0344138261 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"; @@ -38,7 +30,6 @@ import { type SandboxProvider, type CreateSandboxConfig, type CreateSandboxResult, - type SessionRepositoryInfo, type SandboxLifetime, type StopConfig, } from "../provider"; @@ -64,14 +55,15 @@ 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"; + createSandboxLaunchContext, + resolveImageBuildScope, + type SandboxLaunchContext, + type SandboxLaunchConfig, + type SandboxLaunchContextReader, +} from "./launch-context"; import type { AlarmScheduler, BackgroundTasks, SessionWebSocket } from "../../platform-ports"; import { DEFAULT_SANDBOX_STATUS } from "../sandbox-status"; import type { @@ -92,7 +84,6 @@ import { ModalApiError, ModalVmStartupError } from "../client"; import type { ResolveSandboxResult } from "../provider"; export type { SandboxGeneration, SandboxAlarmResult } from "./ports"; -export type { ImageBuildLookup } from "./image-selection"; export type { AlarmScheduler } from "../../platform-ports"; const log = createLogger("lifecycle-manager"); @@ -194,18 +185,9 @@ 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>; } /** @@ -386,14 +368,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,10 +381,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 +404,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 ==================== /** @@ -555,6 +490,7 @@ export class SandboxLifecycleManager /** Memoized session-scoped logger, keyed by the resolved session id. */ private logMemo?: { sessionId: string | undefined; logger: Logger }; + private readonly launchContext: SandboxLaunchContext; /** * Session-scoped logger. Falls back to the module-level logger if no @@ -584,9 +520,24 @@ export class SandboxLifecycleManager private readonly idGenerator: IdGenerator, private readonly shutdown: SandboxShutdownLifecycle, private readonly config: SandboxLifecycleConfig, - private readonly imageBuildLookup?: ImageBuildLookup, + imageBuildLookup?: ImageBuildLookup, private readonly backgroundTasks?: BackgroundTasks - ) {} + ) { + this.launchContext = createSandboxLaunchContext({ + sessionContext, + provider: { + name: provider.name, + capabilities: { supportsSandboxTimeout: provider.capabilities.supportsSandboxTimeout }, + }, + config: { + model: config.model, + mcpServerLookup: config.mcpServerLookup, + slackAgentNotifyLookup: config.slackAgentNotifyLookup, + }, + imageBuildLookup, + getLogger: () => this.log, + }); + } /** * Spawn a sandbox (fresh or from snapshot). @@ -805,7 +756,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,68 +770,46 @@ 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") @@ -914,7 +842,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 @@ -1021,117 +949,6 @@ export class SandboxLifecycleManager } } - /** - * 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; - } - } - /** * Report why the sandbox failed: broadcast it to connected clients and * persist it, as one step. @@ -1274,16 +1091,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,20 +1111,17 @@ 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 = { @@ -1463,8 +1279,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({ @@ -2367,14 +2183,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. */ @@ -2410,38 +2218,6 @@ export class SandboxLifecycleManager 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 { @@ -2658,7 +2434,7 @@ export class SandboxLifecycleManager sessionId: pending.sessionId, sandboxId: pending.sandboxId, generationCreatedAtMs: generation.createdAt, - timeoutSeconds: this.resolveSandboxTimeoutSeconds(this.parseSandboxSettings(session)), + timeoutSeconds: this.launchContext.resolveSandboxSettings(session).timeoutSeconds, }; const retryDeadlineAtMs = Date.now() + PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS; this.bridgeResolution = generation; 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 6005c81643..d2046be704 100644 --- a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts @@ -134,12 +134,50 @@ 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()); + 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", async (action) => { diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index f41a6a4691..45542b079e 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -42,11 +42,10 @@ 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"; import { resolveBootBudgetTimeoutMs } from "../sandbox/lifecycle/decisions"; import { McpServerStore } from "../db/mcp-servers"; import { UserStore } from "../db/user-store"; From 0318fd9dacc28dccd17b3cad01fca9eb88661d11 Mon Sep 17 00:00:00 2001 From: "open-inspect[bot]" <255062780+open-inspect[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:59:35 -0700 Subject: [PATCH 11/44] refactor: replace sandbox launch factory with class (#2149) ## Summary Follow-up to COL-242 / PR #2148, which merged while this requested class conversion was being committed. This branch is based on the updated `main` and contains only the class conversion, not the already-merged extraction. - Replace `createSandboxLaunchContext` with an explicitly constructed `SandboxLaunchContext` class. - Inject the same narrow constructor inputs: environment/repository reader, provider metadata, model/MCP/Slack config, optional image lookup, and lazy logger. - Store dependencies in individual readonly fields; remove the redundant method interface and factory instead of keeping compatibility wrappers. - Update the manager and direct test fixture to use `new SandboxLaunchContext(...)`, and update ownership documentation. Method signatures, payloads, lookup/error policies, synchronous image eligibility, await placement, settings/timeout resolution, lazy logging, and lifecycle authority are unchanged. Constructor assignment performs no session reads, lookups, or logging. ## Validation Rerun on this branch after applying the class-only commit to updated `main`: - `npm run build -w @open-inspect/shared` passed. - `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle` passed: 17 files / 552 tests. - `npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown` passed: 2 files / 20 tests. - `npm run typecheck -w @open-inspect/control-plane` passed all four configurations. - `npm run lint -w @open-inspect/control-plane` passed. - `npm run test:lint-sandbox-boundaries` passed: 2 tests. - Targeted Prettier checks on all four touched files passed. - `git diff --check` and base-branch whitespace checks passed. Existing constructor-dormancy, lazy-logger, exact-payload, ordered-await, ineligible-image no-await, resume, and bridge regressions pass without new behavior assertions or altered expectations. No deployment or live-provider verification was performed. Issue: https://linear.app/colemurray/issue/COL-242 --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/c881621062e6bf0a04c60f35312dc095)* Co-authored-by: waclaude --- .../sandbox-lifecycle-manager-refactor.md | 11 +- .../sandbox/lifecycle/launch-context.test.ts | 6 +- .../src/sandbox/lifecycle/launch-context.ts | 328 +++++++++--------- .../src/sandbox/lifecycle/manager.ts | 5 +- 4 files changed, 173 insertions(+), 177 deletions(-) diff --git a/docs/plans/sandbox-lifecycle-manager-refactor.md b/docs/plans/sandbox-lifecycle-manager-refactor.md index 08be410317..a214e231b1 100644 --- a/docs/plans/sandbox-lifecycle-manager-refactor.md +++ b/docs/plans/sandbox-lifecycle-manager-refactor.md @@ -31,12 +31,11 @@ the manager is not intended to become a tiny facade or lose lifecycle arbitratio ## Launch Contract -`SandboxLaunchContext` is an explicit internal interface. `createSandboxLaunchContext` is retained -only to bind shared, narrow dependencies without introducing a stateful class: a two-method -environment/repository reader, default model and MCP/Slack ports, provider metadata, optional image -lookup and lazy logger. It has 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. +`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 diff --git a/packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts b/packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts index 05f21868db..fedf7547fa 100644 --- a/packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/launch-context.test.ts @@ -8,7 +8,7 @@ import type { SessionRow } from "../../session/types"; import { SandboxProviderError, type SessionRepositoryInfo } from "../provider"; import type { ImageBuildLookup, ImageBuildSpawnRow, SelectedImageBuild } from "./image-selection"; import { - createSandboxLaunchContext, + SandboxLaunchContext, resolveImageBuildScope, type McpServerLookup, type SandboxLaunchContextDependencies, @@ -46,7 +46,7 @@ function fixture(overrides: Partial = {}) { }; const logger = { info: vi.fn(), warn: vi.fn() }; const getLogger = vi.fn(() => logger); - const context = createSandboxLaunchContext({ + const context = new SandboxLaunchContext({ sessionContext, provider: { name: "modal", capabilities: { supportsSandboxTimeout: true } }, config: { model: CONFIGURED_MODEL }, @@ -102,7 +102,7 @@ describe("resolveImageBuildScope", () => { }); }); -describe("createSandboxLaunchContext", () => { +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"); diff --git a/packages/control-plane/src/sandbox/lifecycle/launch-context.ts b/packages/control-plane/src/sandbox/lifecycle/launch-context.ts index ccfcfeead1..62fe7e6a24 100644 --- a/packages/control-plane/src/sandbox/lifecycle/launch-context.ts +++ b/packages/control-plane/src/sandbox/lifecycle/launch-context.ts @@ -71,22 +71,6 @@ export interface ResolvedSandboxSettings { timeoutSeconds: number | undefined; } -/** Input resolution only; startup mode, reservations and provider operations belong to the manager. */ -export interface SandboxLaunchContext { - getUserEnvVars(): Promise | undefined>; - resolveAgent(session: SessionRow): AgentLaunchFields; - resolveRepositories(session: SessionRow): RepositoryLaunchInputs; - lookupImageBuildForSpawn( - scope: ImageBuildScope, - repositories: SessionRepositoryInfo[], - harness: HarnessId - ): Promise; - markImageBuildRestoreFailed(image: SelectedImageBuild, error: unknown): Promise; - resolveAgentSlackNotifyEnabled(session: SessionRow): Promise; - loadMcpServers(repositories: SessionRepositoryInfo[]): Promise; - resolveSandboxSettings(session: SessionRow): ResolvedSandboxSettings; -} - /** Synchronous eligibility keeps ineligible sessions outside the image-lookup await. */ export function resolveImageBuildScope( session: SessionRow, @@ -105,166 +89,180 @@ export function resolveImageBuildScope( * 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 function createSandboxLaunchContext({ - sessionContext, - provider, - config, - imageBuildLookup, - getLogger, -}: SandboxLaunchContextDependencies): SandboxLaunchContext { - return { - getUserEnvVars: () => sessionContext.getUserEnvVars(), +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"]; - resolveAgent(session: SessionRow): AgentLaunchFields { - return { - ...extractProviderAndModel(getValidModelOrDefault(session.model || config.model)), - harness: getValidHarnessOrDefault(session.harness), - }; - }, + constructor({ + sessionContext, + provider, + config, + imageBuildLookup, + getLogger, + }: SandboxLaunchContextDependencies) { + this.sessionContext = sessionContext; + this.provider = provider; + this.config = config; + this.imageBuildLookup = imageBuildLookup; + this.getLogger = getLogger; + } - resolveRepositories(session: SessionRow): RepositoryLaunchInputs { - const repositories = 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, - }, - }; - }, + getUserEnvVars(): Promise | undefined> { + return this.sessionContext.getUserEnvVars(); + } - async lookupImageBuildForSpawn( - scope: ImageBuildScope, - repositories: SessionRepositoryInfo[], - harness: HarnessId - ): Promise { - if (!imageBuildLookup || repositories.length === 0) return null; - try { - const image = await imageBuildLookup.getLatestReady(scope); - const result = await evaluateImageBuildForSpawn(image, repositories, harness); - if (result.outcome === "selected") { - 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; - } - 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) { - getLogger().warn("Failed to look up prebuilt image, using base image", { - event: "image_build.spawn_miss", + 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, - reason: "lookup_failed", - error: e instanceof Error ? e.message : String(e), + image_build_id: result.image.imageBuildId, + runtime_version: result.image.runtimeVersion, }); - return null; + 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 (!imageBuildLookup) return; - try { - await imageBuildLookup.markRestoreFailed( - image.imageBuildId, - `restore failed at spawn: ${error instanceof Error ? error.message : String(error)}` - ); - } catch (e) { - getLogger().warn("Failed to mark prebuilt image restore-failed", { - image_build_id: image.imageBuildId, - error: e instanceof Error ? e.message : String(e), - }); - } - }, + /** 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 (!config.slackAgentNotifyLookup) return false; - try { - return await config.slackAgentNotifyLookup.isEnabledForRepo( - sessionHasRepository(session) ? session.repo_owner : null, - sessionHasRepository(session) ? session.repo_name : null - ); - } catch (err) { - 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 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 (!config.mcpServerLookup) return undefined; - const servers = await config.mcpServerLookup.getDecryptedForSession( - repositories.map(({ repoOwner, repoName }) => ({ repoOwner, repoName })) - ); - 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) { - getLogger().warn("Failed to load MCP servers", { - event: "mcp.load_failed", - error: String(err), - }); - return undefined; - } - }, + 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, provider.name); - if (unsupported.length > 0) { - getLogger().warn("Ignoring persisted sandbox settings unsupported by the provider", { - event: "sandbox.settings_unsupported", - provider: provider.name, - settings: unsupported, - }); - } - sandboxSettings = omitUnsupportedSandboxSettings(settings, provider.name); - } catch { - getLogger().warn("Failed to parse sandbox_settings, using defaults"); - sandboxSettings = {}; + 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, + }); } - if (!provider.capabilities.supportsSandboxTimeout) { - if (sandboxSettings.sandboxTimeoutMs !== undefined) { - throw new SandboxProviderError( - `${provider.name} does not support configurable sandbox timeouts`, - "permanent" - ); - } - return { sandboxSettings, timeoutSeconds: undefined }; + 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" + ); } - const timeoutMs = sandboxSettings.sandboxTimeoutMs; - return { - sandboxSettings, - timeoutSeconds: timeoutMs === undefined ? undefined : timeoutMs / 1000, - }; - }, - }; + 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/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index 0344138261..db315478f2 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -58,9 +58,8 @@ import { isJwtUnexpired, mintJwt } from "../../auth/jwt"; import { parseStoredSandboxBootPhase, sandboxBootPhaseLogFields } from "../boot-phase"; import type { ImageBuildLookup } from "./image-selection"; import { - createSandboxLaunchContext, + SandboxLaunchContext, resolveImageBuildScope, - type SandboxLaunchContext, type SandboxLaunchConfig, type SandboxLaunchContextReader, } from "./launch-context"; @@ -523,7 +522,7 @@ export class SandboxLifecycleManager imageBuildLookup?: ImageBuildLookup, private readonly backgroundTasks?: BackgroundTasks ) { - this.launchContext = createSandboxLaunchContext({ + this.launchContext = new SandboxLaunchContext({ sessionContext, provider: { name: provider.name, From cf345db3028387de90ccdfc955cec4d77550939d Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Tue, 29 Sep 2026 23:05:02 -0700 Subject: [PATCH 12/44] feat(control-plane): manage session visibility and team scope (#2145) ## Summary - Add always-enforced visibility, team-scope, and collaborator routes, with grant checks, descendant handling, active-user validation, and session/team audit rows. - Accept team and visibility on session creation, inherit private ownership and collaborators in child sessions, and return row-derived scope and capabilities in session snapshots and lists. - Add the workspace `requireTeamOnCreate` setting and Settings > Teams toggle; update authentication documentation and the changelog. ## Checkpoint - Based on `main` at `671661e`, with D1 migration `0083` already present. No D1 or DO migration added. `TEAMS_ENFORCEMENT` defaults to `shadow`; existing routes keep `off`/`shadow` legacy handling for non-private rows, while these new mutations always run the resolver. The persisted D1 row, not the path or runtime participants, determines access. - Verified `handleCreateSession` at `routes/session-create.ts:73`, `handleSpawnChild` at `routes/session-child-spawn.ts:54`, `handleSessionSnapshot` at `routes/session-runtime-proxy.ts:164`, and `SessionIndexStore.listByParent` at `db/session-index.ts:766` on the base. The child prompt handler is at `routes/session-children.ts:83-100`, not the cited `279-283` (those lines describe another route); its child/parent check was retained and extended. No behavioral premise needed redesign. - Initial route tests were red with 404s before the new endpoints were mounted. The first complete unit run reported `Test Files 2 failed | 330 passed (332)` and `Tests 3 failed | 5398 passed (5401)` from expectations predating response fields. The first complete integration run reported `Test Files 3 failed | 121 passed (124)` and `Tests 6 failed | 1493 passed | 1 skipped (1500)` from route snapshots, Viewer denial reasons, and off-mode snapshot membership loading. The expectations and route snapshots were corrected; subsequent suites passed. - Final validation: `npm run build -w @open-inspect/shared`, `npm run typecheck`, `npm run lint:fix`, `npm run lint:sql-portability`, `npm run format:check`, `npm run lint:complexity` (report-only), `npm test -w @open-inspect/shared` (1,062 tests), `npm test -w @open-inspect/control-plane` (5,401 tests at full-suite run), `npm run test:integration -w @open-inspect/control-plane` (1,499 passed, 1 skipped at full-suite run), and `npm test -w @open-inspect/web` (1,989 tests) passed. Focused unit/integration tests and typecheck passed after the final small store/route adjustments. - Deliberately left out repository-grant management UI/API, scoped sandbox tokens, and session-page team controls: those are separate follow-up work. No migration or credential expansion is included here. Issue: https://linear.app/colemurray/issue/COL-201/teams-pr-8-control-plane-docs-visibility-move-and-collaborator-routes-team-fields-on-post-sessions-child-inheritance-docsauthmd --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/7c08ddf567238ca57030de14d183e3ea)* ## Summary by CodeRabbit * **New Features** * Sessions can be assigned to teams and set to team, workspace, or private visibility. Access reflects team membership, ownership, and collaborator status. * Authorized users can change visibility, move sessions between teams, and manage collaborators; changes are recorded in the audit log. * Workspace managers can require team assignment when creating sessions. Team assignment checks membership and repository access. * Session lists and snapshots show available access capabilities. * Team access enforcement supports off, shadow, and on modes, with shadow as the default. Private-session restrictions apply in every mode. * **Documentation** * Updated access guidance to explain how team membership, session visibility, and repository permissions affect access. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- CHANGELOG.md | 24 +- docs/AUTH.md | 154 ++- .../src/authorization/session-admission.ts | 36 +- .../control-plane/src/db/session-audit.ts | 51 + .../src/db/session-collaborators.ts | 45 +- .../src/db/session-index.test.ts | 4 +- .../control-plane/src/db/session-index.ts | 54 +- .../src/db/session-scope-store.ts | 114 ++ packages/control-plane/src/db/team-audit.ts | 5 +- .../control-plane/src/db/team-memberships.ts | 13 +- .../src/db/team-repository-grants.ts | 27 + .../control-plane/src/db/team-settings.ts | 21 + .../src/router.create-session.test.ts | 74 +- .../control-plane/src/router.policy.test.ts | 6 +- packages/control-plane/src/routes/catalog.ts | 2 + .../src/routes/session-child-spawn.ts | 3 +- .../src/routes/session-children.test.ts | 16 +- .../src/routes/session-children.ts | 155 ++- .../src/routes/session-create.ts | 44 +- .../src/routes/session-index.test.ts | 17 +- .../control-plane/src/routes/session-index.ts | 26 +- .../src/routes/session-runtime-proxy.ts | 32 +- .../control-plane/src/routes/session-scope.ts | 298 +++++ .../src/routes/session-team-grants.ts | 26 + packages/control-plane/src/routes/sessions.ts | 2 + .../src/routes/settings-teams.ts | 38 + packages/control-plane/src/routes/shared.ts | 21 +- .../src/routing/route-admission.ts | 3 +- .../control-plane/src/session/initialize.ts | 12 + ...ono-route-catalog-conformance.test.ts.snap | 238 ++-- .../route-admission-matrix.test.ts.snap | 20 + .../integration/child-session-ops.test.ts | 185 +++ .../hono-route-catalog-conformance.test.ts | 2 +- .../route-admission-matrix.test.ts | 16 +- .../integration/session-access-routes.test.ts | 4 +- .../integration/session-scope-routes.test.ts | 1038 +++++++++++++++++ .../test/integration/spawn-children.test.ts | 97 +- packages/shared/src/types/audit-events.ts | 5 + packages/shared/src/types/integrations.ts | 8 + packages/shared/src/types/server-messages.ts | 12 +- packages/shared/src/types/session-api.ts | 3 + packages/shared/src/types/sessions.ts | 19 + packages/shared/src/types/teams.ts | 3 + .../src/app/api/settings/teams/route.test.ts | 46 + .../web/src/app/api/settings/teams/route.ts | 3 + .../settings/audit-log-settings.tsx | 5 + .../settings/teams-settings.test.tsx | 56 +- .../components/settings/teams-settings.tsx | 71 +- 48 files changed, 2922 insertions(+), 232 deletions(-) create mode 100644 packages/control-plane/src/db/session-audit.ts create mode 100644 packages/control-plane/src/db/session-scope-store.ts create mode 100644 packages/control-plane/src/db/team-repository-grants.ts create mode 100644 packages/control-plane/src/db/team-settings.ts create mode 100644 packages/control-plane/src/routes/session-scope.ts create mode 100644 packages/control-plane/src/routes/session-team-grants.ts create mode 100644 packages/control-plane/src/routes/settings-teams.ts create mode 100644 packages/control-plane/test/integration/session-scope-routes.test.ts create mode 100644 packages/web/src/app/api/settings/teams/route.test.ts create mode 100644 packages/web/src/app/api/settings/teams/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec9bc8702..b5cd4a45eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,16 @@ New features, integrations, and notable improvements to Open-Inspect — newest ### 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`. - -WebSocket subscribe and per-command session checks now follow the current session row. Private -sessions stay restricted in every enforcement mode; team access follows the resolver when -`TEAMS_ENFORCEMENT=on`, including lifecycle access after membership or scope changes. Workspace-wide -session lists and bulk export follow in subsequent changes; routes to change a session's team or -visibility are not yet available. - -Session lists, the inbox, children lists, bulk export, and analytics now filter by persisted row -visibility. Private sessions remain restricted in every mode; only Owners and administrators receive -their unattributed, scope-filtered cost total in analytics. The WebSocket path follows in a later -change; no route can yet make a session private or team-owned. +**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 changes are available. 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..c7e2c4b8fb 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,8 +45,8 @@ 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 | @@ -59,51 +60,121 @@ 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 (except private +sessions where they are not collaborators), 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. + +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 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; 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. + +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. + +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, 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. Changes to team ownership require the session owner, an +owning-team lead, or a workspace Owner/Administrator, as well as lifecycle permission; changing +private visibility is reserved for the session owner or a workspace Owner. Private-session action +rules apply even while team enforcement is off or in shadow mode. + +### Creating and Moving 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 +does not migrate or hide existing `ownerTeamId: null` workspace rows. + +Moving a session to a team checks active membership in the destination (or an explicit join to an +open team) and repository grants for every repository in the session and included descendants. A +missing grant blocks the move. Moving to no team sets `ownerTeamId` to `null` and turns `team` +visibility into `workspace`. Moving or changing visibility can include descendants. Team grants +constrain selection and moves, not the source-control App token already available to a running +sandbox. + +### Enforcement and Access Paths + +Operators set `TEAMS_ENFORCEMENT` to `off`, `shadow` (the default), or `on`: + +- `off`: legacy workspace-wide session authorization for non-private sessions; private sessions + remain restricted. +- `shadow`: continue legacy access for non-private sessions while recording where team or ownership + rules would deny access; private restrictions still take effect. +- `on`: enforce visibility, team membership, and action/ownership rules for sessions. + +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 +238,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 or move; 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/packages/control-plane/src/authorization/session-admission.ts b/packages/control-plane/src/authorization/session-admission.ts index 684ec19fca..f41c175f00 100644 --- a/packages/control-plane/src/authorization/session-admission.ts +++ b/packages/control-plane/src/authorization/session-admission.ts @@ -1,4 +1,11 @@ -import { checkSessionAccess, type SessionAction, type SessionViewer } from "@open-inspect/shared"; +import { + checkSessionAccess, + sessionCapabilities, + 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"; @@ -37,6 +44,28 @@ export function viewerFromContext( }; } +/** Existing non-private routes keep legacy permissions while enforcement is off or shadowed. */ +export function effectiveSessionCapabilities( + viewer: SessionViewer, + row: SessionAccessRow, + mode: TeamsEnforcementMode +): SessionCapabilities { + const capabilities = sessionCapabilities(viewer, row); + if (viewer.kind !== "user" || row.visibility === "private" || mode === "on") { + return capabilities; + } + const has = (action: SessionAction) => + viewer.permissions.includes(legacyPermissionForAction(action)); + return { + ...capabilities, + canRead: has("read"), + canCollaborate: has("collaborate"), + canManageLifecycle: has("lifecycle"), + canDelete: has("delete"), + canSandbox: has("sandbox"), + }; +} + export type SessionAdmissionOutcome = | { kind: "not_found" } | { kind: "action_denied"; reason: string } @@ -48,9 +77,10 @@ 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" }; 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..a951a5101f --- /dev/null +++ b/packages/control-plane/src/db/session-audit.ts @@ -0,0 +1,51 @@ +import type { SqlDatabase, SqlStatement } from "./sql-database"; + +export type SessionAuditAction = + | "session.visibility_changed" + | "session.moved" + | "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..7a5ae8d396 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,49 @@ 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[]): Promise> { + const result = new Map(); + 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(", ")})` + ) + .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-index.test.ts b/packages/control-plane/src/db/session-index.test.ts index 5d694af16e..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", diff --git a/packages/control-plane/src/db/session-index.ts b/packages/control-plane/src/db/session-index.ts index e67fc61ecb..beab2e1862 100644 --- a/packages/control-plane/src/db/session-index.ts +++ b/packages/control-plane/src/db/session-index.ts @@ -39,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; @@ -112,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 { @@ -203,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) { @@ -307,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; 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..f832562ef0 --- /dev/null +++ b/packages/control-plane/src/db/session-scope-store.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +import { sessionRepositoryRowSchema } from "./session-list-metadata"; +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 listRepositoryIds( + sessionId: string + ): Promise> { + const rows = await this.db + .prepare("SELECT * FROM session_repositories WHERE session_id = ? ORDER BY position") + .bind(sessionId) + .all(); + const repositories = rows.results.map((row) => { + const parsed = sessionRepositoryRowSchema.parse(row); + return { repoOwner: parsed.repo_owner, repoName: parsed.repo_name, repoId: parsed.repo_id }; + }); + if (repositories.length) return repositories; + const row = await this.db + .prepare("SELECT repo_owner, repo_name FROM sessions WHERE id = ?") + .bind(sessionId) + .first(); + const session = row + ? z.object({ repo_owner: z.string().nullable(), repo_name: z.string().nullable() }).parse(row) + : null; + return session?.repo_owner && session.repo_name + ? [{ repoOwner: session.repo_owner, repoName: session.repo_name, repoId: null }] + : []; + } + + 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]; + }) + ); + } + + async updateOwnerTeam( + ids: string[], + teamId: string | null, + audits: SessionAuditStatement[] = [], + beforeStatements: SqlStatement[] = [], + memberUserId?: string + ): Promise { + if (!ids.length) return false; + const activeTeam = teamId + ? " AND EXISTS (SELECT 1 FROM teams WHERE id = ? AND archived_at IS NULL)" + : ""; + const membership = memberUserId + ? " AND EXISTS (SELECT 1 FROM team_memberships WHERE team_id = ? AND user_id = ?)" + : ""; + const auditBySessionId = new Map( + audits.map(({ sessionId, statement }) => [sessionId, statement]) + ); + const statements = [...beforeStatements]; + const updateIndexes: number[] = []; + for (const id of ids) { + updateIndexes.push(statements.length); + statements.push( + this.db + .prepare( + `UPDATE sessions SET owner_team_id = ?, visibility = CASE WHEN ? IS NULL AND visibility = 'team' THEN 'workspace' ELSE visibility END WHERE id = ?${activeTeam}${membership}` + ) + .bind( + teamId, + teamId, + id, + ...(teamId ? [teamId] : []), + ...(memberUserId ? [teamId, memberUserId] : []) + ) + ); + const audit = auditBySessionId.get(id); + if (audit) statements.push(audit); + } + const results = await this.db.batch(statements); + return updateIndexes.every((index) => (results[index]?.meta.changes ?? 0) > 0); + } +} 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..650be21946 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() { @@ -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; + } + + 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-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/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 57dd559b1f..38fcbede6c 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -16,11 +16,11 @@ function routeFor(method: string, path: string) { describe("route policy table", () => { it("publishes the complete canonical route catalog", () => { - expect(routes).toHaveLength(193); + expect(routes).toHaveLength(199); const paths = routes.map((route) => route.path); - expect(new Set(paths).size).toBe(147); - expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(193); + expect(new Set(paths).size).toBe(151); + expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(199); }); it("gates run analytics with analytics.read", () => { diff --git a/packages/control-plane/src/routes/catalog.ts b/packages/control-plane/src/routes/catalog.ts index 418f4e1257..4f96e9244c 100644 --- a/packages/control-plane/src/routes/catalog.ts +++ b/packages/control-plane/src/routes/catalog.ts @@ -32,6 +32,7 @@ import { slackNotifyRoutes } from "./slack-notify"; import { signInProviderRoutes } from "./sign-in-providers"; import { skillRoutes } from "./skills"; import { teamRoutes } from "./teams"; +import { teamSettingsRoutes } from "./settings-teams"; /** Registration order is the precedence order: each module is mounted where it appears. */ export const catalog: readonly RouteModule[] = [ @@ -41,6 +42,7 @@ export const catalog: readonly RouteModule[] = [ signInProviderRoutes, teamRoutes, + teamSettingsRoutes, // Session management, then the agent-initiated Slack notification sessionRoutes, diff --git a/packages/control-plane/src/routes/session-child-spawn.ts b/packages/control-plane/src/routes/session-child-spawn.ts index ea35d37d72..32188883b0 100644 --- a/packages/control-plane/src/routes/session-child-spawn.ts +++ b/packages/control-plane/src/routes/session-child-spawn.ts @@ -267,7 +267,8 @@ export async function handleSpawnChild( model, reasoningEffort, participantUserId: spawnContext.promptAuthor.userId, - platformUserId: spawnContext.promptAuthor.canonicalUserId ?? null, + platformUserId: spawnContext.promptAuthor.canonicalUserId ?? parentSession?.userId ?? 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 04fc095214..7d07dbabce 100644 --- a/packages/control-plane/src/routes/session-children.test.ts +++ b/packages/control-plane/src/routes/session-children.test.ts @@ -132,6 +132,11 @@ describe("handleListChildren", () => { }); it("projects viewer-neutral child summaries through the shared schema", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ + id: "parent", + ownerTeamId: null, + visibility: "workspace", + } as never); vi.spyOn(SessionIndexStore.prototype, "listByParent").mockResolvedValue([ { id: "child", @@ -144,7 +149,7 @@ describe("handleListChildren", () => { baseBranch: "main", status: "active", ownerTeamId: null, - visibility: "private", + visibility: "workspace", parentSessionId: "parent", spawnSource: "agent", spawnDepth: 1, @@ -176,6 +181,8 @@ describe("handleListChildren", () => { children: [ { id: "child", + ownerTeamId: null, + visibility: "workspace", title: "Child", repoOwner: "acme", repoName: "web", @@ -464,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", @@ -482,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 dfab8bdb92..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 { teamsEnforcementMode, viewerFromContext } 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,17 +38,84 @@ 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; @@ -52,6 +131,17 @@ export async function handleListChildren( 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 })); } @@ -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 73c1256ffd..66f30b7439 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 { initializeSession, type SessionInitInput } from "../session/initialize"; @@ -162,6 +166,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; @@ -245,8 +285,8 @@ export async function handleCreateSession( } const input: SessionInitInput = { - ownerTeamId: null, - visibility: "workspace", + ownerTeamId: teamId, + visibility, sessionId, repoOwner, repoName, diff --git a/packages/control-plane/src/routes/session-index.test.ts b/packages/control-plane/src/routes/session-index.test.ts index bf3b013abc..833737aeda 100644 --- a/packages/control-plane/src/routes/session-index.test.ts +++ b/packages/control-plane/src/routes/session-index.test.ts @@ -76,6 +76,8 @@ const USER_PRINCIPAL: Principal = { kind: "user", userId: "user-1" }; const listSession = { id: "session-1", + ownerTeamId: null, + visibility: "workspace" as const, title: "Session 1", repoOwner: "open-inspect", repoName: "background-agents", @@ -378,7 +380,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, }); }); @@ -391,7 +399,12 @@ 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, }); }); diff --git a/packages/control-plane/src/routes/session-index.ts b/packages/control-plane/src/routes/session-index.ts index 035c7c45c5..e566a2d2ca 100644 --- a/packages/control-plane/src/routes/session-index.ts +++ b/packages/control-plane/src/routes/session-index.ts @@ -36,7 +36,12 @@ import { encodeSessionInboxCursor, parseSessionInboxCursor } from "../db/session import { parseQuery } from "./query"; import { TeamMembershipStore } from "../db/team-memberships"; import { D1QueryParameterLimitError } from "../db/query-limits"; -import { teamsEnforcementMode, viewerFromContext } from "../authorization/session-admission"; +import { + effectiveSessionCapabilities, + teamsEnforcementMode, + viewerFromContext, +} from "../authorization/session-admission"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; const sessionInboxQuerySchema = z.object({ category: z @@ -179,6 +184,23 @@ export async function handleListSessions( }) ); if (result instanceof Response) return result; + const collaborators = await new SessionCollaboratorStore(ctx.db).listForSessions( + result.sessions.filter((row) => row.visibility === "private").map((row) => row.id) + ); + 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", @@ -191,7 +213,7 @@ export async function handleListSessions( const response = json( sessionListResponseSchema.parse({ - sessions: result.sessions, + sessions, hasMore: result.hasMore, }) ); diff --git a/packages/control-plane/src/routes/session-runtime-proxy.ts b/packages/control-plane/src/routes/session-runtime-proxy.ts index b77f2e4ff1..f548dc09d2 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.ts @@ -16,6 +16,13 @@ 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 type { SubscriptionProviderId } from "@open-inspect/shared/types/provider-accounts"; import { SessionInternalPaths, type SessionInternalPath } from "../session/contracts"; import type { Env } from "../types"; @@ -163,7 +170,7 @@ async function handleParticipantProfiles( async function handleSessionSnapshot( _request: Request, - _env: Env, + env: Env, params: SessionParams, ctx: SessionRouteContext ): Promise { @@ -175,12 +182,35 @@ async function handleSessionSnapshot( const parsed = sessionSnapshotSchema.safeParse(await response.json().catch(() => null)); if (!parsed.success) return error("Invalid session snapshot", 502); + 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 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 snapshot = sandboxAllowed ? parsed.data : redactSessionSnapshotSandboxAccess(parsed.data); + snapshot.session = { + ...snapshot.session, + 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..cd32dfb997 --- /dev/null +++ b/packages/control-plane/src/routes/session-scope.ts @@ -0,0 +1,298 @@ +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 { TeamAuditStore } from "../db/team-audit"; +import { SessionCollaboratorStore } from "../db/session-collaborators"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionScopeStore } from "../db/session-scope-store"; +import { evaluateSessionAdmission } from "../authorization/session-admission"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamStore } from "../db/teams"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import type { Env } from "../types"; +import type { SqlStatement } from "../db/sql-database"; +import { parseBody } from "./body"; +import { missingTeamRepository } from "./session-team-grants"; +import { + error, + SCM_AGNOSTIC_HUMAN_USER_ROUTE, + json, + requireSession, + type RequestContext, +} from "./shared"; + +const visibilityBody = z.strictObject({ + visibility: sessionVisibilitySchema, + includeChildren: z.boolean().default(true), +}); +const scopeBody = z.strictObject({ + teamId: z.string().min(1).nullable(), + includeChildren: z.boolean().default(true), + joinTeam: z.boolean().default(false), +}); + +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 moveSession( + request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +) { + const body = await parseBody(request, scopeBody, "Invalid scope"); + if (body instanceof Response) return body; + const admission = ctx.sessionAdmission!; + if (admission.viewer.kind !== "user") return denied("missing_permission"); + const actorUserId = admission.viewer.userId; + 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, "move"); + if (descendantDenial) return descendantDenial; + const rows = [ + admission.row, + ...(await Promise.all(ids.slice(1).map((id) => store.get(id)))), + ].filter((row): row is SessionEntry => row !== null); + if (rows.length !== ids.length) return error("Session not found", 404); + const changedRows = rows.filter( + (row) => row.ownerTeamId !== body.teamId || (body.teamId === null && row.visibility === "team") + ); + const beforeStatements: SqlStatement[] = []; + let requiredMemberUserId: string | undefined; + if (body.teamId) { + const team = await new TeamStore(ctx.db).getById(body.teamId); + if (!team) return error("Team not found", 404); + if (team.archivedAt !== null) + return json({ error: "Team archived", code: "team_archived" }, 409); + const memberships = new TeamMembershipStore(ctx.db); + const member = admission.viewer.memberships.has(body.teamId); + if (!member && (!body.joinTeam || team.joinPolicy !== "open")) return denied("not_member"); + requiredMemberUserId = actorUserId; + for (const id of ids) { + const missing = await missingTeamRepository( + ctx.db, + body.teamId, + await scope.listRepositoryIds(id) + ); + if (missing) + return json( + { + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: `${missing.repoOwner}/${missing.repoName}`, + }, + 409 + ); + } + if (!member) { + beforeStatements.push( + memberships.bindAddIfJoinable(body.teamId, admission.viewer.userId), + new TeamAuditStore(ctx.db).bind( + { + requestId: ctx.request_id, + actorUserId: admission.viewer.userId, + action: "team.member_joined", + teamId: body.teamId, + targetUserId: admission.viewer.userId, + before: {}, + after: { role: "member" }, + }, + true + ) + ); + } + } + if (changedRows.length === 0) { + return json({ sessionId: params.id, ownerTeamId: body.teamId, affectedSessionIds: [] }); + } + const auditStore = new SessionAuditStore(ctx.db); + const audits = changedRows.map((row) => ({ + sessionId: row.id, + statement: auditStore.bind( + { + requestId: ctx.request_id, + actorUserId, + action: "session.moved", + sessionId: row.id, + teamId: body.teamId, + before: { teamId: row.ownerTeamId, visibility: row.visibility }, + after: { + teamId: body.teamId, + visibility: + body.teamId === null && row.visibility === "team" ? "workspace" : row.visibility, + }, + }, + true + ), + })); + if ( + !(await scope.updateOwnerTeam( + changedRows.map((row) => row.id), + body.teamId, + audits, + beforeStatements, + requiredMemberUserId + )) + ) { + if (body.teamId) { + if (!(await new TeamStore(ctx.db).isActive(body.teamId))) { + return json({ error: "Team archived", code: "team_archived" }, 409); + } + if (!(await new TeamMembershipStore(ctx.db).listForUser(actorUserId)).has(body.teamId)) { + return denied("not_member"); + } + } + return error("Session not found", 404); + } + return json({ + sessionId: params.id, + ownerTeamId: body.teamId, + affectedSessionIds: changedRows.map((row) => row.id), + }); +} + +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 user = z + .object({ suspended_at: z.number().nullable(), role_id: z.string().nullable() }) + .nullable() + .parse( + await ctx.db + .prepare( + `SELECT users.suspended_at, assignment.role_id FROM users + LEFT JOIN user_role_assignments assignment ON assignment.user_id = users.id + WHERE users.id = ?` + ) + .bind(params.userId) + .first() + ); + if (!user) return error("User not found", 404); + if (user.suspended_at !== null || user.role_id === null) + return json({ error: "User inactive", code: "user_inactive" }, 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", + }); +} + +export const sessionScopeRoutes = new Hono(); +const always = (action: "changeVisibility" | "move" | "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/scope", always("move"), (c) => dispatch(c, moveSession)); +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)) +); 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/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.ts b/packages/control-plane/src/routes/shared.ts index 90c8b8225f..7f72885791 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -47,7 +47,7 @@ export type RouteAuthorizationRequirement = automationIdParam: string; } | { kind: "team"; teamIdParam: string; need: keyof TeamCapabilities | "read" } - | { kind: "session"; sessionIdParam: string; action: SessionAction }; + | { kind: "session"; sessionIdParam: string; action: SessionAction; enforceAlways?: boolean }; type BotServiceName = Exclude; const DEFAULT_AUDIT_ALLOWED = false; @@ -199,18 +199,29 @@ export function requireTeam( 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[] } + options?: { + sessionIdParam?: string; + actorlessGrants?: readonly ActorlessServiceGrant[]; + enforceAlways?: boolean; + } ): RouteAuthorization { 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", }; } diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts index a9d3779ae4..02e7bc619a 100644 --- a/packages/control-plane/src/routing/route-admission.ts +++ b/packages/control-plane/src/routing/route-admission.ts @@ -669,7 +669,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/session/initialize.ts b/packages/control-plane/src/session/initialize.ts index da96b0bc85..cbb3e735cb 100644 --- a/packages/control-plane/src/session/initialize.ts +++ b/packages/control-plane/src/session/initialize.ts @@ -67,6 +67,7 @@ export interface SessionInitInput { platformUserId: string | null; ownerTeamId: string | null; visibility: SessionVisibility; + collaboratorSourceSessionId?: string; // SCM identity scmLogin?: string | null; @@ -167,6 +168,9 @@ export async function initializeSession( // Step 1: D1 index (must succeed before DO init starts sandbox warming) const sessionStore = new SessionIndexStore(ctx.db); + if (input.visibility === "private" && !input.platformUserId) { + throw new Error("Private sessions require a canonical owner"); + } await sessionStore.create({ id: input.sessionId, title: input.title || null, @@ -188,6 +192,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, 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 306b0ae1ea..35033052db 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 @@ -21,76 +21,82 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"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":"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 /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-22-id%2Fraw/visibility","groups":{"id":"fixture-22-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/scope","pathname":"/sessions/fixture-23-id%2Fraw/scope","groups":{"id":"fixture-23-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"move","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","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-28-id%2Fraw/read-state","groups":{"id":"fixture-28-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-29-id%2Fraw","groups":{"id":"fixture-29-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-31-id%2Fraw/export","groups":{"id":"fixture-31-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-32-id%2Fraw/sandbox-access","groups":{"id":"fixture-32-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-33-id%2Fraw","groups":{"id":"fixture-33-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-34-id%2Fraw/stop","groups":{"id":"fixture-34-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-35-id%2Fraw/sandbox-error","groups":{"id":"fixture-35-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-36-id%2Fraw/events","groups":{"id":"fixture-36-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-37-id%2Fraw/artifacts","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/participants","pathname":"/sessions/fixture-38-id%2Fraw/participants","groups":{"id":"fixture-38-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-39-id%2Fraw/participant-profiles","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":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-40-id%2Fraw/messages","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":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-41-id%2Fraw/pr","groups":{"id":"fixture-41-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-42-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-42-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-43-id%2Fraw/xai-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/scm-credentials","pathname":"/sessions/fixture-44-id%2Fraw/scm-credentials","groups":{"id":"fixture-44-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-45-id%2Fraw/tunnel-urls","groups":{"id":"fixture-45-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-46-id%2Fraw/title","groups":{"id":"fixture-46-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-47-id%2Fraw/archive","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/unarchive","pathname":"/sessions/fixture-48-id%2Fraw/unarchive","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":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-49-id%2Fraw/budget","groups":{"id":"fixture-49-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-51-id%2Fraw/ws-token","groups":{"id":"fixture-51-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-52-id%2Fraw/prompt","groups":{"id":"fixture-52-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-53-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-53-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-54-id%2Fraw/media","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":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-55-id%2Fraw/media/fixture-55-artifactId%2Fraw","groups":{"id":"fixture-55-id%2Fraw","artifactId":"fixture-55-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-56-id%2Fraw/attachments","groups":{"id":"fixture-56-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-57-id%2Fraw/attachments/fixture-57-attachmentId%2Fraw","groups":{"id":"fixture-57-id%2Fraw","attachmentId":"fixture-57-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-58-id%2Fraw/diff","groups":{"id":"fixture-58-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-59-id%2Fraw/diff","groups":{"id":"fixture-59-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-60-id%2Fraw/diff/failure","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":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-61-id%2Fraw/diff/fixture-61-revisionId%2Fraw/files/fixture-61-fileId%2Fraw","groups":{"id":"fixture-61-id%2Fraw","revisionId":"fixture-61-revisionId%2Fraw","fileId":"fixture-61-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-62-id%2Fraw/diff/retry","groups":{"id":"fixture-62-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-63-id%2Fraw/skills","groups":{"id":"fixture-63-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-64-id%2Fraw/sandbox-skills","groups":{"id":"fixture-64-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-65-id%2Fraw/children","groups":{"id":"fixture-65-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-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":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-67-id%2Fraw/children/fixture-67-childId%2Fraw","groups":{"id":"fixture-67-id%2Fraw","childId":"fixture-67-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-68-id%2Fraw/children/fixture-68-childId%2Fraw/cancel","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":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-69-id%2Fraw/children/fixture-69-childId%2Fraw/prompt","groups":{"id":"fixture-69-id%2Fraw","childId":"fixture-69-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-70-id%2Fraw/slack-notify","groups":{"id":"fixture-70-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-72-owner%2Fraw/fixture-72-name%2Fraw/metadata","groups":{"owner":"fixture-72-owner%2Fraw","name":"fixture-72-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-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.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-74-owner%2Fraw/fixture-74-name%2Fraw/branches","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"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-75-owner%2Fraw/fixture-75-name%2Fraw/secrets","groups":{"owner":"fixture-75-owner%2Fraw","name":"fixture-75-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-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":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-77-owner%2Fraw/fixture-77-name%2Fraw/secrets/fixture-77-key%2Fraw","groups":{"owner":"fixture-77-owner%2Fraw","name":"fixture-77-name%2Fraw","key":"fixture-77-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-80-key%2Fraw","groups":{"key":"fixture-80-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-83-id%2Fraw","groups":{"id":"fixture-83-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-84-id%2Fraw","groups":{"id":"fixture-84-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-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":"GET /environments/:id/secrets","pathname":"/environments/fixture-86-id%2Fraw/secrets","groups":{"id":"fixture-86-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-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":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-88-id%2Fraw/secrets/import","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":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-89-id%2Fraw/secrets/fixture-89-key%2Fraw","groups":{"id":"fixture-89-id%2Fraw","key":"fixture-89-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-92-id%2Fraw","groups":{"id":"fixture-92-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-93-owner%2Fraw/fixture-93-name%2Fraw","groups":{"owner":"fixture-93-owner%2Fraw","name":"fixture-93-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-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":"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,65 +106,65 @@ 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-104-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-104-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-105-provider%2Fraw/device-authorizations/fixture-105-id%2Fraw/poll","groups":{"provider":"fixture-105-provider%2Fraw","id":"fixture-105-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-106-provider%2Fraw/device-authorizations/fixture-106-id%2Fraw","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":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-107-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-107-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-108-provider%2Fraw/authorization-codes/fixture-108-id%2Fraw","groups":{"provider":"fixture-108-provider%2Fraw","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/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-109-provider%2Fraw/authorization-codes/fixture-109-id%2Fraw/complete","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":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-110-provider%2Fraw/authorization-codes/fixture-110-id%2Fraw","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":"GET /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.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-112-id%2Fraw","groups":{"id":"fixture-112-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-113-id%2Fraw/verify","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/disable","pathname":"/model-provider-accounts/fixture-114-id%2Fraw/disable","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/enable","pathname":"/model-provider-accounts/fixture-115-id%2Fraw/enable","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/reconnect","pathname":"/model-provider-accounts/fixture-116-id%2Fraw/reconnect","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":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-117-id%2Fraw","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":"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 /integration-settings/:id","pathname":"/integration-settings/fixture-117-id%2Fraw","groups":{"id":"fixture-117-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-118-id%2Fraw","groups":{"id":"fixture-118-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-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":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-120-id%2Fraw/repos","groups":{"id":"fixture-120-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-121-id%2Fraw/repos/fixture-121-owner%2Fraw/fixture-121-name%2Fraw","groups":{"id":"fixture-121-id%2Fraw","owner":"fixture-121-owner%2Fraw","name":"fixture-121-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-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":"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-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":"GET /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-124-id%2Fraw/environments/fixture-124-environmentId%2Fraw","groups":{"id":"fixture-124-id%2Fraw","environmentId":"fixture-124-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-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":"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-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":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-127-id%2Fraw/resolved/fixture-127-owner%2Fraw/fixture-127-name%2Fraw","groups":{"id":"fixture-127-id%2Fraw","owner":"fixture-127-owner%2Fraw","name":"fixture-127-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-119-provider%2Fraw","groups":{"provider":"fixture-119-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-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":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-121-id%2Fraw/provider-auth/fixture-121-provider%2Fraw/access-token","groups":{"id":"fixture-121-id%2Fraw","provider":"fixture-121-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-122-id%2Fraw/provider-auth/fixture-122-provider%2Fraw/runtime-credential","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":"GET /integration-settings/:id","pathname":"/integration-settings/fixture-123-id%2Fraw","groups":{"id":"fixture-123-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-124-id%2Fraw","groups":{"id":"fixture-124-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-125-id%2Fraw","groups":{"id":"fixture-125-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-126-id%2Fraw/repos","groups":{"id":"fixture-126-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-127-id%2Fraw/repos/fixture-127-owner%2Fraw/fixture-127-name%2Fraw","groups":{"id":"fixture-127-id%2Fraw","owner":"fixture-127-owner%2Fraw","name":"fixture-127-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-128-id%2Fraw/repos/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":"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-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":"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-130-id%2Fraw/environments/fixture-130-environmentId%2Fraw","groups":{"id":"fixture-130-id%2Fraw","environmentId":"fixture-130-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-131-id%2Fraw/environments/fixture-131-environmentId%2Fraw","groups":{"id":"fixture-131-id%2Fraw","environmentId":"fixture-131-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-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":"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-133-id%2Fraw/resolved/fixture-133-owner%2Fraw/fixture-133-name%2Fraw","groups":{"id":"fixture-133-id%2Fraw","owner":"fixture-133-owner%2Fraw","name":"fixture-133-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-131-id%2Fraw/commit-signing","groups":{"id":"fixture-131-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /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":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-137-id%2Fraw/commit-signing","groups":{"id":"fixture-137-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-138-id%2Fraw/commit-signing","groups":{"id":"fixture-138-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-137-owner%2Fraw/fixture-137-name%2Fraw","groups":{"owner":"fixture-137-owner%2Fraw","name":"fixture-137-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-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":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-143-owner%2Fraw/fixture-143-name%2Fraw","groups":{"owner":"fixture-143-owner%2Fraw","name":"fixture-143-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-144-owner%2Fraw/fixture-144-name%2Fraw","groups":{"owner":"fixture-144-owner%2Fraw","name":"fixture-144-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-143-id%2Fraw","groups":{"id":"fixture-143-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-144-id%2Fraw","groups":{"id":"fixture-144-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-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":"POST /automations/:id/pause","pathname":"/automations/fixture-146-id%2Fraw/pause","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/resume","pathname":"/automations/fixture-147-id%2Fraw/resume","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/trigger","pathname":"/automations/fixture-148-id%2Fraw/trigger","groups":{"id":"fixture-148-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-149-id%2Fraw/invocations","groups":{"id":"fixture-149-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-150-id%2Fraw/runs/fixture-150-runId%2Fraw","groups":{"id":"fixture-150-id%2Fraw","runId":"fixture-150-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-151-id%2Fraw/regenerate-key","groups":{"id":"fixture-151-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-149-id%2Fraw","groups":{"id":"fixture-149-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-150-id%2Fraw","groups":{"id":"fixture-150-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-151-id%2Fraw","groups":{"id":"fixture-151-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-152-id%2Fraw/pause","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":"POST /automations/:id/resume","pathname":"/automations/fixture-153-id%2Fraw/resume","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/trigger","pathname":"/automations/fixture-154-id%2Fraw/trigger","groups":{"id":"fixture-154-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-155-id%2Fraw/invocations","groups":{"id":"fixture-155-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-156-id%2Fraw/runs/fixture-156-runId%2Fraw","groups":{"id":"fixture-156-id%2Fraw","runId":"fixture-156-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-157-id%2Fraw/regenerate-key","groups":{"id":"fixture-157-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-154-id%2Fraw","groups":{"id":"fixture-154-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-155-id%2Fraw","groups":{"id":"fixture-155-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-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":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-160-id%2Fraw","groups":{"id":"fixture-160-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-161-id%2Fraw","groups":{"id":"fixture-161-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-162-id%2Fraw","groups":{"id":"fixture-162-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}", @@ -170,29 +176,29 @@ 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-168-id%2Fraw","groups":{"id":"fixture-168-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-174-id%2Fraw","groups":{"id":"fixture-174-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-172-id%2Fraw/reimport/preview","groups":{"id":"fixture-172-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-173-id%2Fraw/reimport","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":"PATCH /skills/:id","pathname":"/skills/fixture-174-id%2Fraw","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":"PUT /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":"DELETE /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":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-178-id%2Fraw/reimport/preview","groups":{"id":"fixture-178-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-179-id%2Fraw/reimport","groups":{"id":"fixture-179-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-180-id%2Fraw","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":"PUT /skills/:id","pathname":"/skills/fixture-181-id%2Fraw","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":"DELETE /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":"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-179-id%2Fraw","groups":{"id":"fixture-179-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-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":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-185-id%2Fraw","groups":{"id":"fixture-185-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-186-id%2Fraw","groups":{"id":"fixture-186-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-185-id%2Fraw","groups":{"id":"fixture-185-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-191-id%2Fraw","groups":{"id":"fixture-191-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-187-id%2Fraw/role","groups":{"id":"fixture-187-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-188-id%2Fraw/status","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":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-189-id%2Fraw","groups":{"id":"fixture-189-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-190-id%2Fraw","groups":{"id":"fixture-190-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-193-id%2Fraw/role","groups":{"id":"fixture-193-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-194-id%2Fraw/status","groups":{"id":"fixture-194-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":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-195-id%2Fraw","groups":{"id":"fixture-195-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-196-id%2Fraw","groups":{"id":"fixture-196-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}", ] 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 d2b52dabe9..b279156ee4 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 @@ -52,7 +52,13 @@ 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 /settings/teams owner=200", + "PATCH /settings/teams owner=400", "POST /sessions owner=201", + "PUT /sessions/:id/visibility owner=400", + "PUT /sessions/:id/scope owner=400", + "PUT /sessions/:id/collaborators/:userId owner=404", + "DELETE /sessions/:id/collaborators/:userId owner=200", "GET /sessions owner=200", "GET /sessions/inbox owner=200", "PATCH /sessions/:id/read-state owner=400", @@ -234,7 +240,13 @@ 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 /settings/teams anonymous=401", + "PATCH /settings/teams anonymous=401", "POST /sessions anonymous=401", + "PUT /sessions/:id/visibility anonymous=401", + "PUT /sessions/:id/scope anonymous=401", + "PUT /sessions/:id/collaborators/:userId anonymous=401", + "DELETE /sessions/:id/collaborators/:userId anonymous=401", "GET /sessions anonymous=401", "GET /sessions/inbox anonymous=401", "PATCH /sessions/:id/read-state anonymous=401", @@ -427,6 +439,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/scope other-team=404", + "PUT /sessions/:id/collaborators/:userId other-team=404", + "DELETE /sessions/:id/collaborators/:userId other-team=404", "PATCH /sessions/:id/read-state other-team=404", "DELETE /sessions/:id other-team=404", "GET /sessions/:id/export other-team=404", @@ -467,6 +483,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/scope same-team-viewer=403", + "PUT /sessions/:id/collaborators/:userId same-team-viewer=403", + "DELETE /sessions/:id/collaborators/:userId same-team-viewer=200", "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/child-session-ops.test.ts b/packages/control-plane/test/integration/child-session-ops.test.ts index 69430e394c..5631a532c2 100644 --- a/packages/control-plane/test/integration/child-session-ops.test.ts +++ b/packages/control-plane/test/integration/child-session-ops.test.ts @@ -4,6 +4,7 @@ import type { SessionStatus } from "@open-inspect/shared/types/sessions"; import { runInSessionDO } from "./session-do-access"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { SessionIndexStore } from "../../src/db/session-index"; +import { SessionScopeStore } from "../../src/db/session-scope-store"; import { cleanD1Tables } from "./cleanup"; import { initNamedSession, @@ -14,6 +15,8 @@ import { openClientWs, collectMessages, seedMessage, + seedActiveUser, + serviceFetch, TEST_SESSION_PROVIDER_AUTH, } from "./helpers"; @@ -137,6 +140,21 @@ 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 new SessionScopeStore(env.DB).updateOwnerTeam([id], "team_other"); + } + } + describe("GET /sessions/:parentId/children", () => { it("returns children from D1", async () => { const { pName, childName, sandboxToken } = await setupParentAndChild(); @@ -507,7 +525,174 @@ 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 new SessionScopeStore(env.DB).updateOwnerTeam([childName], "team_other"); + + 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/hono-route-catalog-conformance.test.ts b/packages/control-plane/test/integration/hono-route-catalog-conformance.test.ts index adb54f3718..274c4422b8 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(193); + expect(manifest).toHaveLength(199); // 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/route-admission-matrix.test.ts b/packages/control-plane/test/integration/route-admission-matrix.test.ts index 223fb17dfe..47cbc4bab3 100644 --- a/packages/control-plane/test/integration/route-admission-matrix.test.ts +++ b/packages/control-plane/test/integration/route-admission-matrix.test.ts @@ -662,7 +662,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" }, @@ -681,8 +681,18 @@ 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`; 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 fdfa5143cb..962868fb38 100644 --- a/packages/control-plane/test/integration/session-access-routes.test.ts +++ b/packages/control-plane/test/integration/session-access-routes.test.ts @@ -234,7 +234,7 @@ describe("HTTP session access by enforcement mode", () => { } ); - it("does not query memberships in off mode", async () => { + it("loads memberships for snapshot capabilities even in off mode", async () => { const { sessionName } = await session("team"); const list = vi.spyOn(TeamMembershipStore.prototype, "listForUser"); try { @@ -245,7 +245,7 @@ describe("HTTP session access by enforcement mode", () => { }) ).status ).toBe(200); - expect(list).not.toHaveBeenCalled(); + expect(list).toHaveBeenCalledOnce(); } finally { list.mockRestore(); } 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..e844e63bb7 --- /dev/null +++ b/packages/control-plane/test/integration/session-scope-routes.test.ts @@ -0,0 +1,1038 @@ +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 { TeamAuditStore } from "../../src/db/team-audit"; +import { SessionAuditStore } from "../../src/db/session-audit"; +import type { SqlDatabase, SqlStatement } from "../../src/db/sql-database"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { cleanD1Tables } from "./cleanup"; +import { + initSession, + routeRequest, + seedActiveUser, + serviceFetch, + serviceRequestHeaders, + sqlDatabase, +} 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(), + }); +} + +async function grant(teamId: string) { + await env.DB.prepare( + "INSERT INTO team_repository_grants (id, team_id, grant_kind, created_at) VALUES (?, ?, 'installation', ?)" + ) + .bind(crypto.randomUUID(), teamId, Date.now()) + .run(); +} + +describe("session scope routes", () => { + beforeEach(async () => { + await cleanD1Tables(); + await seedActiveUser(COLLABORATOR); + await request("/me/authorization"); + }); + + it("moves every descendant to a granted team and audits the move", async () => { + await session("root"); + await session("child", "root"); + await session("grandchild", "child"); + const team = await new TeamStore(env.DB).create({ + slug: "alpha", + name: "Alpha", + joinPolicy: "open", + }); + await grant(team.id); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + + const moved = await request("/sessions/root/scope", "PUT", { teamId: team.id }); + expect(moved.status).toBe(200); + for (const id of ["root", "child", "grandchild"]) { + expect((await new SessionIndexStore(env.DB).get(id))?.ownerTeamId).toBe(team.id); + } + expect(await (await request(`/sessions?teamIds[]=${team.id}`)).json()).toMatchObject({ + sessions: expect.arrayContaining([ + expect.objectContaining({ id: "root", ownerTeamId: team.id }), + ]), + }); + const audit = await env.DB.prepare( + "SELECT resource_id, team_id, metadata_json FROM authorization_audit_events WHERE action = 'session.moved' ORDER BY resource_id" + ).all<{ resource_id: string; team_id: string; metadata_json: string }>(); + expect( + audit.results.map((row) => ({ + sessionId: row.resource_id, + teamId: row.team_id, + metadata: JSON.parse(row.metadata_json), + })) + ).toEqual( + ["child", "grandchild", "root"].map((sessionId) => ({ + sessionId, + teamId: team.id, + metadata: { + before: { teamId: null, visibility: "workspace" }, + requested: {}, + after: { teamId: team.id, visibility: "workspace" }, + }, + })) + ); + }); + + it("records each independently scoped child's actual source team on a cascade", async () => { + await session("root"); + await session("child", "root"); + const teams = new TeamStore(env.DB); + const source = await teams.create({ + slug: "source", + name: "Source", + joinPolicy: "invite_only", + }); + const target = await teams.create({ + slug: "target", + name: "Target", + joinPolicy: "invite_only", + }); + await env.DB.prepare("UPDATE sessions SET owner_team_id = ? WHERE id = 'child'") + .bind(source.id) + .run(); + await new TeamMembershipStore(env.DB).add(target.id, OWNER); + await grant(target.id); + + expect((await request("/sessions/root/scope", "PUT", { teamId: target.id })).status).toBe(200); + const audits = await env.DB.prepare( + "SELECT resource_id, metadata_json FROM authorization_audit_events WHERE action = 'session.moved' ORDER BY resource_id" + ).all<{ resource_id: string; metadata_json: string }>(); + expect( + audits.results.map((row) => ({ + sessionId: row.resource_id, + before: JSON.parse(row.metadata_json).before.teamId, + })) + ).toEqual([ + { sessionId: "child", before: source.id }, + { sessionId: "root", before: null }, + ]); + }); + + it("does not audit a move when the requested scope is unchanged", async () => { + await session("root"); + expect((await request("/sessions/root/scope", "PUT", { teamId: null })).status).toBe(200); + const audit = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'session.moved'" + ).first<{ count: number }>(); + expect(audit?.count).toBe(0); + }); + + it("does not move a child without includeChildren and refuses a missing grant", async () => { + await session("root"); + await session("child", "root"); + const team = await new TeamStore(env.DB).create({ + slug: "alpha", + name: "Alpha", + joinPolicy: "open", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + const denied = await request("/sessions/root/scope", "PUT", { teamId: team.id }); + expect(denied.status).toBe(409); + expect(await denied.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/web-app", + }); + await grant(team.id); + expect( + (await request("/sessions/root/scope", "PUT", { teamId: team.id, includeChildren: false })) + .status + ).toBe(200); + expect((await new SessionIndexStore(env.DB).get("child"))?.ownerTeamId).toBeNull(); + }); + + it("requires grants for every repository of every descendant before joining a team", async () => { + await session("root"); + await session("child", "root"); + await env.DB.prepare( + `INSERT INTO session_repositories (session_id, position, repo_owner, repo_name, repo_id, base_branch) + VALUES ('child', 1, 'acme', 'api', 67890, 'main')` + ).run(); + const team = await new TeamStore(env.DB).create({ + slug: "multi-repo", + name: "Multi-repo", + joinPolicy: "open", + }); + await env.DB.prepare( + `INSERT INTO team_repository_grants + (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) + VALUES (?, ?, 'repository', 12345, 'acme', 'web-app', ?)` + ) + .bind(crypto.randomUUID(), team.id, Date.now()) + .run(); + + const denied = await request("/sessions/root/scope", "PUT", { + teamId: team.id, + joinTeam: true, + }); + expect(denied.status).toBe(409); + expect(await denied.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/api", + }); + expect((await new SessionIndexStore(env.DB).get("root"))?.ownerTeamId).toBeNull(); + expect((await new SessionIndexStore(env.DB).get("child"))?.ownerTeamId).toBeNull(); + expect((await new TeamMembershipStore(env.DB).listForUser(OWNER)).has(team.id)).toBe(false); + const audit = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action IN ('team.member_joined', 'session.moved')" + ).first<{ count: number }>(); + expect(audit?.count).toBe(0); + + await env.DB.prepare( + `INSERT INTO team_repository_grants + (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) + VALUES (?, ?, 'repository', 67890, 'acme', 'api', ?)` + ) + .bind(crypto.randomUUID(), team.id, Date.now()) + .run(); + expect( + (await request("/sessions/root/scope", "PUT", { teamId: team.id, joinTeam: true })).status + ).toBe(200); + expect((await new SessionIndexStore(env.DB).get("child"))?.ownerTeamId).toBe(team.id); + }); + + it("allows joining an open team on move but refuses archived teams", async () => { + await session("root"); + const team = await new TeamStore(env.DB).create({ + slug: "alpha", + name: "Alpha", + joinPolicy: "open", + }); + await grant(team.id); + expect( + (await request("/sessions/root/scope", "PUT", { teamId: team.id, joinTeam: true })).status + ).toBe(200); + expect((await new TeamMembershipStore(env.DB).listForUser(OWNER)).get(team.id)).toBe("member"); + await new TeamStore(env.DB).archive(team.id); + const denied = await request("/sessions/root/scope", "PUT", { teamId: team.id }); + expect(denied.status).toBe(409); + expect(await denied.json()).toMatchObject({ code: "team_archived" }); + }); + + it("refuses a move when existing destination membership is removed after admission", async () => { + await session("root"); + await session("child", "root"); + const team = await new TeamStore(env.DB).create({ + slug: "revoked", + name: "Revoked", + joinPolicy: "invite_only", + }); + const memberships = new TeamMembershipStore(env.DB); + await memberships.add(team.id, OWNER); + await grant(team.id); + const listForUser = TeamMembershipStore.prototype.listForUser; + const read = vi + .spyOn(TeamMembershipStore.prototype, "listForUser") + .mockImplementation(async function (this: TeamMembershipStore, userId) { + const roles = await listForUser.call(this, userId); + if (userId === OWNER && roles.has(team.id)) { + await memberships.remove(team.id, userId); + } + return roles; + }); + try { + const response = await request("/sessions/root/scope", "PUT", { teamId: team.id }); + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ reason_code: "not_member" }); + expect((await new SessionIndexStore(env.DB).get("root"))?.ownerTeamId).toBeNull(); + expect((await new SessionIndexStore(env.DB).get("child"))?.ownerTeamId).toBeNull(); + const audit = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'session.moved'" + ).first<{ count: number }>(); + expect(audit?.count).toBe(0); + } finally { + read.mockRestore(); + } + }); + + it("does not report or audit a missing descendant as moved", async () => { + await session("root"); + await session("child", "root"); + const team = await new TeamStore(env.DB).create({ + slug: "deleted-child", + name: "Deleted child", + joinPolicy: "invite_only", + }); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + await grant(team.id); + const original = SessionScopeStore.prototype.updateOwnerTeam; + const write = vi + .spyOn(SessionScopeStore.prototype, "updateOwnerTeam") + .mockImplementation(async function (this: SessionScopeStore, ...args) { + await env.DB.prepare("DELETE FROM sessions WHERE id = 'child'").run(); + return original.apply(this, args); + }); + try { + const response = await request("/sessions/root/scope", "PUT", { teamId: team.id }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Session not found" }); + const audits = await env.DB.prepare( + "SELECT resource_id FROM authorization_audit_events WHERE action = 'session.moved'" + ).all<{ resource_id: string }>(); + expect(audits.results).toEqual([{ resource_id: "root" }]); + } finally { + write.mockRestore(); + } + }); + + it("rolls back an open-team join when the move audit fails", async () => { + await session("root"); + const team = await new TeamStore(env.DB).create({ + slug: "open-team", + name: "Open", + joinPolicy: "open", + }); + const db = sqlDatabase(env.DB); + const failAudit: SqlDatabase = { + prepare(sql) { + return sql.includes("INSERT INTO authorization_audit_events") && sql.includes("'session'") + ? db.prepare("INSERT INTO authorization_audit_events (id) VALUES (?)").bind("bad-audit") + : db.prepare(sql); + }, + batch(statements: SqlStatement[]) { + return db.batch(statements); + }, + }; + const join = new TeamMembershipStore(failAudit).bindAddIfJoinable(team.id, OWNER); + const teamAudit = new TeamAuditStore(failAudit).bind( + { + requestId: "test", + actorUserId: OWNER, + action: "team.member_joined", + teamId: team.id, + targetUserId: OWNER, + before: {}, + after: { role: "member" }, + }, + true + ); + const moveAudit = new SessionAuditStore(failAudit).bind({ + requestId: "test", + actorUserId: OWNER, + action: "session.moved", + sessionId: "root", + teamId: team.id, + before: {}, + after: {}, + }); + await expect( + new SessionScopeStore(failAudit).updateOwnerTeam( + ["root"], + team.id, + [{ sessionId: "root", statement: moveAudit }], + [join, teamAudit] + ) + ).rejects.toThrow(); + expect((await new TeamMembershipStore(env.DB).listForUser(OWNER)).has(team.id)).toBe(false); + expect((await new SessionIndexStore(env.DB).get("root"))?.ownerTeamId).toBeNull(); + expect( + ( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action IN ('team.member_joined', 'session.moved')" + ).first<{ count: number }>() + )?.count + ).toBe(0); + }); + + it("does not move or audit when the target becomes archived before the join batch", async () => { + await session("root"); + const team = await new TeamStore(env.DB).create({ + slug: "closed-race", + name: "Closed", + joinPolicy: "open", + }); + const memberships = new TeamMembershipStore(env.DB); + const join = memberships.bindAddIfJoinable(team.id, OWNER); + const joinedAudit = new TeamAuditStore(env.DB).bind( + { + requestId: "test", + actorUserId: OWNER, + action: "team.member_joined", + teamId: team.id, + targetUserId: OWNER, + before: {}, + after: { role: "member" }, + }, + true + ); + const moveAudit = new SessionAuditStore(env.DB).bind( + { + requestId: "test", + actorUserId: OWNER, + action: "session.moved", + sessionId: "root", + teamId: team.id, + before: {}, + after: {}, + }, + true + ); + await new TeamStore(env.DB).archive(team.id); + expect( + await new SessionScopeStore(env.DB).updateOwnerTeam( + ["root"], + team.id, + [{ sessionId: "root", statement: moveAudit }], + [join, joinedAudit], + OWNER + ) + ).toBe(false); + expect((await memberships.listForUser(OWNER)).has(team.id)).toBe(false); + expect((await new SessionIndexStore(env.DB).get("root"))?.ownerTeamId).toBeNull(); + expect( + ( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action IN ('team.member_joined', 'session.moved')" + ).first<{ count: number }>() + )?.count + ).toBe(0); + }); + + it("does not audit an open-team join if membership already exists", async () => { + await session("root"); + const team = await new TeamStore(env.DB).create({ + slug: "already-joined", + name: "Already joined", + joinPolicy: "open", + }); + const memberships = new TeamMembershipStore(env.DB); + const join = memberships.bindAddIfJoinable(team.id, OWNER); + const joinedAudit = new TeamAuditStore(env.DB).bind( + { + requestId: "test", + actorUserId: OWNER, + action: "team.member_joined", + teamId: team.id, + targetUserId: OWNER, + before: {}, + after: { role: "member" }, + }, + true + ); + await memberships.add(team.id, OWNER); + expect( + await new SessionScopeStore(env.DB).updateOwnerTeam( + ["root"], + team.id, + [], + [join, joinedAudit], + OWNER + ) + ).toBe(true); + expect( + ( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'team.member_joined'" + ).first<{ count: number }>() + )?.count + ).toBe(0); + }); + + it("converts team visibility to workspace when removing the owning team", async () => { + await session("root"); + const team = await new TeamStore(env.DB).create({ + slug: "alpha", + name: "Alpha", + joinPolicy: "open", + }); + await grant(team.id); + await new TeamMembershipStore(env.DB).add(team.id, OWNER); + expect((await request("/sessions/root/scope", "PUT", { teamId: team.id })).status).toBe(200); + expect((await request("/sessions/root/visibility", "PUT", { visibility: "team" })).status).toBe( + 200 + ); + expect((await request("/sessions/root/scope", "PUT", { teamId: null })).status).toBe(200); + expect(await new SessionIndexStore(env.DB).get("root")).toMatchObject({ + ownerTeamId: null, + visibility: "workspace", + }); + }); + + it("audits only the descendant that changes on a partially unchanged move", async () => { + await session("root"); + await session("child", "root"); + const team = await new TeamStore(env.DB).create({ + slug: "child-team", + name: "Child team", + joinPolicy: "invite_only", + }); + await env.DB.prepare( + "UPDATE sessions SET owner_team_id = ?, visibility = 'team' WHERE id = 'child'" + ) + .bind(team.id) + .run(); + + const moved = await request("/sessions/root/scope", "PUT", { teamId: null }); + expect(moved.status).toBe(200); + expect(await moved.json()).toMatchObject({ affectedSessionIds: ["child"] }); + expect(await new SessionIndexStore(env.DB).get("child")).toMatchObject({ + ownerTeamId: null, + visibility: "workspace", + }); + const audits = await env.DB.prepare( + "SELECT resource_id, metadata_json FROM authorization_audit_events WHERE action = 'session.moved'" + ).all<{ resource_id: string; metadata_json: string }>(); + expect(audits.results).toHaveLength(1); + expect(audits.results[0].resource_id).toBe("child"); + expect(JSON.parse(audits.results[0].metadata_json)).toEqual({ + before: { teamId: team.id, visibility: "team" }, + requested: {}, + after: { teamId: null, visibility: "workspace" }, + }); + }); + + 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("does not move a child from a team the parent owner cannot access", async () => { + await session("root"); + await session("team-child", "root"); + 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-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/scope", "PUT", { teamId: null }); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Session not found" }); + expect(await new SessionIndexStore(env.DB).get("team-child")).toMatchObject({ + ownerTeamId: team.id, + visibility: "team", + }); + }); + + 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 effective legacy 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: true, + canMove: false, + canChangeVisibility: false, + }, + }, + }); + expect(await (await request("/sessions", "GET", undefined, COLLABORATOR)).json()).toMatchObject( + { + sessions: [ + expect.objectContaining({ + capabilities: expect.objectContaining({ canRead: true, canMove: false }), + }), + ], + } + ); + }); + + 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: true, + canMove: 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); + }); +}); diff --git a/packages/control-plane/test/integration/spawn-children.test.ts b/packages/control-plane/test/integration/spawn-children.test.ts index f0cf67d096..e816b0330f 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,95 @@ 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("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/shared/src/types/audit-events.ts b/packages/shared/src/types/audit-events.ts index 7d55b25622..48ea75bca8 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", 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/server-messages.ts b/packages/shared/src/types/server-messages.ts index 6993186915..da2cb90969 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,10 @@ export type PromptQueueItem = z.infer; const sessionStateSchema = z.object({ id: z.string(), + 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-api.ts b/packages/shared/src/types/session-api.ts index 8e5a61c647..e53de74bcd 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, @@ -227,6 +228,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/sessions.ts b/packages/shared/src/types/sessions.ts index c459bf412a..4ce6bf9ee5 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,19 @@ 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(), + canMove: 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 +230,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; diff --git a/packages/shared/src/types/teams.ts b/packages/shared/src/types/teams.ts index 61f4a06c82..7fbf6097ee 100644 --- a/packages/shared/src/types/teams.ts +++ b/packages/shared/src/types/teams.ts @@ -10,6 +10,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(), diff --git a/packages/web/src/app/api/settings/teams/route.test.ts b/packages/web/src/app/api/settings/teams/route.test.ts new file mode 100644 index 0000000000..88d7a47767 --- /dev/null +++ b/packages/web/src/app/api/settings/teams/route.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { GET, PATCH } from "./route"; + +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); + +describe("team settings proxy", () => { + const context = { params: Promise.resolve(undefined) }; + + beforeEach(() => vi.resetAllMocks()); + + it("relays the policy on GET without caching", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ requireTeamOnCreate: true }) + ); + + const response = await GET(new NextRequest("http://localhost/api/settings/teams"), context); + + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/settings/teams", undefined); + await expect(response.json()).resolves.toEqual({ requireTeamOnCreate: true }); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it("forwards the PATCH body and relays permission failures", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ error: "Forbidden" }, { status: 403 }) + ); + const body = JSON.stringify({ requireTeamOnCreate: true }); + const response = await PATCH( + new NextRequest("http://localhost/api/settings/teams", { + method: "PATCH", + headers: { Cookie: "__Secure-openinspect.session_token=session.signature" }, + body, + }), + context + ); + + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/settings/teams", { + method: "PATCH", + body, + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "Forbidden" }); + }); +}); diff --git a/packages/web/src/app/api/settings/teams/route.ts b/packages/web/src/app/api/settings/teams/route.ts new file mode 100644 index 0000000000..75b47e82af --- /dev/null +++ b/packages/web/src/app/api/settings/teams/route.ts @@ -0,0 +1,3 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET, PATCH } = settingsProxy(() => "/settings/teams", "team settings"); diff --git a/packages/web/src/components/settings/audit-log-settings.tsx b/packages/web/src/components/settings/audit-log-settings.tsx index 372f6c07a5..f61f7324c0 100644 --- a/packages/web/src/components/settings/audit-log-settings.tsx +++ b/packages/web/src/components/settings/audit-log-settings.tsx @@ -41,6 +41,11 @@ const UNRECOGNIZED: BadgeTreatment = { const OPERATION_LABELS: Record = { "session.private_break_glass": "Private session break-glass read", + "session.visibility_changed": "Session visibility changed", + "session.moved": "Session moved", + "session.collaborator_added": "Session collaborator added", + "session.collaborator_removed": "Session collaborator removed", + "session.created_private": "Private session created", "workspace.member_role_updated": "Member role updated", "workspace.member_status_updated": "Member status updated", "workspace.default_role_assigned": "Default role assigned", diff --git a/packages/web/src/components/settings/teams-settings.test.tsx b/packages/web/src/components/settings/teams-settings.test.tsx index 9e71f5c2bc..c96c9919ac 100644 --- a/packages/web/src/components/settings/teams-settings.test.tsx +++ b/packages/web/src/components/settings/teams-settings.test.tsx @@ -3,14 +3,18 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; +import { SWRConfig } from "swr"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TeamMember } from "@/hooks/use-teams"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; import { TeamsSettings } from "./teams-settings"; import { TeamDetail } from "./team-detail"; import { TeamMembersTable } from "./team-members-table"; expect.extend(matchers); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); + const mocks = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), @@ -87,10 +91,19 @@ const member: TeamMember = { avatarUrl: null, }; +function renderTeamsSettings() { + return render( + new Map() }}> + + + ); +} + beforeEach(() => { mocks.hasPermission = true; mocks.candidates = []; mocks.teams = []; + vi.mocked(browserApiFetch).mockResolvedValue(Response.json({ requireTeamOnCreate: false })); }); afterEach(() => { cleanup(); @@ -103,13 +116,52 @@ describe("Teams settings", () => { mocks.teams = [ { id: team.id, slug: team.slug, name: team.name, memberCount: 1, archivedAt: null }, ]; - render(); + renderTeamsSettings(); expect(screen.getByText("1 member - Active")).toBeInTheDocument(); + expect(screen.queryByRole("switch", { name: "Require a team for new sessions" })).toBeNull(); + expect(browserApiFetch).not.toHaveBeenCalled(); + }); + + it("loads and updates the require-team policy for workspace managers", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ requireTeamOnCreate: false })); + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ requireTeamOnCreate: true })); + renderTeamsSettings(); + + const toggle = screen.getByRole("switch", { name: "Require a team for new sessions" }); + expect(toggle).toBeDisabled(); + await waitFor(() => expect(toggle).toBeEnabled()); + expect(toggle).toHaveAttribute("aria-checked", "false"); + fireEvent.click(toggle); + + await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "true")); + expect(browserApiFetch).toHaveBeenNthCalledWith(1, "/api/settings/teams"); + expect(browserApiFetch).toHaveBeenNthCalledWith(2, "/api/settings/teams", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireTeamOnCreate: true }), + }); + }); + + it("keeps the stored value and reports a failed policy update", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ requireTeamOnCreate: true })); + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json({ error: "Forbidden" }, { status: 403 }) + ); + renderTeamsSettings(); + + const toggle = screen.getByRole("switch", { name: "Require a team for new sessions" }); + await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "true")); + fireEvent.click(toggle); + await waitFor(() => + expect(screen.getByText("Failed to update team settings")).toBeInTheDocument() + ); + expect(toggle).toHaveAttribute("aria-checked", "true"); + expect(toggle).toBeEnabled(); }); it("validates slug and surfaces the slug_taken conflict", async () => { mocks.create.mockRejectedValue(new Error("Team slug already exists (slug_taken)")); - render(); + renderTeamsSettings(); fireEvent.click(screen.getByRole("button", { name: "Create team" })); fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "Design" }, diff --git a/packages/web/src/components/settings/teams-settings.tsx b/packages/web/src/components/settings/teams-settings.tsx index 99e5b34379..9b077753e2 100644 --- a/packages/web/src/components/settings/teams-settings.tsx +++ b/packages/web/src/components/settings/teams-settings.tsx @@ -2,24 +2,62 @@ import Link from "next/link"; import { useState, type FormEvent } from "react"; -import { createTeamRequestSchema } from "@open-inspect/shared/types/teams"; +import useSWR from "swr"; +import { createTeamRequestSchema, teamSettingsSchema } from "@open-inspect/shared/types/teams"; import { useCurrentUserAuthorization } from "@/hooks/use-current-user-authorization"; import { useTeams } from "@/hooks/use-teams"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { ErrorBanner } from "@/components/ui/error-banner"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +const TEAM_SETTINGS_KEY = "/api/settings/teams"; export function TeamsSettings() { const { hasPermission } = useCurrentUserAuthorization(); const { teams, loading, error, createTeam } = useTeams(); const canCreate = hasPermission("workspace.members.manage"); + const { + data: teamSettings, + isLoading: teamSettingsLoading, + error: teamSettingsError, + mutate: mutateTeamSettings, + } = useSWR(canCreate ? TEAM_SETTINGS_KEY : null, async () => { + const response = await browserApiFetch(TEAM_SETTINGS_KEY); + if (!response.ok) throw new Error("Failed to load team settings"); + return teamSettingsSchema.parse(await response.json()); + }); const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [slug, setSlug] = useState(""); const [message, setMessage] = useState(null); const [saving, setSaving] = useState(false); + const [settingsSaving, setSettingsSaving] = useState(false); + const [settingsMessage, setSettingsMessage] = useState(null); + + async function updateRequireTeamOnCreate(checked: boolean) { + if (!canCreate || !teamSettings || settingsSaving) return; + setSettingsSaving(true); + setSettingsMessage(null); + try { + const response = await browserApiFetch(TEAM_SETTINGS_KEY, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireTeamOnCreate: checked }), + }); + if (!response.ok) throw new Error("Failed to update team settings"); + await mutateTeamSettings(teamSettingsSchema.parse(await response.json()), { + revalidate: false, + }); + } catch (cause) { + setSettingsMessage(cause instanceof Error ? cause.message : "Failed to update team settings"); + } finally { + setSettingsSaving(false); + } + } async function submit(event: FormEvent) { event.preventDefault(); @@ -64,6 +102,37 @@ export function TeamsSettings() { Create team + {canCreate && ( +
+ + {teamSettingsLoading && ( +

Loading team settings...

+ )} + {teamSettingsError && Failed to load team settings.} + {settingsMessage && {settingsMessage}} +
+ )} {loading &&

Loading teams...

} {error && Failed to load teams.} {!loading && !error && ( From 9467549c1b453bc90adfaf98e3f94305b160469b Mon Sep 17 00:00:00 2001 From: Jehiah Czebotar Date: Wed, 30 Sep 2026 13:27:35 -0400 Subject: [PATCH 13/44] fix(deps): bump PyJWT floor to >=2.14.0 (#2156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ======= - Raise the PyJWT[crypto] version floor from >=2.9.0 to >=2.14.0 in `packages/modal-infra/pyproject.toml` and `packages/sandbox-runtime/pyproject.toml`. Resolves 20 known advisories (1 critical, 6 high) affecting versions below 2.14.0. - Re-lock both packages (`uv lock --upgrade-package pyjwt`), pulling in PyJWT 2.15.1. - Regenerate `packages/sandbox-images/locks/runtime.txt`, which is exported from the sandbox-runtime lockfile and pins PyJWT for the sandbox image build. https://github.com/advisories?query=affects%3Apyjwt Test plan ========= - [x] `pytest tests/ -v` in `packages/modal-infra` — 442 passed - [x] `pytest tests/ -v` in `packages/sandbox-runtime` — 1405 passed, 3 skipped - [x] `ruff check .` and `ruff format --check .` in both packages — clean ## Summary by CodeRabbit * **Chores** * Updated the versions of the JSON Web Token libraries used across application components. This keeps token-related packages aligned with newer releases. --- packages/modal-infra/pyproject.toml | 2 +- packages/modal-infra/uv.lock | 10 +++++----- packages/sandbox-images/locks/runtime.txt | 6 +++--- packages/sandbox-runtime/pyproject.toml | 2 +- packages/sandbox-runtime/uv.lock | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/modal-infra/pyproject.toml b/packages/modal-infra/pyproject.toml index 5e1c45bc05..bef114c69e 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/uv.lock b/packages/modal-infra/uv.lock index 3857456ca3..724862ecd9 100644 --- a/packages/modal-infra/uv.lock +++ b/packages/modal-infra/uv.lock @@ -928,7 +928,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" }, @@ -955,7 +955,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" }, @@ -1207,11 +1207,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +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/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { 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 d20a2d4dbf..0d19ea261e 100644 --- a/packages/sandbox-images/locks/runtime.txt +++ b/packages/sandbox-images/locks/runtime.txt @@ -244,9 +244,9 @@ pydantic-core==2.46.4 \ --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff -pyjwt==2.13.0 \ - --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ - --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 +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/uv.lock b/packages/sandbox-runtime/uv.lock index 9ca7863f41..09a8fd1bf5 100644 --- a/packages/sandbox-runtime/uv.lock +++ b/packages/sandbox-runtime/uv.lock @@ -562,7 +562,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" }, @@ -719,11 +719,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +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/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { 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] From 62101565228e061e68526732e5e4cc6126d035a9 Mon Sep 17 00:00:00 2001 From: Rahul Sethuram Date: Wed, 30 Sep 2026 21:29:34 +0400 Subject: [PATCH 14/44] fix(terraform): pass docs site variables to the Terraform workflow (#2122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The docs site's one-time setup (`packages/docs/README.md:76-83`) sets `docs_site_enabled = true` and `docs_custom_domain` for a local `terraform apply`. The `Terraform` workflow's plan and apply jobs pass neither variable (`.github/workflows/terraform.yml:235-321` and `:426-512` have no `TF_VAR_docs_*` line), so CI evaluates `docs_site_enabled` with its default `false` (`terraform/environments/production/variables.tf:835-839`). `module "docs_site"` uses `count = var.docs_site_enabled ? 1 : 0` (`terraform/environments/production/docs-vercel.tf:8`), so the next `terraform apply -auto-approve` on `main` after the site is provisioned plans to destroy the docs Vercel project and its domain. `scripts/terraform-workflow-contract.test.mjs` did not catch this because it pins only two named inputs (Daytona snapshot memory and the classifier Anthropic key, lines 29-40). It never compares the workflow against the variables that are declared. A second problem shows up once `docs_custom_domain` is threaded the way other optional hostnames are. Actions renders an unset `vars.X || secrets.X` as `""`, and Terraform keeps `""` rather than treating it as null. `docs-vercel.tf:26` passes it straight to `modules/vercel-project`, which creates a `vercel_project_domain` whenever `custom_domain != null` (`terraform/modules/vercel-project/main.tf:40`). `locals.tf:98` would also produce `docs_site_url = "https://"`. ## Change - Pass `TF_VAR_docs_site_enabled` (default `'false'`) and `TF_VAR_docs_custom_domain` through both the plan and apply jobs. They use the same `vars.X || secrets.X` resolution as the other optional settings. - Add a `local.docs_custom_domain` that turns null, empty and whitespace-only values into `null` and trims the value. `docs-vercel.tf` and `docs_custom_domain_url` use it. The local follows the `web_custom_domain` normalization in `locals.tf:32-36`. - The contract test now checks that every variable in `terraform/environments/production/variables.tf` appears exactly once as `TF_VAR_:` in each job. It exempts `control_plane_*`, which the "Stage SchedulerDO deletion migration" step writes to an `auto.tfvars.json`, and `project_root`, which is a checkout path. On `main` these are the only unthreaded variables apart from the two docs ones. - Document `DOCS_SITE_ENABLED` and `DOCS_CUSTOM_DOMAIN` in `packages/docs/README.md`, the CI/CD section of `docs/GETTING_STARTED.md` and `terraform/README.md`. Alternative considered: make the docs project independent of CI by not reading `docs_site_enabled` in the workflow. That would change how the variable is meant to work, so I kept to threading it like every other opt-in flag (`ENABLE_LINEAR_BOT`, `ENABLE_GITHUB_BOT`). ## Reproduction (on `main` at 700f9145) ``` $ node --test scripts/terraform-workflow-contract.test.mjs # new test only ✖ Every production Terraform variable reaches plan and apply + [ 'docs_site_enabled', 'docs_custom_domain' ] - [] $ terraform test -filter=tests/docs_site.tftest.hcl # new run only run "docs_site_ignores_an_empty_custom_domain"... fail condition = module.docs_site[0].custom_domain == null - "" + null ``` ## Tests - `npm run test:terraform-workflow-contract`: 3 pass - `npm run test:node-version-workflow-contract`: 1 pass - `terraform/environments/production`: `terraform fmt -check -recursive`, `terraform validate`, `terraform test`: 63 passed, 0 failed. This includes the new `docs_site_ignores_an_empty_custom_domain` and `docs_site_attaches_a_configured_custom_domain` runs. Run locally with Terraform 1.15.3; CI pins 1.14.8. - `npm run typecheck -w @open-inspect/docs` - `prettier --check` on the touched Markdown, YAML and test files I did not run a real Terraform plan against a provisioned docs project. ## Summary by CodeRabbit * **New Features** * Production deployments can now be configured to keep the documentation site and optionally use a custom domain. The site setting defaults to off; without enabling it, a deployment may remove the docs project and domain. * Blank or whitespace-only custom domains now fall back to the default `vercel.app` address. * **Documentation** * Added setup guidance for configuring the documentation site and its custom domain. --- .github/workflows/terraform.yml | 8 +++++ docs/GETTING_STARTED.md | 6 ++++ packages/docs/README.md | 9 ++++++ scripts/terraform-workflow-contract.test.mjs | 32 +++++++++++++++++++ terraform/README.md | 4 +++ .../environments/production/docs-vercel.tf | 2 +- terraform/environments/production/locals.tf | 9 +++++- .../production/tests/docs_site.tftest.hcl | 28 ++++++++++++++++ 8 files changed, 96 insertions(+), 2 deletions(-) diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index a1aedc2d63..c7fd2726dd 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -319,6 +319,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@v8 @@ -510,6 +514,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/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 66e0d512a1..e6fbb2dcad 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -1151,6 +1151,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 @@ -1233,6 +1237,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/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/scripts/terraform-workflow-contract.test.mjs b/scripts/terraform-workflow-contract.test.mjs index bb28fa64e1..9ece9979e0 100644 --- a/scripts/terraform-workflow-contract.test.mjs +++ b/scripts/terraform-workflow-contract.test.mjs @@ -38,3 +38,35 @@ test("Classifier-only Anthropic key reaches Terraform plan and apply", () => { "classifier Anthropic key" ); }); + +// Inputs the workflow deliberately does not take from the environment: +// control_plane_* values are staged per migration (the "Stage SchedulerDO +// deletion migration" step writes them to an auto.tfvars.json file), and +// project_root is a path inside the checkout. +const NOT_FROM_ENVIRONMENT = new Set([ + "control_plane_migration_tag", + "control_plane_migration_old_tag", + "control_plane_new_sqlite_classes", + "control_plane_deleted_classes", + "project_root", +]); + +test("Every production Terraform variable reaches plan and apply", async () => { + const variables = await readFile( + new URL("../terraform/environments/production/variables.tf", import.meta.url), + "utf8" + ); + const declared = [...variables.matchAll(/^variable "([a-z0-9_]+)"/gm)].map((match) => match[1]); + assert.ok(declared.length > 0, "expected variables in variables.tf"); + + const missing = declared.filter((name) => { + if (NOT_FROM_ENVIRONMENT.has(name)) return false; + try { + assertInPlanAndApply(`TF_VAR_${name}:`, name); + return false; + } catch { + return true; + } + }); + assert.deepEqual(missing, [], "each variable needs exactly one TF_VAR_ input per job"); +}); diff --git a/terraform/README.md b/terraform/README.md index 3169dec4bb..e39a2343bf 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -216,6 +216,10 @@ VERCEL_API_TOKEN VERCEL_TEAM_ID VERCEL_PROJECT_ID +# Documentation site (packages/docs) +DOCS_SITE_ENABLED # Optional; defaults to false, which destroys a provisioned docs project +DOCS_CUSTOM_DOMAIN # Optional; empty serves the vercel.app URL only + # Modal MODAL_TOKEN_ID MODAL_TOKEN_SECRET diff --git a/terraform/environments/production/docs-vercel.tf b/terraform/environments/production/docs-vercel.tf index 7063d1bcc7..c96b34e559 100644 --- a/terraform/environments/production/docs-vercel.tf +++ b/terraform/environments/production/docs-vercel.tf @@ -23,7 +23,7 @@ module "docs_site" { install_command = "cd ../.. && npm install" build_command = "next build" - custom_domain = var.docs_custom_domain + custom_domain = local.docs_custom_domain # The site reads no deployment state: every URL it renders is a constant in # packages/docs/src/lib/site.ts. diff --git a/terraform/environments/production/locals.tf b/terraform/environments/production/locals.tf index 6d0b554713..3af266757a 100644 --- a/terraform/environments/production/locals.tf +++ b/terraform/environments/production/locals.tf @@ -94,8 +94,15 @@ locals { var.web_platform == "vercel" ? module.web_app[0].production_url : local.web_app_url ) + # Documentation site hostname, null when unset. The Terraform workflow passes + # an unset repository variable as "", which must not attach an empty domain. + docs_custom_domain = ( + var.docs_custom_domain == null ? null : + trimspace(var.docs_custom_domain) == "" ? null : trimspace(var.docs_custom_domain) + ) + # Documentation site URL, when it serves a hostname of its own - docs_custom_domain_url = var.docs_custom_domain != null ? "https://${var.docs_custom_domain}" : null + docs_custom_domain_url = local.docs_custom_domain != null ? "https://${local.docs_custom_domain}" : null # Worker script paths (deterministic output locations) control_plane_script_path = "${var.project_root}/packages/control-plane/dist/index.js" diff --git a/terraform/environments/production/tests/docs_site.tftest.hcl b/terraform/environments/production/tests/docs_site.tftest.hcl index 0447429f73..8513b66d55 100644 --- a/terraform/environments/production/tests/docs_site.tftest.hcl +++ b/terraform/environments/production/tests/docs_site.tftest.hcl @@ -78,3 +78,31 @@ run "docs_site_is_separate_from_the_web_app" { error_message = "The docs site must be its own Vercel project, so a docs publish cannot touch the product." } } + +run "docs_site_ignores_an_empty_custom_domain" { + command = plan + variables { + docs_site_enabled = true + vercel_team_id = "test-team" + # The Terraform workflow passes an unset DOCS_CUSTOM_DOMAIN repository + # variable as an empty string, not null. + docs_custom_domain = "" + } + assert { + condition = module.docs_site[0].custom_domain == null + error_message = "An empty docs_custom_domain must not attach a domain to the docs project." + } +} + +run "docs_site_attaches_a_configured_custom_domain" { + command = plan + variables { + docs_site_enabled = true + vercel_team_id = "test-team" + docs_custom_domain = "docs.example.com" + } + assert { + condition = module.docs_site[0].custom_domain == "docs.example.com" + error_message = "A configured docs_custom_domain must be attached to the docs project." + } +} From 2143b532124f779f2c8ba01964968d2ae6c28353 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 30 Sep 2026 10:41:21 -0700 Subject: [PATCH 15/44] fix(terraform): pass teams enforcement to plan and apply (#2160) ## Summary - Pass `TEAMS_ENFORCEMENT` to both Terraform plan and apply as `TF_VAR_teams_enforcement`. - Default to `shadow`, matching the production Terraform variable, while allowing deployments to configure `off` or `on` through a repository variable or secret. The contract test added by #2122 fails on `main` because `teams_enforcement` was added after that PR branched but was not present in either workflow job. ## Verification - Reproduced `npm run test:terraform-workflow-contract` failure on current `main` (`teams_enforcement` missing). - `npm run test:terraform-workflow-contract` (3 passed) - `npm run test:node-version-workflow-contract` (1 passed) - `npx prettier --check .github/workflows/terraform.yml` - `git diff --check origin/main...HEAD` --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/0ed93eaf0b664e8b81a8fa9634967b50)* ## Summary by CodeRabbit * **Chores** * Terraform plan and apply runs now use the configured team-enforcement mode. They prioritize the repository setting, then the secret value, and default to shadow mode if neither is available. This keeps the selected mode consistent across both stages. Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> --- .github/workflows/terraform.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index c7fd2726dd..b436d735f5 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -290,6 +290,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 }} @@ -485,6 +486,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 }} From 8ed1aea276554f0268b8193250397fb75269a7ef Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 30 Sep 2026 11:15:49 -0700 Subject: [PATCH 16/44] fix(control-plane): retry held sandbox saves from alarms (#2152) ## Summary Fixes COL-251. The coordinator retries classified failed shutdown captures from its own alarm when `captureByMs` is due, without depending on a refused runtime reconnect or a user clicking Retry. Earlier wake-ups and capture failures re-arm the existing deadline. - Separate automatic retry policy from authenticated recovery actions. The private `captureFailure` marker in the existing JSON record is set only for terminal capture failures and interrupted captures. Generic `fail()` only publishes failure; checkpoint and restore uncertainty cannot authorize unattended capture/retirement. - Allow capture retries only on the alarm handler's first preservation pass. The post-projection pass still checks shutdown deadlines and watchdog holds, but cannot start a second retry in the same delivery. - Re-arm classified capture failures after releasing `activeOperation`, including failures after an in-flight alarm has been consumed. - Keep manual recovery actions, the fixed 30-minute Retry window, runtime keepalive, and alarm/reconnect retry logging unchanged. Unclassified historical holds remain manual-recovery only; interrupted `capturing` records are classified during restart recovery. - Add 27 regression cases across the PR, including timed-out retries through the composed handler, checkpoint uncertainty during draining, JSON provenance round-trips, and the public projection boundary. No SQL migrations or new database columns, Modal-side changes, or source cleanup after the Retry window closes. The optional internal JSON marker is the only persisted-record addition. ## Verification The latest two lifecycle regressions were reproduced before implementation: one delivery started another capture after its retry timed out, and checkpoint uncertainty during draining scheduled unattended recovery. Both now pass. - `npm test -w @open-inspect/control-plane -- --maxWorkers=2`: 5,492 passed across 334 files - Focused coordinator, safety, repository, alarm handler/scheduler, and rejected-allocation suites: 159 passed - `npm run test:integration -w @open-inspect/control-plane -- test/integration/sandbox-shutdown.test.ts test/integration/session-lifecycle-alarm-recovery.test.ts --maxWorkers=1`: 14 passed - `npm run typecheck -w @open-inspect/control-plane` - `npm run lint` - Prettier check and `git diff --check` - Independent review found no actionable findings. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/37204cb097fb963d7b419250261344aa)* ## Summary by CodeRabbit * **Reliability** * Failed or uncertain shutdown captures can be retried automatically when their capture deadline arrives, without requiring a reconnect. * Early alarms preserve the scheduled capture deadline; retry windows advance in five-minute increments and stop at a defined limit after shutdown. * Retries are coordinated with reconnects and active captures, including after a restart, to avoid duplicate or overlapping attempts. * Superseded or ineligible captures and failed restores without capture deadlines are not retried. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../src/sandbox/lifecycle/manager.ts | 6 +- .../lifecycle/rejected-allocation.test.ts | 3 +- .../src/session/alarm/handler.test.ts | 6 +- .../src/session/alarm/handler.ts | 7 +- .../control-plane/src/session/components.ts | 3 +- .../sandbox-shutdown-repository.test.ts | 10 + .../session/sandbox-shutdown-repository.ts | 2 + .../session/sandbox-shutdown-safety.test.ts | 74 +++- .../src/session/sandbox-shutdown.test.ts | 370 ++++++++++++++++++ .../src/session/sandbox-shutdown.ts | 84 +++- 10 files changed, 535 insertions(+), 30 deletions(-) diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index db315478f2..d742b91a30 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -155,7 +155,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. */ @@ -2101,7 +2101,7 @@ 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( @@ -2110,7 +2110,7 @@ export class SandboxLifecycleManager ); return "hold_watchdogs"; } - return this.shutdown.handleAlarm(); + return this.shutdown.handleAlarm(allowCaptureRetry); } recoverShutdown(action: ShutdownRecoveryAction): Promise { 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 9641248d6a..b84fc5344a 100644 --- a/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts @@ -216,7 +216,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); 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/components.ts b/packages/control-plane/src/session/components.ts index 45542b079e..4831b64192 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -633,7 +633,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 => { 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. */ From 69cad25f373c9fdd16797f4d618c2a756c8cb7cf Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 30 Sep 2026 11:40:05 -0700 Subject: [PATCH 17/44] fix(web): improve command menu hover contrast (#2161) ## Summary - Replace the command menu's solid accent selection background with the existing neutral `muted` surface and `foreground` text, matching other menu highlights. - Keep descriptions and shortcuts readable during both pointer hover and keyboard selection in light and dark themes. - Add a regression test covering pointer selection, keyboard selection, and the neutral highlight classes. ## Root Cause The selected row used a brown accent background while descriptions and shortcuts retained explicitly muted gray text. Their light-mode contrast was approximately 1.28:1. The neutral highlight improves that contrast to approximately 5.15:1 without changing global theme tokens or introducing child-style overrides. ## Validation - Command menu tests: 19 passed. - Global keyboard shortcut tests: 2 passed. - Shared package build and web typecheck passed. - ESLint, Prettier checks, and `git diff --check` passed. - Visually inspected the real application at `http://localhost:3000` with browser-mocked authentication, authorization, and session-list responses. - Verified hover, keyboard selection, settings search, exhaustive-search handoff, and Escape dismissal. - Uploaded viewport screenshots: desktop light and dark at 1440x900, mobile light at 390x844. ## Visual Evidence - Light mode artifact: `725376067a5e3b8b525a0d3cd6b63d86` - Dark mode artifact: `135057d55fc85bd4c172f39034f7c92b` - Mobile light artifact: `2ee196be6250689ab0f75ec2eeaf4e21` ## Existing Accessibility Finding The scoped axe scan reports an existing `aria-required-children` violation because the cmdk listbox contains separator elements. This change does not alter the menu structure; that finding is outside the contrast fix. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/c794d8814455493ca3ce9de38f4b70aa)* ## Summary by CodeRabbit * **Style** * Selected command-menu options now use a muted background and foreground text with a visible inset ring instead of accent colors. Descriptions and shortcut labels remain muted for selected options. * **Tests** * Added coverage confirming the updated selected-option styling for both pointer and keyboard selection. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../components/global-command-menu.test.tsx | 33 +++++++++++++++++++ packages/web/src/components/ui/command.tsx | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/global-command-menu.test.tsx b/packages/web/src/components/global-command-menu.test.tsx index 843aba7763..78abf1dfb9 100644 --- a/packages/web/src/components/global-command-menu.test.tsx +++ b/packages/web/src/components/global-command-menu.test.tsx @@ -81,6 +81,39 @@ describe("GlobalCommandMenu", () => { ).toBeInTheDocument(); }); + it("uses a readable neutral highlight and visible ring for pointer and keyboard selection", async () => { + const user = userEvent.setup(); + renderMenu(); + const newSession = screen.getByRole("option", { name: /New session/ }); + const home = screen.getByRole("option", { name: /Home/ }); + + await user.hover(home); + + expect(home).toHaveAttribute("aria-selected", "true"); + expect(home).toHaveClass( + "data-[selected=true]:bg-muted", + "data-[selected=true]:text-foreground", + "data-[selected=true]:ring-2", + "data-[selected=true]:ring-inset", + "data-[selected=true]:ring-ring" + ); + expect(home).not.toHaveClass("data-[selected=true]:bg-accent"); + + await user.keyboard("{ArrowUp}"); + + expect(newSession).toHaveAttribute("aria-selected", "true"); + expect(home).toHaveAttribute("aria-selected", "false"); + expect(newSession).toHaveClass( + "data-[selected=true]:bg-muted", + "data-[selected=true]:text-foreground", + "data-[selected=true]:ring-2", + "data-[selected=true]:ring-inset", + "data-[selected=true]:ring-ring" + ); + expect(screen.getByText("Start a coding session")).toHaveClass("text-muted-foreground"); + expect(screen.getByText("Cmd/Ctrl+Shift+O")).toHaveClass("text-muted-foreground"); + }); + it("omits session creation destinations without session creation permission", () => { mocks.allowedPermissions = new Set(); diff --git a/packages/web/src/components/ui/command.tsx b/packages/web/src/components/ui/command.tsx index 41b9580872..c9597345b4 100644 --- a/packages/web/src/components/ui/command.tsx +++ b/packages/web/src/components/ui/command.tsx @@ -93,7 +93,7 @@ const CommandItem = React.forwardRef< Date: Wed, 30 Sep 2026 11:48:42 -0700 Subject: [PATCH 18/44] refactor: extract sandbox access mechanics (COL-243) (#2151) ## Summary Implements [COL-243](https://linear.app/colemurray/issue/COL-243), the third increment of COL-240. The prerequisite COL-241 and COL-242 changes are integrated in starting HEAD `44ca1a9`; no deployment is included. - Add internal `sandbox/lifecycle/sandbox-access.ts` for access storage mechanics, terminal JWT signing/reuse, retirement and notifications. - Construct access before shutdown and manager. Shutdown receives `access.retireShutdownAccess` directly, removing the manager callback cycle and obsolete forwarding method. - Keep lifecycle eligibility, startup claims, admission flags, generation arbitration and caller error boundaries in manager. Keep encryption and atomic completion in the unchanged repository. - Narrow manager's storage/config contracts and extend the existing ESLint boundary tests so session/platform consumers cannot import the access collaborator. - Retain assembled-manager, session access-reader, real repository and Workerd coverage, with new extraction-specific regressions and updated ownership notes. ## Internal Operations and Composition Access exposes `clearAccess`, `retireShutdownAccess`, `storeCodeServer`, `storeVnc`, `storeAndBroadcastTunnelUrls`, `storeTtyd`, `mintTtydToken`, `reusableTtydToken`, `broadcastSandboxDashboardUrl` and `broadcastProviderAccessIfConnected` only to internal composition/lifecycle code. The graph is repository/socket/messenger leaves -> access -> shutdown -> manager. Access has narrow storage/broadcast/socket/capability/logging dependencies, no manager reference, mutable lifecycle state, encryption key or retained signing key. Logging resolves the current session context at use, and construction invokes no runtime operations. URL-only retirement retains credentials on resumable providers when supported, falls back to full clearing otherwise, and always clears tunnels and notifies before shutdown detaches with `1000`, `Sandbox state preserved`. Other paths retain their differing order. Dashboard, tunnel and connected-access notifications remain distinct and repeatable. Fresh/restore retain individual artifact writes and existing await points. Resume/bridge retain `completeProviderResume`; only bridge supplies the expected pending reference. Resume secret-read sequencing remains unchanged, and JWT reuse validation is synchronous, so disabled terminal access gains no await. Missing/expired tokens and hash-only restarts cannot invent terminal access. The single terminal TTL and launch/session/sandbox claims are unchanged. ## Changed Paths - Production: `packages/control-plane/src/sandbox/lifecycle/{manager,sandbox-access}.ts`, `packages/control-plane/src/session/components.ts`. - Unit coverage/composition: lifecycle `{manager,sandbox-access,vm-resolve,launch-orchestration,pending-vm-respawn}.test.ts`, `test-helpers.ts`, and `src/session/sandbox-repository.test.ts`. - Workerd coverage/composition: `test/integration/{sandbox-lifecycle-harness.ts,sandbox-shutdown.test.ts,session-components.test.ts}`. - Boundaries: `eslint.config.js`, `scripts/lint-sandbox-boundaries.test.mjs`. - Ownership/gaps: `docs/plans/{sandbox-lifecycle-manager-refactor,sandbox-lifecycle-refactor-baseline}.md`. ## Verification Node `v24.20.0`, installed dependencies; shared built first and expensive checks run sequentially. | Check | Result | | --- | --- | | Pre-edit focused unit baseline | 23 files, 742 tests passed | | `npm run build -w @open-inspect/shared` | Passed | | `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/session/sandbox-access src/session/sandbox-repository src/session/sandbox-shutdown` | 24 files, 776 tests passed | | `npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown sandbox-state-retention` | 3 files, 55 Workerd tests passed | | `npm run test:integration -w @open-inspect/control-plane -- session-components` | 1 file, 5 tests passed | | `npm test -w @open-inspect/control-plane` | 335 files, 5,498 tests passed | | `npm run typecheck -w @open-inspect/control-plane` | All four TypeScript configurations passed | | `npm run build -w @open-inspect/control-plane` | Worker and Node bundles passed; existing bundle-size warnings emitted | | `npm run lint -w @open-inspect/control-plane` | Passed | | `npm run test:lint-sandbox-boundaries` | 2 tests passed | | Additional ESLint on touched integration/config files | Passed | | Prettier check on all touched files | Passed | | `git diff --check` and committed base diff check | Passed | An initial new Workerd fixture accepted the WebSocket peer before DO adoption and failed before its assertions; correcting the fixture ordering made the retirement regression pass. No baseline/environment blocker remains. Full Workerd integration sweep and live-provider canaries were not run; Workerd provider substitutes are not live canaries. ## Separate Existing Behavior The real-SQL reproducer `characterizes the unguarded fresh/restore artifact write across replacement` demonstrates that an old per-artifact write can publish to a replacement after encryption yields. This extraction deliberately preserves that gap rather than adding generation checks or replacing those writes with atomic resume completion. Another characterization pins the existing pre-commit boundary: a successful saved resume followed by terminal-secret read failure can fail the attempt and hold recovery. Access-write/publication failures after committed recovery retain their existing success treatment. Both distinctions are documented; neither is fixed incidentally. No schema, wire, provider-backend, runtime, timeout/retry or access-eligibility changes. Merge this increment before COL-244; this PR does not deploy. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/bac90a60f1ba67df3bca099ab80e15fa)* ## Summary by CodeRabbit * **Refactor** * Centralized sandbox access and shutdown handling, including credentials, terminal access, tunnel links, and notifications. * Updated session and shutdown flows to use the shared handling. * **Tests** * Expanded coverage for access updates, shutdown recovery and cleanup, resume and restore failures, and lifecycle race conditions. * **Documentation** * Clarified sandbox access ownership, lifecycle behavior, failure cases, and verification guidance. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../sandbox-lifecycle-manager-refactor.md | 56 +- .../sandbox-lifecycle-refactor-baseline.md | 23 +- eslint.config.js | 5 + .../lifecycle/launch-orchestration.test.ts | 8 +- .../lifecycle/manager-shutdown.test.ts | 846 +++++++++++++ .../src/sandbox/lifecycle/manager.test.ts | 1042 +++++------------ .../src/sandbox/lifecycle/manager.ts | 169 +-- .../lifecycle/pending-vm-respawn.test.ts | 5 +- .../sandbox/lifecycle/sandbox-access.test.ts | 342 ++++++ .../src/sandbox/lifecycle/sandbox-access.ts | 136 +++ .../src/sandbox/lifecycle/test-helpers.ts | 54 +- .../src/sandbox/lifecycle/vm-resolve.test.ts | 212 +++- .../src/sandbox/provider.test.ts | 37 +- .../control-plane/src/sandbox/provider.ts | 11 + .../control-plane/src/session/components.ts | 59 +- .../src/session/sandbox-repository.test.ts | 53 +- .../integration/sandbox-lifecycle-harness.ts | 44 +- .../test/integration/sandbox-shutdown.test.ts | 91 +- .../integration/session-components.test.ts | 61 +- scripts/lint-sandbox-boundaries.test.mjs | 6 + 20 files changed, 2308 insertions(+), 952 deletions(-) create mode 100644 packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts create mode 100644 packages/control-plane/src/sandbox/lifecycle/sandbox-access.test.ts create mode 100644 packages/control-plane/src/sandbox/lifecycle/sandbox-access.ts diff --git a/docs/plans/sandbox-lifecycle-manager-refactor.md b/docs/plans/sandbox-lifecycle-manager-refactor.md index a214e231b1..fd275b9f67 100644 --- a/docs/plans/sandbox-lifecycle-manager-refactor.md +++ b/docs/plans/sandbox-lifecycle-manager-refactor.md @@ -19,15 +19,18 @@ Paths are relative to `packages/control-plane/src/`. 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. - `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 or shutdown internals. + 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. -Access, VM reconciliation, allocation cleanup and watchdog effects remain manager responsibilities -until their serial extraction increments land. Each increment must integrate before its successor; -the manager is not intended to become a tiny facade or lose lifecycle arbitration. +VM reconciliation, allocation cleanup and watchdog effects remain manager responsibilities until +their serial extraction increments land. Each increment must integrate before its successor; the +manager is not intended to become a tiny facade or lose lifecycle arbitration. ## Launch Contract @@ -70,6 +73,35 @@ There is no universal startup pipeline. Independently landed context injection, 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/bridge; 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. + ## Verification Keep direct narrow-dependency tests for input resolution and lookup effects. Keep assembled tests @@ -78,12 +110,24 @@ ordering, the no-await boundary for ineligible images, retry identity rotation a 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. + +`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 -npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown +npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle 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 diff --git a/docs/plans/sandbox-lifecycle-refactor-baseline.md b/docs/plans/sandbox-lifecycle-refactor-baseline.md index 62a78141a4..b5500c700b 100644 --- a/docs/plans/sandbox-lifecycle-refactor-baseline.md +++ b/docs/plans/sandbox-lifecycle-refactor-baseline.md @@ -72,7 +72,11 @@ 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. + 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. @@ -83,6 +87,23 @@ reproducers/issues before behavior changes: 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 diff --git a/eslint.config.js b/eslint.config.js index 079667270a..5c46088fbf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,11 @@ 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.", + }, ]; export default tseslint.config( diff --git a/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts b/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts index 4af30c454e..e4985b1e08 100644 --- a/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/launch-orchestration.test.ts @@ -3,7 +3,6 @@ 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 { SandboxLifecycleManager } from "./manager"; import { createMockAlarmScheduler, createMockBroadcaster, @@ -14,6 +13,7 @@ import { createMockStorage, createMockWebSocketManager, createTestConfig, + createTestLifecycleManager, createUnmanagedShutdown, noLifetime, } from "./test-helpers"; @@ -117,7 +117,7 @@ function createLaunchFixture() { }, }) ); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, sessionContext, @@ -405,7 +405,7 @@ describe("launch input orchestration", () => { }); return repositories; }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -446,7 +446,7 @@ describe("launch input orchestration", () => { getLatestReady: vi.fn(async () => null), markRestoreFailed: vi.fn(async () => true), }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, 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 69607c2d8d..b6b3a95884 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.test.ts @@ -13,6 +13,8 @@ import { } from "./manager"; 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"; @@ -45,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 @@ -151,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, @@ -828,7 +217,7 @@ describe("lifecycle-owned runtime readiness and cancellation", () => { }, }); const alarms = createMockAlarmScheduler(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -971,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(); @@ -991,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(); @@ -1006,7 +404,7 @@ describe("SandboxLifecycleManager", () => { const idGenerator = createMockIdGenerator(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1063,7 +461,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsExplicitStop: true }, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1125,7 +523,7 @@ describe("SandboxLifecycleManager", () => { throw new Error("provider unavailable"); }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1180,7 +578,7 @@ describe("SandboxLifecycleManager", () => { }), stopSandbox: vi.fn(() => new Promise(() => {})), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1231,7 +629,7 @@ describe("SandboxLifecycleManager", () => { vncAccess: { url: "https://vnc.test", password: "secret" }, })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1258,10 +656,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, @@ -1301,7 +787,7 @@ describe("SandboxLifecycleManager", () => { throw new Error("spawn exploded"); }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1336,7 +822,7 @@ describe("SandboxLifecycleManager", () => { vi.mocked(storage.updateSandboxModalObjectId).mockImplementation(() => { throw new Error("storage unavailable"); }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1372,7 +858,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1399,7 +885,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1425,7 +911,7 @@ describe("SandboxLifecycleManager", () => { const alarmScheduler = createMockAlarmScheduler(); const config = createTestConfig(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1454,7 +940,7 @@ describe("SandboxLifecycleManager", () => { const idGenerator = createMockIdGenerator(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1495,7 +981,7 @@ describe("SandboxLifecycleManager", () => { markRestoreFailed: vi.fn(async () => true), }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1542,7 +1028,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1569,7 +1055,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: now - 60000, }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1606,7 +1092,7 @@ describe("SandboxLifecycleManager", () => { throw new Error("storage unavailable"); }); const broadcaster = createMockBroadcaster(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -1639,7 +1125,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1668,7 +1154,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1698,7 +1184,7 @@ describe("SandboxLifecycleManager", () => { }; const storage = createMockStorage(createMockSession(), sandbox, userEnvVars); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1725,7 +1211,7 @@ describe("SandboxLifecycleManager", () => { snapshot_runtime_version: COMPATIBLE_RUNTIME_VERSION, }); const mockStorage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), mockStorage, mockStorage, @@ -1770,7 +1256,7 @@ describe("SandboxLifecycleManager", () => { ), }); const mockStorage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -1819,7 +1305,7 @@ describe("SandboxLifecycleManager", () => { lifetime: noLifetime(), })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1864,7 +1350,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1903,7 +1389,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1946,7 +1432,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -1991,7 +1477,7 @@ describe("SandboxLifecycleManager", () => { sandboxDashboardUrlBuilder: (id: string) => `https://provider.example/${id}`, }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2028,9 +1514,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 +1534,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 +1553,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 +1592,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsPersistentResume: true }, resumeSandbox: vi.fn(async () => resumeResult), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2140,7 +1657,7 @@ describe("SandboxLifecycleManager", () => { resumeSandbox, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2197,7 +1714,7 @@ describe("SandboxLifecycleManager", () => { resumeSandbox, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2247,7 +1764,7 @@ describe("SandboxLifecycleManager", () => { providerObjectId: "retained-source", runtimeVersion: COMPATIBLE_RUNTIME_VERSION, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2279,7 +1796,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 +1828,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2338,7 +1855,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2365,7 +1882,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2399,7 +1916,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2439,7 +1956,7 @@ describe("SandboxLifecycleManager", () => { ), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2473,7 +1990,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2503,7 +2020,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2535,7 +2052,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: now - 60000, }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2563,7 +2080,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: now - 60000, }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2590,7 +2107,7 @@ describe("SandboxLifecycleManager", () => { alarmScheduler.schedule = vi.fn(async () => { throw new Error("alarm storage unavailable"); }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2624,7 +2141,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2653,7 +2170,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2681,7 +2198,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2707,7 +2224,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2730,7 +2247,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, @@ -2755,7 +2272,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, @@ -2777,7 +2294,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, @@ -2801,7 +2318,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, @@ -2823,7 +2340,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, @@ -2852,7 +2369,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, @@ -2890,7 +2407,7 @@ describe("SandboxLifecycleManager", () => { last_spawn_failure: Date.now(), }); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -2922,7 +2439,7 @@ describe("SandboxLifecycleManager", () => { const broadcaster = createMockBroadcaster(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2964,7 +2481,7 @@ describe("SandboxLifecycleManager", () => { // No takeSnapshot method }; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -2993,7 +2510,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3025,7 +2542,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3060,7 +2577,7 @@ describe("SandboxLifecycleManager", () => { }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3092,7 +2609,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3119,7 +2636,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, @@ -3157,7 +2674,7 @@ describe("SandboxLifecycleManager", () => { sourceStopped: false, })), } satisfies SandboxShutdownLifecycle; - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3196,7 +2713,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsExplicitStop: true }, stopSandbox: vi.fn(async () => ({ success: true })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3231,7 +2748,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsExplicitStop: true }, stopSandbox, }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3262,7 +2779,7 @@ describe("SandboxLifecycleManager", () => { createMockSession(), createMockSandbox({ status: "connecting" }) ); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider({ capabilities: { supportsExplicitStop: true }, stopSandbox: vi.fn(() => providerStop), @@ -3307,22 +2824,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() ); @@ -3351,7 +2878,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, @@ -3380,7 +2907,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(createMockSession(), sandbox); const broadcaster = createMockBroadcaster(); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3417,7 +2944,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, @@ -3452,7 +2979,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, @@ -3483,7 +3010,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, @@ -3515,7 +3042,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(createMockSession(), sandbox); const wsManager = createMockWebSocketManager(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider({ capabilities: { supportsExplicitStop: false } }), storage, storage, @@ -3548,7 +3075,7 @@ describe("SandboxLifecycleManager", () => { const alarmScheduler = createMockAlarmScheduler(); const config = createTestConfig(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3580,7 +3107,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(true); // Has WebSocket const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3604,7 +3131,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3628,7 +3155,7 @@ describe("SandboxLifecycleManager", () => { const wsManager = createMockWebSocketManager(false); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -3654,7 +3181,7 @@ describe("SandboxLifecycleManager", () => { const sandbox = createMockSandbox(); const storage = createMockStorage(createMockSession(), sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3680,7 +3207,7 @@ describe("SandboxLifecycleManager", () => { const alarmScheduler = createMockAlarmScheduler(); const config = createTestConfig(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( createMockProvider(), storage, storage, @@ -3737,7 +3264,7 @@ describe("SandboxLifecycleManager", () => { overrides?.sessionRepositories ?? REPO_MEMBER ); const provider = overrides?.provider ?? createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4048,7 +3575,7 @@ describe("SandboxLifecycleManager", () => { overrides?.sessionRepositories ?? ENV_MEMBERS ); const provider = overrides?.provider ?? createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4287,7 +3814,7 @@ describe("SandboxLifecycleManager", () => { overrides?.sessionRepositories ?? MULTI_REPO_MEMBERS ); const provider = overrides?.provider ?? createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4408,7 +3935,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, @@ -4438,7 +3965,7 @@ describe("SandboxLifecycleManager", () => { }); const provider = createMockProvider(); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4471,7 +3998,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, @@ -4498,7 +4025,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, @@ -4522,7 +4049,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, @@ -4552,7 +4079,7 @@ describe("SandboxLifecycleManager", () => { }); const broadcaster = createMockBroadcaster(); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4585,7 +4112,7 @@ describe("SandboxLifecycleManager", () => { name: "daytona", }; const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4614,7 +4141,7 @@ describe("SandboxLifecycleManager", () => { capabilities: { supportsSandboxTimeout: false }, }); const mockStorage = createMockStorage(session, sandbox); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, mockStorage, mockStorage, @@ -4641,7 +4168,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4668,7 +4195,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4697,7 +4224,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4726,7 +4253,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4755,7 +4282,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4794,7 +4321,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4826,7 +4353,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4858,7 +4385,7 @@ describe("SandboxLifecycleManager", () => { }); const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4889,7 +4416,7 @@ describe("SandboxLifecycleManager", () => { const storage = createMockStorage(session, sandbox); const provider = createMockProvider(); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4930,7 +4457,7 @@ describe("SandboxLifecycleManager", () => { })), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -4963,7 +4490,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, @@ -5144,7 +4671,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, @@ -5207,7 +4734,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, @@ -5303,7 +4830,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"; @@ -5362,7 +4889,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, @@ -5409,7 +4936,7 @@ describe("status writes after a provider await (COL-99)", () => { }; }), }); - const manager = new SandboxLifecycleManager( + const manager = createTestLifecycleManager( provider, storage, storage, @@ -5453,7 +4980,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, @@ -5491,7 +5018,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, @@ -5500,8 +5028,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. @@ -5521,9 +5050,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, @@ -5554,7 +5128,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 d742b91a30..6a9a83710f 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -27,6 +27,7 @@ import { PrebuiltImageUnavailableError, SandboxProviderError, SandboxLaunchRejectedError, + providerResumesAfterStop, type SandboxProvider, type CreateSandboxConfig, type CreateSandboxResult, @@ -54,7 +55,6 @@ 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 { parseStoredSandboxBootPhase, sandboxBootPhaseLogFields } from "../boot-phase"; import type { ImageBuildLookup } from "./image-selection"; import { @@ -81,14 +81,13 @@ import { shutdownPolicyForLaunch, type ShutdownLifecyclePolicy } from "./shutdow import { parsePendingVmReference } from "../providers/pending-vm-reference"; import { ModalApiError, ModalVmStartupError } from "../client"; import type { ResolveSandboxResult } from "../provider"; +import type { SandboxAccess } from "./sandbox-access"; export type { SandboxGeneration, SandboxAlarmResult } from "./ports"; 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; @@ -299,18 +298,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; } /** @@ -380,8 +369,6 @@ export interface SandboxLifecycleConfig extends AlarmPolicyConfig, SandboxLaunch * row (and its public id) exists. */ getSessionId?: () => string; - /** Builds a provider dashboard URL for a persisted provider object ID. */ - sandboxDashboardUrlBuilder?: (providerObjectId: string) => string | null; } /** @@ -482,10 +469,6 @@ export class SandboxLifecycleManager 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 }; @@ -518,6 +501,7 @@ export class SandboxLifecycleManager private readonly alarmScheduler: AlarmScheduler, private readonly idGenerator: IdGenerator, private readonly shutdown: SandboxShutdownLifecycle, + private readonly access: SandboxAccess, private readonly config: SandboxLifecycleConfig, imageBuildLookup?: ImageBuildLookup, private readonly backgroundTasks?: BackgroundTasks @@ -874,17 +858,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", @@ -1147,14 +1131,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, @@ -1162,7 +1146,7 @@ export class SandboxLifecycleManager ); } - this.broadcastProviderAccessIfConnected(); + this.access.broadcastProviderAccessIfConnected(); this.broadcaster.broadcast({ type: "sandbox_restored", @@ -1309,17 +1293,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, { @@ -1353,7 +1331,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) { @@ -1435,7 +1413,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); } /** @@ -1485,27 +1463,6 @@ export class SandboxLifecycleManager } } - /** - * 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" }); - } - /** * Stop a provider-managed sandbox via its API. */ @@ -1682,7 +1639,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" @@ -1740,7 +1697,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(); @@ -1839,7 +1796,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 }; @@ -1877,7 +1834,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(); @@ -1939,7 +1896,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", @@ -1997,7 +1954,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(); @@ -2189,72 +2146,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 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< @@ -2474,7 +2365,7 @@ export class SandboxLifecycleManager auth && auth.generation.sandboxId === generation.sandboxId && auth.generation.createdAt === generation.createdAt - ? await this.mintTtydToken(auth.token, auth.sessionId, generation.sandboxId!) + ? await this.access.mintTtydToken(auth.token, auth.sessionId, generation.sandboxId!) : null; const committed = await this.storage.completeProviderResume( generation, @@ -2523,7 +2414,7 @@ export class SandboxLifecycleManager result.providerObjectId ); } - this.broadcastProviderAccessIfConnected(); + this.access.broadcastProviderAccessIfConnected(); })() .catch((error) => { this.log.warn("Bridge VM resolution failed", { @@ -2555,7 +2446,7 @@ export class SandboxLifecycleManager 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); @@ -2622,7 +2513,7 @@ export class SandboxLifecycleManager 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" }); } 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/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/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 d2046be704..b2165a188a 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, @@ -149,7 +154,10 @@ function fixture(action: "create" | "restore" = "create", imageBuildLookup?: Ima } 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(); @@ -457,6 +465,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(); @@ -474,26 +484,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 () => { @@ -535,6 +699,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!, @@ -545,10 +713,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/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/session/components.ts b/packages/control-plane/src/session/components.ts index 4831b64192..d6ceff7984 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -28,7 +28,7 @@ import { generateId, hashToken, encryptToken } 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"; @@ -46,6 +46,9 @@ import { } 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"; @@ -430,11 +433,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( @@ -461,11 +479,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, @@ -473,9 +492,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), @@ -1037,6 +1055,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; @@ -1047,9 +1066,8 @@ interface LifecycleManagerDeps { sessionContext: SessionContextReader; repoSecretsEncryptionKey: string; messenger: SessionMessenger; - wsManager: SessionWebSocketManager; + lifecycleSockets: LifecycleSocketAdapter; alarmScheduler: RehydratableAlarmScheduler; - sandboxDashboardSettings: SandboxDashboardSettings; backgroundTasks: BackgroundTasks; } @@ -1058,6 +1076,7 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan const { provider, shutdown, + access, env, db, getSessionId, @@ -1065,18 +1084,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(), @@ -1109,12 +1120,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, { @@ -1148,7 +1153,6 @@ function createLifecycleManager(deps: LifecycleManagerDeps): SandboxLifecycleMan bootBudget: { timeoutMs: bootBudget.timeoutMs }, mcpServerLookup, slackAgentNotifyLookup, - sandboxDashboardUrlBuilder, recordWarning: deps.recordWarning, }; @@ -1167,10 +1171,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/sandbox-repository.test.ts b/packages/control-plane/src/session/sandbox-repository.test.ts index 48da858eeb..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 }; @@ -730,6 +770,10 @@ describe("SandboxRepository boot state (SQLite)", () => { 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) => { @@ -743,21 +787,28 @@ describe("SandboxRepository boot state (SQLite)", () => { 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 encrypt(...args); + 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(); 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/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/scripts/lint-sandbox-boundaries.test.mjs b/scripts/lint-sandbox-boundaries.test.mjs index c411b17e18..2c4e91b93d 100644 --- a/scripts/lint-sandbox-boundaries.test.mjs +++ b/scripts/lint-sandbox-boundaries.test.mjs @@ -15,6 +15,7 @@ test("new consumers and platform adapters cannot import sandbox implementations" for (const [name, source] of [ ["SandboxRepository", "../session/sandbox-repository"], ["SandboxLifecycleManager", "../sandbox/lifecycle/manager"], + ["SandboxAccess", "../sandbox/lifecycle/sandbox-access"], ]) { const [result] = await eslint.lintText( `import type { ${name} } from "${source}"; export type Dependency = ${name};`, @@ -39,4 +40,9 @@ test("focused ports remain usable by consumers and extracted lifecycle modules", ); assert.equal(result.errorCount, 0, JSON.stringify(result.messages)); } + const [access] = await eslint.lintText( + 'import type { SandboxAccess } from "./sandbox-access"; export type Dependency = SandboxAccess;', + { filePath: "packages/control-plane/src/sandbox/lifecycle/reconciliation.ts" } + ); + assert.equal(access.errorCount, 0, JSON.stringify(access.messages)); }); From 8b1a66bc8545ba236c33e82300f6ab6150e7b539 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Wed, 30 Sep 2026 11:52:13 -0700 Subject: [PATCH 19/44] fix(control-plane): recover browser PR OAuth credentials (#2162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix user-authored PR creation when a browser continues a bot-created session. Browser WebSocket participants can have a canonical user ID but no cached GitHub subject, which previously skipped OAuth resolution and selected the GitHub App token. - Resolve Better Auth credentials for the exact prompting canonical user even when the cached GitHub subject is null. - Select and verify that user's single linked GitHub account; retain fail-closed handling for a conflicting populated subject, ambiguous identities, or a substituted provider profile. - Preserve legacy participant credentials and genuine missing-account/missing-grant App fallback. The production fix changes only `session/participant-service.ts` and `session/identity.ts`. No schema, WebSocket route, token copying, or reconnect requirement is introduced; existing incomplete participants recover at the next PR credential lookup. ## Regression and TDD Incident: https://open-inspect-prod.vercel.app/session/11ccd3ae2a51d85891d19aa3e4e817cd (PR #2158). Wrote the Worker/D1 regression before implementation. It mints the browser participant through the real authenticated `/sessions/:id/ws-token` route in a Linear-created session, models that participant's processing prompt, and invokes the real PR handler. Better Auth resolves a correctly encrypted grant from D1; GitHub profile HTTP and PR publication are mocked external boundaries. **Red:** PR publication received `{ authType: "app", token: "push-token" }` instead of the expected browser OAuth authentication. **Green:** the same regression receives the browser OAuth authentication after the two-file fix. Additional unit coverage checks canonical account selection, conflicting/ambiguous accounts, provider substitution, unlinking, and missing grants. ## Validation - Shared package build - Control-plane unit tests (one worker) — 334 files, 5,498 passing - Focused integration tests: `create-pr`, `ws-token-participants`, `browser-auth-callback` — 26 passing - Control-plane typecheck, including Node and integration test configurations - ESLint and Prettier on changed files - `git diff --check` No production deployment or live provider canary is claimed. ## Summary by CodeRabbit * **Bug Fixes** * Improved GitHub authentication when creating pull requests in sessions continued by a browser participant. Pull requests can now use that participant’s valid GitHub authorization, including when a cached GitHub identity is missing. * Improved handling of linked GitHub identities: a single canonical identity can be recovered when cached information is absent, while conflicting or ambiguous identities are rejected. Unlinked identities return no GitHub token. --- .../src/session/identity.test.ts | 100 ++++++++++++++---- .../control-plane/src/session/identity.ts | 9 +- .../src/session/participant-service.ts | 8 +- .../test/integration/create-pr.test.ts | 79 +++++++++++++- 4 files changed, 158 insertions(+), 38 deletions(-) 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..946a233965 100644 --- a/packages/control-plane/src/session/identity.ts +++ b/packages/control-plane/src/session/identity.ts @@ -104,17 +104,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 +144,7 @@ export async function resolveCurrentGitHubAccessToken( parseBetterAuthGitHubProfile( await accountClient.accountInfo({ query: selection }), - expectedScmUserId + enrichment.scmUserId ); return token.accessToken; } diff --git a/packages/control-plane/src/session/participant-service.ts b/packages/control-plane/src/session/participant-service.ts index 4fbe8b025d..c3ccdd03d5 100644 --- a/packages/control-plane/src/session/participant-service.ts +++ b/packages/control-plane/src/session/participant-service.ts @@ -38,7 +38,7 @@ export interface ParticipantServiceDeps { generateId: () => string; resolveCurrentGitHubAccessToken?: ( canonicalUserId: string, - scmUserId: string + scmUserId: string | null ) => Promise; } @@ -246,11 +246,7 @@ export class ParticipantService { 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, diff --git a/packages/control-plane/test/integration/create-pr.test.ts b/packages/control-plane/test/integration/create-pr.test.ts index d936f69298..37c5e50c4f 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 { 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 { @@ -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,74 @@ describe("POST /internal/create-pr", () => { }); } + it("uses browser OAuth when continuing a Linear session without cached GitHub identity", async () => { + const browserUserId = "11111111111111111111111111111111"; + const originalFetch = globalThis.fetch.bind(globalThis); + const 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); + }); + try { + 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 }>(); + 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" }); + } finally { + fetchSpy.mockRestore(); + } + }); + it("reuses an existing open PR recorded for the session branch", async () => { const { stub } = await initSession({ userId: "user-1" }); await modelLegacyManualPushSession(stub); From 3beccb03e915688ac0e30253ee721cc7de1ff2e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:52:31 -0700 Subject: [PATCH 20/44] chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#2159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm_and_yarn group with 2 updates in the / directory: [next](https://github.com/vercel/next.js) and [brace-expansion](https://github.com/juliangruber/brace-expansion). Updates `next` from 16.3.5 to 16.3.8
Release notes

Sourced from next's releases.

v16.3.8

This release contains security fixes for the following advisories:

High:

Medium:

Low:

v16.3.7

[!NOTE] This release is backporting bug fixes. It does not include all pending features/changes on canary.

Core Changes

  • turbo-tasks-backend: fix strongly consistent read hanging on a canceled task (#98931)

Credits

Huge thanks to @​lukesandberg for helping!

v16.3.6

This release contains a security fix for GHSA-vcvr-r3jv-pc5j: Remote Code Execution in next/og ImageResponse

Commits
  • b0fad0d v16.3.8
  • 719e4c6 [lts-active] Scope response cache keys to their source route (#218)
  • e92db45 [lts-active] Fix metadata propagation for deduplicated nested caches (#223)
  • 40c2ba9 [lts-active] Match Next data paths case-sensitively (#196)
  • 2d9f50a [lts-active] Fix MCP middleware DNS rebinding (#213)
  • bd9214f [lts-active] Fix draft mode leaks through cross-request 'use cache' dedupli...
  • 8db4a62 [lts-active][webpack] Ensure dynamicParams is respected in `opengraph-image...
  • e002ad6 [lts-active] fix(next/image): Pin DNS resolution when fetching external image...
  • 4c20699 v16.3.7
  • 2521aec [backport] turbo-tasks-backend: fix strongly consistent read hanging on a can...
  • Additional commits viewable in compare view

Updates `brace-expansion` from 5.0.7 to 5.0.12
Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/ColeMurray/background-agents/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Cole Murray --- package-lock.json | 102 +++++++++++++++++++++---------------- packages/docs/package.json | 2 +- packages/web/package.json | 2 +- 3 files changed, 59 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c5908ef1c..be80fe17cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3381,15 +3381,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", - "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "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.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", - "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "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" ], @@ -3403,9 +3403,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", - "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "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" ], @@ -3419,12 +3419,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", - "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "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" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3435,12 +3438,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", - "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "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" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3451,12 +3457,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", - "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "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" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3467,12 +3476,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", - "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "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" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3483,9 +3495,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", - "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "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" ], @@ -3499,9 +3511,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", - "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "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" ], @@ -10326,16 +10338,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -17933,12 +17945,12 @@ } }, "node_modules/next": { - "version": "16.3.5", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", - "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "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.5", + "@next/env": "16.3.8", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -17952,14 +17964,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.5", - "@next/swc-darwin-x64": "16.3.5", - "@next/swc-linux-arm64-gnu": "16.3.5", - "@next/swc-linux-arm64-musl": "16.3.5", - "@next/swc-linux-x64-gnu": "16.3.5", - "@next/swc-linux-x64-musl": "16.3.5", - "@next/swc-win32-arm64-msvc": "16.3.5", - "@next/swc-win32-x64-msvc": "16.3.5", + "@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": { @@ -22862,7 +22874,7 @@ "fumadocs-mdx": "15.4.0", "fumadocs-ui": "16.15.6", "mermaid": "^12.0.0", - "next": "^16.3.0", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -23025,7 +23037,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "next": "^16.3.5", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/docs/package.json b/packages/docs/package.json index 4f4d590be9..f0bc00dfcd 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -22,7 +22,7 @@ "fumadocs-mdx": "15.4.0", "fumadocs-ui": "16.15.6", "mermaid": "^12.0.0", - "next": "^16.3.0", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/packages/web/package.json b/packages/web/package.json index 74913943d9..7a2c1d4a9f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,7 +34,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "next": "^16.3.5", + "next": "^16.3.8", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", From 8b6c5fc2f8e713c8735c6f0b0c19d68460c0366f Mon Sep 17 00:00:00 2001 From: "open-inspect[bot]" <255062780+open-inspect[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:54:44 -0700 Subject: [PATCH 21/44] feat(web): add team-aware session discovery and creation (#2158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes https://linear.app/colemurray/issue/COL-202 - Add a shared, persisted active-team context reconciled against active memberships. The sidebar switcher offers Workspace, memberships, All my teams, and administrator-only All teams, and remains hidden with fewer than two active memberships. - Apply the context to sidebar snapshots and pagination, discovery filters, recent-command requests, and exhaustive search links. Add Team, Owner, and Visibility filters while preserving the existing creator-only Mine behavior. - Add team and visibility to composer defaults, warm-draft identity, and the create BFF. Read the require-team setting through `/me/teams`, block teamless required creation before warming, pass team IDs to target catalogs, and preselect team default environments without overwriting explicit draft choices. - Render session controls from server capabilities, preserve HTTP capabilities when the shipped live subscription omits them, terminate transport on token-mint 404, render not-found without cached session content, and show action denial reason codes in toasts. - Extend inbox filtering through the existing predicate builder and return effective capabilities for roots and descendants. Correct authorization documentation and the changelog. ## Checkpoint Report ### 1. Commands And Results Final validation ran sequentially. Test suites used one worker to respect resource limits. | Command | Result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed | | `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | 334 files, 5,488 tests passed | | `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1` | 124 files, 1,544 tests passed, 1 skipped | | `npm test -w @open-inspect/web -- --maxWorkers=1` | 235 files, 2,137 tests passed | | `npm test -w @open-inspect/shared -- --maxWorkers=1` | 64 files, 1,062 tests passed | | `npm run typecheck` | All workspaces passed | | `npm run lint:fix` | Passed | | `npm run lint:sql-portability` | Clean, existing baseline unchanged | | Changed tracked and new files checked with `npx prettier --check` | Passed | | `git diff --check` and `git diff --check origin/main...HEAD` | Passed | An earlier concurrent attempt at the full test/typecheck commands was interrupted before results. Those interrupted attempts are not counted as passes; the sequential runs above completed successfully. Integration output included existing intentional forced-eviction/error-path diagnostics, without failed tests. Focused red tests preceded implementation. Selected failure output is reproduced verbatim below; all affected files subsequently passed the final full suites. `npm test -w @open-inspect/control-plane -- src/routes/session-index.test.ts -t 'scoped inbox routes'`: ```text AssertionError: expected 200 to be 403 // Object.is equality AssertionError: expected 200 to be 400 // Object.is equality ``` Initial scoped route result: 22 failed, 47 skipped. Initial focused inbox/membership integration result: 8 failed, 6 passed, 42 skipped. `npm test -w @open-inspect/web -- src/hooks/use-active-team.test.tsx src/components/team-switcher.test.tsx src/lib/session-inbox-api.test.ts src/app/api/sessions/inbox/route.test.ts`: ```text Error: Failed to resolve import "./team-switcher" from "src/components/team-switcher.test.tsx". Does the file exist? Error: Failed to resolve import "./use-active-team" from "src/hooks/use-active-team.test.tsx". Does the file exist? Expected: "/api/sessions/inbox?teamIds%5B%5D=team_alpha" Received: "/api/sessions/inbox" AssertionError: expected undefined to deeply equal { canRead: true, …(7) } Test Files 4 failed (4) Tests 3 failed | 22 passed (25) ``` The first native combined sidebar run exposed incomplete test fixtures after the new switcher was mounted: ```text TypeError: Cannot read properties of undefined (reading 'length') Test Files 1 failed | 8 passed (9) Tests 12 failed | 80 passed (92) ``` Fixtures were updated to the actual hook contract, including scope-only aggregate switches. `npm test -w @open-inspect/web -- src/lib/session-socket/reducer.test.ts src/hooks/use-session-socket.test.tsx -t 'retains server capabilities|replaces server capabilities|production subscribed|preserves them when later subscribed'` initially reproduced the production subscription capability loss: 4 failed, 1 passed, 84 skipped. The realistic subscription fixture now passes, alongside explicit denial-replacement tests. `npm test -w @open-inspect/web -- 'src/app/(app)/(sidebar)/page.test.tsx' -t 'first prompt'`: ```text AssertionError: expected "vi.fn()" to be called with arguments: [ Array(1) ] Number of calls: 0 Test Files 1 failed (1) Tests 1 failed | 37 skipped (38) ``` The composer now preserves the first prompt's `reason_code` in its toast and inline error. Fixture-backed browser checks mounted the actual composer, switcher, active-team provider, membership hook, target picker, and discovery page. Desktop 1440x900 and mobile 390x844 viewport captures verified team switching, visibility/environment defaults, filter/search preservation, and no horizontal overflow. Uploaded artifacts: `6a614a28ad2316d04bc1aa2ce7c70f9e`, `464c84a835b2c64ec2ef3c59ec3cb303`, `1e1549940fe2f572b110b7ca98872c06`, and `caf5f893a509f61176f5e67765700ed1`. This was not a live deployment or sandbox-launch check. ### 2. Verified Facts And Drift Branched from `main` at `cf345db`; `origin/main` remained at that revision when preparing this PR. - The sidebar header, All/Mine state, discovery URL codec, composer warm-draft identity, BFF allow-list, and 4010 authorization refresh matched the supplied code locations. - The actual team API uses `defaultVisibility` and `defaultEnvironmentId`, not database-style `default_visibility`; the UI uses the API fields. - The inbox shared projection did not include capabilities and its decoder stripped them. Added an optional capability field to preserve the server response, with missing capabilities still denying controls. - HTTP snapshots carry effective capabilities, but the production WebSocket subscription snapshot omits them. Preserve the previous server-computed capability object for that incremental omission; explicit capability updates replace it. No token-mint or authorization write path was changed. - Trace export has no session response capability flag. Preserve its existing server-effective `sessions.export` grant, additionally requiring the response read capability. No migration was added. This uses D1 migration 0083 and does not change enforcement defaults. Inbox queries reuse the existing predicates: non-private list behavior treats `off` and `shadow` alike, `on` enforces team visibility, and private restrictions apply in every mode. `scope=all` remains administrator-only and does not enumerate break-glass-only private sessions. ### 3. Deliberately Excluded - Team pages, directory widening, team-page links, move dialogs, and team/session membership-management UI remain separate work. Edits to the teams route and teams response schema are localized to the `/me/teams` creation-setting field. - Repository grants and control-plane team-filtered repository/environment catalogs are not implemented here; this PR only forwards the team context. Missing grants still refuse repository-backed team creation with `target_team_missing_grant`. - Bot team selection, automation ownership, credential scoping, and enforcement-default changes are excluded. - No live deployment, external notification, migration, or credential reach expansion was performed. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/11ccd3ae2a51d85891d19aa3e4e817cd)* --------- Co-authored-by: waclaude Co-authored-by: Cole Murray --- CHANGELOG.md | 22 +- docs/AUTH.md | 29 +- .../src/db/session-inbox-store.ts | 81 ++-- .../src/routes/session-index.test.ts | 180 ++++++- .../control-plane/src/routes/session-index.ts | 82 +++- packages/control-plane/src/routes/teams.ts | 3 + .../session/connection-authenticator.test.ts | 82 +++- .../src/session/connection-authenticator.ts | 9 +- .../integration/session-inbox-scoping.test.ts | 237 ++++++++++ .../integration/session-inbox-test-helpers.ts | 51 ++ .../test/integration/session-inbox.test.ts | 49 +- .../test/integration/teams-routes.test.ts | 32 +- .../test/integration/websocket-client.test.ts | 6 + packages/shared/src/types/session-inbox.ts | 7 +- packages/shared/src/types/teams.test.ts | 27 +- packages/shared/src/types/teams.ts | 2 + .../web/src/app/(app)/(sidebar)/layout.tsx | 7 +- .../(sidebar)/page-team-context.test.tsx | 191 ++++++++ .../app/(app)/(sidebar)/page.test-fixture.tsx | 310 +++++++++++++ .../web/src/app/(app)/(sidebar)/page.test.tsx | 295 ++---------- packages/web/src/app/(app)/(sidebar)/page.tsx | 153 ++++-- .../(sidebar)/session/[id]/page.test.tsx | 59 +++ .../app/(app)/(sidebar)/session/[id]/page.tsx | 29 +- .../(app)/(sidebar)/sessions/page.test.tsx | 340 ++++++++++++++ .../src/app/(app)/(sidebar)/sessions/page.tsx | 112 ++++- .../src/app/api/environments/route.test.ts | 37 ++ .../web/src/app/api/environments/route.ts | 7 +- packages/web/src/app/api/repos/route.test.ts | 36 ++ packages/web/src/app/api/repos/route.ts | 9 +- .../src/app/api/sessions/inbox/route.test.ts | 15 + .../web/src/app/api/sessions/inbox/route.ts | 10 +- .../web/src/app/api/sessions/route.test.ts | 26 +- packages/web/src/app/api/sessions/route.ts | 2 + .../components/global-command-menu.test.tsx | 46 +- .../src/components/global-command-menu.tsx | 19 +- .../components/session-discovery-filters.tsx | 90 ++++ .../src/components/session-header.test.tsx | 26 ++ .../web/src/components/session-header.tsx | 4 +- .../src/components/session-list-item.test.tsx | 19 +- .../web/src/components/session-list-item.tsx | 22 +- .../components/session-right-sidebar.test.tsx | 13 +- .../src/components/session-right-sidebar.tsx | 6 +- .../src/components/session-sidebar.test.tsx | 18 + .../web/src/components/session-sidebar.tsx | 68 +-- .../settings/data-controls-settings.test.tsx | 33 +- .../settings/data-controls-settings.tsx | 14 +- .../src/components/sidebar-layout.test.tsx | 155 +++++++ .../web/src/components/sidebar-layout.tsx | 10 +- .../sidebar/budget-section.test.tsx | 16 + .../src/components/sidebar/budget-section.tsx | 13 +- .../sidebar/metadata-section.test.tsx | 27 +- .../components/sidebar/metadata-section.tsx | 7 +- .../web/src/components/team-switcher.test.tsx | 86 ++++ packages/web/src/components/team-switcher.tsx | 41 ++ .../web/src/hooks/use-active-team.test.tsx | 212 +++++++++ packages/web/src/hooks/use-active-team.ts | 99 ++++ .../web/src/hooks/use-environments.test.tsx | 33 ++ packages/web/src/hooks/use-environments.ts | 8 +- packages/web/src/hooks/use-repos.test.tsx | 15 + packages/web/src/hooks/use-repos.ts | 8 +- .../hooks/use-session-attachments.test.tsx | 24 + .../web/src/hooks/use-session-attachments.ts | 11 + .../web/src/hooks/use-session-diffs.test.tsx | 26 ++ packages/web/src/hooks/use-session-diffs.ts | 11 + .../web/src/hooks/use-session-rename.test.tsx | 15 + packages/web/src/hooks/use-session-rename.ts | 7 + .../web/src/hooks/use-session-socket.test.tsx | 438 +++++++++++++----- packages/web/src/hooks/use-session-socket.ts | 36 +- .../hooks/use-session-target-picker.test.ts | 171 ++++++- .../src/hooks/use-session-target-picker.ts | 137 ++++-- .../src/hooks/use-session-transport.test.tsx | 80 ++++ .../web/src/hooks/use-session-transport.ts | 90 ++-- .../src/hooks/use-sidebar-sessions.test.tsx | 46 ++ .../web/src/hooks/use-sidebar-sessions.ts | 26 +- packages/web/src/hooks/use-teams.test.tsx | 22 + packages/web/src/hooks/use-teams.ts | 12 +- .../src/hooks/use-warm-draft-session.test.tsx | 113 +++++ .../web/src/hooks/use-warm-draft-session.ts | 41 +- packages/web/src/lib/archive-session.test.ts | 26 ++ packages/web/src/lib/archive-session.ts | 3 +- packages/web/src/lib/session-action-error.ts | 18 + .../web/src/lib/session-capabilities.test.ts | 52 ++- packages/web/src/lib/session-capabilities.ts | 16 +- .../web/src/lib/session-discovery.test.ts | 106 +++++ packages/web/src/lib/session-discovery.ts | 81 +++- .../web/src/lib/session-inbox-api.test.ts | 41 ++ packages/web/src/lib/session-inbox-api.ts | 27 +- .../src/lib/session-socket/reducer.test.ts | 51 ++ 88 files changed, 4605 insertions(+), 767 deletions(-) create mode 100644 packages/control-plane/test/integration/session-inbox-scoping.test.ts create mode 100644 packages/control-plane/test/integration/session-inbox-test-helpers.ts create mode 100644 packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx create mode 100644 packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx create mode 100644 packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx create mode 100644 packages/web/src/app/api/environments/route.test.ts create mode 100644 packages/web/src/app/api/repos/route.test.ts create mode 100644 packages/web/src/components/team-switcher.test.tsx create mode 100644 packages/web/src/components/team-switcher.tsx create mode 100644 packages/web/src/hooks/use-active-team.test.tsx create mode 100644 packages/web/src/hooks/use-active-team.ts create mode 100644 packages/web/src/hooks/use-environments.test.tsx create mode 100644 packages/web/src/hooks/use-session-diffs.test.tsx create mode 100644 packages/web/src/lib/archive-session.test.ts create mode 100644 packages/web/src/lib/session-action-error.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b5cd4a45eb..b0d1d19128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ New features, integrations, and notable improvements to Open-Inspect — newest first. +## September 30, 2026 + +### Added + +**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. + ## September 29, 2026 ### Added @@ -13,9 +25,13 @@ ownership denials without blocking non-private sessions, while `on` enforces the 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 changes are available. Team grants do not yet narrow the -shared source-control installation token in sandboxes. See -[Authentication and Authorization](docs/AUTH.md). +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 c7e2c4b8fb..c6ad785c48 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -115,7 +115,10 @@ grant for private sessions; they still need the relevant workspace permission to 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. +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. A collaborator may remove themselves with session read access alone; +collaboration or lifecycle permission is not required for self-removal. Session actions have additional rules after visibility: prompting requires collaboration permission, sandbox use requires sandbox permission, and lifecycle operations require lifecycle permission. With @@ -136,14 +139,30 @@ requires a team; `private` requires a workspace user owner. A teamless session m 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 -does not migrate or hide existing `ownerTeamId: null` workspace rows. +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 and moves +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. Moving a session to a team checks active membership in the destination (or an explicit join to an open team) and repository grants for every repository in the session and included descendants. A missing grant blocks the move. Moving to no team sets `ownerTeamId` to `null` and turns `team` visibility into `workspace`. Moving or changing visibility can include descendants. Team grants constrain selection and moves, not the source-control App token already available to a running -sandbox. +sandbox. A cascading move or visibility change refuses the entire request if any included descendant +is inaccessible or denies the requested action; it does not silently skip that descendant. + +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 @@ -155,6 +174,10 @@ Operators set `TEAMS_ENFORCEMENT` to `off`, `shadow` (the default), or `on`: rules would deny access; private restrictions still take effect. - `on`: enforce visibility, team membership, and action/ownership rules for sessions. +The visibility, scope, 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. + The session boundary covers four paths, not just the session page: - **HTTP item routes** authorize by the persisted session row before serving snapshots, actions, diff --git a/packages/control-plane/src/db/session-inbox-store.ts b/packages/control-plane/src/db/session-inbox-store.ts index a4ebd5ac54..fe8d9eace5 100644 --- a/packages/control-plane/src/db/session-inbox-store.ts +++ b/packages/control-plane/src/db/session-inbox-store.ts @@ -1,12 +1,11 @@ import { SESSION_INBOX_CATEGORIES, type SessionInboxCategory, - type SessionInboxItem, type SessionInboxSession, } from "@open-inspect/shared/types/session-inbox"; import type { SessionStatus, SpawnSource } from "@open-inspect/shared/types/sessions"; -import { visibleSessionsPredicate, type SessionReadScope } from "./session-visibility"; -import type { TeamsEnforcementMode } from "../authorization/teams-enforcement"; +import type { SessionVisibility } from "@open-inspect/shared/types/teams"; +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"; @@ -14,28 +13,37 @@ 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; - teamIds?: readonly string[]; - readScope: SessionReadScope; - mode: TeamsEnforcementMode; viewerUserId: string; limit: number; cursor: SessionInboxCursor | null; } export interface ListSessionInboxResult { - items: SessionInboxItem[]; + items: Array<{ rootSession: InboxSession; descendantSessions: InboxSession[] }>; hasMore: boolean; nextCursor: SessionInboxCursor | null; } export type ListSessionInboxSnapshotResult = Record; +/** Persisted ownership is used for server capabilities, then stripped from the wire projection. */ +interface InboxSession extends SessionInboxSession { + userId: string | null; + ownerTeamId: string | null; + visibility: SessionVisibility; +} + 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; @@ -58,9 +66,12 @@ interface InboxPageData { nextCursor: SessionInboxCursor | null; } -function toListItem(row: InboxSessionRow): SessionInboxSession { +function toListItem(row: InboxSessionRow): InboxSession { 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, @@ -205,21 +216,30 @@ export class SessionInboxStore { | "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. @@ -259,41 +279,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< - ListSessionInboxOptions, - "createdByUserIds" | "excludeAutomatedSessions" | "teamIds" | "readScope" | "mode" - > - ): { 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); - } - if (options.teamIds?.length) { - conditions.push(`sessions.owner_team_id IN (${options.teamIds.map(() => "?").join(", ")})`); - params.push(...options.teamIds); - } - if (options.readScope.kind !== "internal") { - const visibility = visibleSessionsPredicate("sessions", options.readScope, { - mode: options.mode, - }); - conditions.push(visibility.sql); - params.push(...visibility.params); - } - 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(); @@ -322,7 +311,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/routes/session-index.test.ts b/packages/control-plane/src/routes/session-index.test.ts index 833737aeda..359f429fac 100644 --- a/packages/control-plane/src/routes/session-index.test.ts +++ b/packages/control-plane/src/routes/session-index.test.ts @@ -7,6 +7,7 @@ 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(), @@ -104,6 +105,9 @@ const listSession = { const inboxSession = { id: listSession.id, + ownerTeamId: listSession.ownerTeamId, + visibility: listSession.visibility, + userId: listSession.userId, title: listSession.title, repoOwner: listSession.repoOwner, repoName: listSession.repoName, @@ -119,12 +123,16 @@ const inboxSession = { const emptyInboxResult = { items: [], hasMore: false, nextCursor: null }; -async function listInbox(query = ""): 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 ); } @@ -410,7 +418,14 @@ describe("session index routes", () => { }); 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: [], @@ -577,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", canMove: false }, + }, + 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(); diff --git a/packages/control-plane/src/routes/session-index.ts b/packages/control-plane/src/routes/session-index.ts index e566a2d2ca..a8cf52b156 100644 --- a/packages/control-plane/src/routes/session-index.ts +++ b/packages/control-plane/src/routes/session-index.ts @@ -12,8 +12,9 @@ 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, @@ -42,6 +43,8 @@ import { 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 @@ -58,6 +61,11 @@ 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"); @@ -231,7 +239,7 @@ export async function handleListSessionInbox( ): 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); } @@ -247,30 +255,42 @@ export async function handleListSessionInbox( 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: teamsEnforcementMode(ctx, env), + mode, teamIds, + ownerFilter, + visibility, + scope, }; if (category === null) { const snapshot = await readSessionList(() => store.listInboxSnapshot(commonOptions)); if (snapshot instanceof Response) return snapshot; - const body = sessionInboxSnapshotSchema.parse({ + 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; @@ -284,14 +304,7 @@ export async function handleListSessionInbox( }) ); if (result instanceof Response) return result; - const nextCursor = result.nextCursor ? encodeSessionInboxCursor(result.nextCursor) : null; - const response = json( - sessionInboxPageSchema.parse({ - items: result.items, - hasMore: result.hasMore, - nextCursor, - }) - ); + 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", @@ -308,12 +321,39 @@ 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.filter((row) => row.visibility === "private").map((row) => row.id) + ); + 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/teams.ts b/packages/control-plane/src/routes/teams.ts index c05aa7edda..382299f0e4 100644 --- a/packages/control-plane/src/routes/teams.ts +++ b/packages/control-plane/src/routes/teams.ts @@ -15,6 +15,7 @@ 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"; @@ -138,7 +139,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( diff --git a/packages/control-plane/src/session/connection-authenticator.test.ts b/packages/control-plane/src/session/connection-authenticator.test.ts index b1a394889d..61bb3e1d30 100644 --- a/packages/control-plane/src/session/connection-authenticator.test.ts +++ b/packages/control-plane/src/session/connection-authenticator.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi } from "vitest"; import { hashToken } from "../auth/crypto"; -import { permissionsForBuiltInRole } from "@open-inspect/shared/rbac"; +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"; @@ -682,6 +682,76 @@ describe("client session access", () => { expect(removeClient).not.toHaveBeenCalled(); }); + it.each(["off", "shadow", "on"] as const)( + "sends fresh effective capabilities on each authenticated subscription in %s mode", + async (mode) => { + 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); + const subscribe = () => + authenticator.handleSubscribe({} as WebSocket, { token: "token", clientId: "client" }); + const expectedCapabilities = { + canRead: true, + canCollaborate: true, + canManageLifecycle: true, + canDelete: mode !== "on", + canMove: false, + canSandbox: true, + canManageCollaborators: false, + canChangeVisibility: false, + }; + + await subscribe(); + expect(send).toHaveBeenLastCalledWith( + {}, + expect.objectContaining({ + type: "subscribed", + session: expect.objectContaining({ capabilities: expectedCapabilities }), + }) + ); + + 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, + canDelete: 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(); + } + ); + 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 }; @@ -752,6 +822,16 @@ describe("client session access", () => { 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, + canMove: true, + canSandbox: false, + canManageCollaborators: true, + canChangeVisibility: true, + }); expect(auditPrivateBreakGlass).toHaveBeenCalledExactlyOnceWith( owner.userId, expect.objectContaining({ id: "session", visibility: "private" }) diff --git a/packages/control-plane/src/session/connection-authenticator.ts b/packages/control-plane/src/session/connection-authenticator.ts index 309bf6290d..569d7fdbc4 100644 --- a/packages/control-plane/src/session/connection-authenticator.ts +++ b/packages/control-plane/src/session/connection-authenticator.ts @@ -5,6 +5,7 @@ import { type AccessDecision, type SessionAction, type SessionAccessRow, + type SessionCapabilities, type SessionViewer, } from "@open-inspect/shared"; import { @@ -42,6 +43,7 @@ import { 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). @@ -451,7 +453,7 @@ export class SessionConnectionAuthenticator implements SessionUpgradeAdmission { ws, clientInfo, enrichment, - this.decide(resolution, "sandbox").allowed, + effectiveSessionCapabilities(resolution.viewer, resolution.row, resolution.mode), canManageSessionBudget(resolution.row.ownerUserId, resolution.authorization) ) ); @@ -493,20 +495,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, 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..f3c1741091 --- /dev/null +++ b/packages/control-plane/test/integration/session-inbox-scoping.test.ts @@ -0,0 +1,237 @@ +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, canMove: false }), + }), + descendantSessions: [ + expect.objectContaining({ + id: "owned-child", + ownerTeamId: "team-a", + visibility: "team", + capabilities: expect.objectContaining({ + canRead: true, + canMove: 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 dd2af4ed50..2f4e5503e7 100644 --- a/packages/control-plane/test/integration/session-inbox.test.ts +++ b/packages/control-plane/test/integration/session-inbox.test.ts @@ -4,56 +4,9 @@ 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 type { SessionViewer } from "@open-inspect/shared"; import { SessionInboxStore } from "../../src/db/session-inbox-store"; -const VIEWER_ID = "11111111111111111111111111111111"; -const viewer: SessionViewer = { - kind: "user", - userId: VIEWER_ID, - roleKey: "member", - permissions: ["sessions.read"], - suspended: false, - memberships: new Map([["team-a", "member"]]), -}; - -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(); -} - -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); diff --git a/packages/control-plane/test/integration/teams-routes.test.ts b/packages/control-plane/test/integration/teams-routes.test.ts index 3dfcb0aa17..d7c07181c0 100644 --- a/packages/control-plane/test/integration/teams-routes.test.ts +++ b/packages/control-plane/test/integration/teams-routes.test.ts @@ -1,9 +1,10 @@ import { 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, type Team } from "@open-inspect/shared/types/teams"; 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"; @@ -45,7 +46,10 @@ describe("team routes", () => { 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); @@ -77,6 +81,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; diff --git a/packages/control-plane/test/integration/websocket-client.test.ts b/packages/control-plane/test/integration/websocket-client.test.ts index e58f379d2c..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(); }); 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/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 7fbf6097ee..78e4c101c8 100644 --- a/packages/shared/src/types/teams.ts +++ b/packages/shared/src/types/teams.ts @@ -106,4 +106,6 @@ 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), }); 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..3ddf187787 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/page-team-context.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment jsdom +/// + +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +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"; + +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("defaults workspace drafts to workspace visibility without offering team visibility", async () => { + render(); + const visibility = screen.getByRole("combobox", { name: "Session visibility" }); + expect(visibility).toHaveValue("workspace"); + expect(within(visibility).queryByRole("option", { name: "Team" })).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "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 () => { + mocks.teams = [team(), team({ id: "team-2", name: "Design", defaultVisibility: "private" })]; + mocks.activeTeamId = "team-1"; + const view = render(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Ship it" }, + }); + await waitFor(() => + expect(sessionCreateBody()).toMatchObject({ teamId: "team-1", visibility: "team" }) + ); + fireEvent.change(screen.getByRole("combobox", { name: "Session visibility" }), { + target: { value: "workspace" }, + }); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith("/api/sessions/session-1/archive", expect.anything()) + ); + await waitFor(() => + expect( + vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === "/api/sessions") + ).toHaveLength(2) + ); + mocks.activeTeamId = "team-2"; + view.rerender(); + expect(screen.getByRole("combobox", { name: "Session visibility" })).toHaveValue("private"); + 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", + }); + }); + }); + + it("offers a composer team choice when the single-team switcher is hidden", () => { + mocks.teams = [team()]; + render(); + fireEvent.change(screen.getByRole("combobox", { name: "Session team" }), { + target: { value: "team-1" }, + }); + expect(mocks.setActiveTeam).toHaveBeenCalledWith("team-1"); + }); + + it("waits for membership and settings reconciliation before warming or sending", async () => { + mocks.teamsLoading = true; + const view = render(); + const input = screen.getByPlaceholderText("What do you want to build?"); + fireEvent.change(input, { target: { value: "Ship it" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", ctrlKey: true }); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + mocks.teamsLoading = false; + mocks.requireTeamOnCreate = true; + mocks.teams = [team()]; + view.rerender(); + expect(mocks.setActiveTeam).toHaveBeenCalledWith("team-1"); + expect(fetch).not.toHaveBeenCalled(); + mocks.activeTeamId = "team-1"; + view.rerender(); + 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", () => { + 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?"); + fireEvent.change(input, { target: { value: "Ship it" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", ctrlKey: true }); + 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", () => { + mocks.teamsError = new Error("Settings unavailable"); + render(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Ship it" }, + }); + expect(screen.getByRole("button", { name: /send/i })).toBeDisabled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("surfaces terminal creation codes on first typing and does not retry the denied draft", async () => { + 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?"); + fireEvent.change(input, { target: { value: "Ship it" } }); + await screen.findByText("Team archived (team_archived)"); + fireEvent.change(input, { target: { value: "" } }); + fireEvent.change(input, { target: { value: "Try again" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter", ctrlKey: true }); + 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 () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + Response.json({ error: "Visibility denied", code: "visibility_denied" }, { status: 403 }) + ) + .mockResolvedValueOnce(Response.json({ sessionId: "session-1", status: "created" })); + render(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "Ship it" }, + }); + await screen.findByText("Visibility denied (visibility_denied)"); + const visibility = screen.getByRole("combobox", { name: "Session visibility" }); + expect(visibility).not.toBeDisabled(); + fireEvent.change(visibility, { target: { value: "private" } }); + 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("waits for a team's default environment before warming the composed draft", async () => { + 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(); + fireEvent.change(screen.getByPlaceholderText("What do you want to build?"), { + target: { value: "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..41ca3c7043 --- /dev/null +++ b/packages/web/src/app/(app)/(sidebar)/page.test-fixture.tsx @@ -0,0 +1,310 @@ +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; + teams: (TeamResponse & { role: TeamRole })[]; + teamsLoading: boolean; + teamsError: unknown; + requireTeamOnCreate: boolean; + } = { + activeTeamId: null, + 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[], + 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: undefined, + 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, + }: { + 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.activeTeamId = null; + 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..2229ca6ca7 100644 --- a/packages/web/src/app/(app)/(sidebar)/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/page.tsx @@ -2,6 +2,8 @@ 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"; @@ -65,6 +67,8 @@ 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 { buildInteractiveProviderRoutingIdentity, parseStoredProviderSelections, @@ -101,8 +105,41 @@ export default function Home() { const { hasPermission } = useCurrentUserAuthorization(); const canCreateSession = hasPermission("sessions.create"); const router = useRouter(); - const picker = useSessionTargetPicker(); + const teamContext = useActiveTeam(); + const { + activeTeamId, + setActiveTeam, + teams, + requireTeamOnCreate, + loading: loadingTeams, + error: teamError, + } = teamContext; + const selectedTeam = teams.find((team) => team.id === activeTeamId); + const teamCreationReady = + !loadingTeams && !teamError && (activeTeamId === null ? !requireTeamOnCreate : !!selectedTeam); + const [visibilityDraft, setVisibilityDraft] = useState<{ + teamId: string | null; + value: SessionVisibility; + } | null>(null); + const visibility = + visibilityDraft && visibilityDraft.teamId === activeTeamId + ? visibilityDraft.value + : (selectedTeam?.defaultVisibility ?? "workspace"); + const picker = useSessionTargetPicker({ + teamId: activeTeamId, + defaultEnvironmentId: selectedTeam?.defaultEnvironmentId, + }); const { sessionTarget, buildRequestFields, isLaunchable } = picker; + + useEffect(() => { + if (!loadingTeams && !teamError && requireTeamOnCreate && activeTeamId === null && teams[0]) { + setActiveTeam(teams[0].id); + } + }, [activeTeamId, loadingTeams, requireTeamOnCreate, setActiveTeam, teamError, teams]); + + useEffect(() => { + setVisibilityDraft(null); + }, [activeTeamId]); const [storedPreference, setStoredPreference] = useState({ model: DEFAULT_MODEL, reasoningEffort: getDefaultReasoningEffort(DEFAULT_MODEL), @@ -221,6 +258,7 @@ export default function Home() { const warmRequest: WarmDraftSessionRequest | null = canCreateSession && + teamCreationReady && session && providerSelectionsHydrated && !providerAccounts.loading && @@ -234,6 +272,8 @@ export default function Home() { reasoningEffort, skillSelection, providerSelections: availableProviderSelections, + teamId: activeTeamId, + visibility, } : null; const warmRoutingIdentity = buildInteractiveProviderRoutingIdentity( @@ -242,11 +282,17 @@ export default function Home() { providerAccounts.accounts ); const { + identity: warmIdentity, sessionId: pendingSessionId, isWarming: isCreatingSession, warm: createSessionForWarming, consume: consumeWarmSession, + error: creationError, } = useWarmDraftSession(warmRequest, warmRoutingIdentity); + const hasDraftContent = prompt.length > 0 || sessionAttachments.attachments.length > 0; + useEffect(() => { + if (hasDraftContent && warmIdentity) void createSessionForWarming(); + }, [createSessionForWarming, hasDraftContent, warmIdentity]); const saveModelPreferenceDraft = useCallback((preference: ModelPreference) => { setModelPreferenceDraft(preference); @@ -286,32 +332,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 +403,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 +426,13 @@ export default function Home() { isAuthenticated={!!session} canCreateSession={canCreateSession} picker={picker} + teamContext={teamContext} + teamCreationReady={teamCreationReady} + visibility={visibility} + onVisibilityChange={(value) => { + if (activeTeamId !== null || value !== "team") + setVisibilityDraft({ teamId: activeTeamId, value }); + }} selectedModel={selectedModel} setSelectedModel={handleModelChange} reasoningEffort={reasoningEffort} @@ -401,18 +440,18 @@ export default function Home() { harness={harness} setHarness={handleHarnessChange} prompt={prompt} - handlePromptChange={handlePromptChange} + handlePromptChange={setPrompt} 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 +471,10 @@ function HomeContent({ isAuthenticated, canCreateSession, picker, + teamContext, + teamCreationReady, + visibility, + onVisibilityChange, selectedModel, setSelectedModel, reasoningEffort, @@ -460,6 +503,10 @@ function HomeContent({ isAuthenticated: boolean; canCreateSession: boolean; picker: SessionTargetSelection; + teamContext: ReturnType; + teamCreationReady: boolean; + visibility: SessionVisibility; + onVisibilityChange: (value: SessionVisibility) => void; selectedModel: ValidModel; setSelectedModel: (value: ValidModel) => void; reasoningEffort: ReasoningEffort | undefined; @@ -509,6 +556,7 @@ function HomeContent({ } = useAttachmentDropZone({ locked: attachmentsLocked, onAdd: attachments.onAdd }); const { sessionTarget, selectedRepo, repos, loadingRepos, isLaunchable } = picker; const selectedProvider = getSubscriptionProviderForModel(selectedModel); + const selectedTeam = teamContext.teams.find((team) => team.id === teamContext.activeTeamId); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.nativeEvent.isComposing) return; @@ -552,6 +600,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 +677,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"]})`} @@ -636,6 +696,41 @@ function HomeContent({ {/* Footer row with session controls */}
+ {teamContext.teams.length === 1 && !teamContext.requireTeamOnCreate ? ( + + ) : ( + + {selectedTeam?.name ?? "Workspace"} + + )} + ({ socket: vi.fn(), prompt: vi.fn() })); +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NEXT_NOT_FOUND"); + }, + useRouter: () => ({ push: vi.fn() }), +})); +vi.mock("./session-snapshot-provider", () => ({ + useSessionSnapshot: () => ({ + session: { id: "session-1", title: "Cached secret", harness: "opencode" }, + }), +})); +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 }), +})); + +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(); +}); 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..06afc9a1f6 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"; @@ -22,6 +22,8 @@ import { 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, @@ -63,8 +65,6 @@ import { formatSessionCost } from "@/lib/session-cost"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useSessionSnapshot } 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 +74,23 @@ 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 sessionId = initialSnapshot.session.id; const { + capabilities, connected, connecting, reconnecting, @@ -104,7 +115,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( @@ -632,10 +643,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)/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 ? (