From f1cf0697e911a701a0fe5d0d3539653d8f72b661 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 1 Oct 2026 00:32:37 -0700 Subject: [PATCH 01/17] fix: supply Modal restore credentials from the control plane (#2178) ## Summary Implements COL-226. Modal snapshot restores no longer get a git token minted by Modal. Every Modal launch path now uses the control-plane credential broker, and Modal holds no source-control credentials. - **The control plane is the only source of VCS identity.** `createModalProviderFromEnv` passes `SCM_PROVIDER` into `ModalSandboxProvider`, which resolves `scmCloneIdentity()` once, the same way the Daytona, E2B, Vercel, and OpenComputer providers do. `ModalClient` sends `clone_host` / `clone_username` on create, restore, and image-build requests. The session lifecycle, launch config, and provider interfaces are unchanged. - **Modal requires the identity and has no fallback.** `api-create-sandbox`, `api-restore-sandbox`, and `api-create-build-sandbox` reject requests without `clone_host` / `clone_username`. `inject_vcs_env_vars` no longer reads `SCM_PROVIDER`, and the GitHub/GitLab/Bitbucket defaults are removed. - **Modal-side minting is removed.** This deletes `clone_token.py`, the `sandbox_runtime.auth.github_app` module and its PyJWT dependency, the `github-app` secret binding on `api_restore_sandbox`, Terraform provisioning of that secret, and the generated `GITHUB_TOKEN` / `GITHUB_APP_TOKEN` / `OI_GITHUB_TOKEN_IS_FALLBACK` aliases on restore. `_gh_wrapper_should_mint` now only checks for a user-supplied `GH_TOKEN` / `GITHUB_TOKEN`. - **Image-build clone auth is simpler.** `ImageBuildCloneAuth` now carries only the token. The Modal provider supplies host and username from its own identity. - **Docs are consolidated** into one CHANGELOG entry plus updated statements in HOW_IT_WORKS, the control-plane, github-bot, and modal-infra READMEs, and the security and sandbox-environment pages. Image builds still receive a one-shot `VCS_CLONE_TOKEN` because they have no session to broker through. User-supplied token overrides are preserved. ## Verification - Control-plane unit tests: 5,655 passed. Integration tests: 1,646 passed, 1 skipped. - Modal-infra pytest: 541 passed. Sandbox-runtime pytest: 1,401 passed, 3 skipped. - Docs tests: 44 passed. The production docs build passed. - `npm run typecheck`, ESLint, Prettier, and Ruff check/format all passed. - Not exercised: a live Modal deploy, or restoring a real production snapshot. ## Rollout - The new Modal endpoints reject requests that lack `clone_host` / `clone_username`. Terraform deploys Modal before the control-plane Worker, so session launches and image builds return 400 for the short window between the two deploys. We accept that window instead of carrying a compatibility fallback. - Terraform no longer provisions Modal's `github-app` secret but does not delete an existing one. Delete it from Modal after the upgrade. Rolling back to a version that binds it requires recreating it. - Keep the GitHub App credentials configured for the control plane and the GitHub bot. - Team-scoped credential minting (COL-205) is unaffected. This PR only moves the non-secret host/username, and tokens are still minted per request by the broker. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/d7e84d56dd72df3752b1971396368869)* ## Summary by CodeRabbit * **Changed** * Snapshot-restored sessions now obtain short-lived Git credentials through the control plane, consistent with fresh and prebuilt sessions. * Sandbox launches and restores now use the configured source-control host and clone username. One-shot image builds continue to use a clone token. * Modal no longer needs GitHub App credentials or the related secret. Existing GitHub App secrets can be deleted after upgrading. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- CHANGELOG.md | 9 + docs/HOW_IT_WORKS.md | 13 +- packages/control-plane/README.md | 9 +- .../src/image-builds/daytona-adapter.test.ts | 2 +- .../src/image-builds/e2b-adapter.test.ts | 2 - .../src/image-builds/modal-adapter.test.ts | 4 - .../src/image-builds/modal-adapter.ts | 3 - .../image-builds/opencomputer-adapter.test.ts | 2 - .../control-plane/src/image-builds/planner.ts | 10 +- .../control-plane/src/image-builds/types.ts | 2 +- .../src/image-builds/vercel-adapter.test.ts | 2 - .../control-plane/src/sandbox/client.test.ts | 83 ++++- packages/control-plane/src/sandbox/client.ts | 19 +- .../lifecycle/pending-vm-respawn.test.ts | 27 +- .../lifecycle/rejected-allocation.test.ts | 14 +- .../src/sandbox/lifecycle/vm-resolve.test.ts | 2 +- .../src/sandbox/provider-factory.ts | 2 +- .../sandbox/providers/modal-backends.test.ts | 21 +- .../sandbox/providers/modal-provider.test.ts | 101 ++++-- .../src/sandbox/providers/modal-provider.ts | 23 +- .../sandbox/providers/vercel/provider.test.ts | 1 - .../integration/modal-backend-builds.test.ts | 2 +- .../content/docs/administration/security.mdx | 7 +- .../docs/reference/sandbox-environment.mdx | 7 +- packages/github-bot/README.md | 7 +- packages/modal-infra/.env.example | 10 - packages/modal-infra/README.md | 21 +- packages/modal-infra/pyproject.toml | 1 - packages/modal-infra/src/app.py | 10 - packages/modal-infra/src/clone_token.py | 36 -- .../modal-infra/src/sandbox/build_session.py | 6 +- packages/modal-infra/src/sandbox/launch.py | 9 +- packages/modal-infra/src/sandbox/manager.py | 22 +- packages/modal-infra/src/sandbox/models.py | 4 +- packages/modal-infra/src/sandbox/vcs_env.py | 41 +-- packages/modal-infra/src/web_api.py | 32 +- .../tests/test_agent_slack_notify_env.py | 6 + .../tests/test_build_sandbox_lifecycle.py | 6 + .../modal-infra/tests/test_clone_token.py | 77 ---- .../modal-infra/tests/test_code_server.py | 8 + .../modal-infra/tests/test_llm_secrets.py | 18 +- .../tests/test_sandbox_env_vars.py | 336 +++++------------- .../modal-infra/tests/test_sandbox_launch.py | 95 ++++- .../tests/test_sandbox_resources.py | 4 + packages/modal-infra/tests/test_ttyd.py | 8 + .../modal-infra/tests/test_tunnel_ports.py | 19 +- packages/modal-infra/tests/test_vm_resolve.py | 2 + packages/modal-infra/tests/test_vnc.py | 11 +- .../tests/test_web_api_build_sandbox.py | 9 +- .../tests/test_web_api_create_sandbox.py | 217 +++++++---- packages/modal-infra/uv.lock | 4 - packages/sandbox-runtime/pyproject.toml | 1 - .../src/sandbox_runtime/auth/__init__.py | 2 - .../src/sandbox_runtime/auth/github_app.py | 90 ----- .../credentials/git_credential_helper.py | 21 +- packages/sandbox-runtime/tests/conftest.py | 1 - .../tests/test_git_credential_helper.py | 46 +-- packages/sandbox-runtime/uv.lock | 2 - terraform/environments/production/modal.tf | 8 - 59 files changed, 717 insertions(+), 840 deletions(-) delete mode 100644 packages/modal-infra/src/clone_token.py delete mode 100644 packages/modal-infra/tests/test_clone_token.py delete mode 100644 packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a8de27a8d7..9c8ad0568c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ remain readable. ## October 1, 2026 +### Changed + +**Modal snapshot restores use brokered git credentials.** Restored sandboxes now fetch git +credentials from the control plane like fresh sessions, instead of receiving a token minted by +Modal. The control plane now sends the VCS host and clone username with every Modal create, restore, +and image-build request, so Modal no longer reads `SCM_PROVIDER` or needs GitHub App credentials. +Terraform no longer provisions Modal's `github-app` secret; you can delete the existing secret from +Modal after upgrading. + ### Added Team leads and workspace administrators can manage encrypted secrets from a team's Secrets tab. diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index df74f70b82..5dcf1bf32e 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -688,13 +688,12 @@ was built for internal use where all employees have access to company repositori | WebSocket Token | Authenticate client connections | Single session | | Managed LLM Token | Short-lived OpenAI or xAI model access | Pinned session provider account | -Fresh and prebuilt-image sandboxes fetch git credentials on demand through the control plane instead -of relying on a token embedded in the environment or remote URL. Snapshot restores may still receive -env-token fallbacks so legacy snapshots can boot through the credential-helper migration. The helper -authorizes HTTPS requests for the configured SCM host, preserving existing setup/start hooks that -clone other private repositories available to the installation. This primarily protects continuously -running sessions and Daytona persistent resumes from expired embedded credentials; Modal snapshot -restores still mint a fresh fallback token on restore. +Session sandboxes, whether fresh, prebuilt-image, or restored from a snapshot, fetch git credentials +on demand through the control plane instead of relying on a token embedded in the environment or +remote URL. The helper authorizes HTTPS requests for the configured SCM host, preserving existing +setup/start hooks that clone other private repositories available to the installation. This protects +continuously running sessions and persistent resumes from expired embedded credentials. One-shot +image builds still receive `VCS_CLONE_TOKEN` because they have no session to broker through. ### Secrets diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index f2c3967a52..90a6e01389 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -500,15 +500,14 @@ The system uses two types of GitHub tokens: | GitHub App Token | Clone, fetch, push | Brokered to credential helper | All repos where App is installed | | User OAuth Token | Create PRs | Server-only | User's accessible repos | -Fresh and prebuilt-image sandboxes do not receive a long-lived `GITHUB_TOKEN`, `GITHUB_APP_TOKEN`, +Session sandboxes, including snapshot restores, do not receive `GITHUB_TOKEN`, `GITHUB_APP_TOKEN`, or `VCS_CLONE_TOKEN` for normal git operations. Git invokes the sandbox credential helper, which calls `/sessions/:id/scm-credentials` with the sandbox auth token and receives short-lived -credentials on demand. Legacy snapshots and one-shot image builds may still receive env-token -fallbacks for compatibility. The helper preserves the existing installation-wide model by serving +credentials on demand. The helper preserves the existing installation-wide model by serving credentials for HTTPS git requests to the configured SCM host, including setup/start hooks that clone auxiliary private repos. This avoids stale embedded credentials in long-running sessions and -Daytona persistent resumes; Modal snapshot restores still mint a fresh fallback token during -restore. +persistent resumes. One-shot image builds still receive `VCS_CLONE_TOKEN` because they have no +session to broker through. If a `create-pr` request is triggered by a participant without a user OAuth token (for example, Slack-created or Google-login sessions), the sandbox can still push the branch with brokered GitHub diff --git a/packages/control-plane/src/image-builds/daytona-adapter.test.ts b/packages/control-plane/src/image-builds/daytona-adapter.test.ts index ef1ee7a321..5576bcc437 100644 --- a/packages/control-plane/src/image-builds/daytona-adapter.test.ts +++ b/packages/control-plane/src/image-builds/daytona-adapter.test.ts @@ -61,7 +61,7 @@ function plan(overrides: Partial = {}): ImageBuildPlan { buildTimeoutMs: 1_800_000, correlation, callbackToken: "a".repeat(64), - cloneAuth: { type: "credential_helper", host: "github.com", username: "x", token: "clone-1" }, + cloneAuth: { type: "credential_helper", token: "clone-1" }, ...overrides, }; } diff --git a/packages/control-plane/src/image-builds/e2b-adapter.test.ts b/packages/control-plane/src/image-builds/e2b-adapter.test.ts index 7077cca13f..96afcea9e3 100644 --- a/packages/control-plane/src/image-builds/e2b-adapter.test.ts +++ b/packages/control-plane/src/image-builds/e2b-adapter.test.ts @@ -31,8 +31,6 @@ function createPlan(): ImageBuildPlan { callbackToken: "callback-token", cloneAuth: { type: "credential_helper", - host: "github.com", - username: "x-access-token", token: "clone-token", }, buildTimeoutMs: 1_800_001, diff --git a/packages/control-plane/src/image-builds/modal-adapter.test.ts b/packages/control-plane/src/image-builds/modal-adapter.test.ts index ddd0140040..cbe4c94f8d 100644 --- a/packages/control-plane/src/image-builds/modal-adapter.test.ts +++ b/packages/control-plane/src/image-builds/modal-adapter.test.ts @@ -27,8 +27,6 @@ function createPlan(): ImageBuildPlan { callbackToken: "modal-callback-token", cloneAuth: { type: "credential_helper", - host: "gitlab.com", - username: "oauth2", token: "clone-token", }, buildTimeoutMs: 1_800_000, @@ -59,8 +57,6 @@ describe("ModalImageBuildAdapter", () => { buildId: "build-1", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], cloneToken: "clone-token", - cloneHost: "gitlab.com", - cloneUsername: "oauth2", buildExecutionTimeoutSeconds: 1800, providerSessionTimeoutSeconds: 2400, userEnvVars: { FOO: "bar" }, diff --git a/packages/control-plane/src/image-builds/modal-adapter.ts b/packages/control-plane/src/image-builds/modal-adapter.ts index fabdf4b0b1..5dcfb44e3b 100644 --- a/packages/control-plane/src/image-builds/modal-adapter.ts +++ b/packages/control-plane/src/image-builds/modal-adapter.ts @@ -27,9 +27,6 @@ export class ModalImageBuildAdapter implements ImageBuildAdapter { buildId: plan.buildId, repositories: plan.repositories, cloneToken: plan.cloneAuth.type === "credential_helper" ? plan.cloneAuth.token : undefined, - cloneHost: plan.cloneAuth.type === "credential_helper" ? plan.cloneAuth.host : undefined, - cloneUsername: - plan.cloneAuth.type === "credential_helper" ? plan.cloneAuth.username : undefined, userEnvVars: plan.userEnvVars, buildExecutionTimeoutSeconds: Math.ceil(plan.buildTimeoutMs / 1000), providerSessionTimeoutSeconds: resolveImageBuildProviderSessionTimeoutSeconds( diff --git a/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts b/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts index 2e7b840d97..37e2bd739f 100644 --- a/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts +++ b/packages/control-plane/src/image-builds/opencomputer-adapter.test.ts @@ -24,8 +24,6 @@ function createPlan(): ImageBuildPlan { callbackToken: "callback-token", cloneAuth: { type: "credential_helper", - host: "github.com", - username: "x-access-token", token: "clone-token", }, buildTimeoutMs: 1_800_001, diff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts index 9a61f7fa8d..e170e9eedc 100644 --- a/packages/control-plane/src/image-builds/planner.ts +++ b/packages/control-plane/src/image-builds/planner.ts @@ -1,7 +1,6 @@ import { resolveBuildTimeoutSeconds } from "@open-inspect/shared/types/integrations"; import { createLogger, type CorrelationContext } from "../logger"; -import { createSourceControlProviderFromEnv, resolveScmProviderFromEnv } from "../source-control"; -import { scmCloneIdentity } from "../sandbox/sandbox-env"; +import { createSourceControlProviderFromEnv } from "../source-control"; import { prepareLegacyManagedProviderEnv } from "../sandbox/managed-provider-env"; import type { Env } from "../types"; import type { SqlDatabase } from "../db/sql-database"; @@ -123,12 +122,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort { try { const provider = createSourceControlProviderFromEnv(this.env); const auth = await provider.generateCredentialHelperAuth(); - return { - type: "credential_helper", - host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host, - username: auth.username, - token: auth.password, - }; + return { type: "credential_helper", token: auth.password }; } catch (e) { logger.warn("image_build.clone_token_failed", { error: e instanceof Error ? e.message : String(e), diff --git a/packages/control-plane/src/image-builds/types.ts b/packages/control-plane/src/image-builds/types.ts index cc06769080..433fa8b443 100644 --- a/packages/control-plane/src/image-builds/types.ts +++ b/packages/control-plane/src/image-builds/types.ts @@ -25,7 +25,7 @@ export type TriggerImageBuildResult = /** Clone auth handed to provider-session build sandboxes (provider-policy.ts). */ export type ImageBuildCloneAuth = - | { type: "credential_helper"; host: string; username: string; token: string } + | { type: "credential_helper"; token: string } | { type: "unavailable" }; /** diff --git a/packages/control-plane/src/image-builds/vercel-adapter.test.ts b/packages/control-plane/src/image-builds/vercel-adapter.test.ts index e11cfcf903..7b2663182e 100644 --- a/packages/control-plane/src/image-builds/vercel-adapter.test.ts +++ b/packages/control-plane/src/image-builds/vercel-adapter.test.ts @@ -24,8 +24,6 @@ function createPlan(buildTimeoutMs = 1_800_001): ImageBuildPlan { callbackToken: "callback-token", cloneAuth: { type: "credential_helper", - host: "github.com", - username: "x-access-token", token: "clone-token", }, buildTimeoutMs, diff --git a/packages/control-plane/src/sandbox/client.test.ts b/packages/control-plane/src/sandbox/client.test.ts index bf17806389..0535640d54 100644 --- a/packages/control-plane/src/sandbox/client.test.ts +++ b/packages/control-plane/src/sandbox/client.test.ts @@ -8,6 +8,9 @@ import { createModalClient, } from "./client"; import { RequestDeadlineError } from "./request-deadline"; +import { scmCloneIdentity } from "./sandbox-env"; + +const GITHUB_IDENTITY = scmCloneIdentity("github"); function rejectWhenAborted(signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason); @@ -186,6 +189,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme"); await expect( client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session", sandboxId: "generation", sandboxBackend: "modal-vm", @@ -204,6 +208,7 @@ describe("ModalClient", () => { ); await expect( createModalClient("secret", "acme").restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "image", sessionId: "session", sandboxId: "generation", @@ -224,6 +229,7 @@ describe("ModalClient", () => { Response.json({ detail: "docker_not_available" }, { status: 501 }) ); const created = createModalClient("secret", "acme").createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session", sandboxId: "generation", sandboxBackend: "modal-vm", @@ -247,6 +253,7 @@ describe("ModalClient", () => { }); const request = createModalClient("secret", "acme").createImageBuildSandbox({ + scmIdentity: GITHUB_IDENTITY, scopeKind: "repo", scopeId: "acme/repo", buildId: "imgb-1", @@ -352,6 +359,7 @@ describe("ModalClient", () => { endpoint: "create sandbox", call: (client: ReturnType) => client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: null, repoName: null, @@ -364,6 +372,7 @@ describe("ModalClient", () => { endpoint: "restore sandbox", call: (client: ReturnType) => client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -388,6 +397,7 @@ describe("ModalClient", () => { endpoint: "create image-build sandbox", call: (client: ReturnType) => client.createImageBuildSandbox({ + scmIdentity: GITHUB_IDENTITY, scopeKind: "repo", scopeId: "acme/repo", buildId: "imgb-1", @@ -420,7 +430,7 @@ describe("ModalClient", () => { ); }); - it("routes the restore session_config through buildSessionConfig (carries mcp_servers)", async () => { + it("sends restore SCM identity without a token and routes session_config through buildSessionConfig", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify({ success: true, data: { sandbox_id: "sb-1" } }), { status: 200, @@ -441,9 +451,15 @@ describe("ModalClient", () => { provider: "anthropic", model: "anthropic/claude-sonnet-4-5", mcpServers: [{ id: "mcp-1", name: "Tool", type: "local", enabled: true }], + scmIdentity: scmCloneIdentity("github"), }); const body = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); + expect(body).toMatchObject({ + clone_host: "github.com", + clone_username: "x-access-token", + }); + expect(body).not.toHaveProperty("clone_token"); expect(body.session_config).toEqual({ session_id: "session-123", harness: "opencode", @@ -456,6 +472,52 @@ describe("ModalClient", () => { }); }); + it.each(["base", "prebuilt", "restore"] as const)( + "sends the same GitLab identity without a token on %s launch", + async (source) => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { sandbox_id: "sb-1", status: "spawning", created_at: 1 }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + const client = createModalClient("secret", "acme", "prod-web"); + const request = { + sessionId: "session-123", + sandboxId: "sandbox-456", + sandboxAuthToken: "auth-token", + controlPlaneUrl: "https://control-plane.test", + repoOwner: "group/subgroup", + repoName: "repo", + harness: "opencode" as const, + provider: "anthropic", + model: "claude-sonnet-4-5", + scmIdentity: scmCloneIdentity("gitlab"), + }; + if (source === "restore") { + await client.restoreSandbox({ ...request, snapshotImageId: "img-1" }); + } else { + await client.createSandbox({ + ...request, + prebuiltImageId: source === "prebuilt" ? "img-1" : undefined, + }); + } + + const body = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); + expect(body).toMatchObject({ + clone_host: "gitlab.com", + clone_username: "oauth2", + }); + expect(body).not.toHaveProperty("clone_token"); + } + ); + it("asks Modal's create handler for early bridge connect by SessionConfig field name", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( @@ -469,6 +531,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", sandboxId: "sandbox-456", repoOwner: "testowner", @@ -497,6 +560,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", sandboxId: "sandbox-456", repoOwner: "testowner", @@ -530,6 +594,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -551,6 +616,7 @@ describe("ModalClient", () => { .mockResolvedValueOnce(Response.json({ success: true, data: { sandbox_id: "sb-2" } })); const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-1", repoOwner: null, repoName: null, @@ -560,6 +626,7 @@ describe("ModalClient", () => { launchDeadlineAtMs: 123456, }); await client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "im-1", sessionId: "session-1", sandboxId: "sandbox-1", @@ -602,6 +669,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -646,6 +714,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); const result = await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -681,6 +750,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: "testowner", repoName: "testrepo", @@ -701,6 +771,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -740,6 +811,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -778,6 +850,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await expect( client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -815,6 +888,7 @@ describe("ModalClient", () => { const client = createModalClient("secret", "acme", "prod-web"); await client.createSandbox({ + scmIdentity: GITHUB_IDENTITY, sessionId: "session-123", repoOwner: null, repoName: null, @@ -824,6 +898,7 @@ describe("ModalClient", () => { vncEnabled: true, }); await client.restoreSandbox({ + scmIdentity: GITHUB_IDENTITY, snapshotImageId: "img-1", sessionId: "session-123", sandboxId: "sandbox-456", @@ -940,8 +1015,7 @@ describe("ModalClient", () => { buildId: "imgb-1", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], cloneToken: "clone-token", - cloneHost: "gitlab.com", - cloneUsername: "oauth2", + scmIdentity: scmCloneIdentity("gitlab"), callbackUrl: "https://worker.test/image-builds/build-complete", failureCallbackUrl: "https://worker.test/image-builds/build-failed", buildExecutionTimeoutSeconds: 1800, @@ -1020,8 +1094,7 @@ describe("ModalClient", () => { buildId: "imgb-1", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], cloneToken: "clone-token", - cloneHost: "github.com", - cloneUsername: "x-access-token", + scmIdentity: GITHUB_IDENTITY, callbackUrl: "https://cp.test/image-builds/build-complete", failureCallbackUrl: "https://cp.test/image-builds/build-failed", buildExecutionTimeoutSeconds: 1800, diff --git a/packages/control-plane/src/sandbox/client.ts b/packages/control-plane/src/sandbox/client.ts index e450bbe83b..1440e75888 100644 --- a/packages/control-plane/src/sandbox/client.ts +++ b/packages/control-plane/src/sandbox/client.ts @@ -12,7 +12,11 @@ import type { McpServerConfig, SandboxSettings } from "@open-inspect/shared/type import { z } from "zod"; import { createLogger } from "../logger"; import type { CorrelationContext } from "../logger"; -import { buildSessionConfig, toRepositoryConfigPayload } from "./sandbox-env"; +import { + buildSessionConfig, + toRepositoryConfigPayload, + type ScmCloneIdentity, +} from "./sandbox-env"; import type { SessionRepositoryInfo } from "./provider"; import { parsePendingVmReference } from "./providers/pending-vm-reference"; import { withRequestDeadline } from "./request-deadline"; @@ -160,6 +164,7 @@ export function buildModalSandboxDashboardUrl(params: { } export interface CreateSandboxRequest { + scmIdentity: ScmCloneIdentity; sandboxBackend?: ModalBackend; retireSandboxId?: string | null; launchDeadlineAtMs?: number; @@ -202,6 +207,7 @@ export interface CreateSandboxResponse { } export interface RestoreSandboxRequest { + scmIdentity: ScmCloneIdentity; sandboxBackend?: ModalBackend; retireSandboxId?: string | null; launchDeadlineAtMs?: number; @@ -283,9 +289,8 @@ export interface CreateImageBuildSandboxRequest { buildId: string; /** Repositories in position order ([0] = primary), cloned at their base branches. */ repositories: Array<{ repoOwner: string; repoName: string; baseBranch: string }>; + scmIdentity: ScmCloneIdentity; cloneToken?: string; - cloneHost?: string; - cloneUsername?: string; callbackUrl: string; failureCallbackUrl: string; userEnvVars?: Record; @@ -506,6 +511,8 @@ export class ModalClient { repo_name: request.repoName, control_plane_url: request.controlPlaneUrl, sandbox_auth_token: request.sandboxAuthToken, + clone_host: request.scmIdentity.host, + clone_username: request.scmIdentity.cloneUsername, agent_session_id: request.agentSessionId || null, harness: request.harness, provider: request.provider || "anthropic", @@ -585,6 +592,8 @@ export class ModalClient { MODAL_SANDBOX_START_REQUEST_DEADLINE_MS, { snapshot_image_id: request.snapshotImageId, + clone_host: request.scmIdentity.host, + clone_username: request.scmIdentity.cloneUsername, session_config: buildSessionConfig(request), sandbox_id: request.sandboxId, control_plane_url: request.controlPlaneUrl, @@ -790,8 +799,8 @@ export class ModalClient { build_id: request.buildId, repositories: request.repositories.map(toRepositoryConfigPayload), clone_token: request.cloneToken, - clone_host: request.cloneHost, - clone_username: request.cloneUsername, + clone_host: request.scmIdentity.host, + clone_username: request.scmIdentity.cloneUsername, callback_url: request.callbackUrl, failure_callback_url: request.failureCallbackUrl, user_env_vars: request.userEnvVars, 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 8934af43e9..b0d858b318 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 @@ -87,7 +87,11 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("snapshot unavailable", 500); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); let state: ShutdownRecord | null = action === "restore" && setupDelayMs ? { @@ -212,7 +216,7 @@ describe("pending VM reference recovery", () => { const client = { createSandbox: vi.fn(), stopSandbox: vi.fn(async () => {}) }; const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ); fixture.shutdown.recordPendingProviderHandle = vi.fn(async () => { sandbox.modal_sandbox_id = "replacement-generation"; @@ -255,7 +259,7 @@ describe("pending VM reference recovery", () => { }; const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ); await fixture.manager.spawnSandbox(); vi.setSystemTime(Date.now() + DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs + 1); @@ -284,7 +288,11 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); const first = createAlarmFixture(sandbox, provider); void first.manager.spawnSandbox(); await vi.waitFor(() => expect(client.createSandbox).toHaveBeenCalledOnce()); @@ -304,7 +312,7 @@ describe("pending VM reference recovery", () => { const createSandbox = vi.fn(); const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider({ createSandbox } as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider({ createSandbox } as unknown as ModalClient, "modal-vm", "github") ); let finishLookup!: (value: undefined) => void; vi.mocked(fixture.storage.getUserEnvVars).mockImplementationOnce( @@ -330,7 +338,8 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), } as unknown as ModalClient, - "modal-vm" + "modal-vm", + "github" ); const sandbox = createMockSandbox({ status: "failed", @@ -364,7 +373,8 @@ describe("pending VM reference recovery", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), } as unknown as ModalClient, - "modal-vm" + "modal-vm", + "github" ); const sandbox = createMockSandbox({ status: "failed", @@ -393,7 +403,8 @@ describe("pending VM reference recovery", () => { })); const provider = new ModalSandboxProvider( { stopSandbox, restoreSandbox } as unknown as ModalClient, - "modal-vm" + "modal-vm", + "github" ); const sandbox = createMockSandbox({ status: "stopped", 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 2d5acda6d5..825ea67bd0 100644 --- a/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/rejected-allocation.test.ts @@ -53,7 +53,11 @@ describe("rejected provider allocation", () => { throw new Error("response lost"); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); const fixture = createAlarmFixture(sandbox, provider); await fixture.manager.spawnSandbox(); expect(sandbox.status).toBe("ready"); @@ -90,7 +94,7 @@ describe("rejected provider allocation", () => { }; const fixture = createAlarmFixture( sandbox, - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ); const spawning = fixture.manager.spawnSandbox(); try { @@ -422,7 +426,11 @@ describe("rejected provider allocation", () => { throw new ModalApiError("not visible", 409, "pending_reference_not_visible"); }), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider( + client as unknown as ModalClient, + "modal-vm", + "github" + ); const stop = vi.spyOn(provider, "stopSandbox"); const fixture = createAlarmFixture(sandbox, provider); expect(await fixture.manager.handleShutdownAlarm()).toBe("hold_watchdogs"); 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 f01449f317..36fbb0554a 100644 --- a/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/vm-resolve.test.ts @@ -80,7 +80,7 @@ function fixture(action: "create" | "restore" = "create", imageBuildLookup?: Ima ), stopSandbox: vi.fn(async () => {}), }; - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"); + const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github"); const backgroundTasks = { submit: vi.fn((task: () => Promise) => void task()) }; let state: ShutdownRecord | null = null; const store = { diff --git a/packages/control-plane/src/sandbox/provider-factory.ts b/packages/control-plane/src/sandbox/provider-factory.ts index 90615f3e1a..94468625f9 100644 --- a/packages/control-plane/src/sandbox/provider-factory.ts +++ b/packages/control-plane/src/sandbox/provider-factory.ts @@ -35,7 +35,7 @@ function createModalProviderFromEnv(env: Env, backend: "modal" | "modal-vm"): Mo env.MODAL_API_URL ); - return createModalProvider(client, backend); + return createModalProvider(client, backend, resolveScmProviderFromEnv(env.SCM_PROVIDER)); } function createVercelProviderFromEnv(env: Env): VercelSandboxProvider { diff --git a/packages/control-plane/src/sandbox/providers/modal-backends.test.ts b/packages/control-plane/src/sandbox/providers/modal-backends.test.ts index 7a8324af27..0808fcc8bc 100644 --- a/packages/control-plane/src/sandbox/providers/modal-backends.test.ts +++ b/packages/control-plane/src/sandbox/providers/modal-backends.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createModalClient, type ModalClient } from "../client"; import { SandboxLaunchRejectedError } from "../provider"; import { ModalSandboxProvider } from "./modal-provider"; +import { scmCloneIdentity } from "../sandbox-env"; import { resolveSandboxDashboardUrl } from "../../session/sandbox-access"; +const scmIdentity = scmCloneIdentity("github"); const config = { sessionId: "session-1", sandboxId: "sandbox-1", @@ -51,7 +53,7 @@ function fixture(confirmation: unknown) { }; return { client, - provider: new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm"), + provider: new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github"), }; } @@ -110,7 +112,7 @@ describe("distinct Modal backend identities", () => { it("accepts older standard Modal responses without a backend echo", async () => { const { client } = fixture(undefined); - const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal"); + const provider = new ModalSandboxProvider(client as unknown as ModalClient, "modal", "github"); await expect(provider.createSandbox(config)).resolves.toMatchObject({ providerObjectId: "sb-1", }); @@ -172,7 +174,11 @@ describe("distinct Modal backend identities", () => { ) ); const client = createModalClient("test-secret", "workspace"); - const result = await client.createImageBuildSandbox({ ...build, sandboxBackend: "modal-vm" }); + const result = await client.createImageBuildSandbox({ + ...build, + scmIdentity, + sandboxBackend: "modal-vm", + }); expect(result).toMatchObject({ providerSessionId: "sb-1", sandboxBackend: { invalid: true } }); }); @@ -190,14 +196,19 @@ describe("distinct Modal backend identities", () => { ); vi.stubGlobal("fetch", fetchMock); const client = createModalClient("test-secret", "workspace"); - await client.createSandbox({ ...config, sandboxBackend: "modal-vm" }); + await client.createSandbox({ ...config, scmIdentity, sandboxBackend: "modal-vm" }); fetchMock.mockResolvedValue( Response.json({ success: true, data: { sandbox_id: "sandbox-1", modal_object_id: "sb-1", sandbox_backend: "modal-vm" }, }) ); - await client.restoreSandbox({ ...config, snapshotImageId: "im-1", sandboxBackend: "modal-vm" }); + await client.restoreSandbox({ + ...config, + scmIdentity, + snapshotImageId: "im-1", + sandboxBackend: "modal-vm", + }); for (const [, init] of fetchMock.mock.calls) { expect(JSON.parse(init.body)).toMatchObject({ sandbox_backend: "modal-vm", 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 c5120e13e7..4af50bfcd3 100644 --- a/packages/control-plane/src/sandbox/providers/modal-provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/modal-provider.test.ts @@ -11,6 +11,7 @@ import { PENDING_VM_REFERENCE_MATERIALIZE_BOUND_MS } from "../lifecycle/decision import { PrebuiltImageUnavailableError, SandboxProviderError } from "../provider"; import { ModalApiError, ModalVmStartupError } from "../client"; import { RequestDeadlineError } from "../request-deadline"; +import { scmCloneIdentity } from "../sandbox-env"; import type { ModalClient, CreateSandboxRequest, @@ -94,6 +95,24 @@ const testConfig = { // ==================== Tests ==================== describe("ModalSandboxProvider", () => { + it("sends the deployment SCM identity on create, prebuilt create, and restore", async () => { + const client = createMockModalClient(); + const provider = new ModalSandboxProvider(client, "modal", "gitlab"); + await provider.createSandbox(testConfig); + await provider.createSandbox({ ...testConfig, prebuiltImageId: "prebuilt-image" }); + await provider.restoreFromSnapshot({ ...testConfig, snapshotImageId: "img-123" }); + + const identity = { scmIdentity: scmCloneIdentity("gitlab") }; + expect(vi.mocked(client.createSandbox).mock.calls.map(([request]) => request)).toEqual([ + expect.objectContaining(identity), + expect.objectContaining({ ...identity, prebuiltImageId: "prebuilt-image" }), + ]); + expect(client.restoreSandbox).toHaveBeenCalledWith( + expect.objectContaining(identity), + undefined + ); + }); + it.each(["not_visible", "other_generation", "unknown"] as const)( "decodes raw and wrapped VM outcome %s without changing its classification", (detail) => { @@ -111,7 +130,7 @@ describe("ModalSandboxProvider", () => { ); it("standard Modal hooks do not enable VM allocation recovery", () => { - const provider = new ModalSandboxProvider(createMockModalClient(), "modal"); + const provider = new ModalSandboxProvider(createMockModalClient(), "modal", "github"); expect(provider.pendingSandboxAllocation(testConfig)).toBeUndefined(); expect(provider.isUnknownStartupError(new ModalApiError("unavailable", 503))).toBe(false); expect( @@ -136,7 +155,8 @@ describe("ModalSandboxProvider", () => { throw error; }, }), - "modal-vm" + "modal-vm", + "github" ); let caught: unknown; try { @@ -170,7 +190,8 @@ describe("ModalSandboxProvider", () => { sandboxBackend: "modal-vm", }), }), - "modal-vm" + "modal-vm", + "github" ); const config = { ...testConfig, generationCreatedAtMs: createdAt, timeoutSeconds: 5_400 }; const pending = provider.pendingSandboxAllocation(config); @@ -199,7 +220,7 @@ describe("ModalSandboxProvider", () => { try { vi.setSystemTime(new Date("2030-01-01T00:00:00.000Z")); const client = createMockModalClient(); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const created = await provider.createSandbox({ ...testConfig, timeoutSeconds: 1200 }); expect(created.lifetime).toEqual({ kind: "finite", @@ -237,7 +258,7 @@ describe("ModalSandboxProvider", () => { describe("capabilities", () => { it("reports correct capabilities", () => { const client = createMockModalClient(); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); expect(provider.name).toBe("modal"); expect(provider.capabilities.supportsSnapshots).toBe(true); @@ -253,7 +274,7 @@ describe("ModalSandboxProvider", () => { throw new Error("fetch failed"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); await expect(provider.createSandbox(testConfig)).rejects.toThrow(SandboxProviderError); try { @@ -270,7 +291,7 @@ describe("ModalSandboxProvider", () => { throw new Error("connect ETIMEDOUT 192.168.1.1:443"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -286,7 +307,7 @@ describe("ModalSandboxProvider", () => { throw new Error("read ECONNRESET"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -302,7 +323,7 @@ describe("ModalSandboxProvider", () => { throw new Error("connect ECONNREFUSED 127.0.0.1:3000"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -318,7 +339,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Network request failed"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -334,7 +355,7 @@ describe("ModalSandboxProvider", () => { throw new RequestDeadlineError("Modal", "createSandbox", 30_000); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -350,7 +371,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 502 Bad Gateway"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -366,7 +387,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 503 Service Unavailable"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -382,7 +403,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 504 Gateway Timeout"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -398,7 +419,7 @@ describe("ModalSandboxProvider", () => { throw new Error("upstream bad gateway error"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -414,7 +435,7 @@ describe("ModalSandboxProvider", () => { throw new Error("service unavailable, try again later"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -430,7 +451,7 @@ describe("ModalSandboxProvider", () => { throw new Error("gateway timeout while waiting for upstream"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -448,7 +469,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 401 Unauthorized"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -464,7 +485,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 403 Forbidden"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -480,7 +501,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 400 Bad Request - Invalid configuration"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -496,7 +517,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Modal API error: 422 Unprocessable Entity"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -512,7 +533,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Invalid repository configuration"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -528,7 +549,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Quota exceeded: maximum sandboxes reached"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -544,7 +565,7 @@ describe("ModalSandboxProvider", () => { throw new Error("Something unexpected happened"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -560,7 +581,7 @@ describe("ModalSandboxProvider", () => { throw "string error"; // Throwing a string, not an Error }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -580,7 +601,7 @@ describe("ModalSandboxProvider", () => { throw originalError; }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -596,7 +617,7 @@ describe("ModalSandboxProvider", () => { throw new Error("timeout exceeded"); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.createSandbox(testConfig); @@ -622,7 +643,7 @@ describe("ModalSandboxProvider", () => { const client = createMockModalClient({ createSandbox: vi.fn(async () => expectedResult), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const result = await provider.createSandbox({ ...testConfig, vncEnabled: true }); @@ -647,7 +668,7 @@ describe("ModalSandboxProvider", () => { }); await expect( - new ModalSandboxProvider(client, "modal").createSandbox({ + new ModalSandboxProvider(client, "modal", "github").createSandbox({ ...testConfig, prebuiltImageId: "im-missing", }) @@ -667,7 +688,7 @@ describe("ModalSandboxProvider", () => { }), }); - const error = await new ModalSandboxProvider(client, "modal") + const error = await new ModalSandboxProvider(client, "modal", "github") .createSandbox({ ...testConfig, prebuiltImageId: "im-valid" }) .catch((caught: unknown) => caught); @@ -679,7 +700,7 @@ describe("ModalSandboxProvider", () => { describe("image builds", () => { it("binds a created image-build sandbox before starting it", async () => { const client = createMockModalClient(); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const correlation = { request_id: "request-1", trace_id: "trace-1" }; const onProviderSessionCreated = vi.fn(async () => undefined); @@ -707,6 +728,7 @@ describe("ModalSandboxProvider", () => { scopeId: "acme/repo", buildId: "build-123", repositories: [{ repoOwner: "acme", repoName: "repo", baseBranch: "develop" }], + scmIdentity: scmCloneIdentity("github"), cloneToken: "clone-token", callbackUrl: "https://worker.test/image-builds/build-complete", failureCallbackUrl: "https://worker.test/image-builds/build-failed", @@ -746,7 +768,8 @@ describe("ModalSandboxProvider", () => { }); const provider = new ModalSandboxProvider( createMockModalClient({ stopSandbox }), - "modal-vm" + "modal-vm", + "github" ); const config = { sessionId: "test-session", @@ -792,7 +815,7 @@ describe("ModalSandboxProvider", () => { throw new ModalApiError("Modal API error: 502 Bad Gateway", 502); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.restoreFromSnapshot({ @@ -820,7 +843,7 @@ describe("ModalSandboxProvider", () => { throw new ModalApiError("Modal API error: 401 Unauthorized", 401); }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.restoreFromSnapshot({ @@ -849,7 +872,7 @@ describe("ModalSandboxProvider", () => { throw modalError; }), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); try { await provider.takeSnapshot({ @@ -874,7 +897,8 @@ describe("ModalSandboxProvider", () => { })); const provider = new ModalSandboxProvider( createMockModalClient({ snapshotBuildSandbox }), - "modal" + "modal", + "github" ); await expect( @@ -900,7 +924,8 @@ describe("ModalSandboxProvider", () => { throw modalError; }), }), - "modal" + "modal", + "github" ); await expect( @@ -922,7 +947,7 @@ describe("ModalSandboxProvider", () => { vncPassword: "vnc-pw", })), }); - const provider = new ModalSandboxProvider(client, "modal"); + const provider = new ModalSandboxProvider(client, "modal", "github"); const result = await provider.restoreFromSnapshot({ snapshotImageId: "img-123", diff --git a/packages/control-plane/src/sandbox/providers/modal-provider.ts b/packages/control-plane/src/sandbox/providers/modal-provider.ts index 48bc40d2fd..8428244095 100644 --- a/packages/control-plane/src/sandbox/providers/modal-provider.ts +++ b/packages/control-plane/src/sandbox/providers/modal-provider.ts @@ -15,6 +15,8 @@ import type { ModalClient, ModalBackend, CreateImageBuildSandboxResponse } from import type { SandboxSettings } from "@open-inspect/shared/types/integrations"; import type { CorrelationContext } from "../../logger"; import { supportsConfigurableSandboxTimeout } from "@open-inspect/shared/types/integrations"; +import type { SourceControlProviderName } from "../../source-control"; +import { scmCloneIdentity, type ScmCloneIdentity } from "../sandbox-env"; import { DEFAULT_SANDBOX_TIMEOUT_SECONDS, PrebuiltImageUnavailableError, @@ -54,11 +56,8 @@ interface StartModalImageBuildConfig { correlation?: CorrelationContext; } -/** Modal extends the shared trigger contract with explicit SCM clone identity. */ export interface ModalImageBuildTriggerConfig extends ImageBuildProviderTriggerConfig { resources?: Pick; - cloneHost?: string; - cloneUsername?: string; } export interface TerminateModalImageBuildConfig { @@ -96,7 +95,7 @@ export interface ModalImageBuildProvider { * @example * ```typescript * const client = createModalClient(secret, workspace, environmentWebSuffix); - * const provider = new ModalSandboxProvider(client, "modal"); + * const provider = new ModalSandboxProvider(client, "modal", "github"); * * try { * const result = await provider.createSandbox(config); @@ -189,11 +188,15 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro return deadline; } + private readonly scmIdentity: ScmCloneIdentity; + constructor( private readonly client: ModalClient, - backend: ModalBackend + backend: ModalBackend, + scmProvider: SourceControlProviderName ) { this.name = backend; + this.scmIdentity = scmCloneIdentity(scmProvider); this.capabilities = { supportsSandboxTimeout: supportsConfigurableSandboxTimeout(this.name), supportsSnapshots: true, @@ -226,6 +229,7 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro provider: config.provider, model: config.model, userEnvVars: config.userEnvVars, + scmIdentity: this.scmIdentity, prebuiltImageId: config.prebuiltImageId, prebuiltImageSha: config.prebuiltImageSha, timeoutSeconds, @@ -273,6 +277,7 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro const result = await this.client.restoreSandbox( { snapshotImageId: config.snapshotImageId, + scmIdentity: this.scmIdentity, launchDeadlineAtMs, sessionId: config.sessionId, sandboxId: config.sandboxId, @@ -448,9 +453,8 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro scopeId: config.scopeId, buildId: config.buildId, repositories: config.repositories, + scmIdentity: this.scmIdentity, cloneToken: config.cloneToken, - ...(config.cloneHost ? { cloneHost: config.cloneHost } : {}), - ...(config.cloneUsername ? { cloneUsername: config.cloneUsername } : {}), callbackUrl: config.callbackUrl, failureCallbackUrl: config.failureCallbackUrl, userEnvVars: config.userEnvVars, @@ -627,7 +631,8 @@ export class ModalSandboxProvider implements SandboxProvider, ModalImageBuildPro */ export function createModalProvider( client: ModalClient, - backend: ModalBackend + backend: ModalBackend, + scmProvider: SourceControlProviderName ): ModalSandboxProvider { - return new ModalSandboxProvider(client, backend); + return new ModalSandboxProvider(client, backend, scmProvider); } diff --git a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts index a4ba615793..cd65952d9f 100644 --- a/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts +++ b/packages/control-plane/src/sandbox/providers/vercel/provider.test.ts @@ -811,7 +811,6 @@ describe("VercelSandboxProvider", () => { ); expect(createCall.env).not.toHaveProperty("GITHUB_TOKEN"); expect(createCall.env).not.toHaveProperty("GITHUB_APP_TOKEN"); - expect(createCall.env).not.toHaveProperty("OI_GITHUB_TOKEN_IS_FALLBACK"); expect(createCall.env).not.toHaveProperty("OI_INTERNAL_CALLBACK_SECRET"); expect(createCall.env).not.toHaveProperty("OI_VERCEL_TOKEN"); expect(createCall.env).not.toHaveProperty("OI_VERCEL_CALLBACK_URL"); diff --git a/packages/control-plane/test/integration/modal-backend-builds.test.ts b/packages/control-plane/test/integration/modal-backend-builds.test.ts index 8523064b92..f1d3267b72 100644 --- a/packages/control-plane/test/integration/modal-backend-builds.test.ts +++ b/packages/control-plane/test/integration/modal-backend-builds.test.ts @@ -58,7 +58,7 @@ describe("Modal backend images over real D1", () => { const factory = { create: () => new ModalImageBuildAdapter( - new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm") + new ModalSandboxProvider(client as unknown as ModalClient, "modal-vm", "github") ), }; const planner: ImageBuildPlannerPort = { diff --git a/packages/docs/content/docs/administration/security.mdx b/packages/docs/content/docs/administration/security.mdx index 87c4563eda..b83dcaab21 100644 --- a/packages/docs/content/docs/administration/security.mdx +++ b/packages/docs/content/docs/administration/security.mdx @@ -4,7 +4,7 @@ description: The trust boundaries of an OpenInspect deployment, which tokens exi audience: admin owner: security status: published -lastReviewed: "2026-09-28" +lastReviewed: "2026-10-01" relatedCode: - README.md - docs/AUTH.md @@ -82,10 +82,7 @@ flowchart TB Git credentials are brokered on demand. The helper authorizes HTTPS requests for the configured source-control host, so setup and start scripts that clone other private repositories available to the installation keep working. -| Sandbox start | How it gets git credentials | -| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| Fresh or prebuilt image | Fetches a short-lived credential from the control plane through a git credential helper. No token is embedded in the environment or the remote URL. | -| Snapshot restore | May still receive an environment-token fallback so older snapshots can boot. Modal snapshot restores mint a fresh fallback token on restore. | +Session sandboxes never receive a system git token in the environment or remote URL, whether they start fresh, from a prebuilt image, or from a snapshot. Each one fetches a short-lived credential from the control plane through a git credential helper. Image builds, which have no session, receive a one-shot clone token instead. The GitHub App private key is held only by the control plane and the GitHub bot, never by Modal or a sandbox. Pull requests use the prompting user's GitHub OAuth token when it is available. A missing token, failed refresh or decryption, or an ordinary retrieval failure falls back to the App identity even for a GitHub-signed-in user. Unexpected token-resolution errors fail PR creation instead. Google-signed-in users without a GitHub token also use the App. Branch pushes use App credentials regardless of who opens the pull request. diff --git a/packages/docs/content/docs/reference/sandbox-environment.mdx b/packages/docs/content/docs/reference/sandbox-environment.mdx index adff0c470d..8a517fb6bb 100644 --- a/packages/docs/content/docs/reference/sandbox-environment.mdx +++ b/packages/docs/content/docs/reference/sandbox-environment.mdx @@ -4,7 +4,7 @@ description: What is preinstalled in an OpenInspect sandbox, how the workspace i audience: team-owner owner: sandbox-runtime status: published -lastReviewed: "2026-09-28" +lastReviewed: "2026-10-01" relatedCode: - packages/sandbox-images/toolchain.json - packages/sandbox-images/targets.json @@ -69,7 +69,8 @@ Repository `.opencode/` and `.claude/` directories are read by their respective | `SANDBOX_AUTH_TOKEN` | Session sandboxes (not image builds) | The sandbox's own session-scoped token for control-plane calls. Image builds use separate callback credentials. | | `SESSION_CONFIG` | Always | Session sandboxes receive the session ID, repositories, MCP servers, and related settings. Image builds receive only the branch and repositories, without a session ID. | | `REPO_OWNER`, `REPO_NAME` | Repository sessions | The primary repository. | -| `GITHUB_APP_TOKEN` | Modal snapshot restores only | A clone token so snapshots taken before the credential helper can still boot; fresh and prebuilt-image sandboxes, and every other provider, use the credential helper instead. | +| `VCS_HOST`, `VCS_CLONE_USERNAME` | Always | The source-control host and HTTPS username the credential helper serves. | +| `VCS_CLONE_TOKEN` | Image builds only | A one-shot clone credential, because builds have no session to fetch credentials through. | | `RESTORED_FROM_SNAPSHOT` | Snapshot restores | Marks a restored sandbox. | | `OPENCODE_CONFIG_CONTENT` | OpenCode sessions | The generated OpenCode configuration. | | `PYTHONUNBUFFERED`, `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `PWD`, `LANG` | Always | Standard process environment. | @@ -86,7 +87,7 @@ The reserved names are listed in full in [Secrets](/configure/secrets). - User-supplied provider API keys in account mode. When a session is bound to a connected OpenAI, xAI, or Claude account, the matching user key is removed from the general sandbox environment and the `*_OAUTH_MANAGED` marker remains. A deployment-wide Anthropic key may still be present on Modal or OpenComputer. The Claude harness receives its setup token only in the `claude` process. - Refresh tokens. OpenAI and xAI sessions fetch short-lived access tokens; refresh tokens stay in the encrypted credential store. Claude accounts instead use a static setup token with an expiry, not a rotating refresh token. - The commit signing private key. Sandboxes receive only the public key; each commit is signed by the control plane. The key is never delivered to a sandbox file, environment, process, or snapshot. -- A long-lived git token in fresh and prebuilt-image sandboxes. Git credentials are minted on demand (next section). +- A long-lived git token in session sandboxes, including snapshot restores. Git credentials are minted on demand (next section). - The Claude account token outside the `claude` process. OpenCode, code-server, the terminal, and user shells never see it. Code the agent runs from Bash and hooks registered in the repository's `.claude/` settings do inherit it. ## Git credentials diff --git a/packages/github-bot/README.md b/packages/github-bot/README.md index 22b1ee54af..60ce705196 100644 --- a/packages/github-bot/README.md +++ b/packages/github-bot/README.md @@ -107,10 +107,9 @@ For the agent to interact with GitHub from the sandbox, these prerequisites must 2. **Git credential helper** configured in the sandbox image/runtime so git operations can request short-lived SCM credentials from the control plane -Fresh and prebuilt-image sandboxes get GitHub CLI credentials through the helper rather than -spawn-time token injection. `GITHUB_TOKEN` and `GITHUB_APP_TOKEN` env fallbacks are only used for -legacy snapshots when the user has not provided an explicit GitHub CLI token. One-shot image-build -sandboxes use only the narrower `VCS_CLONE_TOKEN` fallback because they cannot call the +Session sandboxes, including snapshot restores, get GitHub CLI credentials through the helper rather +than spawn-time token injection; a user-supplied `GH_TOKEN` or `GITHUB_TOKEN` takes precedence. +One-shot image-build sandboxes use only the narrower `VCS_CLONE_TOKEN` because they cannot call the control-plane credential broker. For git operations, the helper keeps the existing installation-wide access model and can authenticate auxiliary private repos on the configured SCM host. diff --git a/packages/modal-infra/.env.example b/packages/modal-infra/.env.example index a680d74ec2..a1bf251c80 100644 --- a/packages/modal-infra/.env.example +++ b/packages/modal-infra/.env.example @@ -6,7 +6,6 @@ # from the control plane's secret store instead. The llm-api-keys secret itself # must exist, because Modal cannot hold a secret with no keys at all: # modal secret create llm-api-keys ANTHROPIC_API_KEY="" -# modal secret create github-app GITHUB_APP_ID="..." GITHUB_APP_PRIVATE_KEY="..." GITHUB_APP_INSTALLATION_ID="..." # modal secret create internal-api MODAL_API_SECRET="..." ALLOWED_CONTROL_PLANE_HOSTS="..." # ============================================================================= @@ -21,15 +20,6 @@ # From: https://console.anthropic.com/ ANTHROPIC_API_KEY= -# ============================================================================= -# GitHub App Credentials (configured as Modal secret "github-app") -# ============================================================================= -# Create at: https://github.com/settings/apps -# Convert private key to PKCS#8: openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in key.pem -out key-pkcs8.pem -GITHUB_APP_ID= -GITHUB_APP_PRIVATE_KEY= -GITHUB_APP_INSTALLATION_ID= - # ============================================================================= # Internal API Secret (configured as Modal secret "internal-api") # ============================================================================= diff --git a/packages/modal-infra/README.md b/packages/modal-infra/README.md index 9efcdb796e..8d3461b4f0 100644 --- a/packages/modal-infra/README.md +++ b/packages/modal-infra/README.md @@ -53,9 +53,11 @@ The in-sandbox runtime (entrypoint supervisor, control-plane bridge, shared type Provided by `packages/sandbox-runtime/src/sandbox_runtime/auth/`: -- **github_app.py**: GitHub App token generation for repo access - **internal.py**: HMAC authentication for control plane requests +Modal holds no source-control credentials. Session sandboxes fetch git credentials on demand from the +control plane; image builds receive a one-shot clone token in the build request. + ### API (`src/`) - **web_api.py**: HTTP endpoints called by the control plane @@ -81,12 +83,6 @@ snapshot, terminate, and delete provider operations. # instead. The secret itself must exist; Modal cannot hold one with no keys. modal secret create llm-api-keys ANTHROPIC_API_KEY="sk-ant-..." -# GitHub App credentials (for repo access) -modal secret create github-app \ - GITHUB_APP_ID="123456" \ - GITHUB_APP_PRIVATE_KEY="$(cat private-key-pkcs8.pem)" \ - GITHUB_APP_INSTALLATION_ID="12345678" - # Internal API secret (for control plane authentication) modal secret create internal-api \ MODAL_API_SECRET="$(openssl rand -hex 32)" \ @@ -145,6 +141,10 @@ Endpoint URLs follow the pattern: `https://{workspace}--open-inspect-{endpoint}. | `api-snapshot-build-sandbox` | POST | Yes | Snapshot the exact tagged build sandbox | | `api-terminate-build-sandbox` | POST | Yes | Terminate the exact tagged build sandbox (idempotent when already absent) | +`api-create-sandbox`, `api-restore-sandbox`, and `api-create-build-sandbox` require `clone_host` and +`clone_username`. The control plane resolves them from its `SCM_PROVIDER`, and Modal sets them as +`VCS_HOST` and `VCS_CLONE_USERNAME` in the sandbox. + ### Example: Create Sandbox ```bash @@ -156,7 +156,9 @@ curl -X POST "https://${WORKSPACE}--open-inspect-api-create-sandbox.modal.run" \ "repo_owner": "your-org", "repo_name": "your-repo", "control_plane_url": "https://your-control-plane.workers.dev", - "sandbox_auth_token": "your-token" + "sandbox_auth_token": "your-token", + "clone_host": "github.com", + "clone_username": "x-access-token" }' ``` @@ -174,9 +176,6 @@ Set via Modal secrets: | Variable | Secret | Description | |----------|--------|-------------| | `ANTHROPIC_API_KEY` | `llm-api-keys` | Anthropic API key for Claude; may be empty when sessions use other providers | -| `GITHUB_APP_ID` | `github-app` | GitHub App ID for repo access | -| `GITHUB_APP_PRIVATE_KEY` | `github-app` | GitHub App private key (PKCS#8) | -| `GITHUB_APP_INSTALLATION_ID` | `github-app` | GitHub App installation ID | | `MODAL_API_SECRET` | `internal-api` | Shared secret for control plane auth | | `ALLOWED_CONTROL_PLANE_HOSTS` | `internal-api` | Comma-separated allowed hostnames for URL validation | diff --git a/packages/modal-infra/pyproject.toml b/packages/modal-infra/pyproject.toml index bef114c69e..070f1deb39 100644 --- a/packages/modal-infra/pyproject.toml +++ b/packages/modal-infra/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "httpx>=0.27.0", "pydantic>=2.0", "fastapi>=0.110.0", - "PyJWT[crypto]>=2.14.0", ] [project.optional-dependencies] diff --git a/packages/modal-infra/src/app.py b/packages/modal-infra/src/app.py index be68bc51ef..c0052b3059 100644 --- a/packages/modal-infra/src/app.py +++ b/packages/modal-infra/src/app.py @@ -34,27 +34,17 @@ "httpx", "fastapi", "modal", # Required for sandbox.manager imports - "PyJWT[crypto]", # For GitHub App token generation ) # Bundle sandbox_runtime so modal-infra shims can import from it at runtime .add_local_dir(str(_SANDBOX_RUNTIME_DIR), remote_path="/root/sandbox_runtime", copy=True) .env(deployed_image_environment()) ) -# Secrets for GitHub App - used for git operations (clone, push) -# These are used to generate installation tokens, NOT injected into sandboxes -github_app_secrets = modal.Secret.from_name( - "github-app", - required_keys=["GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "GITHUB_APP_INSTALLATION_ID"], -) - # Secret for internal API authentication and deployment configuration. # Required keys: # MODAL_API_SECRET: verify requests from control plane to Modal endpoints # Optional keys (add to the same secret as needed): # ALLOWED_CONTROL_PLANE_HOSTS: comma-separated list of permitted callback hosts -# SCM_PROVIDER: "github" (default) or "gitlab" — selects the clone credential type -# GITLAB_ACCESS_TOKEN: GitLab PAT used as clone credential when SCM_PROVIDER=gitlab internal_api_secret = modal.Secret.from_name( "internal-api", required_keys=["MODAL_API_SECRET"], diff --git a/packages/modal-infra/src/clone_token.py b/packages/modal-infra/src/clone_token.py deleted file mode 100644 index bfeddfcbaa..0000000000 --- a/packages/modal-infra/src/clone_token.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Resolve VCS clone tokens for Modal sandbox git operations.""" - -import os - -from .log_config import get_logger - -log = get_logger("clone_token") - - -def resolve_clone_token() -> str | None: - """Return a provider-specific clone token, or None when credentials are unavailable.""" - from sandbox_runtime.auth import generate_installation_token - - scm_provider = os.environ.get("SCM_PROVIDER", "github") - - if scm_provider == "gitlab": - token = os.environ.get("GITLAB_ACCESS_TOKEN") - if not token: - log.warn("gitlab.token_missing") - return token - - try: - app_id = os.environ.get("GITHUB_APP_ID") - private_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") - installation_id = os.environ.get("GITHUB_APP_INSTALLATION_ID") - - if app_id and private_key and installation_id: - return generate_installation_token( - app_id=app_id, - private_key=private_key, - installation_id=installation_id, - ) - except Exception as e: - log.warn("github.token_error", exc=e) - - return None diff --git a/packages/modal-infra/src/sandbox/build_session.py b/packages/modal-infra/src/sandbox/build_session.py index 169a96c9a4..4d0651fd03 100644 --- a/packages/modal-infra/src/sandbox/build_session.py +++ b/packages/modal-infra/src/sandbox/build_session.py @@ -81,9 +81,9 @@ async def create( repositories: list[dict], callback_url: str, failure_callback_url: str, + clone_host: str, + clone_username: str, clone_token: str = "", - clone_host: str | None = None, - clone_username: str | None = None, user_env_vars: dict[str, str] | None = None, build_execution_timeout_seconds: int = DEFAULT_BUILD_TIMEOUT_SECONDS, timeout_seconds: int = DEFAULT_BUILD_TIMEOUT_SECONDS, @@ -120,9 +120,9 @@ async def create( ) inject_vcs_env_vars( env_vars, - clone_token or None, clone_host=clone_host, clone_username=clone_username, + clone_token=clone_token, ) command = ("python", "-m", "sandbox_runtime.entrypoint", MODAL_IMAGE_BUILD_START_ARGUMENT) diff --git a/packages/modal-infra/src/sandbox/launch.py b/packages/modal-infra/src/sandbox/launch.py index 7ef0113d16..55522fc615 100644 --- a/packages/modal-infra/src/sandbox/launch.py +++ b/packages/modal-infra/src/sandbox/launch.py @@ -77,7 +77,6 @@ class RepositoryImageSource: @dataclass(frozen=True) class SnapshotImageSource: image_id: str - clone_token: str | None type SandboxImageSource = BaseImageSource | RepositoryImageSource | SnapshotImageSource @@ -158,8 +157,6 @@ async def launch(self, spec: SandboxLaunchSpec) -> SandboxHandle: } ) - clone_token: str | None = None - include_github_cli_aliases = False snapshot_id: str | None = None if isinstance(spec.source, BaseImageSource): image = docker_base_image() if docker.enabled else base_image @@ -173,8 +170,6 @@ async def launch(self, spec: SandboxLaunchSpec) -> SandboxHandle: else: image = modal.Image.from_id(spec.source.image_id) env_vars["RESTORED_FROM_SNAPSHOT"] = "true" - clone_token = spec.source.clone_token - include_github_cli_aliases = True snapshot_id = spec.source.image_id if config.session_config is not None: @@ -186,8 +181,8 @@ async def launch(self, spec: SandboxLaunchSpec) -> SandboxHandle: inject_vcs_env_vars( env_vars, - clone_token=clone_token if has_repository else None, - include_github_cli_aliases=include_github_cli_aliases, + clone_host=config.clone_host, + clone_username=config.clone_username, ) code_server_password: str | None = None diff --git a/packages/modal-infra/src/sandbox/manager.py b/packages/modal-infra/src/sandbox/manager.py index 7b30154a6a..370f08ddeb 100644 --- a/packages/modal-infra/src/sandbox/manager.py +++ b/packages/modal-infra/src/sandbox/manager.py @@ -306,10 +306,12 @@ async def restore_from_snapshot( self, snapshot_image_id: str, session_config: SessionConfig | dict[str, Any], + *, + clone_host: str, + clone_username: str, sandbox_id: str | None = None, control_plane_url: str = "", sandbox_auth_token: str = "", - clone_token: str | None = None, user_env_vars: dict[str, str] | None = None, timeout_seconds: int = DEFAULT_SANDBOX_TIMEOUT_SECONDS, code_server_enabled: bool = False, @@ -332,7 +334,8 @@ async def restore_from_snapshot( sandbox_id: Optional sandbox ID (generated if not provided) control_plane_url: URL for the control plane sandbox_auth_token: Auth token for the sandbox - clone_token: VCS clone token for git operations + clone_host: VCS host resolved by the control plane + clone_username: VCS clone username resolved by the control plane Returns: SandboxHandle for the restored sandbox @@ -347,14 +350,6 @@ async def restore_from_snapshot( repo_name = session_config.repo_name _has_repository(repo_owner, repo_name) - # Snapshot restore still passes the clone token through for - # repo-backed sandboxes. Snapshots taken before the credential-helper - # migration ship an entrypoint that reads VCS_CLONE_TOKEN from env - # and embeds it in the origin URL; without it, those legacy snapshots - # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too - # so the gh CLI keeps working on snapshots predating the gh wrapper. - # Host scoping remains common with fresh creates. These compatibility - # credentials are explicitly requested only by the restore path. handle = await SandboxLauncher().launch( SandboxLaunchSpec( config=SandboxConfig( @@ -373,11 +368,10 @@ async def restore_from_snapshot( retire_sandbox_id=retire_sandbox_id, sandbox_backend=sandbox_backend, launch_deadline_at_ms=launch_deadline_at_ms, + clone_host=clone_host, + clone_username=clone_username, ), - source=SnapshotImageSource( - image_id=snapshot_image_id, - clone_token=clone_token, - ), + source=SnapshotImageSource(image_id=snapshot_image_id), ) ) diff --git a/packages/modal-infra/src/sandbox/models.py b/packages/modal-infra/src/sandbox/models.py index 61125ecaa1..8a0a11fb09 100644 --- a/packages/modal-infra/src/sandbox/models.py +++ b/packages/modal-infra/src/sandbox/models.py @@ -15,10 +15,12 @@ @dataclass class SandboxConfig: - """Configuration for creating a sandbox.""" + """Shared configuration for creating or restoring a sandbox.""" repo_owner: str | None repo_name: str | None + clone_host: str # VCS identity resolved by the control plane + clone_username: str sandbox_id: str | None = None # Expected sandbox ID from control plane session_config: SessionConfig | dict[str, Any] | None = None control_plane_url: str = "" diff --git a/packages/modal-infra/src/sandbox/vcs_env.py b/packages/modal-infra/src/sandbox/vcs_env.py index 21be896ff8..d809132c2d 100644 --- a/packages/modal-infra/src/sandbox/vcs_env.py +++ b/packages/modal-infra/src/sandbox/vcs_env.py @@ -1,40 +1,15 @@ """SCM credential environment shared by interactive and build sandboxes.""" -import os - def inject_vcs_env_vars( env_vars: dict[str, str], - clone_token: str | None, *, - clone_host: str | None = None, - clone_username: str | None = None, - include_github_cli_aliases: bool = False, + clone_host: str, + clone_username: str, + clone_token: str | None = None, ) -> None: - """Inject provider metadata and optional one-shot clone credentials.""" - scm_provider = os.environ.get("SCM_PROVIDER", "github") - if clone_host and clone_username: - env_vars["VCS_HOST"] = clone_host - env_vars["VCS_CLONE_USERNAME"] = clone_username - elif scm_provider == "bitbucket": - env_vars["VCS_HOST"] = "bitbucket.org" - env_vars["VCS_CLONE_USERNAME"] = "x-token-auth" - elif scm_provider == "gitlab": - env_vars["VCS_HOST"] = "gitlab.com" - env_vars["VCS_CLONE_USERNAME"] = "oauth2" - else: - env_vars["VCS_HOST"] = "github.com" - env_vars["VCS_CLONE_USERNAME"] = "x-access-token" - - if not clone_token: - return - - env_vars["VCS_CLONE_TOKEN"] = clone_token - if include_github_cli_aliases and scm_provider == "github": - has_user_github_cli_token = any( - env_vars.get(key) for key in ("GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN") - ) - if not has_user_github_cli_token: - env_vars["GITHUB_TOKEN"] = clone_token - env_vars["GITHUB_APP_TOKEN"] = clone_token - env_vars["OI_GITHUB_TOKEN_IS_FALLBACK"] = "1" + """Inject the control plane's VCS identity and optional one-shot clone token.""" + env_vars["VCS_HOST"] = clone_host + env_vars["VCS_CLONE_USERNAME"] = clone_username + if clone_token: + env_vars["VCS_CLONE_TOKEN"] = clone_token diff --git a/packages/modal-infra/src/web_api.py b/packages/modal-infra/src/web_api.py index 16e3677a52..15b99a3c74 100644 --- a/packages/modal-infra/src/web_api.py +++ b/packages/modal-infra/src/web_api.py @@ -31,11 +31,9 @@ from .app import ( app, function_image, - github_app_secrets, internal_api_secret, validate_control_plane_url, ) -from .clone_token import resolve_clone_token from .log_config import configure_logging, get_logger from .sandbox.launch_policy import ( DockerImageUnavailableError, @@ -78,8 +76,8 @@ class CreateBuildSandboxRequest(_ModalRequestModel): callback_url: NonEmptyString failure_callback_url: NonEmptyString clone_token: str | None = None - clone_host: str | None = None - clone_username: str | None = None + clone_host: NonEmptyString + clone_username: NonEmptyString user_env_vars: dict[str, str] | None = None build_execution_timeout_seconds: int | None = None provider_session_timeout_seconds: int | None = None @@ -127,6 +125,8 @@ class CreateSandboxRequest(_RepositoryContextModel): sandbox_id: str | None = None control_plane_url: NonEmptyString sandbox_auth_token: NonEmptyString + clone_host: NonEmptyString + clone_username: NonEmptyString agent_session_id: str | None = None opencode_session_id: str | None = None harness: str | None = None @@ -174,6 +174,8 @@ class RestoreSandboxRequest(_ModalRequestModel): sandbox_id: str | None = None control_plane_url: NonEmptyString sandbox_auth_token: NonEmptyString + clone_host: NonEmptyString + clone_username: NonEmptyString user_env_vars: dict[str, str] | None = None timeout_seconds: int | None = Field(default=None, gt=0) code_server_enabled: bool = False @@ -420,6 +422,8 @@ async def api_create_sandbox( "repo_name": "...", "control_plane_url": "...", "sandbox_auth_token": "...", + "clone_host": "github.com", + "clone_username": "x-access-token", "provider": "anthropic", "model": "claude-sonnet-4-6" } @@ -457,6 +461,8 @@ async def api_create_sandbox( session_config=session_config, control_plane_url=parsed_request.control_plane_url, sandbox_auth_token=parsed_request.sandbox_auth_token, + clone_host=parsed_request.clone_host, + clone_username=parsed_request.clone_username, user_env_vars=parsed_request.user_env_vars or None, repo_image_id=parsed_request.repo_image_id or None, repo_image_sha=parsed_request.repo_image_sha or None, @@ -791,7 +797,7 @@ async def api_snapshot_build_sandbox( } -@app.function(image=function_image, secrets=[github_app_secrets, internal_api_secret], timeout=150) +@app.function(image=function_image, secrets=[internal_api_secret], timeout=150) @fastapi_endpoint(method="POST") async def api_restore_sandbox( request: dict, @@ -822,7 +828,9 @@ async def api_restore_sandbox( }, "sandbox_id": "...", "control_plane_url": "...", - "sandbox_auth_token": "..." + "sandbox_auth_token": "...", + "clone_host": "github.com", + "clone_username": "x-access-token" } Returns: @@ -852,11 +860,8 @@ async def api_restore_sandbox( ) session_config = parsed_request.session_config.model_dump(exclude_unset=True) - repo_owner = parsed_request.session_config.repo_owner - repo_name = parsed_request.session_config.repo_name manager = SandboxManager() - clone_token = resolve_clone_token() if repo_owner and repo_name else None # Restore sandbox from snapshot handle = await manager.restore_from_snapshot( @@ -865,7 +870,8 @@ async def api_restore_sandbox( sandbox_id=parsed_request.sandbox_id, control_plane_url=parsed_request.control_plane_url, sandbox_auth_token=parsed_request.sandbox_auth_token, - clone_token=clone_token, + clone_host=parsed_request.clone_host, + clone_username=parsed_request.clone_username, user_env_vars=parsed_request.user_env_vars or None, timeout_seconds=( parsed_request.timeout_seconds @@ -948,8 +954,6 @@ async def api_create_build_sandbox( ), max_seconds=MAX_BUILD_TIMEOUT_SECONDS + IMAGE_BUILD_FINALIZATION_GRACE_SECONDS, ) - clone_host = parsed_request.clone_host or None - clone_username = parsed_request.clone_username or None callback_url = parsed_request.callback_url failure_callback_url = parsed_request.failure_callback_url if not validate_control_plane_url(callback_url) or not validate_control_plane_url( @@ -966,9 +970,9 @@ async def api_create_build_sandbox( repositories=repositories, callback_url=callback_url, failure_callback_url=failure_callback_url, + clone_host=parsed_request.clone_host, + clone_username=parsed_request.clone_username, clone_token=parsed_request.clone_token or "", - clone_host=clone_host, - clone_username=clone_username, user_env_vars=parsed_request.user_env_vars or None, build_execution_timeout_seconds=build_execution_timeout_seconds, timeout_seconds=provider_session_timeout_seconds, diff --git a/packages/modal-infra/tests/test_agent_slack_notify_env.py b/packages/modal-infra/tests/test_agent_slack_notify_env.py index ede0a3c971..3a46f9c432 100644 --- a/packages/modal-infra/tests/test_agent_slack_notify_env.py +++ b/packages/modal-infra/tests/test_agent_slack_notify_env.py @@ -40,6 +40,8 @@ async def test_env_set_when_enabled(self, monkeypatch): manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -58,6 +60,8 @@ async def test_env_omitted_when_disabled(self, monkeypatch): manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -85,6 +89,8 @@ class FakeImage: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-123", session_config={"repo_owner": "acme", "repo_name": "repo"}, sandbox_id="sb-1", diff --git a/packages/modal-infra/tests/test_build_sandbox_lifecycle.py b/packages/modal-infra/tests/test_build_sandbox_lifecycle.py index 9cd465d150..9e306f668f 100644 --- a/packages/modal-infra/tests/test_build_sandbox_lifecycle.py +++ b/packages/modal-infra/tests/test_build_sandbox_lifecycle.py @@ -447,6 +447,8 @@ async def test_create_build_sandbox_selects_the_variant_from_frozen_settings( monkeypatch.setattr("src.images.base.docker_image", docker_image) launch = await ModalBuildSessionService().create( + clone_host="github.com", + clone_username="x-access-token", build_id="build-1", scope_kind="repo", scope_id="acme/repo", @@ -493,6 +495,8 @@ async def test_build_create_retry_adopts_only_owned_backend_allocation(monkeypat monkeypatch.setattr("src.sandbox.build_session.modal.Sandbox.create", create) service = ModalBuildSessionService() launch = await service.create( + clone_host="github.com", + clone_username="x-access-token", build_id="build-1", sandbox_backend="modal-vm", scope_kind="repo", @@ -506,6 +510,8 @@ async def test_build_create_retry_adopts_only_owned_backend_allocation(monkeypat tags["openinspect_backend"] = "modal" with pytest.raises(RuntimeError, match="ownership"): await service.create( + clone_host="github.com", + clone_username="x-access-token", build_id="build-1", sandbox_backend="modal-vm", scope_kind="repo", diff --git a/packages/modal-infra/tests/test_clone_token.py b/packages/modal-infra/tests/test_clone_token.py deleted file mode 100644 index 93b3013345..0000000000 --- a/packages/modal-infra/tests/test_clone_token.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Tests for VCS clone token resolution.""" - -import pytest - -from src.clone_token import resolve_clone_token - - -@pytest.fixture(autouse=True) -def clear_clone_token_env(monkeypatch: pytest.MonkeyPatch) -> None: - for name in [ - "SCM_PROVIDER", - "GITLAB_ACCESS_TOKEN", - "GITHUB_APP_ID", - "GITHUB_APP_PRIVATE_KEY", - "GITHUB_APP_INSTALLATION_ID", - ]: - monkeypatch.delenv(name, raising=False) - - -def test_resolve_clone_token_uses_gitlab_access_token(monkeypatch): - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - monkeypatch.setenv("GITLAB_ACCESS_TOKEN", "glpat-token") - - assert resolve_clone_token() == "glpat-token" - - -def test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch): - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - - assert resolve_clone_token() is None - - -def test_resolve_clone_token_generates_github_installation_token(monkeypatch): - monkeypatch.setenv("GITHUB_APP_ID", "123") - monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "private-key") - monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") - captured = {} - - def fake_generate_installation_token(**kwargs): - captured.update(kwargs) - return "ghs-token" - - monkeypatch.setattr( - "sandbox_runtime.auth.generate_installation_token", fake_generate_installation_token - ) - - assert resolve_clone_token() == "ghs-token" - assert captured == { - "app_id": "123", - "private_key": "private-key", - "installation_id": "456", - } - - -def test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch): - monkeypatch.setenv("GITHUB_APP_ID", "123") - monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") - - def fail_if_called(**_kwargs): - raise AssertionError("generate_installation_token should not be called") - - monkeypatch.setattr("sandbox_runtime.auth.generate_installation_token", fail_if_called) - - assert resolve_clone_token() is None - - -def test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch): - monkeypatch.setenv("GITHUB_APP_ID", "123") - monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "private-key") - monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") - - def raise_from_generate(**_kwargs): - raise RuntimeError("token generation failed") - - monkeypatch.setattr("sandbox_runtime.auth.generate_installation_token", raise_from_generate) - - assert resolve_clone_token() is None diff --git a/packages/modal-infra/tests/test_code_server.py b/packages/modal-infra/tests/test_code_server.py index 302d8c7675..36adbf7ccc 100644 --- a/packages/modal-infra/tests/test_code_server.py +++ b/packages/modal-infra/tests/test_code_server.py @@ -108,6 +108,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -149,6 +151,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -199,6 +203,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -247,6 +253,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", diff --git a/packages/modal-infra/tests/test_llm_secrets.py b/packages/modal-infra/tests/test_llm_secrets.py index a6448ecc7b..4bd037392c 100644 --- a/packages/modal-infra/tests/test_llm_secrets.py +++ b/packages/modal-infra/tests/test_llm_secrets.py @@ -31,7 +31,14 @@ class FakeSandbox: async def test_create_attaches_the_deployment_wide_secret(captured_launch, fake_llm_secret): - await SandboxManager().create_sandbox(SandboxConfig(repo_owner="acme", repo_name="repo")) + await SandboxManager().create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) + ) assert len(fake_llm_secret) == 1 assert captured_launch["secrets"] == fake_llm_secret @@ -47,6 +54,8 @@ class FakeImage: monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **k: FakeImage()) await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"repo_owner": "acme", "repo_name": "repo", "session_id": "sess-1"}, ) @@ -57,7 +66,12 @@ class FakeImage: async def test_each_launch_resolves_a_fresh_secret(captured_launch, fake_llm_secret): - config = SandboxConfig(repo_owner="acme", repo_name="repo") + config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) await SandboxManager().create_sandbox(config) first_secret = captured_launch["secrets"][0] await SandboxManager().create_sandbox(config) diff --git a/packages/modal-infra/tests/test_sandbox_env_vars.py b/packages/modal-infra/tests/test_sandbox_env_vars.py index be4130a938..46dda9b3e6 100644 --- a/packages/modal-infra/tests/test_sandbox_env_vars.py +++ b/packages/modal-infra/tests/test_sandbox_env_vars.py @@ -52,7 +52,14 @@ async def test_create_sandbox_rejects_partial_repo_metadata(repo_owner, repo_nam manager = SandboxManager() with pytest.raises(ValueError, match="repo_owner and repo_name must be provided together"): - await manager.create_sandbox(SandboxConfig(repo_owner=repo_owner, repo_name=repo_name)) + await manager.create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner=repo_owner, + repo_name=repo_name, + ) + ) @pytest.mark.asyncio @@ -68,6 +75,8 @@ async def test_restore_rejects_partial_repo_metadata(session_config): with pytest.raises(ValueError, match="repo_owner and repo_name must be provided together"): await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config=session_config, ) @@ -91,6 +100,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://control-plane.example", @@ -138,6 +149,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -184,6 +197,8 @@ async def test_create_preserves_managed_provider_env_isolation( await SandboxManager().create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", user_env_vars={managed_marker: "1", "CUSTOM_SECRET": "value"}, @@ -208,6 +223,8 @@ async def test_restore_preserves_managed_provider_env_isolation( captured = _fake_restore_setup(monkeypatch) await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"session_id": "sess-1"}, user_env_vars={managed_marker: "1", "CUSTOM_SECRET": "value"}, @@ -247,6 +264,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -287,6 +306,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -335,6 +356,8 @@ class FakeSandbox: # Create with custom timeout config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", timeout_seconds=5400, @@ -343,6 +366,8 @@ class FakeSandbox: # Restore with same timeout await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -382,6 +407,8 @@ async def test_restore_includes_branch_in_session_config(monkeypatch): manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -404,6 +431,8 @@ async def test_restore_omits_branch_when_none(monkeypatch): manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -423,6 +452,8 @@ async def test_restore_serializes_typed_session_config(monkeypatch): captured = _fake_restore_setup(monkeypatch) await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config=SessionConfig( session_id="sess-1", @@ -459,98 +490,37 @@ class FakeSandbox: return fake_create_aio -# Note: fresh and repo-image sandboxes never receive SCM tokens in the -# environment; created sandboxes rely on brokered credentials only. - +_SYSTEM_TOKEN_KEYS = ("VCS_CLONE_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN", "GH_TOKEN") -@pytest.mark.asyncio -async def test_vcs_env_vars_default_github(monkeypatch): - """SCM_PROVIDER unset → github.com defaults, no token in env.""" - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - ) - await manager.create_sandbox(config) - - env = captured["env"] - assert env["VCS_HOST"] == "github.com" - assert env["VCS_CLONE_USERNAME"] == "x-access-token" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_vcs_env_vars_gitlab(monkeypatch): - """SCM_PROVIDER=gitlab → gitlab.com + oauth2, no token in env.""" - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - ) - await manager.create_sandbox(config) - - env = captured["env"] - assert env["VCS_HOST"] == "gitlab.com" +def _assert_identity_without_system_tokens(env: dict[str, str]) -> None: + assert env["VCS_HOST"] == "gitlab.example" assert env["VCS_CLONE_USERNAME"] == "oauth2" - assert "VCS_CLONE_TOKEN" not in env + for key in _SYSTEM_TOKEN_KEYS: + assert key not in env @pytest.mark.asyncio -async def test_vcs_env_vars_bitbucket(monkeypatch): - """SCM_PROVIDER=bitbucket → bitbucket.org + x-token-auth, no token in env.""" - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "bitbucket") - - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - ) - await manager.create_sandbox(config) - - env = captured["env"] - assert env["VCS_HOST"] == "bitbucket.org" - assert env["VCS_CLONE_USERNAME"] == "x-token-auth" - assert "VCS_CLONE_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_repo_image_boot_omits_fallback_tokens(monkeypatch): - """Repo-image boots rely on brokered credentials only.""" +@pytest.mark.parametrize( + "repo_fields", + [ + {"repo_owner": "acme", "repo_name": "repo"}, + {"repo_owner": "acme", "repo_name": "repo", "repo_image_id": "repo-img-1"}, + {"repo_owner": None, "repo_name": None}, + ], + ids=["base", "repo-image", "no-repo"], +) +async def test_create_injects_control_plane_identity_without_tokens(monkeypatch, repo_fields): + """Created sandboxes get the supplied VCS identity and rely on brokered credentials.""" captured = {} - - class FakeImage: - object_id = "repo-img-1" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - config = SandboxConfig( - repo_owner="acme", - repo_name="repo", - repo_image_id="repo-img-1", + await SandboxManager().create_sandbox( + SandboxConfig(clone_host="gitlab.example", clone_username="oauth2", **repo_fields) ) - await manager.create_sandbox(config) - env = captured["env"] - assert env["FROM_REPO_IMAGE"] == "true" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env - assert "OI_GITHUB_TOKEN_IS_FALLBACK" not in env + _assert_identity_without_system_tokens(captured["env"]) @pytest.mark.asyncio @@ -558,17 +528,13 @@ class FakeImage: async def test_repo_image_boot_preserves_user_github_cli_token(monkeypatch, token_key): """Repo-image boots do not replace user-provided GitHub CLI tokens.""" captured = {} - - class FakeImage: - object_id = "repo-img-1" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - await manager.create_sandbox( + await SandboxManager().create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", repo_image_id="repo-img-1", @@ -579,172 +545,64 @@ class FakeImage: env = captured["env"] assert env[token_key] == "user_token" assert "VCS_CLONE_TOKEN" not in env - assert env.get("GITHUB_TOKEN") != "ghs_repo_image_token" - assert env.get("GITHUB_APP_TOKEN") != "ghs_repo_image_token" - assert "OI_GITHUB_TOKEN_IS_FALLBACK" not in env - - -@pytest.mark.asyncio -async def test_no_repo_sandbox_gets_provider_host_scoping(monkeypatch): - """No-repository sandboxes still get provider host scoping. - - Without VCS_HOST, a GitLab/Bitbucket deployment's repo-less sandboxes - fall back to github.com credential-helper behavior. - """ - captured = {} - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "gitlab") - - manager = SandboxManager() - await manager.create_sandbox(SandboxConfig(repo_owner=None, repo_name=None)) - - env = captured["env"] - assert env["VCS_HOST"] == "gitlab.com" - assert env["VCS_CLONE_USERNAME"] == "oauth2" - assert "VCS_CLONE_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_restore_no_repo_gets_host_scoping_without_tokens(monkeypatch): - """No-repository snapshot restores get host scoping but never a clone token.""" - captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "bitbucket") - - manager = SandboxManager() - await manager.restore_from_snapshot( - snapshot_image_id="img-abc", - session_config={ - "session_id": "sess-1", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - }, - clone_token="bb_token_xyz", - ) - - env = captured["env"] - assert env["VCS_HOST"] == "bitbucket.org" - assert env["VCS_CLONE_USERNAME"] == "x-token-auth" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env @pytest.mark.asyncio -async def test_restore_preserves_vcs_clone_token_for_legacy_snapshots(monkeypatch): - """Snapshot restore still injects VCS_CLONE_TOKEN. - - Snapshots taken before the credential-helper migration ship an old - entrypoint that reads the env var and embeds it in the origin URL. - Without it those snapshots can't fetch. The new entrypoint ignores it - and routes through the helper, so the var is harmless on fresh images. - For a non-GitHub provider, the GitHub CLI aliases stay absent. - """ - captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) - monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.setenv("SCM_PROVIDER", "bitbucket") - - manager = SandboxManager() - await manager.restore_from_snapshot( - snapshot_image_id="img-abc", - session_config={ - "repo_owner": "acme", - "repo_name": "repo", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "session_id": "sess-1", - }, - clone_token="bb_token_xyz", - ) - - env = captured["env"] - assert env["VCS_HOST"] == "bitbucket.org" - assert env["VCS_CLONE_USERNAME"] == "x-token-auth" - assert env["VCS_CLONE_TOKEN"] == "bb_token_xyz" - assert "GITHUB_APP_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - - -@pytest.mark.asyncio -async def test_restore_github_includes_gh_cli_aliases(monkeypatch): - """On GitHub, snapshot restore also sets GITHUB_TOKEN/GITHUB_APP_TOKEN. - - Legacy snapshots lack the gh wrapper, so the CLI needs the token in env. - """ +@pytest.mark.parametrize( + "repo_fields", + [{"repo_owner": "acme", "repo_name": "repo"}, {"repo_owner": None, "repo_name": None}], + ids=["repo", "no-repo"], +) +async def test_restore_injects_control_plane_identity_without_system_tokens( + monkeypatch, repo_fields +): + """Restores never forward function-level system credentials into the sandbox.""" captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) + monkeypatch.setenv("VCS_CLONE_TOKEN", "system-clone-token") + monkeypatch.setenv("GITLAB_ACCESS_TOKEN", "system-gitlab-token") + monkeypatch.setenv("GITHUB_TOKEN", "system-github-token") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "system-private-key") - manager = SandboxManager() - await manager.restore_from_snapshot( + await SandboxManager().restore_from_snapshot( snapshot_image_id="img-abc", - session_config={ - "repo_owner": "acme", - "repo_name": "repo", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "session_id": "sess-1", - }, - clone_token="ghs_restore_token", + session_config={"session_id": "sess-1", **repo_fields}, + clone_host="gitlab.example", + clone_username="oauth2", ) env = captured["env"] - assert env["VCS_HOST"] == "github.com" - assert env["VCS_CLONE_TOKEN"] == "ghs_restore_token" - assert env["GITHUB_TOKEN"] == "ghs_restore_token" - assert env["GITHUB_APP_TOKEN"] == "ghs_restore_token" - # Marked so the gh wrapper on helper-capable snapshots refreshes past it - # instead of reusing the soon-expired restore token. - assert env["OI_GITHUB_TOKEN_IS_FALLBACK"] == "1" + assert env["RESTORED_FROM_SNAPSHOT"] == "true" + assert env["REPO_OWNER"] == (repo_fields["repo_owner"] or "") + _assert_identity_without_system_tokens(env) + assert "GITLAB_ACCESS_TOKEN" not in env + assert "GITHUB_APP_PRIVATE_KEY" not in env @pytest.mark.asyncio -async def test_no_repo_restore_omits_clone_token(monkeypatch): - """No-repository snapshot restores get host scoping but no VCS credentials.""" +@pytest.mark.parametrize( + "user_token_key", [None, "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN", "VCS_CLONE_TOKEN"] +) +async def test_restore_preserves_user_tokens_without_generating_gh_cli_aliases( + monkeypatch, user_token_key +): + """Restore neither strips user tokens nor adds system GitHub CLI aliases.""" captured = {} - - class FakeImage: - object_id = "img-123" - - monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: FakeImage()) + monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", lambda *a, **kw: object()) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_sandbox_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) - manager = SandboxManager() - await manager.restore_from_snapshot( + await SandboxManager().restore_from_snapshot( snapshot_image_id="img-abc", - session_config={ - "repo_owner": None, - "repo_name": None, - "provider": "anthropic", - "model": "claude-sonnet-4-6", - "session_id": "sess-1", - }, - clone_token="ghs_restore_token", + session_config={"session_id": "sess-1", "repo_owner": "acme", "repo_name": "repo"}, + clone_host="github.com", + clone_username="x-access-token", + user_env_vars={user_token_key: "user-token"} if user_token_key else None, ) env = captured["env"] - assert "REPOSITORY_MODE" not in env - assert env["REPO_OWNER"] == "" - assert env["REPO_NAME"] == "" - assert env["VCS_HOST"] == "github.com" - assert env["VCS_CLONE_USERNAME"] == "x-access-token" - assert "VCS_CLONE_TOKEN" not in env - assert "GITHUB_TOKEN" not in env - assert "GITHUB_APP_TOKEN" not in env - assert "OI_GITHUB_TOKEN_IS_FALLBACK" not in env + for key in _SYSTEM_TOKEN_KEYS: + if key == user_token_key: + assert env[key] == "user-token" + else: + assert key not in env diff --git a/packages/modal-infra/tests/test_sandbox_launch.py b/packages/modal-infra/tests/test_sandbox_launch.py index f56fadd258..7753d8f29a 100644 --- a/packages/modal-infra/tests/test_sandbox_launch.py +++ b/packages/modal-infra/tests/test_sandbox_launch.py @@ -70,7 +70,12 @@ async def create_aio(*args, **kwargs): ids=["defaults", "cpu-only", "memory-only", "cpu-and-memory"], ) async def test_launch_matrix_preserves_common_and_source_specific_behavior( - monkeypatch, image_source, resources, expected_cpu, expected_memory, timeout_seconds + monkeypatch, + image_source, + resources, + expected_cpu, + expected_memory, + timeout_seconds, ): captured: dict = {} base_image = object() @@ -81,7 +86,6 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( monkeypatch.setattr("src.sandbox.launch.base_image", base_image) monkeypatch.setattr("src.sandbox.launch.modal.Image.from_id", images.__getitem__) monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", _fake_create(captured)) - monkeypatch.delenv("SCM_PROVIDER", raising=False) monkeypatch.setattr( SandboxLauncher, "_generate_code_server_password", staticmethod(lambda: "code-password") ) @@ -100,6 +104,8 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( "sandbox_id": "sandbox-1", "control_plane_url": "https://control.example", "sandbox_auth_token": "sandbox-token", + "clone_host": "github.example", + "clone_username": "provided-user", "timeout_seconds": timeout_seconds, "user_env_vars": { "CONTROL_PLANE_URL": "https://user.example", @@ -128,7 +134,6 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( "repo_name": "repo", "future_field": {"preserved": True}, }, - clone_token="legacy-clone-token", **common, ) expected_image = images["snapshot-image-1"] @@ -176,6 +181,12 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( assert env["AGENT_SLACK_NOTIFY_ENABLED"] == "true" assert env["TERMINAL_ENABLED"] == "true" assert "IMAGE_BUILD_MODE" not in env + assert "GITHUB_APP_PRIVATE_KEY" not in env + assert env["VCS_HOST"] == "github.example" + assert env["VCS_CLONE_USERNAME"] == "provided-user" + assert "VCS_CLONE_TOKEN" not in env + assert "GITHUB_TOKEN" not in env + assert "GITHUB_APP_TOKEN" not in env if image_source == "repository": assert env["FROM_REPO_IMAGE"] == "true" @@ -186,12 +197,8 @@ async def test_launch_matrix_preserves_common_and_source_specific_behavior( if image_source == "snapshot": assert env["RESTORED_FROM_SNAPSHOT"] == "true" assert '"future_field": {"preserved": true}' in env["SESSION_CONFIG"] - assert env["VCS_CLONE_TOKEN"] == "legacy-clone-token" - assert env["GITHUB_TOKEN"] == "legacy-clone-token" - assert env["GITHUB_APP_TOKEN"] == "legacy-clone-token" else: assert "RESTORED_FROM_SNAPSHOT" not in env - assert "VCS_CLONE_TOKEN" not in env session_config = json.loads(env["SESSION_CONFIG"]) assert session_config["branch"] == "feature/shared-launch" @@ -218,7 +225,13 @@ async def test_repository_image_create_validates_repo_before_image_lookup(monkey with pytest.raises(ValueError, match="repo_owner and repo_name must be provided together"): await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name=None, repo_image_id="repo-image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name=None, + repo_image_id="repo-image-1", + ) ) from_id.assert_not_called() @@ -246,12 +259,20 @@ async def test_launch_preserves_image_error_classification( with pytest.raises(expected_error) as raised: if image_source == "snapshot": await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="image-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, ) else: await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", repo_image_id="image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + repo_image_id="image-1", + ) ) if expected_error is RepositoryImageUnavailableError: @@ -276,7 +297,14 @@ async def test_base_image_spawn_errors_propagate_without_retry(monkeypatch, miss monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", SimpleNamespace(aio=create)) with pytest.raises(type(error)) as raised: - await SandboxManager().create_sandbox(SandboxConfig(repo_owner=None, repo_name=None)) + await SandboxManager().create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner=None, + repo_name=None, + ) + ) assert raised.value is error create.assert_awaited_once() @@ -317,6 +345,8 @@ async def test_launch_returns_handle_despite_tunnel_failures(monkeypatch, image_ if image_source == "snapshot": handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="image-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, **common, @@ -324,6 +354,8 @@ async def test_launch_returns_handle_despite_tunnel_failures(monkeypatch, image_ else: handle = await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", repo_image_id="image-1" if image_source == "repository" else None, @@ -365,7 +397,13 @@ async def create_aio(*_args, **_kwargs): with pytest.raises(RepositoryImageUnavailableError) as exc_info: await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", repo_image_id="image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + repo_image_id="image-1", + ) ) assert isinstance(exc_info.value.__cause__, NotFoundError) @@ -386,7 +424,13 @@ def missing_secret(_name, **_kwargs): with pytest.raises(NotFoundError, match="secret not found"): await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", repo_image_id="repo-image-1") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + repo_image_id="repo-image-1", + ) ) create.aio.assert_not_awaited() @@ -398,7 +442,14 @@ async def test_base_image_not_found_is_not_classified_as_repository_image(monkey monkeypatch.setattr("src.sandbox.launch.modal.Sandbox.create", create) with pytest.raises(NotFoundError, match="base image not found"): - await SandboxManager().create_sandbox(SandboxConfig(repo_owner="acme", repo_name="repo")) + await SandboxManager().create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) + ) create.aio.assert_awaited_once() @@ -429,6 +480,8 @@ def _docker_config(**overrides) -> SandboxConfig: ), "control_plane_url": "https://control.example", "sandbox_auth_token": "token", + "clone_host": "github.com", + "clone_username": "x-access-token", "user_env_vars": {DOCKER_ENABLED_ENV_VAR: "false", "CUSTOM_ENV": "preserved"}, "settings": dict(DOCKER_SETTINGS), "sandbox_backend": "modal-vm", @@ -464,6 +517,8 @@ async def test_docker_launch_selects_vm_runtime_and_named_allocation( if image_source == "snapshot": handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="snapshot-1", session_config={"session_id": "session-1", "repo_owner": "acme", "repo_name": "repo"}, sandbox_id="sandbox-acme-repo-1700000000000", @@ -569,6 +624,8 @@ async def test_expired_vm_launch_cannot_materialize_after_lookup(monkeypatch, im launch = manager.create_sandbox(_docker_config(launch_deadline_at_ms=1)) else: launch = manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="snapshot-1", session_config={"session_id": "session-1"}, sandbox_id="sandbox-acme-repo-1700000000000", @@ -647,6 +704,8 @@ async def launch(): if image_source == "base": return await manager.create_sandbox(config) return await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="snapshot-1", session_config=config.session_config, sandbox_id=config.sandbox_id, @@ -947,13 +1006,21 @@ async def test_launch_rejects_boolean_tunnel_ports(monkeypatch, restore, ports, if restore: handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="image-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, settings=settings, ) else: handle = await manager.create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo", settings=settings) + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + settings=settings, + ) ) kwargs = create.call_args.kwargs diff --git a/packages/modal-infra/tests/test_sandbox_resources.py b/packages/modal-infra/tests/test_sandbox_resources.py index 2505511766..09595dc73e 100644 --- a/packages/modal-infra/tests/test_sandbox_resources.py +++ b/packages/modal-infra/tests/test_sandbox_resources.py @@ -58,6 +58,8 @@ async def test_create_sandbox_passes_cpu_and_memory(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", settings={"cpuCores": 2, "memoryMib": 4096}, @@ -86,6 +88,8 @@ class FakeImage: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"repo_owner": "acme", "repo_name": "repo"}, settings={"cpuCores": 1, "memoryMib": 2048}, diff --git a/packages/modal-infra/tests/test_ttyd.py b/packages/modal-infra/tests/test_ttyd.py index 3ddd6ae978..dddf1c5631 100644 --- a/packages/modal-infra/tests/test_ttyd.py +++ b/packages/modal-infra/tests/test_ttyd.py @@ -143,6 +143,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -180,6 +182,8 @@ class FakeSandbox: manager = SandboxManager() config = SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", control_plane_url="https://cp.example.com", @@ -231,6 +235,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", @@ -278,6 +284,8 @@ class FakeSandbox: manager = SandboxManager() handle = await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={ "repo_owner": "acme", diff --git a/packages/modal-infra/tests/test_tunnel_ports.py b/packages/modal-infra/tests/test_tunnel_ports.py index ead6c6b51a..96a826e75e 100644 --- a/packages/modal-infra/tests/test_tunnel_ports.py +++ b/packages/modal-infra/tests/test_tunnel_ports.py @@ -414,6 +414,8 @@ class FakeSandbox: manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", settings={"tunnelPorts": [3000, 5173]}, @@ -444,7 +446,14 @@ class FakeSandbox: ) manager = SandboxManager() - await manager.create_sandbox(SandboxConfig(repo_owner="acme", repo_name="repo")) + await manager.create_sandbox( + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) + ) assert EXPECTED_TUNNEL_PORTS_ENV_VAR not in captured["env"] @@ -479,6 +488,8 @@ class FakeSandbox: manager = SandboxManager() await manager.restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-abc", session_config={"repo_owner": "acme", "repo_name": "repo"}, settings={"tunnelPorts": [3000]}, @@ -606,6 +617,8 @@ async def test_sets_code_server_port_env_when_enabled(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", code_server_enabled=True, @@ -623,6 +636,8 @@ async def test_omits_code_server_port_env_when_disabled(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", code_server_enabled=False, @@ -640,6 +655,8 @@ async def test_sets_terminal_port_env_when_terminal_enabled(self, monkeypatch): manager = SandboxManager() await manager.create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", settings={"terminalEnabled": True, "terminalPort": 7000}, diff --git a/packages/modal-infra/tests/test_vm_resolve.py b/packages/modal-infra/tests/test_vm_resolve.py index 4513793da0..a72d72110d 100644 --- a/packages/modal-infra/tests/test_vm_resolve.py +++ b/packages/modal-infra/tests/test_vm_resolve.py @@ -431,6 +431,8 @@ async def test_vm_launch_reports_typed_outcomes(monkeypatch, endpoint, case, det "sandbox_id": GENERATION, "control_plane_url": "https://control.example", "sandbox_auth_token": "secret", + "clone_host": "github.com", + "clone_username": "x-access-token", "sandbox_backend": "modal-vm", "launch_deadline_at_ms": 1 if case == "expired" else 9999999999999, } diff --git a/packages/modal-infra/tests/test_vnc.py b/packages/modal-infra/tests/test_vnc.py index 18e2412789..d0c899f709 100644 --- a/packages/modal-infra/tests/test_vnc.py +++ b/packages/modal-infra/tests/test_vnc.py @@ -45,6 +45,8 @@ async def test_returns_url_and_password_and_exposes_only_novnc(self, monkeypatch handle = await SandboxManager().create_sandbox( SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", repo_owner="acme", repo_name="repo", vnc_enabled=True, @@ -71,7 +73,12 @@ async def test_disabled_vnc_has_no_credentials_or_port(self, monkeypatch): ) handle = await SandboxManager().create_sandbox( - SandboxConfig(repo_owner="acme", repo_name="repo") + SandboxConfig( + clone_host="github.com", + clone_username="x-access-token", + repo_owner="acme", + repo_name="repo", + ) ) assert handle.vnc_url is None @@ -96,6 +103,8 @@ async def test_generates_credentials_and_returns_them_with_url(self, monkeypatch ) handle = await SandboxManager().restore_from_snapshot( + clone_host="github.com", + clone_username="x-access-token", snapshot_image_id="img-1", session_config={"repo_owner": "acme", "repo_name": "repo"}, vnc_enabled=True, 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 362eeb5a3e..e08d33fe52 100644 --- a/packages/modal-infra/tests/test_web_api_build_sandbox.py +++ b/packages/modal-infra/tests/test_web_api_build_sandbox.py @@ -188,7 +188,12 @@ def _patch_dependencies(monkeypatch: pytest.MonkeyPatch): return service +VCS_IDENTITY = {"clone_host": "github.com", "clone_username": "x-access-token"} + + async def _call(endpoint, request: dict) -> dict: + if endpoint is web_api.api_create_build_sandbox: + request = {**VCS_IDENTITY, **request} return await endpoint.get_raw_f()( request, authorization="Bearer test", @@ -277,9 +282,9 @@ async def test_create_build_sandbox_forwards_callback_context_and_returns_provid repositories=REPOSITORIES, callback_url="https://worker.test/image-builds/build-complete", failure_callback_url="https://worker.test/image-builds/build-failed", + clone_host="github.com", + clone_username="x-access-token", clone_token="clone-token", - clone_host=None, - clone_username=None, user_env_vars={"FOO": "bar"}, build_execution_timeout_seconds=DEFAULT_BUILD_TIMEOUT_SECONDS, timeout_seconds=2400, diff --git a/packages/modal-infra/tests/test_web_api_create_sandbox.py b/packages/modal-infra/tests/test_web_api_create_sandbox.py index 52b170f28f..393c5a96cf 100644 --- a/packages/modal-infra/tests/test_web_api_create_sandbox.py +++ b/packages/modal-infra/tests/test_web_api_create_sandbox.py @@ -1,6 +1,7 @@ """Tests for Modal create-sandbox API request assembly.""" import asyncio +import os from types import SimpleNamespace from unittest.mock import ANY, AsyncMock, MagicMock @@ -72,7 +73,10 @@ async def restore_from_snapshot(self, **kwargs): monkeypatch.setattr(manager_module, "SandboxManager", FakeManager) -async def _call_create_sandbox(request: dict, **headers) -> dict: +VCS_IDENTITY = {"clone_host": "github.com", "clone_username": "x-access-token"} + + +async def _call_create_sandbox(request: dict, *, with_identity: bool = True, **headers) -> dict: request_headers = { "authorization": "Bearer test", "x_trace_id": None, @@ -82,12 +86,12 @@ async def _call_create_sandbox(request: dict, **headers) -> dict: **headers, } return await web_api.api_create_sandbox.get_raw_f()( - request, + {**VCS_IDENTITY, **request} if with_identity else request, **request_headers, ) -async def _call_restore_sandbox(request: dict, **headers) -> dict: +async def _call_restore_sandbox(request: dict, *, with_identity: bool = True, **headers) -> dict: request_headers = { "authorization": "Bearer test", "x_trace_id": None, @@ -97,7 +101,7 @@ async def _call_restore_sandbox(request: dict, **headers) -> dict: **headers, } return await web_api.api_restore_sandbox.get_raw_f()( - request, + {**VCS_IDENTITY, **request} if with_identity else request, **request_headers, ) @@ -156,6 +160,63 @@ async def test_sandbox_requests_reject_invalid_typed_fields(monkeypatch, call, p assert exc_info.value.status_code == 400 +@pytest.mark.parametrize("endpoint", [web_api.api_create_sandbox, web_api.api_restore_sandbox]) +def test_sandbox_endpoints_do_not_bind_github_app_secrets(endpoint): + assert [secret.name for secret in endpoint.spec.secrets] == [ + "internal-api", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("restore", [False, True], ids=["create", "restore"]) +@pytest.mark.parametrize("repo_fields", [{}, {"repo_owner": "acme", "repo_name": "repo"}]) +@pytest.mark.parametrize("field", ["clone_host", "clone_username"]) +@pytest.mark.parametrize("value", [123, True, [], {}]) +async def test_sandbox_requests_reject_invalid_clone_fields( + monkeypatch, restore, repo_fields, field, value +): + _patch_auth(monkeypatch) + if restore: + call = _call_restore_sandbox + request = { + **RESTORE_REQUEST, + "session_config": {**RESTORE_REQUEST["session_config"], **repo_fields}, + } + else: + call = _call_create_sandbox + request = {**CREATE_REQUEST, **repo_fields} + + with pytest.raises(HTTPException) as exc_info: + await call({**request, field: value}) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("restore", [False, True], ids=["create", "restore"]) +@pytest.mark.parametrize( + "identity", + [ + {}, + {"clone_username": "oauth2"}, + {"clone_host": "gitlab.example"}, + {"clone_host": None, "clone_username": None}, + {"clone_host": "", "clone_username": ""}, + ], + ids=["omitted", "host-missing", "username-missing", "null", "empty"], +) +async def test_sandbox_requests_require_vcs_identity(monkeypatch, restore, identity): + """The control plane is the only source of the sandbox VCS identity.""" + _patch_auth(monkeypatch) + call = _call_restore_sandbox if restore else _call_create_sandbox + request = RESTORE_REQUEST if restore else CREATE_REQUEST + + with pytest.raises(HTTPException) as exc_info: + await call({**request, **identity}, with_identity=False) + + assert exc_info.value.status_code == 400 + + @pytest.mark.asyncio @pytest.mark.parametrize( ("call", "payload", "field"), @@ -410,27 +471,25 @@ def reject_auth(_authorization): @pytest.mark.asyncio -async def test_create_sandbox_does_not_resolve_clone_token_for_fresh_boot(monkeypatch): - """Fresh base-image boots authenticate via the credential helper only.""" +@pytest.mark.parametrize("repo_image_id", [None, "repo-image-1"]) +async def test_create_sandbox_passes_broker_context(monkeypatch, repo_image_id): + """Fresh and repo-image boots authenticate via the credential helper only.""" captured = {} - calls = [] - _patch_auth(monkeypatch) _patch_manager(monkeypatch, captured) - monkeypatch.setattr(web_api, "resolve_clone_token", lambda: calls.append(True) or "ghs_token") result = await _call_create_sandbox( { - "session_id": "sess-1", + **CREATE_REQUEST, "repo_owner": "acme", "repo_name": "repo", - "control_plane_url": "https://control-plane.example", - "sandbox_auth_token": "sandbox-token", + "repo_image_id": repo_image_id, } ) assert result["success"] is True - assert calls == [] + assert captured["config"].control_plane_url == CREATE_REQUEST["control_plane_url"] + assert captured["config"].sandbox_auth_token == CREATE_REQUEST["sandbox_auth_token"] @pytest.mark.asyncio @@ -516,36 +575,6 @@ async def test_create_sandbox_rejects_invalid_timeout(monkeypatch, timeout_secon assert exc_info.value.detail == "timeout_seconds must be a positive integer" -@pytest.mark.asyncio -async def test_create_sandbox_does_not_resolve_clone_token_for_repo_image_boot(monkeypatch): - """Repo-image boots authenticate via brokered credentials only.""" - captured = {} - calls = [] - - _patch_auth(monkeypatch) - _patch_manager(monkeypatch, captured) - - def resolve_clone_token() -> str: - calls.append(True) - return "ghs_prebuilt" - - monkeypatch.setattr(web_api, "resolve_clone_token", resolve_clone_token) - - result = await _call_create_sandbox( - { - "session_id": "sess-1", - "repo_owner": "acme", - "repo_name": "repo", - "control_plane_url": "https://control-plane.example", - "sandbox_auth_token": "sandbox-token", - "repo_image_id": "repo-image-1", - } - ) - - assert result["success"] is True - assert calls == [] - - @pytest.mark.asyncio async def test_create_sandbox_threads_missing_repo_fields(monkeypatch): """No-repository sandboxes are represented by null repo fields.""" @@ -598,14 +627,31 @@ async def test_create_sandbox_rejects_partial_repo_context(monkeypatch, request_ @pytest.mark.asyncio -async def test_restore_sandbox_without_repo_does_not_resolve_clone_token(monkeypatch): - """No-repository snapshot restores must not mint a repository clone token.""" +@pytest.mark.parametrize( + "clone_fields", + [ + {}, + {"clone_token": None}, + {"clone_token": ""}, + {"clone_token": "provided-token"}, + {"clone_token": {}}, + ], +) +@pytest.mark.parametrize("repo_fields", [{}, {"repo_owner": "acme", "repo_name": "repo"}]) +async def test_restore_sandbox_never_resolves_or_forwards_static_clone_token( + monkeypatch, clone_fields, repo_fields +): + """Restore ignores obsolete token input and credentials in the function environment.""" captured = {} - calls = [] _patch_auth(monkeypatch) _patch_restore_manager(monkeypatch, captured) - monkeypatch.setattr(web_api, "resolve_clone_token", lambda: calls.append(True) or "ghs_token") + monkeypatch.setenv("GITLAB_ACCESS_TOKEN", "system-gitlab-token") + monkeypatch.setenv("GITHUB_APP_ID", "123") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "private-key") + monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") + environment_lookup = MagicMock(wraps=os.environ.get) + monkeypatch.setattr(os.environ, "get", environment_lookup) result = await _call_restore_sandbox( { @@ -614,15 +660,30 @@ async def test_restore_sandbox_without_repo_does_not_resolve_clone_token(monkeyp "session_id": "sess-1", "provider": "anthropic", "model": "claude-sonnet-4-6", + **repo_fields, }, "control_plane_url": "https://control-plane.example", "sandbox_auth_token": "sandbox-token", + **clone_fields, + "clone_host": "gitlab.example", + "clone_username": "oauth2", } ) assert result["success"] is True - assert calls == [] - assert captured["restore"]["clone_token"] is None + assert {call.args[0] for call in environment_lookup.call_args_list}.isdisjoint( + { + "GITLAB_ACCESS_TOKEN", + "GITHUB_APP_ID", + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_APP_INSTALLATION_ID", + } + ) + assert "clone_token" not in captured["restore"] + assert captured["restore"]["clone_host"] == "gitlab.example" + assert captured["restore"]["clone_username"] == "oauth2" + assert captured["restore"]["control_plane_url"] == RESTORE_REQUEST["control_plane_url"] + assert captured["restore"]["sandbox_auth_token"] == RESTORE_REQUEST["sandbox_auth_token"] @pytest.mark.asyncio @@ -674,37 +735,53 @@ async def test_restore_sandbox_forwards_vnc_and_returns_credentials(monkeypatch) @pytest.mark.asyncio -async def test_restore_sandbox_uses_normalized_repo_context(monkeypatch): - """Snapshot restores should validate and pass a single normalized repo context.""" +@pytest.mark.parametrize("restore", [False, True], ids=["create", "restore"]) +@pytest.mark.parametrize("repo_fields", [{}, {"repo_owner": " acme ", "repo_name": " repo "}]) +async def test_sandbox_requests_forward_metadata_and_user_tokens_with_normalized_repo_context( + monkeypatch, restore, repo_fields +): + """Create and restore share launch metadata, outside the nested session config.""" captured = {} - calls = [] _patch_auth(monkeypatch) - _patch_restore_manager(monkeypatch, captured) - monkeypatch.setattr(web_api, "resolve_clone_token", lambda: calls.append(True) or "ghs_token") + if restore: + _patch_restore_manager(monkeypatch, captured) + call = _call_restore_sandbox + request = { + **RESTORE_REQUEST, + "session_config": {**RESTORE_REQUEST["session_config"], **repo_fields}, + } + else: + _patch_manager(monkeypatch, captured) + call = _call_create_sandbox + request = {**CREATE_REQUEST, **repo_fields} - result = await _call_restore_sandbox( + result = await call( { - "snapshot_image_id": "img-abc", - "session_config": { - "session_id": "sess-1", - "repo_owner": " acme ", - "repo_name": " repo ", - "provider": "anthropic", - "model": "claude-sonnet-4-6", - }, - "control_plane_url": "https://control-plane.example", - "sandbox_auth_token": "sandbox-token", + **request, + "clone_host": "gitlab.example", + "clone_username": "oauth2", + "user_env_vars": {"GH_TOKEN": "user-token", "VCS_CLONE_TOKEN": "user-clone-token"}, } ) - session_config = captured["restore"]["session_config"] + config = captured["restore"] if restore else vars(captured["config"]) + session_config = config["session_config"] if restore else config["session_config"].model_dump() assert result["success"] is True - assert calls == [True] - assert session_config["repo_owner"] == "acme" - assert session_config["repo_name"] == "repo" - assert captured["restore"]["clone_token"] == "ghs_token" + assert session_config.get("repo_owner") == ("acme" if repo_fields else None) + assert session_config.get("repo_name") == ("repo" if repo_fields else None) + assert "clone_host" not in session_config + assert "clone_username" not in session_config + assert "clone_token" not in config + assert config["clone_host"] == "gitlab.example" + assert config["clone_username"] == "oauth2" + assert config["control_plane_url"] == request["control_plane_url"] + assert config["sandbox_auth_token"] == request["sandbox_auth_token"] + assert config["user_env_vars"] == { + "GH_TOKEN": "user-token", + "VCS_CLONE_TOKEN": "user-clone-token", + } @pytest.mark.asyncio diff --git a/packages/modal-infra/uv.lock b/packages/modal-infra/uv.lock index 724862ecd9..759e297e5c 100644 --- a/packages/modal-infra/uv.lock +++ b/packages/modal-infra/uv.lock @@ -909,7 +909,6 @@ dependencies = [ { name = "modal" }, { name = "open-inspect-sandbox-runtime" }, { name = "pydantic" }, - { name = "pyjwt", extra = ["crypto"] }, ] [package.optional-dependencies] @@ -928,7 +927,6 @@ 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.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" }, @@ -944,7 +942,6 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "pydantic" }, - { name = "pyjwt", extra = ["crypto"] }, { name = "websockets" }, ] @@ -955,7 +952,6 @@ 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.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" }, diff --git a/packages/sandbox-runtime/pyproject.toml b/packages/sandbox-runtime/pyproject.toml index c7936ac5bf..8568a9cb86 100644 --- a/packages/sandbox-runtime/pyproject.toml +++ b/packages/sandbox-runtime/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "httpx>=0.27.0", "websockets>=13.0", "pydantic>=2.0", - "PyJWT[crypto]>=2.14.0", # Exact pin: the wheel bundles the `claude` binary and its message shapes # are what harness/claude.py translates. "claude-agent-sdk==0.2.161", diff --git a/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py b/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py index 6f6e25a980..0488bdf804 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py @@ -1,6 +1,5 @@ """Authentication utilities for Open-Inspect sandbox runtime.""" -from .github_app import generate_installation_token from .internal import ( AuthConfigurationError, require_secret, @@ -9,7 +8,6 @@ __all__ = [ "AuthConfigurationError", - "generate_installation_token", "require_secret", "verify_internal_token", ] diff --git a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py b/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py deleted file mode 100644 index 43a8f9b485..0000000000 --- a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -GitHub App token generation for git operations. - -Generates short-lived installation access tokens for: -- Cloning private repositories during image builds -- Git fetch/sync at sandbox startup -- Git push when creating pull requests - -Tokens are valid for ~1 hour. -""" - -import time - -import httpx -import jwt - - -def generate_jwt(app_id: str, private_key: str) -> str: - """ - Generate a JWT for GitHub App authentication. - - Args: - app_id: The GitHub App's ID - private_key: The App's private key (PEM format) - - Returns: - Signed JWT valid for 10 minutes - """ - now = int(time.time()) - payload = { - "iat": now - 60, # Issued 60 seconds ago (clock skew tolerance) - "exp": now + 600, # Expires in 10 minutes - "iss": app_id, - } - return jwt.encode(payload, private_key, algorithm="RS256") - - -def get_installation_token(jwt_token: str, installation_id: str) -> str: - """ - Exchange a JWT for an installation access token. - - Args: - jwt_token: The signed JWT - installation_id: The GitHub App installation ID - - Returns: - Installation access token (valid for 1 hour) - - Raises: - httpx.HTTPStatusError: If the GitHub API request fails - """ - url = f"https://api.github.com/app/installations/{installation_id}/access_tokens" - headers = { - "Authorization": f"Bearer {jwt_token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - - with httpx.Client() as client: - response = client.post(url, headers=headers) - response.raise_for_status() - return response.json()["token"] - - -def generate_installation_token( - app_id: str, - private_key: str, - installation_id: str, -) -> str: - """ - Generate a fresh GitHub App installation token. - - This is the main entry point for token generation. It: - 1. Creates a JWT signed with the App's private key - 2. Exchanges it for an installation access token - - Args: - app_id: The GitHub App's ID - private_key: The App's private key (PEM format) - installation_id: The GitHub App installation ID - - Returns: - Installation access token (valid for 1 hour) - - Raises: - httpx.HTTPStatusError: If the GitHub API request fails - jwt.PyJWTError: If JWT encoding fails - """ - jwt_token = generate_jwt(app_id, private_key) - return get_installation_token(jwt_token, installation_id) diff --git a/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py b/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py index 491ef7a38f..98d5235cdc 100644 --- a/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py +++ b/packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py @@ -268,27 +268,10 @@ def _emit_response(input_lines: dict[str, str], credentials: dict[str, object]) def _gh_wrapper_should_mint(env: Mapping[str, str]) -> bool: - """Decide whether the gh CLI needs a freshly-minted token. - - gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so - we mint only when the environment has nothing usable: no user-provided - token, and either nothing at all or just the system's short-lived - installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=1``, which - expires in ~1h and must be refreshed). A user-provided token always wins. - - The marker is authoritative on its own: a value comparison between - ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user - override, because the manager only sets the marker when it injected both - values itself. - """ + """Mint for GitHub only when neither CLI-supported user token is present.""" if env.get("VCS_HOST", "github.com").strip().lower() != "github.com": return False # non-github deployment: never touch gh's own auth - if env.get("GH_TOKEN"): - return False # user-owned; the manager never injects GH_TOKEN - if env.get("OI_GITHUB_TOKEN_IS_FALLBACK") == "1": - return True # only the expiring system fallback is present → refresh - # Otherwise mint only when there's no genuine user token to leave alone. - return not (env.get("GITHUB_TOKEN") or env.get("GITHUB_APP_TOKEN")) + return not (env.get("GH_TOKEN") or env.get("GITHUB_TOKEN")) def _print_gh_token() -> int: diff --git a/packages/sandbox-runtime/tests/conftest.py b/packages/sandbox-runtime/tests/conftest.py index adfe37504e..18ac21f59f 100644 --- a/packages/sandbox-runtime/tests/conftest.py +++ b/packages/sandbox-runtime/tests/conftest.py @@ -23,7 +23,6 @@ "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_TOKEN", - "OI_GITHUB_TOKEN_IS_FALLBACK", ) diff --git a/packages/sandbox-runtime/tests/test_git_credential_helper.py b/packages/sandbox-runtime/tests/test_git_credential_helper.py index f745616d56..593f649c31 100644 --- a/packages/sandbox-runtime/tests/test_git_credential_helper.py +++ b/packages/sandbox-runtime/tests/test_git_credential_helper.py @@ -553,51 +553,11 @@ def test_control_plane_response_invalid_expiry_is_fatal(cache_dir: Path, env_set ({"VCS_HOST": "github.com"}, True), # Non-github deployment → never touch gh's own auth. ({"VCS_HOST": "gitlab.com"}, False), - # A user-set GH_TOKEN always wins (the manager never injects GH_TOKEN). + # CLI-supported user tokens always win. ({"VCS_HOST": "github.com", "GH_TOKEN": "user"}, False), - # A user token without the fallback marker → leave it in place. ({"VCS_HOST": "github.com", "GITHUB_TOKEN": "user"}, False), - ({"VCS_HOST": "github.com", "GITHUB_APP_TOKEN": "user"}, False), - # Marked system fallback → refresh the soon-to-expire installation token. - ( - { - "VCS_HOST": "github.com", - "GITHUB_TOKEN": "stale", - "GITHUB_APP_TOKEN": "stale", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - True, - ), - # Marker with only GITHUB_TOKEN present → still refresh. - ( - { - "VCS_HOST": "github.com", - "GITHUB_TOKEN": "stale", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - True, - ), - # Dropped heuristic: the marker alone forces a refresh even when the - # two values differ (this case used to be read as a user override). - ( - { - "VCS_HOST": "github.com", - "GITHUB_TOKEN": "stale", - "GITHUB_APP_TOKEN": "user_app", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - True, - ), - # A user GH_TOKEN still wins even with the fallback marker present. - ( - { - "VCS_HOST": "github.com", - "GH_TOKEN": "user", - "GITHUB_TOKEN": "stale", - "OI_GITHUB_TOKEN_IS_FALLBACK": "1", - }, - False, - ), + # gh does not read GITHUB_APP_TOKEN; the wrapper must still obtain a usable token. + ({"VCS_HOST": "github.com", "GITHUB_APP_TOKEN": "user"}, True), ], ) def test_gh_wrapper_should_mint(env: dict[str, str], expected: bool) -> None: diff --git a/packages/sandbox-runtime/uv.lock b/packages/sandbox-runtime/uv.lock index 09a8fd1bf5..71aadac586 100644 --- a/packages/sandbox-runtime/uv.lock +++ b/packages/sandbox-runtime/uv.lock @@ -543,7 +543,6 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "pydantic" }, - { name = "pyjwt", extra = ["crypto"] }, { name = "websockets" }, ] @@ -562,7 +561,6 @@ 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.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" }, diff --git a/terraform/environments/production/modal.tf b/terraform/environments/production/modal.tf index ba266b0b05..0a49c0d7a0 100644 --- a/terraform/environments/production/modal.tf +++ b/terraform/environments/production/modal.tf @@ -33,14 +33,6 @@ module "modal_app" { name = "llm-api-keys" values = local.modal_llm_secret_values }, - { - name = "github-app" - values = { - GITHUB_APP_ID = var.github_app_id - GITHUB_APP_PRIVATE_KEY = var.github_app_private_key - GITHUB_APP_INSTALLATION_ID = var.github_app_installation_id - } - }, { name = "internal-api" values = { From 2cb8b44e78e2ef515f783dd1d55d16bcbc87600d Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 1 Oct 2026 09:47:44 -0700 Subject: [PATCH 02/17] feat: add team repository grants and access checks (#2181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements repository-grant management, team-scoped repository catalogs, and repository authorization at the specified write sites. Issue: https://linear.app/colemurray/issue/COL-204 - Add installation-wide or named repository grants, with SCM identity validation, mutually exclusive grant kinds, and an atomic 500-repository cap. - Batch actual grant mutations with `teams.grants_version` increments and `team.grant_added` / `team.grant_removed` domain audit rows. Duplicate PUTs are idempotent; existing resource references are never rewritten. - Filter `/repos?teamId=` after reading the existing global `repos:list:v3` cache. Workspace and actorless calls without a team keep the full catalog. Missing and inaccessible teams return the identical 404. - Share identity-bearing target/grant checks across session creation, sandbox child creation, session moves, environment repository edits, automation selections, skill assignments/import sources, repository secrets, image-build triggers, and environment secret import. - Workspace repository surfaces retain existing permissions when no team grants the repository. Otherwise, callers need membership in any granting team, lead membership for repository secrets, or a built-in Owner/Administrator role. Installation grants count for every repository; these rules do not depend on enforcement mode. - Manual team-owned environment builds require membership in the owning team or Owner/Administrator status, and still require the team's repository coverage. Secret import checks both destination coverage and source secret authorization before copying or scheduling. - Add the capability-gated Repositories tab and BFF routes. Members read grants; leads and administrators add/remove them. Empty or failed team catalogs never implicitly select “No repository”; the missing-grant error names the repository. - Audit missing-team and nonmember team 404s through `authorizationDenial` with `team_not_visible`, retaining the identical `Team not found` response. Allowed-decision auditing is unchanged by this PR. ## Checkpoint Report ### Commands And Results Validation was run sequentially with one Vitest worker, then repeated after rebasing onto `main` at `70b8ca4`: | Command | Result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed; also rebuilt by the final root typecheck | | `npm run typecheck` | Passed across every workspace, including control-plane Node and integration types | | `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 -- --maxWorkers=1` | 345 files passed; 5,846 tests passed | | `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1` | 128 files passed; 1,654 tests passed, 1 skipped; shell timeout 900000 ms | | `npm test -w @open-inspect/web -- --maxWorkers=1` | 263 files passed; 2,630 tests passed | | `npm test -w @open-inspect/shared -- --maxWorkers=1` | 64 files passed; 1,074 tests passed | | `git diff --name-only -z origin/main...HEAD -- "*.ts" "*.tsx" "*.md" \| xargs -0 npx prettier --check` | Passed | | `git diff --check origin/main...HEAD` | Passed | The passing integration suite emitted workerd eviction/invalid-request diagnostics and NDJSON warnings; the web suite emitted jsdom navigation and timeout warnings. Neither suite reported test failures or Vitest unhandled errors. Targeted regression runs also passed: grant storage/workspace authorization/image routes (168 tests), source-import authorization/environment secrets (146 tests), and the regenerated route/admission snapshots plus grant integration tests (28 tests). ### Red Tests And Resolved Failures Tests were added before the grant storage/API implementations. Initial failures included these verbatim excerpts: ```text TypeError: store.add is not a function TypeError: store.listTeamsForRepository is not a function Error: The property "listTeamsForRepository" is not defined on the object. ``` The repository-owner lookup red run reported: ```text Test Files 2 failed (2) Tests 24 failed | 17 passed (41) ``` After adding the lookup but before replacing the old mode-dependent authorization, the behavioral red run reported: ```text AssertionError: expected undefined to be 403 // Object.is equality Test Files 1 failed | 1 passed (2) Tests 12 failed | 29 passed (41) ``` Intermediate validation failures were resolved rather than suppressed: ```text AssertionError: expected 409 to be 200 // Object.is equality AssertionError: expected 500 to be 201 // Object.is equality AssertionError: expected [] to deep equally contain { Object (action) } ``` The first two exposed outdated fixtures: automation rows omitted their required null `owner_team_id`, and the team-owned sandbox-child fixture had neither a grant nor a configured SCM resolution. Fixtures now accurately represent workspace ownership or granted team ownership. The third exposed the missing denial audit decision and is covered by the minimal admission fix. Initial type/lint failures also included: ```text Property 'name' is missing in type '{ readonly id: "role_builtin_owner"; readonly key: "owner"; }' but required in type '{ id: string; key: "owner" | "administrator" | "member" | "viewer" | null; name: string; }'. error `import()` type annotations are forbidden @typescript-eslint/consistent-type-imports ``` These were corrected with complete authorization fixtures and normal type imports. The grant-read policy's `auditAllowed: false` spread required narrowing `requireTeam`'s return type to its actual `active-user` variant; its runtime behavior was not changed by that type correction. ### Verified Facts And Drift - No schema migration: the existing D1 migration `0083` and DO migration `56` provide the needed fields. `listForTeam` retains its numeric-ID projection; null repository IDs require an installation grant. - The global repository cache remains `repos:list:v3`. Scope filtering applies to both cached and freshly fetched results, never to the cached installation catalog itself. - The supplied session-create pointers had moved to `session-create.ts:104–108` and `185–198` on the initial checkout; the existing separate grant check was moved into the common target helper without changing creation ownership or visibility semantics. - The listed grant URLs introduce two distinct paths, not one. The verified catalog is 205 routes and 156 paths, rather than 205/155; policy tests and both snapshots reflect the actual routes. - `enforceTeamRequirement`'s missing-team and nonmember branches returned only a response, so no denial decision reached the audit writer. They now use the existing denial mechanism and the same reason code/body. - Automation/environment stores already carry `owner_team_id`, but their creation APIs still write null ownership. Checks use persisted ownership on updates; no ownership API was pulled forward. - Rebased over the merged quiet-team-read/subscribe-capability and composer/scope-refresh changes in #2174 and #2176. Their implementations remain intact. Conflict resolution preserved the new `requireTeam` options/defaults, both changelog entries, and regenerated the route snapshots from the combined implementation. ### Deliberately Excluded - No installation-token mint/cache changes, provider scope arguments, Modal restore changes, or App-key removal. Sandbox credentials are not narrowed or revoked by this PR; scoped credentials remain a separate change. - No automation/environment ownership APIs or scheduler ownership/execution guards, team secrets, or bot bindings. - No independent composer-page, active-team, scope-purge, allowed-read auditing, or member-email visibility implementation. Merged follow-ups are inherited rather than reimplemented. - Existing workspace-level skill catalog lifecycle and background save-hook admission remain unchanged. Repository prebuild disablement remains a permission-gated cleanup operation that does not resolve or execute an inaccessible repository. ## Browser Verification Verified the real Next.js application with isolated browser API fixtures, without committing fixture routes or bypassing application authentication: - Named grant add/remove, scope locking, removal from the installation selector, and installation-wide grant behavior. - Members retain read-only grant visibility; missing capabilities expose no management controls. - Desktop and mobile layouts, including no horizontal overflow at 390 px. Viewport captures from `http://127.0.0.1:3131/teams/design`: | Capture | Viewport | Uploaded Artifact | | --- | --- | --- | | Named grant management | 1440 × 1000 | `5e87768770faab32555d127ef9030c3b` | | Mobile grant management | 390 × 844 | `b378de863da0bbdb4210eb6e225babb2` | | Read-only member, installation grant | 390 × 844 | `430aae856ae5bd1e9b597fb6cd56120d` | Browser verification covers rendering/interactions with fixture-backed APIs, not live OAuth or GitHub credentials. Backend authorization and storage are separately exercised by the real-D1 integration suite. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/cf9a05bc1aa439a722201fa0456eb202)* ## Summary by CodeRabbit * **New Features** * Team leads and workspace Owners or Administrators can grant a team access to all installation repositories or selected repositories, and remove grants. Team members can view grants. * Team-scoped repository and environment catalogs show only resources covered by the team’s grants. * Repository access checks apply to sessions, automations, environments, skills, secrets, and image builds. Team-owned resources also require appropriate team membership or leadership. * Session target selection displays grant and selection errors and lets you choose repositories again when a selection becomes unavailable. * Audit logs display repository grant additions and removals. * **Bug Fixes** * Secret imports verify the repository’s current identity and access before importing. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- CHANGELOG.md | 16 + docs/AUTH.md | 25 +- .../src/db/team-repository-grants.test.ts | 166 +++++++ .../src/db/team-repository-grants.ts | 157 ++++++ packages/control-plane/src/db/teams.ts | 9 + .../src/router.create-session.test.ts | 66 ++- .../control-plane/src/router.policy.test.ts | 6 +- .../src/router.spawn-child-grants.test.ts | 239 ++++++++++ .../src/router.spawn-child.test.ts | 34 +- .../src/routes/automation-create.test.ts | 17 + .../src/routes/automation-crud.ts | 97 +++- .../src/routes/automation-update.test.ts | 239 +++++++++- .../src/routes/automation-validation.test.ts | 99 ++++ .../src/routes/automation-validation.ts | 47 +- .../src/routes/automations.test-support.ts | 3 + ...ironment-secrets.repository-grants.test.ts | 435 +++++++++++++++++ .../src/routes/environment-secrets.ts | 29 +- .../src/routes/environments-catalog.test.ts | 306 ++++++++++++ .../routes/environments-target-denied.test.ts | 318 +++++++++++++ .../control-plane/src/routes/environments.ts | 118 ++++- .../image-builds.repository-grants.test.ts | 409 ++++++++++++++++ .../control-plane/src/routes/image-builds.ts | 74 ++- packages/control-plane/src/routes/repos.ts | 47 +- .../routes/repository-grants.test-support.ts | 93 ++++ .../routes/secrets.repository-grants.test.ts | 157 ++++++ packages/control-plane/src/routes/secrets.ts | 16 + .../src/routes/session-child-spawn.ts | 26 +- .../src/routes/session-create.ts | 34 +- .../session-target-authorization.test.ts | 241 ++++++++++ .../routes/session-target-authorization.ts | 49 +- packages/control-plane/src/routes/shared.ts | 2 +- .../routes/skills.repository-grants.test.ts | 435 +++++++++++++++++ packages/control-plane/src/routes/skills.ts | 99 +++- packages/control-plane/src/routes/teams.ts | 88 +++- ...workspace-repository-authorization.test.ts | 362 ++++++++++++++ .../workspace-repository-authorization.ts | 93 ++++ .../src/routing/route-admission.ts | 17 +- .../src/skills/git-import.test.ts | 48 +- .../control-plane/src/skills/git-import.ts | 49 +- ...ono-route-catalog-conformance.test.ts.snap | 249 +++++----- .../route-admission-matrix.test.ts.snap | 19 +- .../automation-authorization.test.ts | 62 +++ ...environment-secret-import-identity.test.ts | 179 +++++++ .../integration/environments-catalog.test.ts | 167 +++++++ .../integration/environments-routes.test.ts | 28 +- .../hono-route-catalog-conformance.test.ts | 2 +- .../test/integration/spawn-children.test.ts | 44 +- .../test/integration/team-grants.test.ts | 289 +++++++++++ packages/shared/src/types/audit-events.ts | 2 + .../shared/src/types/repository-catalog.ts | 1 + packages/shared/src/types/teams.ts | 41 +- packages/web/src/app/api/repos/route.test.ts | 25 +- packages/web/src/app/api/repos/route.ts | 5 +- .../repository-grants/[grantId]/route.test.ts | 41 ++ .../[id]/repository-grants/[grantId]/route.ts | 7 + .../[id]/repository-grants/route.test.ts | 81 ++++ .../api/teams/[id]/repository-grants/route.ts | 6 + .../components/session-target-picker.test.tsx | 121 +++++ .../src/components/session-target-picker.tsx | 27 +- .../settings/audit-log-settings.test.tsx | 4 +- .../settings/audit-log-settings.tsx | 2 + .../web/src/components/teams/team-page.tsx | 6 +- .../teams/team-repositories.test.tsx | 351 ++++++++++++++ .../components/teams/team-repositories.tsx | 280 +++++++++++ .../src/components/teams/teams-pages.test.tsx | 50 +- packages/web/src/hooks/use-repos.test.tsx | 16 + packages/web/src/hooks/use-repos.ts | 2 + .../hooks/use-session-target-picker.test.ts | 447 +++++++++++++++++- .../src/hooks/use-session-target-picker.ts | 120 +++-- .../src/hooks/use-warm-draft-session.test.tsx | 114 +++++ .../web/src/hooks/use-warm-draft-session.ts | 12 +- 71 files changed, 7214 insertions(+), 351 deletions(-) create mode 100644 packages/control-plane/src/db/team-repository-grants.test.ts create mode 100644 packages/control-plane/src/router.spawn-child-grants.test.ts create mode 100644 packages/control-plane/src/routes/automation-validation.test.ts create mode 100644 packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts create mode 100644 packages/control-plane/src/routes/environments-catalog.test.ts create mode 100644 packages/control-plane/src/routes/environments-target-denied.test.ts create mode 100644 packages/control-plane/src/routes/image-builds.repository-grants.test.ts create mode 100644 packages/control-plane/src/routes/repository-grants.test-support.ts create mode 100644 packages/control-plane/src/routes/secrets.repository-grants.test.ts create mode 100644 packages/control-plane/src/routes/session-target-authorization.test.ts create mode 100644 packages/control-plane/src/routes/skills.repository-grants.test.ts create mode 100644 packages/control-plane/src/routes/workspace-repository-authorization.test.ts create mode 100644 packages/control-plane/src/routes/workspace-repository-authorization.ts create mode 100644 packages/control-plane/test/integration/environment-secret-import-identity.test.ts create mode 100644 packages/control-plane/test/integration/environments-catalog.test.ts create mode 100644 packages/control-plane/test/integration/team-grants.test.ts create mode 100644 packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts create mode 100644 packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts create mode 100644 packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts create mode 100644 packages/web/src/app/api/teams/[id]/repository-grants/route.ts create mode 100644 packages/web/src/components/session-target-picker.test.tsx create mode 100644 packages/web/src/components/teams/team-repositories.test.tsx create mode 100644 packages/web/src/components/teams/team-repositories.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c8ad0568c..7248b0b326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,22 @@ terminal access or per-session caches. Visibility changes require a changed sele non-private child-session cascades. Workspace audit readers can filter by teams they do not belong to. +### Added + +Teams now have a Repositories tab. Members can view grants; team leads and workspace administrators +can grant all installation repositories or select named repositories, and remove grants. Team-scoped +repository catalogs and repository-bearing writes check these grants, with explicit missing-grant +errors and audited grant changes. Workspace-level session catalogs remain installation-wide. Grant +changes advance the team's grant version but do not yet narrow or revoke sandbox installation +tokens. + +Workspace-level skills, repository secrets, and image builds retain existing permissions on +repositories granted to no team. Team-owned repositories additionally require membership (lead +membership for repository secrets), or workspace Owner/Administrator access, in every enforcement +mode. Manual team-owned environment builds require access to the owning team. Hidden and missing +team requests now record identical denied authorization decisions without changing their 404 +responses. + ## September 30, 2026 ### Added diff --git a/docs/AUTH.md b/docs/AUTH.md index 26d241f797..db16011670 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -199,9 +199,28 @@ selection; team selection in bots is a later phase, so their teamless creation A refused when the setting is enabled. Automation runs are exempt until automation team ownership is supported. The setting does not migrate or hide existing `ownerTeamId: null` workspace rows. -There is no repository-grant creation API or UI yet. Repository-backed team sessions without -existing grants are refused with `target_team_missing_grant`; creating a team does not grant it -repository access. Repository-less team sessions do not need repository grants. +Team leads and workspace Owners/Administrators manage repository grants in the team's Repositories +tab or through `/teams/:id/repository-grants`. Team members and workspace Owners/Administrators can +read the grants. A team can have either installation-wide access or named grants by SCM repository +ID, but not both. Creating a team does not grant repository access. Repository-backed team sessions +without covering grants are refused with `target_team_missing_grant`. Repository-less team sessions +do not need grants. Removing a grant advances the team's grant version and leaves existing +repository references intact; grants do not yet narrow or revoke sandbox installation tokens. + +Repository skills, repository secrets, and repository image builds remain workspace-level resources; +grants do not assign them to an owning team. They keep their existing permission checks when no team +grants the repository. Once any team grants it, callers must be current members of an active +granting team (leads for repository secrets), or be a workspace Owner or Administrator. Installation +grants count for every repository. Importing repository secrets into an environment checks the +source repository's workspace-level grant access as well as the destination owning team's coverage, +if the environment has an owning team. These checks apply in every `TEAMS_ENFORCEMENT` mode. + +Manual environment image builds instead follow the environment's owning team. For a team-owned +environment, the caller must be a current member of that team or a workspace Owner/Administrator, +and the active owning team must have grants covering every current repository in the environment. +Membership or grants in another team cannot replace that coverage, including for Owners and +Administrators. Workspace-level environment builds keep their existing checks. These rules apply in +every `TEAMS_ENFORCEMENT` mode. Sessions, automations, and environments cannot move between teams or between a team and the workspace. A session's owning team is fixed at creation: a team-owned session never becomes diff --git a/packages/control-plane/src/db/team-repository-grants.test.ts b/packages/control-plane/src/db/team-repository-grants.test.ts new file mode 100644 index 0000000000..42b965c894 --- /dev/null +++ b/packages/control-plane/src/db/team-repository-grants.test.ts @@ -0,0 +1,166 @@ +import { DatabaseSync } from "node:sqlite"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { applyMigrations } from "../node/migrate"; +import { createNodeSqlDatabase, type NodeSqlDatabase } from "../node/sqlite-database"; +import { TeamStore } from "./teams"; +import { TeamRepositoryGrantStore } from "./team-repository-grants"; + +describe("team repository grant writes", () => { + let db: NodeSqlDatabase; + let store: TeamRepositoryGrantStore; + let teamId: string; + + beforeEach(async () => { + const sqlite = new DatabaseSync(":memory:"); + applyMigrations( + sqlite, + resolve(dirname(fileURLToPath(import.meta.url)), "../../../../terraform/d1/migrations") + ); + db = createNodeSqlDatabase(sqlite); + store = new TeamRepositoryGrantStore(db); + teamId = ( + await new TeamStore(db).create({ + slug: "engineering", + name: "Engineering", + joinPolicy: "invite_only", + }) + ).id; + }); + afterEach(() => db.close()); + + const named = (repoExternalId = 1) => ({ + kind: "repository" as const, + repoExternalId, + owner: "acme", + name: `repo-${repoExternalId}`, + }); + + it("adds display-ready grants and bumps the version only for actual writes", async () => { + const grant = await store.add(teamId, named()); + expect(grant).toMatchObject({ + teamId, + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + expect(await store.add(teamId, named())).toEqual(grant); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + expect(await store.remove(teamId, grant.id)).toBe(true); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(2); + expect(await store.remove(teamId, grant.id)).toBe(false); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(2); + }); + + it.each(["installation", "repository"] as const)( + "refuses mixing %s with the other grant kind", + async (kind) => { + await store.add(teamId, kind === "installation" ? { kind } : named()); + await expect( + store.add(teamId, kind === "installation" ? named() : { kind: "installation" }) + ).rejects.toThrow("Remove the existing grants before changing grant kind"); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + } + ); + + it("refuses the 501st named grant, including concurrent additions at the cap", async () => { + await db.batch( + Array.from({ length: 499 }, (_, i) => + db + .prepare( + "INSERT INTO team_repository_grants (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) VALUES (?, ?, 'repository', ?, 'acme', ?, 1)" + ) + .bind(`grant-${i}`, teamId, i + 1, `repo-${i + 1}`) + ) + ); + const results = await Promise.allSettled([ + store.add(teamId, named(500)), + store.add(teamId, named(501)), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(await store.listForTeam(teamId)).toHaveLength(500); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + }); + + it("validates the discriminated request before writing", async () => { + await expect(store.add(teamId, { ...named(), repoExternalId: -1 })).rejects.toThrow(); + expect(await store.listForTeam(teamId)).toEqual([]); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(0); + }); + + it("does not delete another team's grant", async () => { + const grant = await store.add(teamId, named()); + const other = await new TeamStore(db).create({ + slug: "other", + name: "Other", + joinPolicy: "open", + }); + expect(await store.remove(other.id, grant.id)).toBe(false); + expect((await new TeamStore(db).getById(other.id))?.grantsVersion).toBe(0); + expect(await store.covers(teamId, [1])).toBe(true); + expect(await store.covers(teamId, [null])).toBe(false); + }); + + it("does not mutate grants on an archived team", async () => { + const grant = await store.add(teamId, named()); + await new TeamStore(db).archive(teamId); + expect(await store.remove(teamId, grant.id)).toBe(false); + await expect(store.add(teamId, named(2))).rejects.toThrow("Team is not active"); + expect(await store.covers(teamId, [1])).toBe(true); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(1); + }); + + it("covers unresolved repository rows only with an installation grant", async () => { + await store.add(teamId, { kind: "installation" }); + expect(await store.covers(teamId, [null, 2])).toBe(true); + }); + + it("leaves repositories workspace-level when no grant names them", async () => { + expect(await store.listTeamsForRepository(1)).toEqual([]); + await store.add(teamId, named(2)); + expect(await store.listTeamsForRepository(1)).toEqual([]); + expect(await store.listTeamsForRepository(2)).toEqual([teamId]); + }); + + it("finds every granting team by numeric identity, including installation grants", async () => { + const other = await new TeamStore(db).create({ + slug: "other", + name: "Other", + joinPolicy: "open", + }); + await store.add(teamId, named(1)); + await store.add(other.id, { kind: "installation" }); + expect(await store.listTeamsForRepository(1)).toEqual([other.id, teamId].sort()); + expect(await store.listTeamsForRepository(999)).toEqual([other.id]); + }); + + it("does not turn a retained archived-team grant into workspace-wide access", async () => { + await store.add(teamId, named(1)); + await new TeamStore(db).archive(teamId); + expect(await store.listTeamsForRepository(1)).toEqual([teamId]); + }); + + it("rolls mutation, version and domain audit back together", async () => { + const failingStore = new TeamRepositoryGrantStore({ + prepare: (sql) => db.prepare(sql), + batch: (statements) => + db.batch([...statements, db.prepare("INSERT INTO missing_table VALUES (1)")]), + }); + await expect( + failingStore.add(teamId, named(), { actorUserId: "actor", requestId: "request" }) + ).rejects.toThrow(); + expect(await store.listForTeam(teamId)).toEqual([]); + expect((await new TeamStore(db).getById(teamId))?.grantsVersion).toBe(0); + expect( + await db + .prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events WHERE action = 'team.grant_added'" + ) + .first() + ).toEqual({ count: 0 }); + }); +}); diff --git a/packages/control-plane/src/db/team-repository-grants.ts b/packages/control-plane/src/db/team-repository-grants.ts index 4d2fe2b0b1..34bbe1c2ce 100644 --- a/packages/control-plane/src/db/team-repository-grants.ts +++ b/packages/control-plane/src/db/team-repository-grants.ts @@ -1,4 +1,13 @@ import { z } from "zod"; +import { + addTeamRepositoryGrantRequestSchema, + MAX_TEAM_REPOSITORY_GRANTS, + teamRepositoryGrantSchema, + type AddTeamRepositoryGrantRequest, + type TeamRepositoryGrant, +} from "@open-inspect/shared/types/teams"; +import { generateId } from "../auth/crypto"; +import { TeamAuditStore } from "./team-audit"; import type { SqlDatabase } from "./sql-database"; const grantSchema = z.object({ @@ -6,6 +15,32 @@ const grantSchema = z.object({ repo_external_id: z.number().nullable(), }); +export class TeamRepositoryGrantConflictError extends Error { + constructor(readonly code: "grant_kind_conflict" | "repository_grant_limit" | "team_not_active") { + super( + code === "grant_kind_conflict" + ? "Remove the existing grants before changing grant kind" + : code === "repository_grant_limit" + ? "Team repository grant limit reached" + : "Team is not active" + ); + } +} + +type GrantActor = { actorUserId: string; requestId: string }; + +function toGrant(row: Record): TeamRepositoryGrant { + return teamRepositoryGrantSchema.parse({ + id: row.id, + teamId: row.team_id, + kind: row.grant_kind, + repoExternalId: row.repo_external_id, + owner: row.repo_owner, + name: row.repo_name, + createdAt: row.created_at, + }); +} + export class TeamRepositoryGrantStore { constructor(private readonly db: SqlDatabase) {} @@ -17,6 +52,128 @@ export class TeamRepositoryGrantStore { return z.array(grantSchema).parse(rows.results); } + async listDetailsForTeam(teamId: string): Promise { + const rows = await this.db + .prepare("SELECT * FROM team_repository_grants WHERE team_id = ? ORDER BY created_at, id") + .bind(teamId) + .all(); + return rows.results.map(toGrant); + } + + async listTeamsForRepository(repoId: number): Promise { + const rows = await this.db + .prepare( + `SELECT DISTINCT team_id FROM team_repository_grants + WHERE grant_kind = 'installation' OR (grant_kind = 'repository' AND repo_external_id = ?) + ORDER BY team_id` + ) + .bind(repoId) + .all(); + return z + .array(z.object({ team_id: z.string() })) + .parse(rows.results) + .map((row) => row.team_id); + } + + async add( + teamId: string, + input: AddTeamRepositoryGrantRequest, + actor?: GrantActor + ): Promise { + const body = addTeamRepositoryGrantRequestSchema.parse(input); + const grant = teamRepositoryGrantSchema.parse({ + id: `grant_${generateId()}`, + teamId, + kind: body.kind, + repoExternalId: body.kind === "repository" ? body.repoExternalId : null, + owner: body.kind === "repository" ? body.owner : null, + name: body.kind === "repository" ? body.name : null, + createdAt: Date.now(), + }); + const statements = [ + this.db + .prepare( + `INSERT INTO team_repository_grants + (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) + SELECT ?, id, ?, ?, ?, ?, ? FROM teams + WHERE id = ? AND archived_at IS NULL + AND NOT EXISTS (SELECT 1 FROM team_repository_grants WHERE team_id = ? AND grant_kind != ?) + AND (SELECT COUNT(*) FROM team_repository_grants WHERE team_id = ?) < ? + ON CONFLICT DO NOTHING` + ) + .bind( + grant.id, + grant.kind, + grant.repoExternalId, + grant.owner, + grant.name, + grant.createdAt, + teamId, + teamId, + body.kind, + teamId, + MAX_TEAM_REPOSITORY_GRANTS + ), + this.db + .prepare( + "UPDATE teams SET grants_version = grants_version + 1, updated_at = ? WHERE id = ? AND changes() = 1" + ) + .bind(grant.createdAt, teamId), + ]; + if (actor) { + statements.push( + new TeamAuditStore(this.db).bind( + { ...actor, teamId, action: "team.grant_added", before: {}, after: grant }, + true + ) + ); + } + const [inserted] = await this.db.batch(statements); + if (inserted.meta.changes === 1) return grant; + + const grants = await this.listDetailsForTeam(teamId); + const existing = grants.find( + (row) => row.kind === body.kind && row.repoExternalId === grant.repoExternalId + ); + if (existing) return existing; + if (grants.some((row) => row.kind !== body.kind)) + throw new TeamRepositoryGrantConflictError("grant_kind_conflict"); + if (grants.length >= MAX_TEAM_REPOSITORY_GRANTS) + throw new TeamRepositoryGrantConflictError("repository_grant_limit"); + throw new TeamRepositoryGrantConflictError("team_not_active"); + } + + async remove(teamId: string, grantId: string, actor?: GrantActor): Promise { + const row = await this.db + .prepare("SELECT * FROM team_repository_grants WHERE team_id = ? AND id = ?") + .bind(teamId, grantId) + .first(); + if (!row) return false; + const before = toGrant(row); + const statements = [ + this.db + .prepare( + `DELETE FROM team_repository_grants WHERE team_id = ? AND id = ? + AND EXISTS (SELECT 1 FROM teams WHERE id = ? AND archived_at IS NULL)` + ) + .bind(teamId, grantId, teamId), + this.db + .prepare( + "UPDATE teams SET grants_version = grants_version + 1, updated_at = ? WHERE id = ? AND changes() = 1" + ) + .bind(Date.now(), teamId), + ]; + if (actor) + statements.push( + new TeamAuditStore(this.db).bind( + { ...actor, teamId, action: "team.grant_removed", before, after: {} }, + true + ) + ); + const [deleted] = await this.db.batch(statements); + return deleted.meta.changes === 1; + } + async covers(teamId: string, repoIds: readonly (number | null)[]): Promise { if (repoIds.length === 0) return true; const grants = await this.listForTeam(teamId); diff --git a/packages/control-plane/src/db/teams.ts b/packages/control-plane/src/db/teams.ts index 6253ee4f40..4dea537f3d 100644 --- a/packages/control-plane/src/db/teams.ts +++ b/packages/control-plane/src/db/teams.ts @@ -46,6 +46,15 @@ export class TeamStore { return row ? toTeam(row) : null; } + async isActive(id: string): Promise { + return ( + (await this.db + .prepare("SELECT 1 AS ok FROM teams WHERE id = ? AND archived_at IS NULL") + .bind(id) + .first()) !== null + ); + } + async getBySlug(slug: string): Promise { const row = await this.db.prepare("SELECT * FROM teams WHERE slug = ?").bind(slug).first(); return row ? toTeam(row) : null; diff --git a/packages/control-plane/src/router.create-session.test.ts b/packages/control-plane/src/router.create-session.test.ts index a71e3c472e..fc3a6b98e5 100644 --- a/packages/control-plane/src/router.create-session.test.ts +++ b/packages/control-plane/src/router.create-session.test.ts @@ -90,6 +90,7 @@ describe("handleCreateSession D1 ordering", () => { afterEach(() => vi.restoreAllMocks()); beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); vi.mocked(resolveManagedSkills).mockResolvedValue({ selection: { mode: "all" }, resolverVersion: 1, @@ -232,7 +233,23 @@ describe("handleCreateSession D1 ordering", () => { }; } - it("rejects a team session when its repository is not granted", async () => { + it.each([ + { + target: "scalar repository", + body: { repoOwner: "acme", repoName: "web-app" }, + repository: "acme/web-app", + }, + { + target: "repository list", + body: { repositories: [{ repoOwner: "acme", repoName: "widgets" }] }, + repository: "acme/widgets", + }, + { + target: "environment", + body: { environmentId: "env_1" }, + repository: "acme/environment-repo", + }, + ])("rejects a team session when its $target is not granted", async ({ body, repository }) => { vi.spyOn(TeamStore.prototype, "getById").mockResolvedValue({ id: "team_alpha", slug: "alpha", @@ -250,16 +267,51 @@ describe("handleCreateSession D1 ordering", () => { new Map([["team_alpha", "member"]]) ); vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); - const response = await createSessionRequestWithBody(createEnv(vi.fn()), { + const initFetch = vi.fn(); + const create = vi.fn(); + vi.mocked(SessionIndexStore).mockImplementation(function () { + return { create } as never; + }); + const response = await createSessionRequestWithBody(createEnv(initFetch), { teamId: "team_alpha", - repoOwner: "acme", - repoName: "web-app", + ...body, }); expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ code: "target_team_missing_grant", - repository: "acme/web-app", + repository, + }); + expect(create).not.toHaveBeenCalled(); + expect(initFetch).not.toHaveBeenCalled(); + }); + + it("checks membership before disclosing team repository grants", 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()); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + const initFetch = vi.fn(); + const response = await createSessionRequestWithBody(createEnv(initFetch), { + teamId: "team_alpha", + repoOwner: "acme", + repoName: "web-app", + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "not_member" }); + expect(grants).not.toHaveBeenCalled(); + expect(initFetch).not.toHaveBeenCalled(); }); it("uses the team's default visibility when creating an owned session", async () => { @@ -364,6 +416,10 @@ describe("handleCreateSession D1 ordering", () => { permission: deniedPermission, }); expect(create).not.toHaveBeenCalled(); + expect(initFetch).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(resolveSessionRepositories).not.toHaveBeenCalled(); + expect(resolveEnvironmentTarget).not.toHaveBeenCalled(); } else { expect(create).toHaveBeenCalledOnce(); } diff --git a/packages/control-plane/src/router.policy.test.ts b/packages/control-plane/src/router.policy.test.ts index 00ec322a57..870127e8ba 100644 --- a/packages/control-plane/src/router.policy.test.ts +++ b/packages/control-plane/src/router.policy.test.ts @@ -24,11 +24,11 @@ describe("route policy table", () => { }); it("publishes the complete canonical route catalog", () => { - expect(routes).toHaveLength(203); + expect(routes).toHaveLength(206); const paths = routes.map((route) => route.path); - expect(new Set(paths).size).toBe(154); - expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(203); + expect(new Set(paths).size).toBe(156); + expect(new Set(routes.map((route) => `${route.method}:${route.path}`)).size).toBe(206); }); it("gates run analytics with analytics.read", () => { diff --git a/packages/control-plane/src/router.spawn-child-grants.test.ts b/packages/control-plane/src/router.spawn-child-grants.test.ts new file mode 100644 index 0000000000..4c17bbf2e2 --- /dev/null +++ b/packages/control-plane/src/router.spawn-child-grants.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + authorizationDatabase, + createTestEnv, + fakeSessionRuntimeDispatch, + handleRequest, + signedServiceRequest, + TEST_BACKGROUND_TASK_CONTEXT, + TEST_SERVICE_SECRETS, +} from "./router.test-support"; +import { createRequestMetrics } from "./db/instrumented-sql-database"; +import { SessionIndexStore } from "./db/session-index"; +import { TeamRepositoryGrantStore } from "./db/team-repository-grants"; +import { TeamStore } from "./db/teams"; +import { TeamMembershipStore } from "./db/team-memberships"; +import { handleSpawnChild } from "./routes/session-child-spawn"; +import { withSessionRuntime } from "./routes/session-route"; +import { HttpError, resolveRepoOrError } from "./routes/shared"; +import type * as SharedRoutes from "./routes/shared"; +import * as integrationSettings from "./session/integration-settings-resolution"; +import type { SpawnContext } from "./session/spawn-context"; +import type { Env } from "./types"; + +vi.mock("./db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return { getIdentity: async () => ({ userId: "canonical-user-123" }) }; + }), +})); + +vi.mock("./routes/shared", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveRepoOrError: vi.fn() }; +}); + +describe("handleSpawnChild repository grants", () => { + const parentId = "parent-session-1"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "member"]]) + ); + vi.spyOn(SessionIndexStore.prototype, "getSpawnDepth").mockResolvedValue(0); + vi.spyOn(SessionIndexStore.prototype, "countTotalChildren").mockResolvedValue(0); + vi.spyOn(integrationSettings, "resolveSandboxSettings").mockResolvedValue({}); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + function makeGrantFixture( + repoId = 12345, + environmentId: string | null = null, + hasRepository = true + ) { + const spawnContext: SpawnContext = { + repoOwner: hasRepository ? "acme" : null, + repoName: hasRepository ? "web-app" : null, + repoId: hasRepository ? repoId : null, + harness: "opencode", + model: "anthropic/claude-sonnet-4-6", + reasoningEffort: null, + sandboxTimeoutMs: 14_400_000, + baseBranch: "main", + promptAuthor: { + userId: "user-1", + canonicalUserId: "canonical-user-123", + scmUserId: "12345", + scmLogin: "acmedev", + scmName: "Acme Dev", + scmEmail: "dev@acme.test", + }, + }; + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue({ + id: parentId, + title: null, + userId: null, + repoOwner: spawnContext.repoOwner, + repoName: spawnContext.repoName, + model: spawnContext.model, + reasoningEffort: spawnContext.reasoningEffort, + baseBranch: spawnContext.baseBranch, + status: "active", + ownerTeamId: "team_alpha", + visibility: "workspace", + environmentId, + createdAt: 1, + updatedAt: 1, + }); + const store = { + acquireChildAdmissionLease: vi + .spyOn(SessionIndexStore.prototype, "acquireChildAdmissionLease") + .mockResolvedValue(null), + create: vi.spyOn(SessionIndexStore.prototype, "create").mockResolvedValue(undefined), + }; + const childStub = { + fetch: vi.fn<(request: Request) => Promise>(), + }; + const env = createTestEnv({ + ...TEST_SERVICE_SECRETS, + SCM_PROVIDER: "github", + DB: authorizationDatabase({ + userId: "canonical-user-123", + permissions: [ + "sessions.read", + "sessions.create", + "repositories.use", + "environments.use", + "sessions.collaborate", + ], + }), + SESSION: fakeSessionRuntimeDispatch(async (request, sessionId) => + sessionId === parentId ? Response.json(spawnContext) : childStub.fetch(request) + ), + }); + return { store, env, childStub }; + } + + async function makeRequest(env: Env): Promise { + return handleRequest( + await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { + method: "POST", + body: JSON.stringify({ title: "Child task", prompt: "Do the thing" }), + service: "linear-bot", + actor: "linear:U1", + }), + env, + TEST_BACKGROUND_TASK_CONTEXT + ); + } + + it("denies a child whose inherited repository is not granted to its parent team", async () => { + const { store, env, childStub } = makeGrantFixture(); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const response = await makeRequest(env); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/web-app", + }); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + }); + + it.each(["service", "sandbox"] as const)( + "rejects a repo-less %s child after the parent team is archived", + async (principal) => { + const { store, env, childStub } = makeGrantFixture(12345, null, false); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + const response = + principal === "service" + ? await makeRequest(env) + : await handleSpawnChild( + new Request(`https://test.local/sessions/${parentId}/children`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Child", prompt: "Do the thing" }), + }), + env, + { id: parentId }, + withSessionRuntime(env, { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: env.DB, + principal: { kind: "sandbox", sessionId: parentId }, + }) + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ code: "team_not_active" }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + } + ); + + it("checks sandbox child grants with the current SCM ID instead of a stale inherited ID", async () => { + const { store, env: fixture, childStub } = makeGrantFixture(999, "env_parent"); + const covers = vi + .spyOn(TeamRepositoryGrantStore.prototype, "covers") + .mockImplementation(async (_teamId, ids) => ids.every((id) => id === 999)); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + const env = createTestEnv({ SESSION: fixture.SESSION }); + const ctx = withSessionRuntime(env, { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: env.DB, + principal: { kind: "sandbox", sessionId: parentId }, + }); + + const response = await handleSpawnChild( + new Request(`https://test.local/sessions/${parentId}/children`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Child", prompt: "Do the thing" }), + }), + env, + { id: parentId }, + ctx + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ code: "target_team_missing_grant" }); + expect(resolveRepoOrError).toHaveBeenCalledWith(env, "acme", "web-app", ctx, expect.anything()); + expect(covers).toHaveBeenCalledWith("team_alpha", [12345]); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + }); + + it("does not fall back to an inherited ID when SCM resolution fails for a team child", async () => { + const { store, env, childStub } = makeGrantFixture(); + vi.mocked(resolveRepoOrError).mockRejectedValue(new HttpError("Repository not installed", 404)); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const response = await makeRequest(env); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ error: "Repository not installed" }); + expect(grants).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); + expect(store.create).not.toHaveBeenCalled(); + expect(childStub.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts index 9d3a5a0fd1..eaeef49743 100644 --- a/packages/control-plane/src/router.spawn-child.test.ts +++ b/packages/control-plane/src/router.spawn-child.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { HarnessId } from "@open-inspect/shared/harnesses"; import { fakeSessionRuntimeDispatch, @@ -9,6 +9,9 @@ import { } from "./router.test-support"; import { getEffectiveEnabledModels } from "./db/model-preferences"; import { SessionIndexStore } from "./db/session-index"; +import { TeamMembershipStore } from "./db/team-memberships"; +import { resolveRepoOrError } from "./routes/shared"; +import type * as SharedRoutes from "./routes/shared"; import { SessionInternalPaths } from "./session/contracts"; const integrationSettingsMocks = vi.hoisted(() => ({ @@ -33,6 +36,11 @@ vi.mock("./db/user-store", () => ({ vi.mock("./session/integration-settings-resolution", () => integrationSettingsMocks); +vi.mock("./routes/shared", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveRepoOrError: vi.fn() }; +}); + describe("handleSpawnChild prompt enqueue handling", () => { const parentId = "parent-session-1"; @@ -97,16 +105,17 @@ describe("handleSpawnChild prompt enqueue handling", () => { const makeStore = ( parentUserId: string | null = null, context: typeof spawnContext = spawnContext, - environmentId: string | null = "env_parent" + environmentId: string | null = "env_parent", + ownerTeamId: string | null = null ) => ({ get: vi.fn().mockResolvedValue({ id: parentId, userId: parentUserId, - ownerTeamId: null, visibility: "workspace", repoOwner: context.repoOwner, repoName: context.repoName, environmentId, + ownerTeamId, }), getSpawnDepth: vi.fn().mockResolvedValue(0), getCompleteProviderAuth: vi.fn().mockResolvedValue(parentProviderAuth), @@ -123,11 +132,15 @@ describe("handleSpawnChild prompt enqueue handling", () => { beforeEach(() => { vi.clearAllMocks(); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team_alpha", "member"]]) + ); vi.mocked(getEffectiveEnabledModels).mockResolvedValue(["anthropic/claude-sonnet-4-6"]); integrationSettingsMocks.resolveCodeServerEnabled.mockResolvedValue(false); integrationSettingsMocks.resolveVncEnabled.mockResolvedValue(false); integrationSettingsMocks.resolveSandboxSettings.mockResolvedValue({}); }); + afterEach(() => vi.restoreAllMocks()); it("copies the exact parent provider auth snapshot with immediate inheritance", async () => { const store = makeStore(); @@ -247,11 +260,15 @@ describe("handleSpawnChild prompt enqueue handling", () => { } it("rejects a repository-backed child when the actor cannot use repositories", async () => { - const store = makeStore(null, spawnContext, null); + const store = makeStore(null, spawnContext, null, "team_alpha"); vi.mocked(SessionIndexStore).mockImplementation(function () { return store as never; }); - const { env } = makeSuccessfulEnv(spawnContext, ["sessions.create", "sessions.collaborate"]); + const { env } = makeSuccessfulEnv(spawnContext, [ + "sessions.read", + "sessions.create", + "sessions.collaborate", + ]); const response = await makeRequest(env); @@ -261,14 +278,17 @@ describe("handleSpawnChild prompt enqueue handling", () => { permission: "repositories.use", }); expect(store.create).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); }); it("rejects an environment-backed child when the actor cannot use environments", async () => { - const store = makeStore(); + const store = makeStore(null, spawnContext, "env_parent", "team_alpha"); vi.mocked(SessionIndexStore).mockImplementation(function () { return store as never; }); const { env } = makeSuccessfulEnv(spawnContext, [ + "sessions.read", "sessions.create", "sessions.collaborate", "repositories.use", @@ -282,6 +302,8 @@ describe("handleSpawnChild prompt enqueue handling", () => { permission: "environments.use", }); expect(store.create).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(store.acquireChildAdmissionLease).not.toHaveBeenCalled(); }); async function getInitBody(childStub: DurableObjectStub) { diff --git a/packages/control-plane/src/routes/automation-create.test.ts b/packages/control-plane/src/routes/automation-create.test.ts index 0cfdab9322..422f3f1fe4 100644 --- a/packages/control-plane/src/routes/automation-create.test.ts +++ b/packages/control-plane/src/routes/automation-create.test.ts @@ -143,6 +143,23 @@ describe("automation create route", () => { instructions: "Run tests", }; + it("denies repository targets before SCM lookup or persistence", async () => { + const res = await callRoute("POST", "/automations", { + body: validBody, + permissions: PERMISSION_IDS.filter((permission) => permission !== "repositories.use"), + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "repositories.use", + }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + it("rejects a Slack channel condition with the wrong operator", async () => { const res = await callRoute("POST", "/automations", { body: { diff --git a/packages/control-plane/src/routes/automation-crud.ts b/packages/control-plane/src/routes/automation-crud.ts index 01f49b53b1..812f171b27 100644 --- a/packages/control-plane/src/routes/automation-crud.ts +++ b/packages/control-plane/src/routes/automation-crud.ts @@ -20,7 +20,6 @@ import { updateAutomationRequestSchema, } from "@open-inspect/shared/types/automations"; import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts"; -import type { PermissionId } from "@open-inspect/shared/rbac"; import { checkHarnessCompatibility, getValidHarnessOrDefault, @@ -34,6 +33,7 @@ import { type AutomationRepositoryInsert, } from "../db/automation-store"; import { SlackChannelStore } from "../db/slack-channel-store"; +import { EnvironmentStore } from "../db/environments"; import { AutomationModelProviderAuthStore, toProviderSelections, @@ -75,13 +75,14 @@ import { getEnvironmentSelection, getRepositorySelection, getTriggerEventTypeError, - requireTargetPermissions, resolveEnvironmentSelection, resolveReasoningEffort, resolveRepositorySelection, validateSlackTriggerConfig, validateTargetCounts, } from "./automation-validation"; +import { authorizeSessionTarget } from "./session-target-authorization"; +import { authorizeTeamRepositories } from "./workspace-repository-authorization"; const logger = createLogger("router:automations"); @@ -154,13 +155,19 @@ async function handleCreateAutomation( if (e instanceof TargetSelectionError) return error(e.message, 400); throw e; } - if (ctx.principal?.kind === "user") { - const targetAuthorizationError = requireTargetPermissions(ctx, [ - ...(requestedRepositories.length > 0 ? (["repositories.use"] as const) : []), - ...(requestedEnvironmentIds.length > 0 ? (["environments.use"] as const) : []), - ]); - if (targetAuthorizationError) return targetAuthorizationError; - } + const repositoryAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: requestedRepositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })), + }); + if (repositoryAuthorizationError) return repositoryAuthorizationError; + const environmentAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + environmentId: requestedEnvironmentIds[0], + }); + if (environmentAuthorizationError) return environmentAuthorizationError; try { await resolveEnvironmentSelection(ctx.db, requestedEnvironmentIds); } catch (e) { @@ -218,7 +225,8 @@ async function handleCreateAutomation( return error("Invalid reasoning effort for selected model", 400); } - const newRepositories = await resolveRepositorySelection(env, requestedRepositories, ctx); + const newRepositories = await resolveRepositorySelection(env, requestedRepositories, ctx, null); + if (newRepositories instanceof Response) return newRepositories; let providerSelections: ModelProviderSelections; try { @@ -511,18 +519,23 @@ async function handleUpdateAutomation( // it simply applies from the next invocation. const selection = getRepositorySelection(body); const environmentSelection = getEnvironmentSelection(body); - const requiredTargetPermissions: PermissionId[] = [ - ...(selection.kind === "replace" && selection.repositories.length > 0 - ? (["repositories.use"] as const) - : []), - ...(environmentSelection.kind === "replace" && environmentSelection.environmentIds.length > 0 - ? (["environments.use"] as const) - : []), - ]; - if (requiredTargetPermissions.length > 0) { - const targetAuthorizationError = requireTargetPermissions(ctx, requiredTargetPermissions); - if (targetAuthorizationError) return targetAuthorizationError; - } + const repositoryAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: + selection.kind === "replace" + ? selection.repositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })) + : undefined, + }); + if (repositoryAuthorizationError) return repositoryAuthorizationError; + const environmentAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + environmentId: + environmentSelection.kind === "replace" ? environmentSelection.environmentIds[0] : undefined, + }); + if (environmentAuthorizationError) return environmentAuthorizationError; // The count rules span both selections, so when EITHER is replaced they are // validated against the automation's FINAL state (the replacement plus the @@ -533,11 +546,11 @@ async function handleUpdateAutomation( const replacementEnvironmentIds: string[] | null = environmentSelection.kind === "replace" ? environmentSelection.environmentIds : null; if (selection.kind === "replace" || replacementEnvironmentIds !== null) { + const existingRepositories = + selection.kind === "unchanged" ? await store.getRepositoriesForAutomation(id) : []; try { const finalRepositoryCount = - selection.kind === "replace" - ? selection.repositories.length - : (await store.getRepositoriesForAutomation(id)).length; + selection.kind === "replace" ? selection.repositories.length : existingRepositories.length; const finalEnvironmentCount = replacementEnvironmentIds !== null ? replacementEnvironmentIds.length @@ -550,8 +563,40 @@ async function handleUpdateAutomation( if (e instanceof TargetSelectionError) return error(e.message, 400); throw e; } + if (existing.owner_team_id && replacementEnvironmentIds?.length) { + const environments = new EnvironmentStore(ctx.db); + for (const environmentId of replacementEnvironmentIds) { + const repositories = await environments.getRepositoriesForEnvironment(environmentId); + const denied = await authorizeTeamRepositories(ctx, { + teamId: existing.owner_team_id, + repositories: repositories.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })), + }); + if (denied) return denied; + } + } if (selection.kind === "replace") { - replacementRepositories = await resolveRepositorySelection(env, selection.repositories, ctx); + const resolved = await resolveRepositorySelection( + env, + selection.repositories, + ctx, + existing.owner_team_id + ); + if (resolved instanceof Response) return resolved; + replacementRepositories = resolved; + } else if (existing.owner_team_id) { + const targetAuthorizationError = await authorizeTeamRepositories(ctx, { + teamId: existing.owner_team_id, + repositories: existingRepositories.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })), + }); + if (targetAuthorizationError) return targetAuthorizationError; } } diff --git a/packages/control-plane/src/routes/automation-update.test.ts b/packages/control-plane/src/routes/automation-update.test.ts index ad6ff50fe9..705247f7e4 100644 --- a/packages/control-plane/src/routes/automation-update.test.ts +++ b/packages/control-plane/src/routes/automation-update.test.ts @@ -7,7 +7,9 @@ * supply the principal. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; import type * as AuthenticateModule from "../auth/authenticate"; import { resolveRepoOrError } from "./shared"; import { PERMISSION_IDS } from "@open-inspect/shared/rbac"; @@ -110,9 +112,11 @@ vi.mock("./shared", async (importOriginal) => { const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); describe("automation read, update, and delete routes", () => { + afterEach(() => vi.restoreAllMocks()); beforeEach(() => { vi.clearAllMocks(); applyMockDefaults(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); vi.mocked(resolveRepoOrError).mockResolvedValue({ repoId: 12345, repoOwner: "acme", @@ -183,9 +187,242 @@ describe("automation read, update, and delete routes", () => { permission, }); expect(mockBatch).not.toHaveBeenCalled(); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockEnvironmentStore.getById).not.toHaveBeenCalled(); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).not.toHaveBeenCalled(); } ); + it("denies a repository replacement not granted to the persisted owner team", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { repositories: [{ repoOwner: "acme", repoName: "api" }] }, + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/api", + }); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceRepositories).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + "denies an ungranted repository in a later replacement environment (mixed targets: %s)", + async (mixedTargets) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + mockEnvironmentStore.getRepositoriesForEnvironment.mockImplementation( + async (id: string) => [ + { + environment_id: id, + position: 0, + repo_owner: "acme", + repo_name: id === "env_granted" ? "web" : "api", + repo_id: id === "env_granted" ? 1 : 2, + base_branch: "main", + }, + ] + ); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 1 }, + { grant_kind: "repository", repo_external_id: 12345 }, + ]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { + name: "Updated", + environmentIds: ["env_granted", "env_ungranted"], + ...(mixedTargets ? { repositories: [{ repoOwner: "acme", repoName: "app" }] } : {}), + }, + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/api", + }); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).toHaveBeenCalledWith( + "env_granted" + ); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).toHaveBeenCalledWith( + "env_ungranted" + ); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceRepositories).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + } + ); + + it.each([ + { repoId: 7, grant: { grant_kind: "repository", repo_external_id: 7 } as const }, + { repoId: null, grant: { grant_kind: "installation", repo_external_id: null } as const }, + ])( + "accepts granted environment repositories without repositories.use", + async ({ repoId, grant }) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + mockEnvironmentStore.getRepositoriesForEnvironment.mockResolvedValue([ + { + environment_id: "env_1", + position: 0, + repo_owner: "acme", + repo_name: "app", + repo_id: repoId, + base_branch: "main", + }, + ]); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([grant]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + permissions: PERMISSION_IDS.filter((permission) => permission !== "repositories.use"), + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).toHaveBeenCalledWith("env_1"); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( + "auto-1", + ["env_1"], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalled(); + } + ); + + it("requires an installation grant for an unresolved replacement environment repository", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + mockEnvironmentStore.getRepositoriesForEnvironment.mockResolvedValue([ + { + environment_id: "env_1", + position: 0, + repo_owner: "acme", + repo_name: "app", + repo_id: null, + base_branch: "main", + }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/app", + }); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("leaves workspace-level automation environment replacements unscoped", async () => { + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentStore.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(mockBatch).toHaveBeenCalled(); + }); + + it.each([{ environmentIds: ["env_1"] }, { environmentIds: [] }])( + "validates unchanged repositories against team grants when environment targets change", + async ({ environmentIds }) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + mockStore.getRepositoriesForAutomation.mockResolvedValue([ + { repo_owner: "acme", repo_name: "app", repo_id: 7, base_branch: "main" }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds }, + permissions: PERMISSION_IDS.filter((permission) => permission !== "repositories.use"), + }); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/app", + }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + } + ); + + it.each([ + { + repoId: 7, + grant: { grant_kind: "repository", repo_external_id: 7 } as const, + }, + { + repoId: null, + grant: { grant_kind: "installation", repo_external_id: null } as const, + }, + ])( + "clears environments without new use permissions for granted unchanged repositories", + async ({ repoId, grant }) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + mockStore.getRepositoriesForAutomation.mockResolvedValue([ + { repo_owner: "acme", repo_name: "app", repo_id: repoId, base_branch: "main" }, + ]); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([grant]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: [] }, + permissions: PERMISSION_IDS.filter( + (permission) => permission !== "repositories.use" && permission !== "environments.use" + ), + }); + + expect(res.status).toBe(200); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + expect(grants).toHaveBeenCalledWith("team_alpha"); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceRepositories).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( + "auto-1", + [], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalled(); + } + ); + + it("does not revalidate unchanged targets for an unrelated team automation edit", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, owner_team_id: "team_alpha" }); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const res = await callRoute("PUT", "/automations/auto-1", { body: { name: "Updated" } }); + + expect(res.status).toBe(200); + expect(grants).not.toHaveBeenCalled(); + expect(mockBatch).toHaveBeenCalled(); + }); + it("updates automation fields", async () => { mockStore.getById.mockResolvedValue(sampleRow); diff --git a/packages/control-plane/src/routes/automation-validation.test.ts b/packages/control-plane/src/routes/automation-validation.test.ts new file mode 100644 index 0000000000..0fe169cc20 --- /dev/null +++ b/packages/control-plane/src/routes/automation-validation.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRequestMetrics } from "../db/instrumented-sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { createTestEnv, TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; +import { resolveRepoOrError, type RequestContext } from "./shared"; +import type * as SharedRoutes from "./shared"; +import { resolveRepositorySelection } from "./automation-validation"; + +vi.mock("./shared", async (importOriginal) => ({ + ...(await importOriginal()), + resolveRepoOrError: vi.fn(), +})); + +const env = createTestEnv(); +const repositories = [{ repoOwner: "acme", repoName: "app", baseBranch: null }]; + +function context(): RequestContext { + return { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: env.DB, + principal: { kind: "user", userId: "user-1" }, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "role-1", key: null, name: "Test" }, + permissions: ["repositories.use"], + }, + }; +} + +describe("resolveRepositorySelection target authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 7, + repoOwner: "acme", + repoName: "app", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("checks user target permission before SCM resolution", async () => { + const ctx = context(); + if (ctx.authorization) ctx.authorization.permissions = []; + + const result = await resolveRepositorySelection(env, repositories, ctx, null); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error("Expected target denial"); + expect(result.status).toBe(403); + await expect(result.json()).resolves.toMatchObject({ permission: "repositories.use" }); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + }); + + it("checks service actor target permission before SCM resolution", async () => { + const ctx = context(); + ctx.principal = { kind: "service", service: "slack-bot", actor: null }; + if (ctx.authorization) ctx.authorization.permissions = []; + + const result = await resolveRepositorySelection(env, repositories, ctx, null); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error("Expected target denial"); + expect(result.status).toBe(403); + expect(resolveRepoOrError).not.toHaveBeenCalled(); + }); + + it("checks the resolved ID against the target team's grants", async () => { + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers").mockResolvedValue(false); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const result = await resolveRepositorySelection(env, repositories, context(), "team_alpha"); + + expect(result).toBeInstanceOf(Response); + if (!(result instanceof Response)) throw new Error("Expected target denial"); + expect(result.status).toBe(409); + await expect(result.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + }); + + it("keeps workspace-owned selections without team grant lookups", async () => { + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + await expect(resolveRepositorySelection(env, repositories, context(), null)).resolves.toEqual([ + { repo_owner: "acme", repo_name: "app", repo_id: 7, base_branch: "main" }, + ]); + expect(grants).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/routes/automation-validation.ts b/packages/control-plane/src/routes/automation-validation.ts index 2e5c7175cf..fe2595363f 100644 --- a/packages/control-plane/src/routes/automation-validation.ts +++ b/packages/control-plane/src/routes/automation-validation.ts @@ -8,11 +8,11 @@ import { createAutomationRequestSchema, validateAutomationTargetCounts, } from "@open-inspect/shared/types/automations"; -import type { PermissionId } from "@open-inspect/shared/rbac"; import { isValidReasoningEffort } from "@open-inspect/shared/models"; import { type AutomationRepositoryInsert } from "../db/automation-store"; import { EnvironmentStore } from "../db/environments"; -import { type RequestContext, json, resolveRepoOrError } from "./shared"; +import { type RequestContext, resolveRepoOrError } from "./shared"; +import { authorizeSessionTarget } from "./session-target-authorization"; import type { Env } from "../types"; import type { SqlDatabase } from "../db/sql-database"; import { z } from "zod"; @@ -20,24 +20,6 @@ import { createLogger } from "../logger"; const logger = createLogger("router:automations"); -export function requireTargetPermissions( - ctx: RequestContext, - requiredPermissions: readonly PermissionId[] -): Response | null { - const authorization = ctx.authorization; - if (!authorization) return json({ error: "Authorization unavailable" }, 503); - const missingPermission = requiredPermissions.find( - (permission) => !authorization.permissions.includes(permission) - ); - if (missingPermission) { - return json( - { error: "Forbidden", code: "permission_required", permission: missingPermission }, - 403 - ); - } - return null; -} - export const createAutomationBodySchema = createAutomationRequestSchema.extend({ // Bot-asserted actor display fields are cosmetic only; identity enforcement // still runs against the raw pre-Zod body before these parsed values are used. @@ -217,8 +199,18 @@ export async function resolveEnvironmentSelection( export async function resolveRepositorySelection( env: Env, repositories: NormalizedRepositoryInput[], - ctx: RequestContext -): Promise { + ctx: RequestContext, + teamId: string | null +): Promise { + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: repositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })), + }); + if (targetAuthorizationError) return targetAuthorizationError; + const settled = await Promise.allSettled( repositories.map((repository) => resolveRepoOrError(env, repository.repoOwner, repository.repoName, ctx, logger) @@ -229,7 +221,7 @@ export async function resolveRepositorySelection( return result.value; }); - return repositories.map((repository, index) => { + const inserts = repositories.map((repository, index) => { const access = resolved[index]; return { repo_owner: repository.repoOwner, @@ -238,6 +230,15 @@ export async function resolveRepositorySelection( base_branch: repository.baseBranch ?? access.defaultBranch, }; }); + const resolvedTargetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId, + repositories: inserts.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })), + }); + return resolvedTargetAuthorizationError ?? inserts; } /** Extract the watched channel IDs from a slack automation's `slack_channel` condition. */ diff --git a/packages/control-plane/src/routes/automations.test-support.ts b/packages/control-plane/src/routes/automations.test-support.ts index ea291ba843..02e6aa3565 100644 --- a/packages/control-plane/src/routes/automations.test-support.ts +++ b/packages/control-plane/src/routes/automations.test-support.ts @@ -69,6 +69,7 @@ export const mockUserStore = { export const mockEnvironmentStore = { getById: vi.fn(), + getRepositoriesForEnvironment: vi.fn(), }; /** Shared D1 batch spy — createEnv wires it as env.DB.batch. */ @@ -169,6 +170,7 @@ export const sampleRow = { consecutive_failures: 0, created_by: "user-1", user_id: "user-1", + owner_team_id: null, created_at: now, updated_at: now, deleted_at: null, @@ -209,6 +211,7 @@ export function applyMockDefaults(): void { runs: [{ id: "run-1" }], }); mockEnvironmentStore.getById.mockResolvedValue({ id: "env_1", name: "Fullstack" }); + mockEnvironmentStore.getRepositoriesForEnvironment.mockResolvedValue([]); mockProviderAccountStore.getById.mockResolvedValue({ id: "0123456789abcdef0123456789abcdef", provider: "openai", diff --git a/packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts b/packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts new file mode 100644 index 0000000000..6ed4129a94 --- /dev/null +++ b/packages/control-plane/src/routes/environment-secrets.repository-grants.test.ts @@ -0,0 +1,435 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + authorizationDatabase, + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { EnvironmentStore } from "../db/environments"; +import { EnvironmentSecretsStore } from "../db/environment-secrets"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { environmentSecretsRoutes } from "./environment-secrets"; + +const mocks = vi.hoisted(() => ({ + checkRepositoryAccess: vi.fn(), + scheduleImageBuildOnSave: vi.fn(), + supersedeImageBuildsForSecretsChange: vi.fn(), +})); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + }), +})); +vi.mock("../image-builds/save-hooks", () => ({ + scheduleImageBuildOnSave: mocks.scheduleImageBuildOnSave, + supersedeImageBuildsForSecretsChange: mocks.supersedeImageBuildsForSecretsChange, +})); + +const env = () => createRepositoryGrantEnv(["environments.secrets.manage"]); +const request = createRepositoryGrantRequest(environmentSecretsRoutes, env); +const path = "/environments/env-1/secrets/import"; +const body = { repoOwner: "acme", repoName: "repo" }; +const destination = { + id: "env-1", + owner_team_id: "owner-team", + name: "Environment", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, +}; +const sourceRepository = { + environment_id: "env-1", + position: 0, + repo_owner: "acme", + repo_name: "repo", + repo_id: 123, + base_branch: "main", +}; + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + mocks.supersedeImageBuildsForSecretsChange.mockResolvedValue(undefined); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(destination); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue([ + sourceRepository, + ]); + vi.spyOn(EnvironmentSecretsStore.prototype, "importFromRepo").mockResolvedValue({ + keys: ["KEY"], + created: 1, + updated: 0, + }); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("environment secret import source grants", () => { + it.each([null, "owner-team"] as const)( + "denies a granted source without a granting-team lead (destination owner %s)", + async (ownerTeamId) => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + owner_team_id: ownerTeamId, + prebuild_enabled: 1, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + ownerTeamId === null ? new Map([["team-1", "lead"]]) : new Map([["owner-team", "member"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "other-team", + "owner-team", + ]); + + const response = await request(path, "POST", body); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Repository grant required", + code: "repository_grant_required", + reason_code: "repository_grant_required", + repository: "acme/repo", + }); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it("denies an ungranted member source even when the source belongs to the environment", async () => { + expect((await request(path, "POST", body)).status).toBe(409); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + }); + + it("uses the current team's grant, not another caller team's grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-1", "member"], + ["owner-team", "lead"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "team-1" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + expect((await request(path, "POST", body)).status).toBe(409); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + }); + + it("checks membership before repository resolution", async () => { + expect((await request(path, "POST", { repoOwner: "acme", repoName: "other" })).status).toBe( + 403 + ); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + }); + + it.each([123, null])( + "allows any source granting-team lead with only environments.secrets.manage (%s)", + async (repoId) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["owner-team", "member"], + ["other-team", "lead"], + ]) + ); + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + { ...sourceRepository, repo_id: repoId }, + ]); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + "other-team", + ]); + const environment = env(); + environment.DB = authorizationDatabase({ permissions: ["environments.secrets.manage"] }); + expect((await request(path, "POST", body, environment)).status).toBe(200); + expect(EnvironmentSecretsStore.prototype.importFromRepo).toHaveBeenCalledWith( + "env-1", + 123, + undefined + ); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + } + ); + + it("does not grant import access based on a matching repository name with a different ID", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 456 }, + ]); + expect((await request(path, "POST", body)).status).toBe(409); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "environment secret source identity in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + prebuild_enabled: 1, + }); + }); + + it.each([ + ["custom", 123], + ["custom", 456], + ["owner", 123], + ["owner", 456], + ["administrator", 123], + ["administrator", 456], + ] as const)( + "rejects stored ID 123 resolving to 456 for %s with a grant for %s", + async (role, grantedRepoId) => { + if (role !== "custom") { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[role], name: role }, + }); + } + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === "custom" ? new Map([["owner-team", "lead"]]) : new Map() + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: grantedRepoId }, + ]); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + ]); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + + const response = await request(path, "POST", body, environment); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "repository_identity_mismatch", + repository: "acme/repo", + }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it.each(["custom", "owner", "administrator"] as const)( + "rejects a stale ID in a workspace-owned environment for %s without team membership", + async (role) => { + if (role !== "custom") { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[role], name: role }, + }); + } + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + owner_team_id: null, + prebuild_enabled: 1, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + + const response = await request(path, "POST", body, environment); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "repository_identity_mismatch", + repository: "acme/repo", + }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it.each([123, null])( + "returns 404 when the member repository disappears (persisted ID %s)", + async (repoId) => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + { ...sourceRepository, repo_id: repoId }, + ]); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + ]); + mocks.checkRepositoryAccess.mockResolvedValue(null); + + const response = await request(path, "POST", body, environment); + + expect(response.status).toBe(404); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it.each([ + { persistedRepoId: 123, resolvedRepoId: 123 }, + { persistedRepoId: null, resolvedRepoId: 456 }, + ])( + "copies freshly resolved ID $resolvedRepoId after grants (persisted ID $persistedRepoId)", + async ({ persistedRepoId, resolvedRepoId }) => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + { ...sourceRepository, repo_id: persistedRepoId }, + ]); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: resolvedRepoId, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => { + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + return teamId === "owner-team" + ? [{ grant_kind: "repository", repo_external_id: resolvedRepoId }] + : []; + } + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "owner-team", + ]); + + expect((await request(path, "POST", body, environment)).status).toBe(200); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith( + resolvedRepoId + ); + expect(EnvironmentSecretsStore.prototype.importFromRepo).toHaveBeenCalledWith( + "env-1", + resolvedRepoId, + undefined + ); + expect(mocks.supersedeImageBuildsForSecretsChange).toHaveBeenCalledOnce(); + expect(mocks.scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + } + ); + } +); + +describe.each(["off", "shadow", "on"] as const)( + "workspace environment secret ownership in %s mode", + (mode) => { + it("preserves workspace-owned import with only its existing custom-role permission", async () => { + const environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + environment.DB = authorizationDatabase({ permissions: ["environments.secrets.manage"] }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...destination, + owner_team_id: null, + }); + + expect((await request(path, "POST", body, environment)).status).toBe(200); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + }); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s environment secret authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it("allows environment secret import from another team's source without lead membership", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + + expect((await request(path, "POST", body)).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(EnvironmentSecretsStore.prototype.importFromRepo).toHaveBeenCalledWith( + "env-1", + 123, + undefined + ); + }); + + it("cannot import source secrets without the destination team's covering grant", async () => { + const response = await request(path, "POST", body); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(EnvironmentSecretsStore.prototype.importFromRepo).not.toHaveBeenCalled(); + expect(mocks.supersedeImageBuildsForSecretsChange).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + } +); diff --git a/packages/control-plane/src/routes/environment-secrets.ts b/packages/control-plane/src/routes/environment-secrets.ts index 4330bcfca7..606735eb20 100644 --- a/packages/control-plane/src/routes/environment-secrets.ts +++ b/packages/control-plane/src/routes/environment-secrets.ts @@ -30,6 +30,10 @@ import { secretsRequestBodySchema, } from "./secret-request-schemas"; import type { Env } from "../types"; +import { + authorizeTeamRepositories, + authorizeWorkspaceRepositories, +} from "./workspace-repository-authorization"; const logger = createLogger("router:environment-secrets"); @@ -256,11 +260,28 @@ async function handleImportEnvironmentSecrets( return error(`${srcOwner}/${srcName} is not a member of this environment`, 403); } - // Resolve the source repo_id (rows written before resolution may lack it). - let repoId = sourceRepo.repo_id; - if (repoId == null) { - repoId = (await resolveRepoOrError(env, srcOwner, srcName, ctx, logger)).repoId; + const { repoId } = await resolveRepoOrError(env, srcOwner, srcName, ctx, logger); + if (sourceRepo.repo_id !== null && sourceRepo.repo_id !== repoId) { + return json( + { + error: "Repository identity changed", + code: "repository_identity_mismatch", + repository: `${srcOwner}/${srcName}`, + }, + 409 + ); } + const denied = await authorizeTeamRepositories(ctx, { + teamId: environment.owner_team_id, + repositories: [{ owner: srcOwner, name: srcName, repoId }], + }); + if (denied) return denied; + + const sourceDenied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: srcOwner, name: srcName, repoId }], + requireLead: true, + }); + if (sourceDenied) return sourceDenied; const secretsStore = new EnvironmentSecretsStore(ctx.db, config.key); try { diff --git a/packages/control-plane/src/routes/environments-catalog.test.ts b/packages/control-plane/src/routes/environments-catalog.test.ts new file mode 100644 index 0000000000..0eeb7d9730 --- /dev/null +++ b/packages/control-plane/src/routes/environments-catalog.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import type * as AuthenticateModule from "../auth/authenticate"; +import * as requestAudit from "../authorization/request-audit"; +import { AuthorizationStore } from "../db/authorization-store"; +import { + EnvironmentStore, + toEnvironment, + type EnvironmentRepositoryRow, + type EnvironmentRow, +} from "../db/environments"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { + authorizationDatabase, + createTestEnv, + createTestRequestHandler, + TEST_BACKGROUND_TASK_CONTEXT, +} from "../router.test-support"; +import * as sourceControl from "../source-control"; +import type { Env } from "../types"; +import { environmentRoutes } from "./environments"; + +const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })); +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: mocks.authenticate, +})); + +const TEAM_ID = "team_selected"; +const targets: [string, (number | null)[]][] = [ + ["covered", [1]], + ["denied", [2]], + ["multi", [1, 2]], + ["nullable", [null]], + ["empty", []], +]; +const catalog: EnvironmentRow[] = targets.map(([name]) => ({ + id: `env_${name}`, + owner_team_id: null, + name, + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, +})); +const repositoriesById = new Map( + targets.map(([name, repoIds]) => [ + `env_${name}`, + repoIds.map((repoId, position) => ({ + environment_id: `env_${name}`, + position, + repo_owner: "acme", + repo_name: `repo-${repoId ?? 1}`, + repo_id: repoId, + base_branch: "main", + })), + ]) +); +const fullCatalog = catalog.map((row) => toEnvironment(row, repositoriesById.get(row.id) ?? [])); +const handleRequest = createTestRequestHandler([environmentRoutes]); +let environment: Env; + +function list(query = "?teamId=team_selected"): Promise { + return handleRequest( + new Request(`https://test.local/environments${query}`), + environment, + TEST_BACKGROUND_TASK_CONTEXT + ); +} + +describe("environment catalog team scope", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + })); + environment = createTestEnv({ + DB: authorizationDatabase({ permissions: ["environments.read"] }), + }); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([[TEAM_ID, "member"]]) + ); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 1 }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository"); + vi.spyOn(EnvironmentStore.prototype, "list").mockResolvedValue({ + environments: catalog, + total: catalog.length, + }); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironmentIds").mockResolvedValue( + repositoriesById + ); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment"); + vi.spyOn(requestAudit, "auditRouteAuthorizationDecision").mockResolvedValue(undefined); + vi.spyOn(sourceControl, "createSourceControlProviderFromEnv").mockImplementation(() => { + throw new Error("Catalog listing must not resolve SCM repositories"); + }); + }); + afterEach(() => { + expect(sourceControl.createSourceControlProviderFromEnv).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.covers).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it.each([null, TEAM_ID, "team_other"])( + "returns only fully covered targets, regardless of environment owner %s", + async (ownerTeamId) => { + vi.mocked(EnvironmentStore.prototype.list).mockResolvedValue({ + environments: catalog.map((row) => ({ ...row, owner_team_id: ownerTeamId })), + total: catalog.length, + }); + + const response = await list(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ environments: [fullCatalog[0]], total: 1 }); + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledWith("user-1"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledExactlyOnceWith( + TEAM_ID + ); + expect( + EnvironmentStore.prototype.getRepositoriesForEnvironmentIds + ).toHaveBeenCalledExactlyOnceWith(catalog.map((row) => row.id)); + expect(requestAudit.auditRouteAuthorizationDecision).not.toHaveBeenCalled(); + } + ); + + it("includes a multi-repository target only when every numeric ID is granted", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 1 }, + { grant_kind: "repository", repo_external_id: 2 }, + ]); + + expect(await (await list()).json()).toEqual({ + environments: fullCatalog.slice(0, 3), + total: 3, + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledOnce(); + }); + + it("returns all nonempty targets, including nullable IDs, for an installation grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + + expect(await (await list()).json()).toEqual({ + environments: fullCatalog.slice(0, 4), + total: 4, + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledOnce(); + }); + + it("returns an empty catalog when the team has no grants", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([]); + + expect(await (await list()).json()).toEqual({ environments: [], total: 0 }); + }); + + it.each(["", "?teamId="])("preserves the full workspace catalog for %s", async (query) => { + expect(await (await list(query)).json()).toEqual({ + environments: fullCatalog, + total: catalog.length, + }); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it.each([TEAM_ID, "team_missing"])( + "conceals and audits nonmember or missing team %s identically", + async (teamId) => { + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(teamId === TEAM_ID); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team_other", "lead"]]) + ); + + const response = await list(`?teamId=${teamId}`); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + expect(requestAudit.auditRouteAuthorizationDecision).toHaveBeenCalledExactlyOnceWith({ + ctx: expect.anything(), + method: "GET", + path: "/environments", + response, + teamId, + decision: { + kind: "denied", + reasonCode: "team_not_visible", + reason: "Team not found", + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + effectivePermissions: [], + }, + }); + } + ); + + it.each(["owner", "administrator"] as const)( + "allows built-in %s without membership but still applies the team's grants", + async (key) => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + + expect(await (await list()).json()).toEqual({ environments: [fullCatalog[0]], total: 1 }); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + const archivedResponse = await list(); + expect(archivedResponse.status).toBe(404); + expect(await archivedResponse.json()).toEqual({ error: "Team not found" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledOnce(); + } + ); + + it("conceals an archived team even from its members", async () => { + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + + const response = await list(); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + }); + + it.each(["", "?teamId=team_selected"])( + "retains environments.read admission for %s", + async (query) => { + environment.DB = authorizationDatabase({ permissions: ["repositories.use"] }); + + const response = await list(query); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "environments.read", + }); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + } + ); + + it.each(["slack-bot", "linear-bot"] as const)( + "preserves actorless %s workspace access but conceals an opaque team scope", + async (service) => { + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "service", service, actor: null }, + request, + })); + + expect(await (await list("")).json()).toEqual({ + environments: fullCatalog, + total: catalog.length, + }); + vi.mocked(EnvironmentStore.prototype.list).mockClear(); + const response = await list(); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(EnvironmentStore.prototype.list).not.toHaveBeenCalled(); + expect(requestAudit.auditRouteAuthorizationDecision).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/environments", + teamId: TEAM_ID, + decision: expect.objectContaining({ reasonCode: "team_not_visible" }), + }) + ); + } + ); + + it("scopes an acting service using its canonical actor's actual membership", async () => { + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { + kind: "service", + service: "slack-bot", + actor: { + provider: "slack", + providerUserId: "U_ACTOR", + participantUserId: "slack:U_ACTOR", + canonicalUserId: "user-1", + }, + }, + request, + })); + + expect(await (await list()).json()).toEqual({ environments: [fullCatalog[0]], total: 1 }); + expect(TeamMembershipStore.prototype.listForUser).toHaveBeenCalledExactlyOnceWith("user-1"); + }); +}); diff --git a/packages/control-plane/src/routes/environments-target-denied.test.ts b/packages/control-plane/src/routes/environments-target-denied.test.ts new file mode 100644 index 0000000000..8e205bde6c --- /dev/null +++ b/packages/control-plane/src/routes/environments-target-denied.test.ts @@ -0,0 +1,318 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PERMISSION_IDS, type PermissionId } from "@open-inspect/shared/rbac"; +import type * as AuthenticateModule from "../auth/authenticate"; +import type * as SourceControlModule from "../source-control"; +import { + EnvironmentStore, + type EnvironmentRepositoryRow, + type EnvironmentRow, +} from "../db/environments"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { scheduleImageBuildOnSave } from "../image-builds/save-hooks"; +import { + authorizationDatabase, + createTestEnv, + createTestRequestHandler, + TEST_BACKGROUND_TASK_CONTEXT, +} from "../router.test-support"; +import { environmentRoutes } from "./environments"; + +const scmProvider = vi.hoisted(() => ({ checkRepositoryAccess: vi.fn() })); + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + }), +})); + +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: vi.fn(() => scmProvider), +})); + +vi.mock("../image-builds/save-hooks", () => ({ scheduleImageBuildOnSave: vi.fn() })); + +const handleRequest = createTestRequestHandler([environmentRoutes]); +const existing: EnvironmentRow = { + id: "env_1", + owner_team_id: "team_alpha", + name: "Alpha", + description: null, + prebuild_enabled: 1, + channel_associations: null, + created_at: 1, + updated_at: 1, +}; +const repositories = [{ repoOwner: "legacy/group", repoName: "old-app" }]; +const existingRepositories: EnvironmentRepositoryRow[] = [ + { + environment_id: "env_1", + position: 0, + repo_owner: "legacy/group", + repo_name: "old-app", + repo_id: 6, + base_branch: "release", + }, +]; +const batch = vi.fn(); + +async function callRoute( + method: "POST" | "PUT", + permissions: readonly PermissionId[] = PERMISSION_IDS, + body: object = { name: "Alpha", repositories, prebuildEnabled: true } +) { + return handleRequest( + new Request(`https://test.local/environments${method === "PUT" ? "/env_1" : ""}`, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + createTestEnv({ DB: authorizationDatabase({ permissions, batch }) }), + TEST_BACKGROUND_TASK_CONTEXT + ); +} + +describe("environment target denials", () => { + beforeEach(() => { + vi.clearAllMocks(); + batch.mockResolvedValue([]); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(EnvironmentStore.prototype, "getByName").mockResolvedValue(null); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(existing); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue( + existingRepositories + ); + scmProvider.checkRepositoryAccess.mockResolvedValue({ + repoId: 7, + repoOwner: "canonical/group", + repoName: "app", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each(["POST", "PUT"] as const)( + "denies %s repository selection before SCM lookup or writes", + async (method) => { + const create = vi.spyOn(EnvironmentStore.prototype, "create"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + const grants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + + const response = await callRoute( + method, + PERMISSION_IDS.filter((permission) => permission !== "repositories.use") + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "repositories.use", + }); + expect(scmProvider.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + } + ); + + it("denies updates using the persisted owner team and resolved repository identity", async () => { + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers").mockResolvedValue(false); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const response = await callRoute("PUT"); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it.each([ + { change: "enabling prebuilds", prebuildEnabled: 0, body: { prebuildEnabled: true } }, + { change: "changing metadata", prebuildEnabled: 1, body: { description: "Updated" } }, + ])("denies $change without replacing repositories after grant revocation", async (testCase) => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...existing, + prebuild_enabled: testCase.prebuildEnabled, + }); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + + const response = await callRoute("PUT", ["environments.manage"], testCase.body); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/app", + }); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledWith({ + owner: "legacy/group", + name: "old-app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it("checks every unchanged member when a later repository's grant is revoked", async () => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + ...existingRepositories, + { + environment_id: "env_1", + position: 1, + repo_owner: "legacy/group", + repo_name: "old-api", + repo_id: 8, + base_branch: "main", + }, + ]); + scmProvider.checkRepositoryAccess.mockResolvedValueOnce({ + repoId: 7, + repoOwner: "canonical/group", + repoName: "app", + defaultBranch: "main", + }); + scmProvider.checkRepositoryAccess.mockResolvedValueOnce({ + repoId: 8, + repoOwner: "canonical/group", + repoName: "api", + defaultBranch: "main", + }); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { description: "Updated" }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/api", + }); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledTimes(2); + expect(covers).toHaveBeenCalledWith("team_alpha", [7, 8]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it("does not authorize a recreated unchanged repository using its stale persisted ID", async () => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 6 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { description: "Updated" }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "canonical/group/app", + }); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(update).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it("allows granted unchanged members without repositories.use or replacing them", async () => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { description: "Updated" }); + + expect(response.status).toBe(200); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(update).toHaveBeenCalledWith("env_1", { description: "Updated" }, undefined); + expect(batch).toHaveBeenCalledOnce(); + expect(scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); + + it("allows disabling prebuilds after revocation without checking unchanged members", async () => { + vi.mocked(EnvironmentStore.prototype.getById) + .mockResolvedValueOnce(existing) + .mockResolvedValue({ ...existing, prebuild_enabled: 0 }); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([]); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], { prebuildEnabled: false }); + + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledWith("env_1", { prebuild_enabled: 0 }, undefined); + expect(batch).toHaveBeenCalledOnce(); + expect(scmProvider.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + + it.each([ + { change: "enabling prebuilds", prebuildEnabled: 0, body: { prebuildEnabled: true } }, + { change: "changing metadata", prebuildEnabled: 1, body: { description: "Updated" } }, + ])("preserves unowned workspace behavior when $change", async (testCase) => { + vi.mocked(EnvironmentStore.prototype.getById) + .mockResolvedValueOnce({ + ...existing, + owner_team_id: null, + prebuild_enabled: testCase.prebuildEnabled, + }) + .mockResolvedValue({ ...existing, owner_team_id: null }); + const grants = vi + .spyOn(TeamRepositoryGrantStore.prototype, "listForTeam") + .mockResolvedValue([]); + const update = vi.spyOn(EnvironmentStore.prototype, "update"); + + const response = await callRoute("PUT", ["environments.manage"], testCase.body); + + expect(response.status).toBe(200); + expect(update).toHaveBeenCalledOnce(); + expect(update.mock.calls[0]?.[2]).toBeUndefined(); + expect(batch).toHaveBeenCalledOnce(); + expect(scmProvider.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(grants).not.toHaveBeenCalled(); + expect(scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); + + it("does not re-resolve or re-authorize replacement repositories before scheduling", async () => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 7 }, + ]); + const covers = vi.spyOn(TeamRepositoryGrantStore.prototype, "covers"); + + const response = await callRoute("PUT"); + + expect(response.status).toBe(200); + expect(scmProvider.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(covers).toHaveBeenCalledOnce(); + expect(covers).toHaveBeenCalledWith("team_alpha", [7]); + expect(batch).toHaveBeenCalledOnce(); + expect(scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/control-plane/src/routes/environments.ts b/packages/control-plane/src/routes/environments.ts index 1a7726e982..5fb22bd900 100644 --- a/packages/control-plane/src/routes/environments.ts +++ b/packages/control-plane/src/routes/environments.ts @@ -21,6 +21,10 @@ import { type EnvironmentRepositoryInsert, type EnvironmentScalarFields, } from "../db/environments"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamStore } from "../db/teams"; +import { auditRouteAuthorizationDecision } from "../authorization/request-audit"; import { generateId } from "../auth/crypto"; import { scheduleImageBuildOnSave } from "../image-builds/save-hooks"; import { createLogger } from "../logger"; @@ -33,6 +37,8 @@ import { requirePermission, } from "./shared"; import type { Env } from "../types"; +import { authorizeSessionTarget } from "./session-target-authorization"; +import { authorizeTeamRepositories } from "./workspace-repository-authorization"; const logger = createLogger("router:environments"); @@ -72,16 +78,63 @@ export async function resolveEnvironmentRepositories( } async function handleListEnvironments( - _request: Request, + request: Request, env: Env, _params: object, ctx: RequestContext ): Promise { + const teamId = new URL(request.url).searchParams.get("teamId"); + let grants: Awaited> | undefined; + if (teamId) { + const userId = ctx.authorization?.userId; + const roleKey = ctx.authorization?.role.key; + const allowed = + !!userId && + (await new TeamStore(ctx.db).isActive(teamId)) && + (roleKey === "owner" || + roleKey === "administrator" || + (await new TeamMembershipStore(ctx.db).listForUser(userId)).has(teamId)); + if (!allowed) { + const response = error("Team not found", 404); + await auditRouteAuthorizationDecision({ + ctx, + method: request.method, + path: "/environments", + response, + teamId, + decision: { + kind: "denied", + reasonCode: "team_not_visible", + reason: "Team not found", + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + effectivePermissions: [], + }, + }); + return response; + } + grants = await new TeamRepositoryGrantStore(ctx.db).listForTeam(teamId); + } + const store = new EnvironmentStore(ctx.db); - const { environments, total } = await store.list(); + let { environments, total } = await store.list(); const repositoriesById = await store.getRepositoriesForEnvironmentIds( environments.map((e) => e.id) ); + if (grants) { + const installationGrant = grants.some((grant) => grant.grant_kind === "installation"); + const grantedRepoIds = new Set(grants.map((grant) => grant.repo_external_id)); + environments = environments.filter((row) => { + const repositories = repositoriesById.get(row.id) ?? []; + return ( + repositories.length > 0 && + (installationGrant || + repositories.every( + (repository) => repository.repo_id !== null && grantedRepoIds.has(repository.repo_id) + )) + ); + }); + total = environments.length; + } return json({ environments: environments.map((row) => toEnvironment(row, repositoriesById.get(row.id) ?? [])), @@ -104,6 +157,15 @@ async function handleCreateEnvironment( return error(`An environment named "${name}" already exists`, 409); } + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: repositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })), + }); + if (targetAuthorizationError) return targetAuthorizationError; + const inserts = await resolveEnvironmentRepositories(env, repositories, ctx); const now = Date.now(); @@ -178,10 +240,54 @@ async function handleUpdateEnvironment( } } - const inserts = - repositories !== undefined - ? await resolveEnvironmentRepositories(env, repositories, ctx) - : undefined; + let inserts: EnvironmentRepositoryInsert[] | undefined; + if (repositories !== undefined) { + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, + repositories: repositories.map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + })), + }); + if (targetAuthorizationError) return targetAuthorizationError; + + inserts = await resolveEnvironmentRepositories(env, repositories, ctx); + const resolvedTargetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: existing.owner_team_id, + repositories: inserts.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })), + }); + if (resolvedTargetAuthorizationError) return resolvedTargetAuthorizationError; + } + + if ( + (prebuildEnabled ?? existing.prebuild_enabled === 1) && + existing.owner_team_id !== null && + inserts === undefined + ) { + const existingRepositories = await store.getRepositoriesForEnvironment(id); + const resolved = await resolveEnvironmentRepositories( + env, + existingRepositories.map((repository) => ({ + repoOwner: repository.repo_owner, + repoName: repository.repo_name, + baseBranch: repository.base_branch, + })), + ctx + ); + const denied = await authorizeTeamRepositories(ctx, { + teamId: existing.owner_team_id, + repositories: resolved.map((repository) => ({ + owner: repository.repo_owner, + name: repository.repo_name, + repoId: repository.repo_id, + })), + }); + if (denied) return denied; + } const fields: EnvironmentScalarFields = {}; if (name !== undefined) fields.name = name; diff --git a/packages/control-plane/src/routes/image-builds.repository-grants.test.ts b/packages/control-plane/src/routes/image-builds.repository-grants.test.ts new file mode 100644 index 0000000000..6f7c814aff --- /dev/null +++ b/packages/control-plane/src/routes/image-builds.repository-grants.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + authorizationDatabase, + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { EnvironmentStore } from "../db/environments"; +import { RepoMetadataStore } from "../db/repo-metadata"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { imageBuildRoutes } from "./image-builds"; + +const mocks = vi.hoisted(() => ({ + checkRepositoryAccess: vi.fn(), + triggerBuild: vi.fn(), + scheduleImageBuildOnSave: vi.fn(), +})); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + }), +})); +vi.mock("../image-builds/workflow", () => ({ + createImageBuildWorkflowFromEnv: () => ({ + triggerBuild: mocks.triggerBuild, + triggerBuildWithTarget: mocks.triggerBuild, + }), +})); +vi.mock("../image-builds/save-hooks", () => ({ + scheduleImageBuildOnSave: mocks.scheduleImageBuildOnSave, +})); + +const env = () => + createRepositoryGrantEnv(["repositories.images.manage", "environments.images.manage"]); +const request = createRepositoryGrantRequest(imageBuildRoutes, env); +const repositoryImageWrites = [ + ["POST", "/image-builds/trigger/repo/acme/repo", undefined], + ["PUT", "/image-builds/toggle/repo/acme/repo", { enabled: true }], +] as const; +const environmentRow = { + id: "env-1", + owner_team_id: "owner-team", + name: "Environment", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, +}; +const environmentRepository = { + environment_id: "env-1", + position: 0, + repo_owner: "acme", + repo_name: "repo", + repo_id: 123, + base_branch: "main", +}; + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + mocks.triggerBuild.mockResolvedValue({ type: "building", buildId: "build-1" }); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(environmentRow); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue([ + environmentRepository, + ]); + vi.spyOn(RepoMetadataStore.prototype, "setImageBuildEnabled").mockResolvedValue(undefined); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("image build repository-bearing writes", () => { + it("denies a repo trigger before invoking the workflow", async () => { + expect((await request("/image-builds/trigger/repo/acme/repo", "POST")).status).toBe(403); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + it("denies toggle-on before writing or scheduling", async () => { + expect( + (await request("/image-builds/toggle/repo/acme/repo", "PUT", { enabled: true })).status + ).toBe(403); + expect(RepoMetadataStore.prototype.setImageBuildEnabled).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + }); + it("denies an environment trigger when its persisted owner lacks grants", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "member"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "team-1" ? [{ grant_kind: "installation", repo_external_id: null }] : [] + ); + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(409); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + it("allows a granted repo trigger with images.manage alone", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + expect((await request("/image-builds/trigger/repo/acme/repo", "POST")).status).toBe(200); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + expect(mocks.triggerBuild).toHaveBeenCalledWith( + { kind: "repo", id: "acme/repo" }, + expect.objectContaining({ kind: "repo", repoId: 123 }), + expect.objectContaining({ request_id: expect.any(String) }) + ); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + }); + it("allows an owning-team member to enable repo images with images.manage alone", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + + expect( + (await request("/image-builds/toggle/repo/acme/repo", "PUT", { enabled: true })).status + ).toBe(200); + expect(RepoMetadataStore.prototype.setImageBuildEnabled).toHaveBeenCalledWith( + "acme", + "repo", + true + ); + expect(mocks.scheduleImageBuildOnSave).toHaveBeenCalledOnce(); + }); + it("allows disabling without SCM resolution or a surviving grant", async () => { + mocks.checkRepositoryAccess.mockRejectedValue(new Error("Repository gone")); + expect( + (await request("/image-builds/toggle/repo/acme/repo", "PUT", { enabled: false })).status + ).toBe(200); + expect(RepoMetadataStore.prototype.setImageBuildEnabled).toHaveBeenCalledWith( + "acme", + "repo", + false + ); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + }); + it("preserves workspace-owned environment trigger behavior", async () => { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...environmentRow, + owner_team_id: null, + }); + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(200); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + }); + + it("checks every current repository against the environment owner regardless of persisted IDs", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "member"]]) + ); + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + environmentRepository, + { + ...environmentRepository, + position: 1, + repo_name: "other", + repo_id: null, + }, + ]); + mocks.checkRepositoryAccess.mockImplementation( + async ({ owner, name }: { owner: string; name: string }) => ({ + repoId: name === "other" ? 456 : 123, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(409); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "other" }); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledTimes(2); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("rejects a reused environment repository name when only the persisted old ID is granted", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "member"]]) + ); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(409); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("allows the current environment repository ID when the persisted ID is stale", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "member"]]) + ); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 456 }] : [] + ); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(200); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + }); + + it("rejects a disappeared environment repository despite its persisted ID and grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["owner-team", "member"]]) + ); + mocks.checkRepositoryAccess.mockResolvedValue(null); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(404); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("allows an environment owner-team member with only environments.images.manage", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-1", "member"], + ["owner-team", "member"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation(async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + const environment = env(); + environment.DB = authorizationDatabase({ permissions: ["environments.images.manage"] }); + expect( + (await request("/image-builds/trigger/environment/env-1", "POST", undefined, environment)) + .status + ).toBe(200); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalledWith("team-1"); + }); + + it.each(["off", "shadow", "on"] as const)( + "denies a nonmember before SCM access in %s mode even when the owner team has grants", + async (mode) => { + const environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + const response = await request( + "/image-builds/trigger/environment/env-1", + "POST", + undefined, + environment + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Forbidden", + code: "not_member", + reason_code: "not_member", + }); + expect(EnvironmentStore.prototype.getRepositoriesForEnvironment).not.toHaveBeenCalled(); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + } + ); + + it("denies an environment trigger for a caller without any team membership", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(403); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + + it("denies an unrelated team lead even when their own team has an installation grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-1", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(403); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "workspace image build repository ownership in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + }); + + it.each(repositoryImageWrites)( + "allows image %s with no grants anywhere and only existing permissions", + async (method, path, body) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect((await request(path, method, body, environment)).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it.each(["member", "lead", null] as const)( + "denies another team's repositories for an unrelated %s across image routes", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === null ? new Map() : new Map([["team-1", role]]) + ); + for (const [method, path, body] of repositoryImageWrites) { + expect((await request(path, method, body, environment)).status).toBe(403); + } + expect(RepoMetadataStore.prototype.setImageBuildEnabled).not.toHaveBeenCalled(); + expect(mocks.scheduleImageBuildOnSave).not.toHaveBeenCalled(); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + } + ); + + it("preserves workspace-owned trigger with only its existing custom-role permission", async () => { + environment.DB = authorizationDatabase({ permissions: ["environments.images.manage"] }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...environmentRow, + owner_team_id: null, + }); + + expect( + (await request("/image-builds/trigger/environment/env-1", "POST", undefined, environment)) + .status + ).toBe(200); + expect(TeamMembershipStore.prototype.listForUser).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + }); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s image build repository authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it.each(repositoryImageWrites)( + "allows image %s for another team's repository without membership", + async (method, path, body) => { + expect((await request(path, method, body)).status).toBe(200); + } + ); + + it("allows a team-owned environment trigger when the owner team has a covering grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockImplementation( + async (teamId) => + teamId === "owner-team" ? [{ grant_kind: "repository", repo_external_id: 123 }] : [] + ); + + expect((await request("/image-builds/trigger/environment/env-1", "POST")).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(mocks.triggerBuild).toHaveBeenCalledOnce(); + }); + + it("cannot build an environment repository missing the owner team's grant", async () => { + const response = await request("/image-builds/trigger/environment/env-1", "POST"); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + expect(mocks.triggerBuild).not.toHaveBeenCalled(); + }); + } +); diff --git a/packages/control-plane/src/routes/image-builds.ts b/packages/control-plane/src/routes/image-builds.ts index 33c22fb244..9388748f14 100644 --- a/packages/control-plane/src/routes/image-builds.ts +++ b/packages/control-plane/src/routes/image-builds.ts @@ -20,6 +20,8 @@ import { import { z } from "zod"; import { ImageBuildStore } from "../db/image-builds"; import { RepoMetadataStore } from "../db/repo-metadata"; +import { EnvironmentStore } from "../db/environments"; +import { TeamMembershipStore } from "../db/team-memberships"; import { createLogger } from "../logger"; import { getImageBuildCallbackBearerToken } from "../image-builds/callback-auth"; import { ImageBuildError } from "../image-builds/errors"; @@ -37,6 +39,7 @@ import { listEnabledScopes, listEnabledScopeUnits, resolveScopeTarget, + type ResolvedImageBuildTarget, } from "../image-builds/scope"; import { createImageBuildWorkflowFromEnv } from "../image-builds/workflow"; import type { @@ -54,8 +57,13 @@ import { json, NO_AUTHORIZATION, requirePermission, + resolveRepoOrError, } from "./shared"; import { parseQuery } from "./query"; +import { + authorizeTeamRepositories, + authorizeWorkspaceRepositories, +} from "./workspace-repository-authorization"; const logger = createLogger("router:image-builds"); const MAX_CALLBACK_BODY_BYTES = 16 * 1024; @@ -246,13 +254,14 @@ async function handleBuildFailed( async function triggerBuildForScope( env: Env, scope: ImageBuildScope, - ctx: RequestContext + ctx: RequestContext, + target?: ResolvedImageBuildTarget ): Promise { try { - const result = await createImageBuildWorkflowFromEnv(env, ctx.db).triggerBuild( - scope, - workflowContext(ctx) - ); + const workflow = createImageBuildWorkflowFromEnv(env, ctx.db); + const result = target + ? await workflow.triggerBuildWithTarget(scope, target, workflowContext(ctx)) + : await workflow.triggerBuild(scope, workflowContext(ctx)); if (result.type === "up_to_date") { // Unreachable via the trigger routes (triggerBuild is unconditional); // guards the union exhaustively. @@ -282,6 +291,40 @@ async function handleTriggerEnvironmentBuild( if (providerError) return providerError; const environmentId = params.id; + const store = new EnvironmentStore(ctx.db); + const environment = await store.getById(environmentId); + if (!environment) return error("Environment not found", 404); + if (environment.owner_team_id !== null) { + const authorization = ctx.authorization; + if (!authorization) { + return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); + } + const roleKey = authorization.role.key; + if (roleKey !== "owner" && roleKey !== "administrator") { + const memberships = await new TeamMembershipStore(ctx.db).listForUser(authorization.userId); + if (!memberships.has(environment.owner_team_id)) { + return json({ error: "Forbidden", code: "not_member", reason_code: "not_member" }, 403); + } + } + const repositories = await store.getRepositoriesForEnvironment(environmentId); + const resolved = await Promise.all( + repositories.map(async (repository) => { + const access = await resolveRepoOrError( + env, + repository.repo_owner, + repository.repo_name, + ctx, + logger + ); + return { owner: access.repoOwner, name: access.repoName, repoId: access.repoId }; + }) + ); + const denied = await authorizeTeamRepositories(ctx, { + teamId: environment.owner_team_id, + repositories: resolved, + }); + if (denied) return denied; + } return triggerBuildForScope(env, { kind: "environment", id: environmentId }, ctx); } @@ -301,8 +344,18 @@ async function handleTriggerRepoBuild( const repository = repositoryParams(params); if (repository instanceof Response) return repository; - - return triggerBuildForScope(env, repoImageBuildScope(repository.owner, repository.name), ctx); + const scope = repoImageBuildScope(repository.owner, repository.name); + try { + const target = await resolveScopeTarget(env, ctx.db, scope); + if (target.kind !== "repo") throw new Error("Expected repository image build scope"); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: repository.owner, name: repository.name, repoId: target.repoId }], + }); + if (denied) return denied; + return triggerBuildForScope(env, scope, ctx, target); + } catch (e) { + return imageBuildErrorToResponse(e); + } } /** @@ -340,7 +393,12 @@ async function handleToggleRepoImageBuilds( // a repo that became unresolvable must remain disableable. if (body.enabled) { try { - await resolveScopeTarget(env, ctx.db, scope); + const target = await resolveScopeTarget(env, ctx.db, scope); + if (target.kind !== "repo") throw new Error("Expected repository image build scope"); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner, name, repoId: target.repoId }], + }); + if (denied) return denied; } catch (e) { return imageBuildErrorToResponse(e); } diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts index daad4c0851..6ada5a252f 100644 --- a/packages/control-plane/src/routes/repos.ts +++ b/packages/control-plane/src/routes/repos.ts @@ -7,6 +7,10 @@ import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { repositoryParams } from "./repository-params"; import { RepoMetadataStore } from "../db/repo-metadata"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamStore } from "../db/teams"; +import { auditRouteAuthorizationDecision } from "../authorization/request-audit"; import type { Env } from "../types"; import type { SqlDatabase } from "../db/sql-database"; import { @@ -164,6 +168,43 @@ async function handleListRepos( _params: object, ctx: RequestContext ): Promise { + const teamId = new URL(request.url).searchParams.get("teamId"); + let grants: Awaited> | undefined; + if (teamId !== null) { + const userId = ctx.authorization?.userId; + const roleKey = ctx.authorization?.role.key; + const allowed = + !!userId && + (await new TeamStore(ctx.db).isActive(teamId)) && + (roleKey === "owner" || + roleKey === "administrator" || + (await new TeamMembershipStore(ctx.db).listForUser(userId)).has(teamId)); + if (!allowed) { + const response = error("Team not found", 404); + await auditRouteAuthorizationDecision({ + ctx, + method: request.method, + path: "/repos", + response, + teamId, + decision: { + kind: "denied", + reasonCode: "team_not_visible", + reason: "Team not found", + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + effectivePermissions: [], + }, + }); + return response; + } + grants = await new TeamRepositoryGrantStore(ctx.db).listForTeam(teamId); + } + const filterRepos = (repos: EnrichedRepository[]) => { + if (!grants || grants.some((grant) => grant.grant_kind === "installation")) return repos; + const ids = new Set(grants.map((grant) => grant.repo_external_id)); + return repos.filter((repo) => ids.has(repo.id)); + }; + const teamScope = grants ? { teamHasRepositoryGrants: grants.length > 0 } : {}; const cacheStore = env.REPOS_CACHE; const scmIdentity = await reposCacheIdentity(env); @@ -194,7 +235,8 @@ async function handleListRepos( } return json({ - repos: cached.repos, + repos: filterRepos(cached.repos), + ...teamScope, cached: true, cachedAt: cached.cachedAt, }); @@ -225,7 +267,8 @@ async function handleListRepos( } return json({ - repos: result.repos, + repos: filterRepos(result.repos), + ...teamScope, cached: false, cachedAt: result.cachedAt, }); diff --git a/packages/control-plane/src/routes/repository-grants.test-support.ts b/packages/control-plane/src/routes/repository-grants.test-support.ts new file mode 100644 index 0000000000..42bcd38299 --- /dev/null +++ b/packages/control-plane/src/routes/repository-grants.test-support.ts @@ -0,0 +1,93 @@ +import { vi } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { createTestBackgroundTasks } from "../background-tasks.test-support"; +import type { SqlDatabase, SqlStatement } from "../db/sql-database"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { createControlPlaneApp, type RouteModule } from "../routing/hono-app"; +import type { Env } from "../types"; + +const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })); +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: mocks.authenticate, +})); + +/** Only admission queries have rows; domain stores are spied on by their suites. */ +export function authorizationDatabase({ + permissions, +}: { + permissions: readonly PermissionId[]; +}): SqlDatabase { + return { + prepare(sql) { + const statement: SqlStatement = { + bind: () => statement, + first: async () => + (sql.includes("FROM users u") + ? { + user_id: "user-1", + suspended_at: null, + role_id: "role-1", + role_key: null, + role_name: "Custom", + } + : null) as T | null, + all: async () => ({ + results: (sql.includes("FROM role_permissions") + ? permissions.map((permission_id) => ({ permission_id })) + : []) as T[], + meta: { changes: 0 }, + }), + run: async () => ({ results: [] as T[], meta: { changes: 0 } }), + }; + return statement; + }, + batch: async () => [], + }; +} + +export function createRepositoryGrantEnv(permissions: readonly PermissionId[]): Env { + return { + DB: authorizationDatabase({ permissions }), + SCM_PROVIDER: "github", + SANDBOX_PROVIDER: "modal", + TEAMS_ENFORCEMENT: "on", + REPO_SECRETS_ENCRYPTION_KEY: "test-key", + } as Env; +} + +/** Mount only the domain under test, without importing the production route catalog. */ +export function createRepositoryGrantRequest(module: RouteModule, env: () => Env) { + const app = createControlPlaneApp([module], { + backgroundTasks: () => createTestBackgroundTasks(), + }); + return (path: string, method: string, body?: unknown, environment = env()): Promise => + Promise.resolve( + app.fetch( + new Request(`https://test.local${path}`, { + method, + headers: { "Content-Type": "application/json", "If-Match": "revision-1" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + environment + ) + ); +} + +export function setupRepositoryGrantSpies(): void { + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + })); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team-1", "member"]]) + ); + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository").mockResolvedValue([ + "other-team", + ]); +} diff --git a/packages/control-plane/src/routes/secrets.repository-grants.test.ts b/packages/control-plane/src/routes/secrets.repository-grants.test.ts new file mode 100644 index 0000000000..c044977bb3 --- /dev/null +++ b/packages/control-plane/src/routes/secrets.repository-grants.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + authorizationDatabase, + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { RepoSecretsStore } from "../db/repo-secrets"; +import { GlobalSecretsStore } from "../db/global-secrets"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { secretsRoutes } from "./secrets"; + +const mocks = vi.hoisted(() => ({ checkRepositoryAccess: vi.fn() })); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + }), +})); + +const env = () => createRepositoryGrantEnv(["repositories.secrets.manage"]); +const request = createRepositoryGrantRequest(secretsRoutes, env); +const repositorySecretRequests = [ + ["GET", "/repos/acme/repo/secrets", undefined], + ["PUT", "/repos/acme/repo/secrets", { secrets: { KEY: "value" } }], + ["DELETE", "/repos/acme/repo/secrets/KEY", undefined], +] as const; + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + vi.spyOn(RepoSecretsStore.prototype, "setSecrets").mockResolvedValue({ + keys: ["KEY"], + created: 1, + updated: 0, + }); + vi.spyOn(RepoSecretsStore.prototype, "listSecretKeys").mockResolvedValue([]); + vi.spyOn(RepoSecretsStore.prototype, "deleteSecret").mockResolvedValue(true); + vi.spyOn(GlobalSecretsStore.prototype, "listSecretKeys").mockResolvedValue([]); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("repository secret source grants", () => { + it.each(repositorySecretRequests)( + "denies %s source access for a granted member who is not a lead", + async (method, path, body) => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + expect((await request(path, method, body)).status).toBe(403); + expect(RepoSecretsStore.prototype.listSecretKeys).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.setSecrets).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.deleteSecret).not.toHaveBeenCalled(); + } + ); + + it("denies an active lead without a covering source grant", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-1", "lead"]]) + ); + expect((await request("/repos/acme/repo/secrets", "GET")).status).toBe(403); + expect(RepoSecretsStore.prototype.listSecretKeys).not.toHaveBeenCalled(); + }); + + it.each(repositorySecretRequests)( + "allows %s for an owning-team lead without adding repositories.use", + async (method, path, body) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-1", "lead"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "other-team", + "team-1", + ]); + expect((await request(path, method, body)).status).toBe(200); + } + ); + + it("retains repositories.secrets.manage admission before grants", async () => { + const environment = env(); + environment.DB = authorizationDatabase({ permissions: [] }); + expect((await request("/repos/acme/repo/secrets", "GET", undefined, environment)).status).toBe( + 403 + ); + expect(mocks.checkRepositoryAccess).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "workspace repository secret ownership in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + }); + + it.each(repositorySecretRequests)( + "allows secret %s with no grants anywhere and only existing permissions", + async (method, path, body) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect((await request(path, method, body, environment)).status).toBe(200); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it.each(["member", "lead", null] as const)( + "denies another team's repositories for an unrelated %s across secret routes", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === null ? new Map() : new Map([["team-1", role]]) + ); + for (const [method, path, body] of repositorySecretRequests) { + expect((await request(path, method, body, environment)).status).toBe(403); + } + expect(RepoSecretsStore.prototype.listSecretKeys).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.setSecrets).not.toHaveBeenCalled(); + expect(RepoSecretsStore.prototype.deleteSecret).not.toHaveBeenCalled(); + } + ); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s repository secret authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it.each(repositorySecretRequests)( + "allows secret %s for another team's repository without lead membership", + async (method, path, body) => { + expect((await request(path, method, body)).status).toBe(200); + } + ); + } +); diff --git a/packages/control-plane/src/routes/secrets.ts b/packages/control-plane/src/routes/secrets.ts index f752bc17f1..8104ed752c 100644 --- a/packages/control-plane/src/routes/secrets.ts +++ b/packages/control-plane/src/routes/secrets.ts @@ -12,6 +12,7 @@ import { GlobalSecretsStore } from "../db/global-secrets"; import { SecretsValidationError, normalizeKey, validateKey } from "../db/secrets-validation"; import type { Env } from "../types"; import { createLogger } from "../logger"; +import { authorizeWorkspaceRepositories } from "./workspace-repository-authorization"; import { GITHUB_USER_OR_SERVICE_ROUTE, type RequestContext, @@ -45,6 +46,11 @@ async function handleSetRepoSecrets( const { owner, name } = repository; const resolved = await resolveRepoOrError(env, owner, name, ctx, logger); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + requireLead: true, + }); + if (denied) return denied; const body = await parseBody( request, @@ -119,6 +125,11 @@ async function handleListRepoSecrets( const { owner, name } = repository; const resolved = await resolveRepoOrError(env, owner, name, ctx, logger); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + requireLead: true, + }); + if (denied) return denied; const store = new RepoSecretsStore(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY); const globalStore = new GlobalSecretsStore(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY); @@ -189,6 +200,11 @@ async function handleDeleteRepoSecret( } const resolved = await resolveRepoOrError(env, owner, name, ctx, logger); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + requireLead: true, + }); + if (denied) return denied; const store = new RepoSecretsStore(ctx.db, env.REPO_SECRETS_ENCRYPTION_KEY); diff --git a/packages/control-plane/src/routes/session-child-spawn.ts b/packages/control-plane/src/routes/session-child-spawn.ts index 3a598aa8ad..7285d99309 100644 --- a/packages/control-plane/src/routes/session-child-spawn.ts +++ b/packages/control-plane/src/routes/session-child-spawn.ts @@ -39,6 +39,7 @@ import { permissionRequirement, sessionRequirement, requireAll, + resolveRepoOrError, } from "./shared"; import { type SessionRouteContext, dispatchSession } from "./session-route"; import { DEFAULT_BASE_BRANCH } from "../repos/default-branch"; @@ -155,12 +156,31 @@ export async function handleSpawnChild( } } - const targetAuthorizationError = authorizeSessionTarget(ctx, { + const inheritedRepositories = + parentRepoOwner && parentRepoName ? [{ owner: parentRepoOwner, name: parentRepoName }] : []; + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, environmentId: parentEnvironmentId, - hasRepository: Boolean(parentRepoOwner && parentRepoName), + repositories: inheritedRepositories, }); if (targetAuthorizationError) return targetAuthorizationError; + const teamId = parentSession?.ownerTeamId ?? null; + let childRepoId = spawnContext.repoId; + if (teamId && parentRepoOwner && parentRepoName) { + const resolved = await resolveRepoOrError(env, parentRepoOwner, parentRepoName, ctx, logger); + childRepoId = resolved.repoId; + } + const resolvedTargetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId, + environmentId: parentEnvironmentId, + repositories: inheritedRepositories.map((repository) => ({ + ...repository, + repoId: childRepoId, + })), + }); + if (resolvedTargetAuthorizationError) return resolvedTargetAuthorizationError; + let enabledModels: ValidModel[]; try { enabledModels = await getEffectiveEnabledModels(ctx.db); @@ -256,7 +276,7 @@ export async function handleSpawnChild( sessionId: childId, repoOwner: spawnContext.repoOwner, repoName: spawnContext.repoName, - repoId: spawnContext.repoId, + repoId: childRepoId, environmentId: parentEnvironmentId, branch: spawnContext.repoOwner && spawnContext.repoName diff --git a/packages/control-plane/src/routes/session-create.ts b/packages/control-plane/src/routes/session-create.ts index 05c5a387ae..217eb1870a 100644 --- a/packages/control-plane/src/routes/session-create.ts +++ b/packages/control-plane/src/routes/session-create.ts @@ -21,7 +21,6 @@ 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"; @@ -101,9 +100,12 @@ export async function handleCreateSession( throw e; } - const targetAuthorizationError = authorizeSessionTarget(ctx, { + const targetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId: null, environmentId: body.environmentId, - hasRepository: Boolean(repositoryContext || body.repositories), + repositories: (body.repositories ?? (repositoryContext ? [repositoryContext] : [])).map( + (repository) => ({ owner: repository.repoOwner, name: repository.repoName }) + ), }); if (targetAuthorizationError) return targetAuthorizationError; @@ -182,21 +184,19 @@ export async function handleCreateSession( ) { 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 resolvedTargetAuthorizationError = await authorizeSessionTarget(ctx, { + teamId, + environmentId, + repositories: ( + repositories ?? (repoOwner && repoName ? [{ repoOwner, repoName, repoId }] : []) + ).map((repository) => ({ + owner: repository.repoOwner, + name: repository.repoName, + repoId: repository.repoId, + })), + }); + if (resolvedTargetAuthorizationError) return resolvedTargetAuthorizationError; const visibility = body.visibility ?? team?.defaultVisibility ?? "workspace"; if (visibility === "team" && !teamId) return json({ error: "A team is required", code: "team_required" }, 400); diff --git a/packages/control-plane/src/routes/session-target-authorization.test.ts b/packages/control-plane/src/routes/session-target-authorization.test.ts new file mode 100644 index 0000000000..ffbeb99dea --- /dev/null +++ b/packages/control-plane/src/routes/session-target-authorization.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import type { Principal } from "../auth/principal"; +import { createRequestMetrics } from "../db/instrumented-sql-database"; +import { TeamStore } from "../db/teams"; +import type { SqlStatement } from "../db/sql-database"; +import { + createTestEnv, + emptyStatement, + TEST_BACKGROUND_TASK_CONTEXT, +} from "../router.test-support"; +import type { RequestContext } from "./shared"; +import { authorizeSessionTarget, type SessionTarget } from "./session-target-authorization"; + +const repository = { owner: "acme/group", name: "app", repoId: 7 }; +const target: SessionTarget = { teamId: "team_alpha", repositories: [repository] }; +const user: Principal = { kind: "user", userId: "user-1" }; +const sandbox: Principal = { kind: "sandbox", sessionId: "parent-1" }; + +function context( + principal: Principal = user, + permissions: PermissionId[] = ["repositories.use", "environments.use"], + grants: readonly { + grant_kind: "installation" | "repository"; + repo_external_id: number | null; + }[] = [] +): RequestContext { + const statement: SqlStatement = { + ...emptyStatement(), + bind: () => statement, + all: async () => ({ + results: [...grants] as T[], + meta: { changes: 0 }, + }), + }; + return { + request_id: "request-1", + trace_id: "trace-1", + metrics: createRequestMetrics(), + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + db: { ...createTestEnv().DB, prepare: vi.fn(() => statement) }, + principal, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "role-1", key: null, name: "Test" }, + permissions, + }, + }; +} + +describe("authorizeSessionTarget", () => { + beforeEach(() => { + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each([user, { kind: "service", service: "slack-bot", actor: null } satisfies Principal])( + "checks $kind repository permission before looking up grants", + async (principal) => { + const ctx = context(principal, []); + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toEqual({ + error: "Forbidden", + code: "permission_required", + permission: "repositories.use", + }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + } + ); + + it("requires environment permission instead of repository permission", async () => { + const ctx = context(user, ["repositories.use"]); + const response = await authorizeSessionTarget(ctx, { ...target, environmentId: "env_1" }); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toMatchObject({ permission: "environments.use" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + }); + + it("checks environment member grants without requiring repository permission", async () => { + const response = await authorizeSessionTarget(context(user, ["environments.use"]), { + ...target, + environmentId: "env_1", + }); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/group/app", + }); + }); + + it("fails closed when user authorization is unavailable", async () => { + const ctx = context(); + delete ctx.authorization; + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(503); + await expect(response?.json()).resolves.toMatchObject({ code: "authorization_unavailable" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it("enforces the service capability ceiling before actor permissions", async () => { + const ctx = context({ kind: "service", service: "web", actor: null }); + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toMatchObject({ code: "service_capability_required" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it("bypasses grants for workspace-owned targets", async () => { + const ctx = context(); + expect(await authorizeSessionTarget(ctx, { ...target, teamId: null })).toBeNull(); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + }); + + it.each([{ repositories: undefined }, { repositories: [] }])( + "refuses inactive ownership for a repo-less target", + async ({ repositories }) => { + const ctx = context(user, []); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + + const denied = await authorizeSessionTarget(ctx, { teamId: "team_alpha", repositories }); + expect(denied?.status).toBe(403); + await expect(denied?.json()).resolves.toMatchObject({ code: "team_not_active" }); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + } + ); + + it("preflights environment permission without team lookup when repository members are absent", async () => { + const ctx = context(user, ["environments.use"]); + + expect(await authorizeSessionTarget(ctx, { teamId: null, environmentId: "env_1" })).toBeNull(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it.each([user, sandbox])("rejects an inactive target team for $kind", async (principal) => { + const ctx = context(principal); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(403); + await expect(response?.json()).resolves.toEqual({ + error: "Team is not active", + code: "team_not_active", + }); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + }); + + it("matches grants by numeric repository ID", async () => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "repository", repo_external_id: 7 }] + ); + expect(await authorizeSessionTarget(ctx, target)).toBeNull(); + }); + + it("reports the first uncovered repository in target order", async () => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "repository", repo_external_id: 7 }] + ); + const response = await authorizeSessionTarget(ctx, { + ...target, + repositories: [repository, { owner: "acme", name: "api", repoId: 8 }], + }); + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toMatchObject({ repository: "acme/api" }); + }); + + it.each([null, undefined])( + "fails closed for unresolved ID %s without an installation grant", + async (repoId) => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "repository", repo_external_id: 7 }] + ); + const response = await authorizeSessionTarget(ctx, { + ...target, + repositories: [{ ...repository, repoId }], + }); + expect(response?.status).toBe(409); + } + ); + + it.each([user, sandbox])( + "allows an active repo-less team target for $kind without grants", + async (principal) => { + const ctx = context(principal, []); + expect( + await authorizeSessionTarget(ctx, { teamId: "team_alpha", repositories: [] }) + ).toBeNull(); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("team_alpha"); + expect(ctx.db.prepare).not.toHaveBeenCalled(); + } + ); + + it("allows unresolved IDs only with an installation grant", async () => { + const ctx = context( + user, + ["repositories.use"], + [{ grant_kind: "installation", repo_external_id: null }] + ); + expect( + await authorizeSessionTarget(ctx, { + ...target, + repositories: [{ owner: "acme", name: "app" }], + }) + ).toBeNull(); + }); + + it("still checks team grants for a sandbox without user permissions", async () => { + const ctx = context(sandbox, []); + delete ctx.authorization; + const response = await authorizeSessionTarget(ctx, target); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toMatchObject({ code: "target_team_missing_grant" }); + }); + + it("allows a sandbox with a covered team target without user authorization", async () => { + const ctx = context(sandbox, [], [{ grant_kind: "repository", repo_external_id: 7 }]); + delete ctx.authorization; + expect(await authorizeSessionTarget(ctx, target)).toBeNull(); + }); +}); diff --git a/packages/control-plane/src/routes/session-target-authorization.ts b/packages/control-plane/src/routes/session-target-authorization.ts index db1923185f..a96fe2121f 100644 --- a/packages/control-plane/src/routes/session-target-authorization.ts +++ b/packages/control-plane/src/routes/session-target-authorization.ts @@ -1,37 +1,46 @@ import type { PermissionId } from "@open-inspect/shared/rbac"; import { serviceAllowsPermission } from "../authorization/service-permissions"; import { json, type RequestContext } from "./shared"; +import { authorizeTeamRepositories } from "./workspace-repository-authorization"; export interface SessionTarget { + teamId: string | null; environmentId?: string | null; - hasRepository: boolean; + repositories?: readonly { owner: string; name: string; repoId?: number | null }[]; } -/** Enforce use of the environment or repository inherited by a new session. */ -export function authorizeSessionTarget( +/** Preflight permissions with teamId: null; check team grants after resolving repository IDs. */ +export async function authorizeSessionTarget( ctx: RequestContext, target: SessionTarget -): Response | null { - if (ctx.principal?.kind !== "user" && ctx.principal?.kind !== "service") return null; - +): Promise { const permission: PermissionId | null = target.environmentId ? "environments.use" - : target.hasRepository + : target.repositories?.length ? "repositories.use" : null; - if (!permission) return null; - if ( - ctx.principal.kind === "service" && - !serviceAllowsPermission(ctx.principal.service, permission) - ) { - return json({ error: "Forbidden", code: "service_capability_required" }, 403); - } - if (!ctx.authorization) { - return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); + if (permission && (ctx.principal?.kind === "user" || ctx.principal?.kind === "service")) { + if ( + ctx.principal.kind === "service" && + !serviceAllowsPermission(ctx.principal.service, permission) + ) { + return json({ error: "Forbidden", code: "service_capability_required" }, 403); + } + if (!ctx.authorization) { + return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); + } + if (!ctx.authorization.permissions.includes(permission)) { + return json({ error: "Forbidden", code: "permission_required", permission }, 403); + } } - if (!ctx.authorization.permissions.includes(permission)) { - return json({ error: "Forbidden", code: "permission_required", permission }, 403); - } - return null; + + return authorizeTeamRepositories(ctx, { + teamId: target.teamId, + repositories: (target.repositories ?? []).map((repository) => ({ + owner: repository.owner, + name: repository.name, + repoId: repository.repoId ?? null, + })), + }); } diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 2168da643d..fb3767d5e7 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -191,7 +191,7 @@ export function requireAutomation( export function requireTeam( need: keyof TeamCapabilities | "read" | "member", options?: { teamIdParam?: string; auditAllowed?: boolean } -): RouteAuthorization { +): Extract { const requirement: RouteAuthorizationRequirement = { kind: "team", teamIdParam: options?.teamIdParam ?? "id", diff --git a/packages/control-plane/src/routes/skills.repository-grants.test.ts b/packages/control-plane/src/routes/skills.repository-grants.test.ts new file mode 100644 index 0000000000..d0d02cbae9 --- /dev/null +++ b/packages/control-plane/src/routes/skills.repository-grants.test.ts @@ -0,0 +1,435 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { + skillImportPreviewResponseSchema, + skillSchema, + type SkillAssignmentInput, +} from "@open-inspect/shared/types/skills"; +import { + createRepositoryGrantEnv, + createRepositoryGrantRequest, + setupRepositoryGrantSpies, +} from "./repository-grants.test-support"; +import { AuthorizationStore } from "../db/authorization-store"; +import { SkillStore, SkillValidationError } from "../db/skills"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { SourceControlProviderError } from "../source-control"; +import type * as SourceControlModule from "../source-control"; +import type { Env } from "../types"; +import { skillRoutes } from "./skills"; + +const mocks = vi.hoisted(() => ({ + checkRepositoryAccess: vi.fn(), + resolveCommit: vi.fn(), + listTree: vi.fn(), + readBlob: vi.fn(), +})); +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: () => ({ + name: "github", + checkRepositoryAccess: mocks.checkRepositoryAccess, + resolveCommit: mocks.resolveCommit, + listTree: mocks.listTree, + readBlob: mocks.readBlob, + }), +})); + +const env = () => createRepositoryGrantEnv(["skills.manage"]); +const request = createRepositoryGrantRequest(skillRoutes, env); +const content = { description: "Deploy a service", body: "# Deploy", metadata: {}, files: [] }; +const source = { + provider: "github", + repoOwner: "acme", + repoName: "repo", + requestedRef: null, + resolvedRef: "main", + commitSha: "a".repeat(40), + subdirectory: null, + sourceSha256: "b".repeat(64), + importedAt: 1, + revisionId: "revision-1", +}; +const skill = skillSchema.parse({ + id: "skill-1", + name: "deploy", + ...content, + enabled: true, + currentRevisionId: "revision-1", + revisionNumber: 1, + revisionSha256: "c".repeat(64), + revisionCreatedBy: "user-1", + creatorDisplayName: null, + lastEditorDisplayName: null, + revisionAuthorDisplayName: null, + assignments: [], + source, + createdBy: "user-1", + updatedBy: "user-1", + createdAt: 1, + updatedAt: 1, + license: null, + compatibility: null, +}); +const importSource = { repository: { repoOwner: "acme", repoName: "repo" } }; +const confirmation = { + expectedCommitSha: "a".repeat(40), + expectedSourceSha256: "b".repeat(64), + expectedRevisionSha256: "c".repeat(64), +}; +const assignment: SkillAssignmentInput = { + type: "repository", + repository: { repoOwner: "acme", repoName: "repo", baseBranch: null }, +}; +const skillAssignmentWrites = [ + ["POST", "/skills", { name: "deploy", content, assignments: [assignment] }, 201], + ["PUT", "/skills/skill-1", { content, assignments: [assignment] }, 200], +] as const; +const skillImportRequests = [ + ["/skills/import/preview", { source: importSource }], + ["/skills/import", { source: importSource, ...confirmation }], + ["/skills/skill-1/reimport/preview", {}], + ["/skills/skill-1/reimport", confirmation], +] as const; + +async function expectAllowedSkillImports(environment = env()): Promise { + const preview = await request( + "/skills/import/preview", + "POST", + { source: importSource }, + environment + ); + expect(preview.status).toBe(200); + const imported = skillImportPreviewResponseSchema.parse(await preview.json()); + const currentConfirmation = { + expectedCommitSha: imported.source.commitSha, + expectedSourceSha256: imported.source.sourceSha256, + expectedRevisionSha256: imported.revisionSha256, + }; + expect( + ( + await request( + "/skills/import", + "POST", + { source: importSource, assignments: [assignment], ...currentConfirmation }, + environment + ) + ).status + ).toBe(201); + const reimportPreview = await request( + "/skills/skill-1/reimport/preview", + "POST", + {}, + environment + ); + expect(reimportPreview.status).toBe(200); + const reimported = skillImportPreviewResponseSchema.parse(await reimportPreview.json()); + expect( + ( + await request( + "/skills/skill-1/reimport", + "POST", + { + expectedCommitSha: reimported.source.commitSha, + expectedSourceSha256: reimported.source.sourceSha256, + expectedRevisionSha256: reimported.revisionSha256, + }, + environment + ) + ).status + ).toBe(200); + expect(SkillStore.prototype.create).toHaveBeenCalledOnce(); + expect(SkillStore.prototype.applyImportedRevision).toHaveBeenCalledOnce(); +} + +beforeEach(() => { + vi.clearAllMocks(); + setupRepositoryGrantSpies(); + mocks.checkRepositoryAccess.mockResolvedValue({ + repoId: 123, + repoOwner: "acme", + repoName: "repo", + defaultBranch: "main", + }); + mocks.resolveCommit.mockResolvedValue({ sha: "a".repeat(40) }); + mocks.listTree.mockResolvedValue({ + entries: [ + { + path: "SKILL.md", + type: "file", + blobId: "blob", + sizeBytes: 50, + executable: false, + }, + ], + truncated: false, + }); + mocks.readBlob.mockResolvedValue( + new TextEncoder().encode("---\nname: deploy\ndescription: Deploy a service\n---\n# Deploy\n") + ); + vi.spyOn(SkillStore.prototype, "create").mockResolvedValue(skill); + vi.spyOn(SkillStore.prototype, "get").mockResolvedValue(skill); + vi.spyOn(SkillStore.prototype, "replaceContentAndAssignments").mockResolvedValue(skill); + vi.spyOn(SkillStore.prototype, "applyImportedRevision").mockResolvedValue({ + skill, + revisionCreated: true, + }); + vi.spyOn(SkillStore.prototype, "nameAvailable").mockResolvedValue(true); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("skill repository-bearing writes", () => { + it.each(skillAssignmentWrites)( + "denies %s %s with another team's assignment before persistence", + async (method, path, body) => { + expect((await request(path, method, body)).status).toBe(403); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.replaceContentAndAssignments).not.toHaveBeenCalled(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + } + ); + + it.each(skillImportRequests)( + "denies source access at %s before reading repository content", + async (path, body) => { + expect((await request(path, "POST", body)).status).toBe(403); + expect(mocks.resolveCommit).not.toHaveBeenCalled(); + expect(mocks.listTree).not.toHaveBeenCalled(); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + } + ); + + it.each(skillAssignmentWrites)( + "allows %s %s for an owning-team member with skills.manage alone", + async (method, path, body, status) => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "other-team", + "team-1", + ]); + expect((await request(path, method, body)).status).toBe(status); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + } + ); + + it("resolves assignments sequentially", async () => { + let activeLookups = 0; + let peakLookups = 0; + mocks.checkRepositoryAccess.mockImplementation( + async ({ owner, name }: { owner: string; name: string }) => { + activeLookups++; + peakLookups = Math.max(peakLookups, activeLookups); + await Promise.resolve(); + activeLookups--; + return { + repoId: name === "other" ? 456 : 123, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }; + } + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + const response = await request("/skills", "POST", { + name: "deploy", + content, + assignments: [ + assignment, + { type: "repository", repository: { repoOwner: "acme", repoName: "other" } }, + ], + }); + + expect(response.status).toBe(201); + expect(peakLookups).toBe(1); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledTimes(2); + }); + + it("resolves duplicate repositories once without removing assignments from store validation", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + vi.mocked(SkillStore.prototype.create).mockRejectedValue( + new SkillValidationError("Skill assignments must be unique") + ); + const assignments = [assignment, assignment]; + + expect( + (await request("/skills", "POST", { name: "deploy", content, assignments })).status + ).toBe(400); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(SkillStore.prototype.create).toHaveBeenCalledWith( + expect.objectContaining({ assignments }), + "user-1" + ); + }); + + it.each([ + { failure: new SourceControlProviderError("SCM unavailable", "transient", 503), status: 503 }, + { + failure: new SourceControlProviderError("SCM rejected access", "permanent", 401), + status: 502, + }, + ])( + "preserves importer resolution errors ($status) before grants and content reads", + async ({ failure, status }) => { + mocks.checkRepositoryAccess.mockRejectedValue(failure); + for (const [path, body] of skillImportRequests) { + const response = await request(path, "POST", body); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ + error: `Failed to reach acme/repo: ${failure.message}`, + }); + } + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + expect(mocks.resolveCommit).not.toHaveBeenCalled(); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + } + ); + + it("preserves the importer-specific inaccessible repository response", async () => { + mocks.checkRepositoryAccess.mockResolvedValue(null); + const response = await request("/skills/import/preview", "POST", { source: importSource }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: + "acme/repo is not accessible to this installation. Grant the app access to the repository and try again.", + }); + expect(mocks.readBlob).not.toHaveBeenCalled(); + }); + + it("rejects an uninstalled assignment before persistence even with an installation grant", async () => { + mocks.checkRepositoryAccess.mockResolvedValue(null); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + expect( + (await request("/skills", "POST", { name: "deploy", content, assignments: [assignment] })) + .status + ).toBe(404); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + }); + + it("rejects a replaced numeric source ID rather than trusting recorded provenance names", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockImplementation( + async (repoId) => (repoId === 456 ? ["team-1"] : ["other-team"]) + ); + expect((await request("/skills/skill-1/reimport", "POST", confirmation)).status).toBe(403); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "repo" }); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + }); + + it("checks import assignments independently of a granted source", async () => { + mocks.checkRepositoryAccess.mockImplementation( + async ({ owner, name }: { owner: string; name: string }) => ({ + repoId: name === "source" ? 456 : 123, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockImplementation( + async (repoId) => (repoId === 456 ? ["team-1"] : ["other-team"]) + ); + expect( + ( + await request("/skills/import", "POST", { + source: { repository: { repoOwner: "acme", repoName: "source" } }, + assignments: [assignment], + ...confirmation, + }) + ).status + ).toBe(403); + expect(mocks.readBlob).not.toHaveBeenCalled(); + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + }); + + it("allows an owning-team member to preview and confirm import and reimport", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-1", + ]); + await expectAllowedSkillImports(); + expect(mocks.checkRepositoryAccess).toHaveBeenCalledTimes(5); + }); +}); + +describe.each(["off", "shadow", "on"] as const)( + "workspace skill repository ownership in %s mode", + (mode) => { + let environment: Env; + beforeEach(() => { + environment = env(); + environment.TEAMS_ENFORCEMENT = mode; + }); + + it.each(skillAssignmentWrites)( + "allows %s %s with no grants anywhere and only existing permissions", + async (method, path, body, status) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect((await request(path, method, body, environment)).status).toBe(status); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it("allows import and reimport preview confirmation with no grants anywhere", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + await expectAllowedSkillImports(environment); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + }); + + it.each(["member", "lead", null] as const)( + "denies another team's repositories for an unrelated %s across skill routes", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + role === null ? new Map() : new Map([["team-1", role]]) + ); + for (const [method, path, body] of skillAssignmentWrites) { + expect((await request(path, method, body, environment)).status).toBe(403); + } + for (const [path, body] of skillImportRequests) { + expect((await request(path, "POST", body, environment)).status).toBe(403); + } + expect(SkillStore.prototype.create).not.toHaveBeenCalled(); + expect(SkillStore.prototype.replaceContentAndAssignments).not.toHaveBeenCalled(); + expect(SkillStore.prototype.applyImportedRevision).not.toHaveBeenCalled(); + expect(mocks.readBlob).not.toHaveBeenCalled(); + } + ); + } +); + +describe.each(["owner", "administrator"] as const)( + "built-in %s skill repository authorization", + (key) => { + beforeEach(() => { + vi.spyOn(AuthorizationStore.prototype, "getEffectiveAuthorization").mockResolvedValue({ + userId: "user-1", + suspendedAt: null, + role: { ...BUILT_IN_ROLE_REGISTRY[key], name: key }, + }); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + }); + + it.each(skillAssignmentWrites)( + "allows %s %s for another team's repository without membership", + async (method, path, body, status) => { + expect((await request(path, method, body)).status).toBe(status); + } + ); + + it("allows import and reimport preview confirmation for another team's repository", async () => { + await expectAllowedSkillImports(); + }); + } +); diff --git a/packages/control-plane/src/routes/skills.ts b/packages/control-plane/src/routes/skills.ts index 127b945596..9aa1feae7d 100644 --- a/packages/control-plane/src/routes/skills.ts +++ b/packages/control-plane/src/routes/skills.ts @@ -31,7 +31,12 @@ import { buildValidatedSkillRevision, SkillRevisionValidationError, } from "../skills/content-addressing"; -import { fetchSkillImport, SkillImportError, type SkillImportResult } from "../skills/git-import"; +import { + fetchSkillImport, + resolveSkillImportRepository, + SkillImportError, + type SkillImportResult, +} from "../skills/git-import"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; import { z } from "zod"; @@ -44,7 +49,12 @@ import { SCM_AGNOSTIC_HUMAN_USER_ROUTE, SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, requirePermission, + resolveRepoOrError, } from "./shared"; +import { + authorizeWorkspaceRepositories, + type RepositoryAuthorizationTarget, +} from "./workspace-repository-authorization"; const log = createLogger("router:skills"); @@ -133,7 +143,7 @@ async function handleGetSkill( async function handleCreateSkill( request: Request, - _env: Env, + env: Env, _params: object, ctx: RequestContext ): Promise { @@ -141,6 +151,14 @@ async function handleCreateSkill( if (!userId) return error("Canonical user required", 403); const parsed = await parseBody(request, createSkillInputSchema, "Invalid skill"); if (parsed instanceof Response) return parsed; + const denied = await authorizeSkillRepositories( + env, + ctx, + parsed.assignments.flatMap((assignment) => + assignment.type === "repository" ? [assignment.repository] : [] + ) + ); + if (denied) return denied; try { const skill = await new SkillStore(ctx.db).create(parsed, userId); audit(ctx, { @@ -154,6 +172,45 @@ async function handleCreateSkill( } } +async function authorizeSkillRepositories( + env: Env, + ctx: RequestContext, + repositories: readonly { repoOwner: string; repoName: string }[] +): Promise { + if (repositories.length === 0) return null; + const resolved: RepositoryAuthorizationTarget[] = []; + const seen = new Set(); + for (const repository of repositories) { + const key = `${repository.repoOwner}/${repository.repoName}`; + if (seen.has(key)) continue; + seen.add(key); + const access = await resolveRepoOrError( + env, + repository.repoOwner, + repository.repoName, + ctx, + log + ); + resolved.push({ owner: access.repoOwner, name: access.repoName, repoId: access.repoId }); + } + return authorizeWorkspaceRepositories(ctx, { repositories: resolved }); +} + +async function fetchAuthorizedSkillImport( + env: Env, + ctx: RequestContext, + source: SkillImportSourceInput, + name?: string | null +): Promise { + const provider = createRouteSourceControlProvider(env); + const resolved = await resolveSkillImportRepository(provider, source); + const denied = await authorizeWorkspaceRepositories(ctx, { + repositories: [{ owner: resolved.repoOwner, name: resolved.repoName, repoId: resolved.repoId }], + }); + if (denied) return denied; + return fetchSkillImport(provider, source, name, resolved); +} + async function handlePreviewSkill( request: Request, _env: Env, @@ -248,11 +305,8 @@ async function handlePreviewSkillImport( ); if (parsed instanceof Response) return parsed; try { - const result = await fetchSkillImport( - createRouteSourceControlProvider(env), - parsed.source, - parsed.name - ); + const result = await fetchAuthorizedSkillImport(env, ctx, parsed.source, parsed.name); + if (result instanceof Response) return result; return json(await importPreviewResponse(ctx, result)); } catch (e) { return skillImportWriteError(e); @@ -269,12 +323,17 @@ async function handleImportSkill( if (!userId) return error("Canonical user required", 403); const parsed = await parseBody(request, importSkillInputSchema, "Invalid skill import"); if (parsed instanceof Response) return parsed; + const denied = await authorizeSkillRepositories( + env, + ctx, + parsed.assignments.flatMap((assignment) => + assignment.type === "repository" ? [assignment.repository] : [] + ) + ); + if (denied) return denied; try { - const result = await fetchSkillImport( - createRouteSourceControlProvider(env), - parsed.source, - parsed.name - ); + const result = await fetchAuthorizedSkillImport(env, ctx, parsed.source, parsed.name); + if (result instanceof Response) return result; const stale = confirmedImport(result, parsed); if (stale) return stale; const skill = await new SkillStore(ctx.db).create( @@ -342,7 +401,8 @@ async function handlePreviewSkillReimport( const provider = createRouteSourceControlProvider(env); const source = recordedImportSource(skill.source, parsed.ref, provider.name); if (source instanceof Response) return source; - const result = await fetchSkillImport(provider, source, skill.name); + const result = await fetchAuthorizedSkillImport(env, ctx, source, skill.name); + if (result instanceof Response) return result; return json(await importPreviewResponse(ctx, result, skill.name)); } catch (e) { return skillImportWriteError(e); @@ -372,7 +432,8 @@ async function handleReimportSkill( const provider = createRouteSourceControlProvider(env); const source = recordedImportSource(skill.source, parsed.ref, provider.name); if (source instanceof Response) return source; - const result = await fetchSkillImport(provider, source, skill.name); + const result = await fetchAuthorizedSkillImport(env, ctx, source, skill.name); + if (result instanceof Response) return result; const stale = confirmedImport(result, parsed); if (stale) return stale; const applied = await store.applyImportedRevision( @@ -429,7 +490,7 @@ async function handleSetSkillEnabled( async function handleReplaceSkillContentAndAssignments( request: Request, - _env: Env, + env: Env, params: { id: string }, ctx: RequestContext ): Promise { @@ -444,6 +505,14 @@ async function handleReplaceSkillContentAndAssignments( "Invalid skill edit" ); if (parsed instanceof Response) return parsed; + const denied = await authorizeSkillRepositories( + env, + ctx, + parsed.assignments.flatMap((assignment) => + assignment.type === "repository" ? [assignment.repository] : [] + ) + ); + if (denied) return denied; try { const skill = await new SkillStore(ctx.db).replaceContentAndAssignments( id, diff --git a/packages/control-plane/src/routes/teams.ts b/packages/control-plane/src/routes/teams.ts index bae56c3077..4dcde19f7a 100644 --- a/packages/control-plane/src/routes/teams.ts +++ b/packages/control-plane/src/routes/teams.ts @@ -7,6 +7,8 @@ import { teamRoleSchema, teamSessionsResponseSchema, updateTeamRequestSchema, + addTeamRepositoryGrantRequestSchema, + teamRepositoryGrantsResponseSchema, type Team, type TeamRole, } from "@open-inspect/shared/types/teams"; @@ -31,6 +33,10 @@ import { } from "../db/team-memberships"; import { TeamSlugConflictError, TeamStore } from "../db/teams"; import { TeamSettingsStore } from "../db/team-settings"; +import { + TeamRepositoryGrantConflictError, + TeamRepositoryGrantStore, +} from "../db/team-repository-grants"; import type { RequestContext } from "../http/request-context"; import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; @@ -47,9 +53,13 @@ import { requireTeam, requireAll, permissionRequirement, + resolveRepoOrError, + type RouteAuthorization, } from "./shared"; +import { createLogger } from "../logger"; const PRIVATE = { cacheControl: "private, no-store" } as const; +const logger = createLogger("router:teams"); const ACTIVE_USER = { kind: "active-global", service: { kind: "deny" }, @@ -112,6 +122,9 @@ async function auditTeamEvent( } function mutationError(cause: unknown): Response { + if (cause instanceof TeamRepositoryGrantConflictError) { + return json({ error: cause.message, code: cause.code }, 409); + } if (cause instanceof LastLeadError) return json({ error: cause.message, code: "last_lead" }, 409); if (cause instanceof TeamMembershipNotFoundError) return error("Team membership not found", 404); if (cause instanceof TeamSlugConflictError) { @@ -426,8 +439,69 @@ async function joinTeam( return json(await responseTeam(ctx, team)); } +async function repositoryGrants( + _request: Request, + _env: Env, + _params: { id: string }, + ctx: RequestContext +) { + return json( + teamRepositoryGrantsResponseSchema.parse({ + grants: await new TeamRepositoryGrantStore(ctx.db).listDetailsForTeam(admittedTeam(ctx).id), + }) + ); +} + +async function putRepositoryGrant( + request: Request, + env: Env, + _params: { id: string }, + ctx: RequestContext +) { + const body = await parseBody(request, addTeamRepositoryGrantRequestSchema); + if (body instanceof Response) return body; + const team = admittedTeam(ctx); + if (body.kind === "repository") { + const repository = await resolveRepoOrError(env, body.owner, body.name, ctx, logger); + if (repository.repoId !== body.repoExternalId) { + return json( + { error: "Repository identity changed", code: "repository_identity_mismatch" }, + 409 + ); + } + body.owner = repository.repoOwner; + body.name = repository.repoName; + } + try { + const grant = await new TeamRepositoryGrantStore(ctx.db).add(team.id, body, { + actorUserId: viewer(ctx).userId, + requestId: ctx.request_id, + }); + return json({ grant }); + } catch (cause) { + return mutationError(cause); + } +} + +async function deleteRepositoryGrant( + _request: Request, + _env: Env, + params: { id: string; grantId: string }, + ctx: RequestContext +) { + if (admittedTeam(ctx).archivedAt !== null) { + return json({ error: "Team is not active", code: "team_not_active" }, 409); + } + const deleted = await new TeamRepositoryGrantStore(ctx.db).remove( + admittedTeam(ctx).id, + params.grantId, + { actorUserId: viewer(ctx).userId, requestId: ctx.request_id } + ); + return deleted ? new Response(null, { status: 204 }) : error("Repository grant not found", 404); +} + export const teamRoutes = new Hono(); -const policy = (authorization: ReturnType) => +const policy = (authorization: RouteAuthorization) => admit({ ...SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, ...PRIVATE, authorization }); const read = policy(requireTeam("read")); const manage = policy(requireTeam("canEditMetadata")); @@ -483,3 +557,15 @@ teamRoutes.get( }), (c) => dispatch(c, teamSessions) ); +teamRoutes.get( + "/teams/:id/repository-grants", + policy({ ...requireTeam("member"), auditAllowed: false }), + (c) => dispatch(c, repositoryGrants) +); +const repositoriesManage = policy(requireTeam("canManageRepositories")); +teamRoutes.put("/teams/:id/repository-grants", repositoriesManage, (c) => + dispatch(c, putRepositoryGrant) +); +teamRoutes.delete("/teams/:id/repository-grants/:grantId", repositoriesManage, (c) => + dispatch(c, deleteRepositoryGrant) +); diff --git a/packages/control-plane/src/routes/workspace-repository-authorization.test.ts b/packages/control-plane/src/routes/workspace-repository-authorization.test.ts new file mode 100644 index 0000000000..0540697a3f --- /dev/null +++ b/packages/control-plane/src/routes/workspace-repository-authorization.test.ts @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac"; +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { createRequestMetrics } from "../db/instrumented-sql-database"; +import { createTestEnv, TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support"; +import type { RequestContext } from "./shared"; +import { + authorizeTeamRepositories, + authorizeWorkspaceRepositories, +} from "./workspace-repository-authorization"; + +const repository = { owner: "acme", name: "repo", repoId: 123 }; + +function context(): RequestContext { + return { + db: createTestEnv().DB, + metrics: createRequestMetrics(), + request_id: "request-1", + trace_id: "trace-1", + executionCtx: TEST_BACKGROUND_TASK_CONTEXT, + principal: { kind: "user", userId: "user-1" }, + authorization: { + userId: "user-1", + suspendedAt: null, + role: { id: "custom-role", key: null, name: "Custom" }, + permissions: [], + }, + teamsEnforcementMode: "on", + }; +} + +describe("workspace repository grants", () => { + beforeEach(() => { + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamMembershipStore.prototype, "listForUser").mockResolvedValue( + new Map([["team-other", "member"]]) + ); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository").mockResolvedValue([ + "team-granted", + ]); + }); + afterEach(() => vi.restoreAllMocks()); + + it.each([null, 0, -1, Number.NaN, 1.5, Number.MAX_SAFE_INTEGER + 1])( + "rejects invalid numeric repository IDs (%s)", + async (repoId) => { + expect( + ( + await authorizeWorkspaceRepositories(context(), { + repositories: [{ ...repository, repoId }], + }) + )?.status + ).toBe(403); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).not.toHaveBeenCalled(); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "preserves unowned repositories in %s mode, even for callers on no team", + async (mode) => { + const ctx = context(); + ctx.teamsEnforcementMode = mode; + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect(await authorizeWorkspaceRepositories(ctx, { repositories: [repository] })).toBeNull(); + expect( + await authorizeWorkspaceRepositories(ctx, { repositories: [repository], requireLead: true }) + ).toBeNull(); + } + ); + + it.each(["off", "shadow", "on"] as const)( + "denies team-owned repositories to nonmembers in %s mode", + async (mode) => { + const ctx = context(); + ctx.teamsEnforcementMode = mode; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue(new Map()); + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + } + ); + + it("accepts membership in any granting team rather than choosing a home team", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-member", "member"]]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-other", + "team-member", + ]); + expect( + await authorizeWorkspaceRepositories(context(), { repositories: [repository] }) + ).toBeNull(); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + }); + + it.each(["off", "shadow", "on"] as const)( + "refuses retained archived memberships in %s mode without making the repository unowned", + async (mode) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-granted", "lead"]]) + ); + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + const ctx = context(); + ctx.teamsEnforcementMode = mode; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + expect( + ( + await authorizeWorkspaceRepositories(ctx, { + repositories: [repository], + requireLead: true, + }) + )?.status + ).toBe(403); + expect(TeamRepositoryGrantStore.prototype.listTeamsForRepository).toHaveBeenCalledWith(123); + } + ); + + it("accepts an active granting team but not an archived lead", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-archived", "lead"], + ["team-active", "member"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([ + "team-archived", + "team-active", + ]); + vi.mocked(TeamStore.prototype.isActive).mockImplementation( + async (teamId) => teamId === "team-active" + ); + expect( + await authorizeWorkspaceRepositories(context(), { repositories: [repository] }) + ).toBeNull(); + expect( + ( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository], + requireLead: true, + }) + )?.status + ).toBe(403); + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-active", "lead"]]) + ); + expect( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository], + requireLead: true, + }) + ).toBeNull(); + }); + + it("allows grants from different caller teams without adding repositories.use", async () => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([ + ["team-1", "member"], + ["team-2", "lead"], + ]) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockImplementation( + async (repoId) => [repoId === 123 ? "team-1" : "team-2"] + ); + expect( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository, { owner: "acme", name: "other", repoId: 456 }], + }) + ).toBeNull(); + }); + + it("does not need an enforcement mode to apply ownership checks", async () => { + const ctx = context(); + delete ctx.teamsEnforcementMode; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + vi.mocked(TeamRepositoryGrantStore.prototype.listTeamsForRepository).mockResolvedValue([]); + expect(await authorizeWorkspaceRepositories(ctx, { repositories: [repository] })).toBeNull(); + }); + + it.each(["member", "lead"] as const)( + "permits a granting team's %s and limits secrets to leads", + async (role) => { + vi.mocked(TeamMembershipStore.prototype.listForUser).mockResolvedValue( + new Map([["team-granted", role]]) + ); + expect( + await authorizeWorkspaceRepositories(context(), { repositories: [repository] }) + ).toBeNull(); + expect( + ( + await authorizeWorkspaceRepositories(context(), { + repositories: [repository], + requireLead: true, + }) + )?.status ?? null + ).toBe(role === "lead" ? null : 403); + } + ); + + it.each(["owner", "administrator"] as const)( + "allows built-in %s access to every workspace repository surface", + async (key) => { + const ctx = context(); + if (!ctx.authorization) throw new Error("Expected authorization"); + ctx.authorization.role = { ...BUILT_IN_ROLE_REGISTRY[key], name: key }; + expect( + await authorizeWorkspaceRepositories(ctx, { + repositories: [repository], + requireLead: true, + }) + ).toBeNull(); + expect(await authorizeWorkspaceRepositories(ctx, { repositories: [repository] })).toBeNull(); + } + ); + + it("does not derive privileged access from a custom role's display name", async () => { + const ctx = context(); + if (!ctx.authorization) throw new Error("Expected authorization"); + ctx.authorization.role.name = "Owner"; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(403); + }); + + it("fails closed without an admitted actor", async () => { + const ctx = context(); + delete ctx.authorization; + expect( + (await authorizeWorkspaceRepositories(ctx, { repositories: [repository] }))?.status + ).toBe(503); + }); +}); + +describe("explicit team repository grants", () => { + beforeEach(() => { + vi.spyOn(TeamStore.prototype, "isActive").mockResolvedValue(true); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + }); + afterEach(() => vi.restoreAllMocks()); + + it("does not substitute the caller's other teams for the persisted owner", async () => { + const response = await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [repository], + }); + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("owner-team"); + }); + + it("denies an archived owner even if it has installation access", async () => { + vi.mocked(TeamStore.prototype.isActive).mockResolvedValue(false); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + expect( + ( + await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [repository], + }) + )?.status + ).toBe(403); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("preserves a workspace-owned target", async () => { + expect( + await authorizeTeamRepositories(context(), { + teamId: null, + repositories: [repository], + }) + ).toBeNull(); + expect(TeamStore.prototype.isActive).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("allows a granted active owner without requiring a new use permission", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + expect( + await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [repository], + }) + ).toBeNull(); + }); + + it("allows a null repository ID only with an installation grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + + expect( + await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [{ ...repository, repoId: null }], + }) + ).toBeNull(); + }); + + it("denies a null repository ID with only repository grants", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + + const response = await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories: [{ ...repository, repoId: null }], + }); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toEqual({ + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "acme/repo", + }); + }); + + it("names the first uncovered repository while preserving readonly target order", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 123 }, + ]); + const repositories = [ + repository, + { owner: "group/subgroup", name: "api", repoId: 456 }, + { owner: "acme", name: "other", repoId: 789 }, + ] as const; + + const response = await authorizeTeamRepositories(context(), { + teamId: "owner-team", + repositories, + }); + + expect(response?.status).toBe(409); + await expect(response?.json()).resolves.toMatchObject({ repository: "group/subgroup/api" }); + }); + + it("checks team activity but needs no grants for an empty repository set", async () => { + expect( + await authorizeTeamRepositories(context(), { teamId: "owner-team", repositories: [] }) + ).toBeNull(); + expect(TeamStore.prototype.isActive).toHaveBeenCalledWith("owner-team"); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/routes/workspace-repository-authorization.ts b/packages/control-plane/src/routes/workspace-repository-authorization.ts new file mode 100644 index 0000000000..edb97a624b --- /dev/null +++ b/packages/control-plane/src/routes/workspace-repository-authorization.ts @@ -0,0 +1,93 @@ +import { TeamMembershipStore } from "../db/team-memberships"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { TeamStore } from "../db/teams"; +import { json, type RequestContext } from "./shared"; +import { missingTeamRepository } from "./session-team-grants"; + +export interface RepositoryAuthorizationTarget { + owner: string; + name: string; + repoId: number | null; +} + +function deniedRepository(repository: RepositoryAuthorizationTarget): Response { + return json( + { + error: "Repository grant required", + code: "repository_grant_required", + reason_code: "repository_grant_required", + repository: `${repository.owner}/${repository.name}`, + }, + 403 + ); +} + +/** Grant-only check; callers retain their existing permission and membership admission. */ +export async function authorizeTeamRepositories( + ctx: RequestContext, + target: { teamId: string | null; repositories: readonly RepositoryAuthorizationTarget[] } +): Promise { + if (target.teamId === null) return null; + if (!(await new TeamStore(ctx.db).isActive(target.teamId))) { + return json({ error: "Team is not active", code: "team_not_active" }, 403); + } + const missing = await missingTeamRepository( + ctx.db, + target.teamId, + target.repositories.map((repository) => ({ + repoOwner: repository.owner, + repoName: repository.name, + repoId: repository.repoId, + })) + ); + if (!missing) return null; + return json( + { + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: `${missing.repoOwner}/${missing.repoName}`, + }, + 409 + ); +} + +/** Unowned repositories stay workspace-level; any granting team may authorize its members. */ +export async function authorizeWorkspaceRepositories( + ctx: RequestContext, + target: { + repositories: readonly RepositoryAuthorizationTarget[]; + requireLead?: boolean; + } +): Promise { + if (target.repositories.length === 0) return null; + const authorization = ctx.authorization; + if (!authorization) { + return json({ error: "Authorization unavailable", code: "authorization_unavailable" }, 503); + } + const roleKey = authorization.role.key; + if (roleKey === "owner" || roleKey === "administrator") return null; + + const store = new TeamRepositoryGrantStore(ctx.db); + const teams = new TeamStore(ctx.db); + for (const repository of target.repositories) { + const repoId = repository.repoId; + if (repoId === null || !Number.isSafeInteger(repoId) || repoId <= 0) + return deniedRepository(repository); + const owners = await store.listTeamsForRepository(repoId); + if (owners.length === 0) continue; + const memberships = (ctx.sessionMemberships ??= await new TeamMembershipStore( + ctx.db + ).listForUser(authorization.userId)); + let allowed = false; + for (const teamId of owners) { + if (target.requireLead ? memberships.get(teamId) !== "lead" : !memberships.has(teamId)) + continue; + if (await teams.isActive(teamId)) { + allowed = true; + break; + } + } + if (!allowed) return deniedRepository(repository); + } + return null; +} diff --git a/packages/control-plane/src/routing/route-admission.ts b/packages/control-plane/src/routing/route-admission.ts index 55a24884e6..0471c54657 100644 --- a/packages/control-plane/src/routing/route-admission.ts +++ b/packages/control-plane/src/routing/route-admission.ts @@ -610,7 +610,14 @@ async function enforceTeamRequirement( if (!teamId) return { response: json({ error: "Invalid team route" }, 400) }; try { const team = await new TeamStore(ctx.db).getById(teamId); - if (!team) return { response: error("Team not found", 404) }; + if (!team) + return authorizationDenial( + error("Team not found", 404), + evidence, + requirement, + "team_not_visible", + "Team not found" + ); const memberships = new TeamMembershipStore(ctx.db); const viewer = viewerFromContext( ctx, @@ -625,7 +632,13 @@ async function enforceTeamRequirement( requirement.need === "removeMember" || (requirement.need === "read" && team.archivedAt !== null)) ) - return { response: error("Team not found", 404) }; + return authorizationDenial( + error("Team not found", 404), + evidence, + requirement, + "team_not_visible", + "Team not found" + ); const access = resolveTeamAccess( { userId: viewer.userId, diff --git a/packages/control-plane/src/skills/git-import.test.ts b/packages/control-plane/src/skills/git-import.test.ts index e0595b08bb..432076039a 100644 --- a/packages/control-plane/src/skills/git-import.test.ts +++ b/packages/control-plane/src/skills/git-import.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MAX_SKILL_FILE_BYTES, MAX_SKILL_REVISION_BYTES, @@ -102,6 +102,29 @@ const SKILL_MD = [ ].join("\n"); describe("fetchSkillImport", () => { + it("reuses the repository resolution already authorized by the route", async () => { + const provider = fakeProvider({ "SKILL.md": { content: SKILL_MD } }); + const check = vi.spyOn(provider, "checkRepositoryAccess"); + const resolveCommit = vi.spyOn(provider, "resolveCommit"); + const result = await fetchSkillImport(provider, source(), undefined, { + repoId: 123, + repoOwner: "canonical-owner", + repoName: "canonical-name", + defaultBranch: "develop", + }); + expect(check).not.toHaveBeenCalled(); + expect(resolveCommit).toHaveBeenCalledWith({ + owner: "canonical-owner", + name: "canonical-name", + ref: "develop", + }); + expect(result.source).toMatchObject({ + repoOwner: "canonical-owner", + repoName: "canonical-name", + resolvedRef: "develop", + }); + }); + it("maps SKILL.md and supporting files onto a validated revision", async () => { const provider = fakeProvider({ "SKILL.md": { content: SKILL_MD }, @@ -213,6 +236,29 @@ describe("fetchSkillImport", () => { ]); }); + it("derives an unnamed skill from the requested alias while reading the canonical repository", async () => { + const provider = fakeProvider( + { "SKILL.md": { content: "---\ndescription: Deploys the API\n---\n# Deploy\n" } }, + { normalizedIdentity: { repoOwner: "canonical-owner", repoName: "renamed-skills" } } + ); + const resolveCommit = vi.spyOn(provider, "resolveCommit"); + const listTree = vi.spyOn(provider, "listTree"); + const readBlob = vi.spyOn(provider, "readBlob"); + + const result = await fetchSkillImport(provider, source()); + + expect(result.name).toBe("skills"); + expect(result.source).toMatchObject({ + repoOwner: "canonical-owner", + repoName: "renamed-skills", + }); + for (const read of [resolveCommit, listTree, readBlob]) { + expect(read).toHaveBeenCalledWith( + expect.objectContaining({ owner: "canonical-owner", name: "renamed-skills" }) + ); + } + }); + it("surfaces frontmatter that has no managed-skill field", async () => { const provider = fakeProvider({ "SKILL.md": { diff --git a/packages/control-plane/src/skills/git-import.ts b/packages/control-plane/src/skills/git-import.ts index 47246676c0..0ecafbe219 100644 --- a/packages/control-plane/src/skills/git-import.ts +++ b/packages/control-plane/src/skills/git-import.ts @@ -21,7 +21,11 @@ import { type SkillImportSourceInput, type SkillImportWarning, } from "@open-inspect/shared/types/skills"; -import type { RepositoryReader, RepositoryTreeEntry } from "../source-control"; +import type { + RepositoryAccessResult, + RepositoryReader, + RepositoryTreeEntry, +} from "../source-control"; import { SourceControlProviderError } from "../source-control"; import { buildValidatedSkillRevision, hashImportedSourceTree } from "./content-addressing"; import { parseSkillMarkdown, SkillMarkdownError, type ParsedSkillMarkdown } from "./skill-markdown"; @@ -265,22 +269,19 @@ function resolveName( return parsed.data; } -/** - * Fetch, map, and validate one skill directory at a resolved commit. - * - * @param nameOverride - Canonical name to store under, overriding the source. - * @throws SkillImportError with the status the caller should return. - */ -export async function fetchSkillImport( +/** Resolve source access with the importer's HTTP error semantics, before reading content. */ +export async function resolveSkillImportRepository( provider: RepositoryReader, - source: SkillImportSourceInput, - nameOverride?: string | null -): Promise { - const repository = { owner: source.repository.repoOwner, name: source.repository.repoName }; - const label = `${repository.owner}/${repository.name}`; + source: SkillImportSourceInput +): Promise { + const requestedRepository = { + owner: source.repository.repoOwner, + name: source.repository.repoName, + }; + const label = `${requestedRepository.owner}/${requestedRepository.name}`; let access: Awaited>; try { - access = await provider.checkRepositoryAccess(repository); + access = await provider.checkRepositoryAccess(requestedRepository); } catch (error) { throw providerFailure(error, `Failed to reach ${label}`); } @@ -290,7 +291,25 @@ export async function fetchSkillImport( 404 ); } + return access; +} +/** + * Fetch, map, and validate one skill directory at a resolved commit. + * + * @param nameOverride - Canonical name to store under, overriding the source. + * @param resolvedRepository - Reuse the SCM identity already authorized by the route. + * @throws SkillImportError with the status the caller should return. + */ +export async function fetchSkillImport( + provider: RepositoryReader, + source: SkillImportSourceInput, + nameOverride?: string | null, + resolvedRepository?: RepositoryAccessResult +): Promise { + const label = `${source.repository.repoOwner}/${source.repository.repoName}`; + const access = resolvedRepository ?? (await resolveSkillImportRepository(provider, source)); + const repository = { owner: access.repoOwner, name: access.repoName }; const requestedRef = source.ref ?? null; const resolvedRef = requestedRef ?? access.defaultBranch; let commit: Awaited>; @@ -392,7 +411,7 @@ export async function fetchSkillImport( nameOverride, mapped.frontmatterName, source.subdirectory ?? null, - repository.name, + source.repository.repoName, warnings ); const revision = await buildValidatedSkillRevision(name, mapped.content); 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 bfee1eda97..f240422421 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 @@ -22,82 +22,85 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"DELETE /teams/:id/members/:userId","pathname":"/teams/fixture-17-id%2Fraw/members/fixture-17-userId%2Fraw","groups":{"id":"fixture-17-id%2Fraw","userId":"fixture-17-userId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"removeMember","targetUserIdParam":"userId"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /teams/:id/join","pathname":"/teams/fixture-18-id%2Fraw/join","groups":{"id":"fixture-18-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canJoin"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /teams/:id/sessions","pathname":"/teams/fixture-19-id%2Fraw/sessions","groups":{"id":"fixture-19-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"},{"kind":"permission","permission":"sessions.read"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/repository-grants","pathname":"/teams/fixture-20-id%2Fraw/repository-grants","groups":{"id":"fixture-20-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"member"}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/repository-grants","pathname":"/teams/fixture-21-id%2Fraw/repository-grants","groups":{"id":"fixture-21-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/repository-grants/:grantId","pathname":"/teams/fixture-22-id%2Fraw/repository-grants/fixture-22-grantId%2Fraw","groups":{"id":"fixture-22-id%2Fraw","grantId":"fixture-22-grantId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageRepositories"}],"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-23-id%2Fraw/visibility","groups":{"id":"fixture-23-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"changeVisibility","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-24-id%2Fraw/collaborators/fixture-24-userId%2Fraw","groups":{"id":"fixture-24-id%2Fraw","userId":"fixture-24-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-25-id%2Fraw/collaborators/fixture-25-userId%2Fraw","groups":{"id":"fixture-25-id%2Fraw","userId":"fixture-25-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/collaborator-candidates","pathname":"/sessions/fixture-26-id%2Fraw/collaborator-candidates","groups":{"id":"fixture-26-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/visibility","pathname":"/sessions/fixture-26-id%2Fraw/visibility","groups":{"id":"fixture-26-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"changeVisibility","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /sessions/:id/collaborators/:userId","pathname":"/sessions/fixture-27-id%2Fraw/collaborators/fixture-27-userId%2Fraw","groups":{"id":"fixture-27-id%2Fraw","userId":"fixture-27-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-28-id%2Fraw/collaborators/fixture-28-userId%2Fraw","groups":{"id":"fixture-28-id%2Fraw","userId":"fixture-28-userId%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/collaborator-candidates","pathname":"/sessions/fixture-29-id%2Fraw/collaborator-candidates","groups":{"id":"fixture-29-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"manageCollaborators","enforceAlways":true}],"service":{"kind":"deny"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /sessions","pathname":"/sessions","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /sessions/inbox","pathname":"/sessions/inbox","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"sessions.read"}],"auditAllowed":false,"service":{"kind":"deny"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-29-id%2Fraw/read-state","groups":{"id":"fixture-29-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /sessions/:id","pathname":"/sessions/fixture-30-id%2Fraw","groups":{"id":"fixture-30-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"delete"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /sessions/:id/read-state","pathname":"/sessions/fixture-32-id%2Fraw/read-state","groups":{"id":"fixture-32-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-33-id%2Fraw","groups":{"id":"fixture-33-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-32-id%2Fraw/export","groups":{"id":"fixture-32-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"permission","permission":"sessions.export"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-access","pathname":"/sessions/fixture-33-id%2Fraw/sandbox-access","groups":{"id":"fixture-33-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id","pathname":"/sessions/fixture-34-id%2Fraw","groups":{"id":"fixture-34-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/stop","pathname":"/sessions/fixture-35-id%2Fraw/stop","groups":{"id":"fixture-35-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor","actorlessGrants":[{"service":"linear-bot"}]},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/sandbox-error","pathname":"/sessions/fixture-36-id%2Fraw/sandbox-error","groups":{"id":"fixture-36-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-37-id%2Fraw/events","groups":{"id":"fixture-37-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/artifacts","pathname":"/sessions/fixture-38-id%2Fraw/artifacts","groups":{"id":"fixture-38-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"},{"service":"linear-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participants","pathname":"/sessions/fixture-39-id%2Fraw/participants","groups":{"id":"fixture-39-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/participant-profiles","pathname":"/sessions/fixture-40-id%2Fraw/participant-profiles","groups":{"id":"fixture-40-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/messages","pathname":"/sessions/fixture-41-id%2Fraw/messages","groups":{"id":"fixture-41-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pr","pathname":"/sessions/fixture-42-id%2Fraw/pr","groups":{"id":"fixture-42-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/openai-token-refresh","pathname":"/sessions/fixture-43-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-43-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/xai-token-refresh","pathname":"/sessions/fixture-44-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-44-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-45-id%2Fraw/scm-credentials","groups":{"id":"fixture-45-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":["github","gitlab"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/tunnel-urls","pathname":"/sessions/fixture-46-id%2Fraw/tunnel-urls","groups":{"id":"fixture-46-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"sandbox"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/title","pathname":"/sessions/fixture-47-id%2Fraw/title","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/archive","pathname":"/sessions/fixture-48-id%2Fraw/archive","groups":{"id":"fixture-48-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/unarchive","pathname":"/sessions/fixture-49-id%2Fraw/unarchive","groups":{"id":"fixture-49-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PATCH /sessions/:id/budget","pathname":"/sessions/fixture-50-id%2Fraw/budget","groups":{"id":"fixture-50-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/export","pathname":"/sessions/fixture-35-id%2Fraw/export","groups":{"id":"fixture-35-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-36-id%2Fraw/sandbox-access","groups":{"id":"fixture-36-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-37-id%2Fraw","groups":{"id":"fixture-37-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-38-id%2Fraw/stop","groups":{"id":"fixture-38-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-39-id%2Fraw/sandbox-error","groups":{"id":"fixture-39-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/events","pathname":"/sessions/fixture-40-id%2Fraw/events","groups":{"id":"fixture-40-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-41-id%2Fraw/artifacts","groups":{"id":"fixture-41-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-42-id%2Fraw/participants","groups":{"id":"fixture-42-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-43-id%2Fraw/participant-profiles","groups":{"id":"fixture-43-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-44-id%2Fraw/messages","groups":{"id":"fixture-44-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-45-id%2Fraw/pr","groups":{"id":"fixture-45-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-46-id%2Fraw/openai-token-refresh","groups":{"id":"fixture-46-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-47-id%2Fraw/xai-token-refresh","groups":{"id":"fixture-47-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/scm-credentials","pathname":"/sessions/fixture-48-id%2Fraw/scm-credentials","groups":{"id":"fixture-48-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-49-id%2Fraw/tunnel-urls","groups":{"id":"fixture-49-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-50-id%2Fraw/title","groups":{"id":"fixture-50-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-51-id%2Fraw/archive","groups":{"id":"fixture-51-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-52-id%2Fraw/unarchive","groups":{"id":"fixture-52-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-53-id%2Fraw/budget","groups":{"id":"fixture-53-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-52-id%2Fraw/ws-token","groups":{"id":"fixture-52-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/prompt","pathname":"/sessions/fixture-53-id%2Fraw/prompt","groups":{"id":"fixture-53-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-54-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-54-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/media","pathname":"/sessions/fixture-55-id%2Fraw/media","groups":{"id":"fixture-55-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/media/:artifactId","pathname":"/sessions/fixture-56-id%2Fraw/media/fixture-56-artifactId%2Fraw","groups":{"id":"fixture-56-id%2Fraw","artifactId":"fixture-56-artifactId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/attachments","pathname":"/sessions/fixture-57-id%2Fraw/attachments","groups":{"id":"fixture-57-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/attachments/:attachmentId","pathname":"/sessions/fixture-58-id%2Fraw/attachments/fixture-58-attachmentId%2Fraw","groups":{"id":"fixture-58-id%2Fraw","attachmentId":"fixture-58-attachmentId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff","pathname":"/sessions/fixture-59-id%2Fraw/diff","groups":{"id":"fixture-59-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /sessions/:id/diff","pathname":"/sessions/fixture-60-id%2Fraw/diff","groups":{"id":"fixture-60-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/failure","pathname":"/sessions/fixture-61-id%2Fraw/diff/failure","groups":{"id":"fixture-61-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-62-id%2Fraw/diff/fixture-62-revisionId%2Fraw/files/fixture-62-fileId%2Fraw","groups":{"id":"fixture-62-id%2Fraw","revisionId":"fixture-62-revisionId%2Fraw","fileId":"fixture-62-fileId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/diff/retry","pathname":"/sessions/fixture-63-id%2Fraw/diff/retry","groups":{"id":"fixture-63-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/skills","pathname":"/sessions/fixture-64-id%2Fraw/skills","groups":{"id":"fixture-64-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/sandbox-skills","pathname":"/sessions/fixture-65-id%2Fraw/sandbox-skills","groups":{"id":"fixture-65-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-66-id%2Fraw/children","groups":{"id":"fixture-66-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"},{"kind":"permission","permission":"sessions.create"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children","pathname":"/sessions/fixture-67-id%2Fraw/children","groups":{"id":"fixture-67-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /sessions/:id/children/:childId","pathname":"/sessions/fixture-68-id%2Fraw/children/fixture-68-childId%2Fraw","groups":{"id":"fixture-68-id%2Fraw","childId":"fixture-68-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"read"}],"service":{"kind":"actor"},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/cancel","pathname":"/sessions/fixture-69-id%2Fraw/children/fixture-69-childId%2Fraw/cancel","groups":{"id":"fixture-69-id%2Fraw","childId":"fixture-69-childId%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"read"},{"kind":"session","sessionIdParam":"childId","action":"lifecycle"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/children/:childId/prompt","pathname":"/sessions/fixture-70-id%2Fraw/children/fixture-70-childId%2Fraw/prompt","groups":{"id":"fixture-70-id%2Fraw","childId":"fixture-70-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-71-id%2Fraw/slack-notify","groups":{"id":"fixture-71-id%2Fraw"},"authentication":"user-or-service-with-sandbox-fallback","authorization":{"kind":"active-user","allOf":[{"kind":"session","sessionIdParam":"id","action":"collaborate"}],"service":{"kind":"actor"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/ws-token","pathname":"/sessions/fixture-55-id%2Fraw/ws-token","groups":{"id":"fixture-55-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-56-id%2Fraw/prompt","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":"POST /sessions/:id/pull-requests/refresh","pathname":"/sessions/fixture-57-id%2Fraw/pull-requests/refresh","groups":{"id":"fixture-57-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-58-id%2Fraw/media","groups":{"id":"fixture-58-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-59-id%2Fraw/media/fixture-59-artifactId%2Fraw","groups":{"id":"fixture-59-id%2Fraw","artifactId":"fixture-59-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-60-id%2Fraw/attachments","groups":{"id":"fixture-60-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-61-id%2Fraw/attachments/fixture-61-attachmentId%2Fraw","groups":{"id":"fixture-61-id%2Fraw","attachmentId":"fixture-61-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-62-id%2Fraw/diff","groups":{"id":"fixture-62-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-63-id%2Fraw/diff","groups":{"id":"fixture-63-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-64-id%2Fraw/diff/failure","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":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/diff/:revisionId/files/:fileId","pathname":"/sessions/fixture-65-id%2Fraw/diff/fixture-65-revisionId%2Fraw/files/fixture-65-fileId%2Fraw","groups":{"id":"fixture-65-id%2Fraw","revisionId":"fixture-65-revisionId%2Fraw","fileId":"fixture-65-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-66-id%2Fraw/diff/retry","groups":{"id":"fixture-66-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-67-id%2Fraw/skills","groups":{"id":"fixture-67-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-68-id%2Fraw/sandbox-skills","groups":{"id":"fixture-68-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/children","pathname":"/sessions/fixture-69-id%2Fraw/children","groups":{"id":"fixture-69-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-70-id%2Fraw/children","groups":{"id":"fixture-70-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-71-id%2Fraw/children/fixture-71-childId%2Fraw","groups":{"id":"fixture-71-id%2Fraw","childId":"fixture-71-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-72-id%2Fraw/children/fixture-72-childId%2Fraw/cancel","groups":{"id":"fixture-72-id%2Fraw","childId":"fixture-72-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-73-id%2Fraw/children/fixture-73-childId%2Fraw/prompt","groups":{"id":"fixture-73-id%2Fraw","childId":"fixture-73-childId%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/slack-notify","pathname":"/sessions/fixture-74-id%2Fraw/slack-notify","groups":{"id":"fixture-74-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-73-owner%2Fraw/fixture-73-name%2Fraw/metadata","groups":{"owner":"fixture-73-owner%2Fraw","name":"fixture-73-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/metadata","pathname":"/repos/fixture-74-owner%2Fraw/fixture-74-name%2Fraw/metadata","groups":{"owner":"fixture-74-owner%2Fraw","name":"fixture-74-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/branches","pathname":"/repos/fixture-75-owner%2Fraw/fixture-75-name%2Fraw/branches","groups":{"owner":"fixture-75-owner%2Fraw","name":"fixture-75-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /repos/:owner/:name/secrets","pathname":"/repos/fixture-76-owner%2Fraw/fixture-76-name%2Fraw/secrets","groups":{"owner":"fixture-76-owner%2Fraw","name":"fixture-76-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /repos/:owner/:name/secrets","pathname":"/repos/fixture-77-owner%2Fraw/fixture-77-name%2Fraw/secrets","groups":{"owner":"fixture-77-owner%2Fraw","name":"fixture-77-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /repos/:owner/:name/secrets/:key","pathname":"/repos/fixture-78-owner%2Fraw/fixture-78-name%2Fraw/secrets/fixture-78-key%2Fraw","groups":{"owner":"fixture-78-owner%2Fraw","name":"fixture-78-name%2Fraw","key":"fixture-78-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /repos/:owner/:name/metadata","pathname":"/repos/fixture-76-owner%2Fraw/fixture-76-name%2Fraw/metadata","groups":{"owner":"fixture-76-owner%2Fraw","name":"fixture-76-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-77-owner%2Fraw/fixture-77-name%2Fraw/metadata","groups":{"owner":"fixture-77-owner%2Fraw","name":"fixture-77-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-78-owner%2Fraw/fixture-78-name%2Fraw/branches","groups":{"owner":"fixture-78-owner%2Fraw","name":"fixture-78-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-79-owner%2Fraw/fixture-79-name%2Fraw/secrets","groups":{"owner":"fixture-79-owner%2Fraw","name":"fixture-79-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-80-owner%2Fraw/fixture-80-name%2Fraw/secrets","groups":{"owner":"fixture-80-owner%2Fraw","name":"fixture-80-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-81-owner%2Fraw/fixture-81-name%2Fraw/secrets/fixture-81-key%2Fraw","groups":{"owner":"fixture-81-owner%2Fraw","name":"fixture-81-name%2Fraw","key":"fixture-81-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-81-key%2Fraw","groups":{"key":"fixture-81-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"global_secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /secrets/:key","pathname":"/secrets/fixture-84-key%2Fraw","groups":{"key":"fixture-84-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-84-id%2Fraw","groups":{"id":"fixture-84-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.read"}],"auditAllowed":false,"service":{"kind":"actor","actorlessGrants":[{"service":"github-bot"}]}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id","pathname":"/environments/fixture-85-id%2Fraw","groups":{"id":"fixture-85-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id","pathname":"/environments/fixture-86-id%2Fraw","groups":{"id":"fixture-86-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /environments/:id/secrets","pathname":"/environments/fixture-87-id%2Fraw/secrets","groups":{"id":"fixture-87-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /environments/:id/secrets","pathname":"/environments/fixture-88-id%2Fraw/secrets","groups":{"id":"fixture-88-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /environments/:id/secrets/import","pathname":"/environments/fixture-89-id%2Fraw/secrets/import","groups":{"id":"fixture-89-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"DELETE /environments/:id/secrets/:key","pathname":"/environments/fixture-90-id%2Fraw/secrets/fixture-90-key%2Fraw","groups":{"id":"fixture-90-id%2Fraw","key":"fixture-90-key%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.secrets.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /environments/:id","pathname":"/environments/fixture-87-id%2Fraw","groups":{"id":"fixture-87-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-88-id%2Fraw","groups":{"id":"fixture-88-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-89-id%2Fraw","groups":{"id":"fixture-89-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-90-id%2Fraw/secrets","groups":{"id":"fixture-90-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-91-id%2Fraw/secrets","groups":{"id":"fixture-91-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-92-id%2Fraw/secrets/import","groups":{"id":"fixture-92-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-93-id%2Fraw/secrets/fixture-93-key%2Fraw","groups":{"id":"fixture-93-id%2Fraw","key":"fixture-93-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-93-id%2Fraw","groups":{"id":"fixture-93-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /image-builds/trigger/repo/:owner/:name","pathname":"/image-builds/trigger/repo/fixture-94-owner%2Fraw/fixture-94-name%2Fraw","groups":{"owner":"fixture-94-owner%2Fraw","name":"fixture-94-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /image-builds/toggle/repo/:owner/:name","pathname":"/image-builds/toggle/repo/fixture-95-owner%2Fraw/fixture-95-name%2Fraw","groups":{"owner":"fixture-95-owner%2Fraw","name":"fixture-95-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.images.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /image-builds/trigger/environment/:id","pathname":"/image-builds/trigger/environment/fixture-96-id%2Fraw","groups":{"id":"fixture-96-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-97-owner%2Fraw/fixture-97-name%2Fraw","groups":{"owner":"fixture-97-owner%2Fraw","name":"fixture-97-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-98-owner%2Fraw/fixture-98-name%2Fraw","groups":{"owner":"fixture-98-owner%2Fraw","name":"fixture-98-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}", @@ -107,65 +110,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-105-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-105-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-106-provider%2Fraw/device-authorizations/fixture-106-id%2Fraw/poll","groups":{"provider":"fixture-106-provider%2Fraw","id":"fixture-106-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-107-provider%2Fraw/device-authorizations/fixture-107-id%2Fraw","groups":{"provider":"fixture-107-provider%2Fraw","id":"fixture-107-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-108-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-108-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-109-provider%2Fraw/authorization-codes/fixture-109-id%2Fraw","groups":{"provider":"fixture-109-provider%2Fraw","id":"fixture-109-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-110-provider%2Fraw/authorization-codes/fixture-110-id%2Fraw/complete","groups":{"provider":"fixture-110-provider%2Fraw","id":"fixture-110-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-111-provider%2Fraw/authorization-codes/fixture-111-id%2Fraw","groups":{"provider":"fixture-111-provider%2Fraw","id":"fixture-111-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-112-id%2Fraw","groups":{"id":"fixture-112-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PATCH /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-113-id%2Fraw","groups":{"id":"fixture-113-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-114-id%2Fraw/verify","groups":{"id":"fixture-114-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-115-id%2Fraw/disable","groups":{"id":"fixture-115-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-116-id%2Fraw/enable","groups":{"id":"fixture-116-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /model-provider-accounts/:id/reconnect","pathname":"/model-provider-accounts/fixture-117-id%2Fraw/reconnect","groups":{"id":"fixture-117-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-118-id%2Fraw","groups":{"id":"fixture-118-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations","pathname":"/model-provider-accounts/fixture-108-provider%2Fraw/device-authorizations","groups":{"provider":"fixture-108-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:provider/device-authorizations/:id/poll","pathname":"/model-provider-accounts/fixture-109-provider%2Fraw/device-authorizations/fixture-109-id%2Fraw/poll","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/device-authorizations/:id","pathname":"/model-provider-accounts/fixture-110-provider%2Fraw/device-authorizations/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":"POST /model-provider-accounts/:provider/authorization-codes","pathname":"/model-provider-accounts/fixture-111-provider%2Fraw/authorization-codes","groups":{"provider":"fixture-111-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-112-provider%2Fraw/authorization-codes/fixture-112-id%2Fraw","groups":{"provider":"fixture-112-provider%2Fraw","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/:provider/authorization-codes/:id/complete","pathname":"/model-provider-accounts/fixture-113-provider%2Fraw/authorization-codes/fixture-113-id%2Fraw/complete","groups":{"provider":"fixture-113-provider%2Fraw","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":"DELETE /model-provider-accounts/:provider/authorization-codes/:id","pathname":"/model-provider-accounts/fixture-114-provider%2Fraw/authorization-codes/fixture-114-id%2Fraw","groups":{"provider":"fixture-114-provider%2Fraw","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":"GET /model-provider-accounts/:id","pathname":"/model-provider-accounts/fixture-115-id%2Fraw","groups":{"id":"fixture-115-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-116-id%2Fraw","groups":{"id":"fixture-116-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/verify","pathname":"/model-provider-accounts/fixture-117-id%2Fraw/verify","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":"POST /model-provider-accounts/:id/disable","pathname":"/model-provider-accounts/fixture-118-id%2Fraw/disable","groups":{"id":"fixture-118-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /model-provider-accounts/:id/enable","pathname":"/model-provider-accounts/fixture-119-id%2Fraw/enable","groups":{"id":"fixture-119-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-120-id%2Fraw/reconnect","groups":{"id":"fixture-120-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-121-id%2Fraw","groups":{"id":"fixture-121-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-120-provider%2Fraw","groups":{"provider":"fixture-120-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /model-provider-account-defaults/:provider","pathname":"/model-provider-account-defaults/fixture-121-provider%2Fraw","groups":{"provider":"fixture-121-provider%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/access-token","pathname":"/sessions/fixture-122-id%2Fraw/provider-auth/fixture-122-provider%2Fraw/access-token","groups":{"id":"fixture-122-id%2Fraw","provider":"fixture-122-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"POST /sessions/:id/provider-auth/:provider/runtime-credential","pathname":"/sessions/fixture-123-id%2Fraw/provider-auth/fixture-123-provider%2Fraw/runtime-credential","groups":{"id":"fixture-123-id%2Fraw","provider":"fixture-123-provider%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"no-store","hasServiceActorClaims":false}", - "{"identity":"GET /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.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-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":"DELETE /integration-settings/:id","pathname":"/integration-settings/fixture-126-id%2Fraw","groups":{"id":"fixture-126-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/repos","pathname":"/integration-settings/fixture-127-id%2Fraw/repos","groups":{"id":"fixture-127-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-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":"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-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":"DELETE /integration-settings/:id/repos/:owner/:name","pathname":"/integration-settings/fixture-130-id%2Fraw/repos/fixture-130-owner%2Fraw/fixture-130-name%2Fraw","groups":{"id":"fixture-130-id%2Fraw","owner":"fixture-130-owner%2Fraw","name":"fixture-130-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"repositories.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /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":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /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":"DELETE /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-133-id%2Fraw/environments/fixture-133-environmentId%2Fraw","groups":{"id":"fixture-133-id%2Fraw","environmentId":"fixture-133-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"environments.settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /integration-settings/:id/resolved/:owner/:name","pathname":"/integration-settings/fixture-134-id%2Fraw/resolved/fixture-134-owner%2Fraw/fixture-134-name%2Fraw","groups":{"id":"fixture-134-id%2Fraw","owner":"fixture-134-owner%2Fraw","name":"fixture-134-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-123-provider%2Fraw","groups":{"provider":"fixture-123-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-124-provider%2Fraw","groups":{"provider":"fixture-124-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-125-id%2Fraw/provider-auth/fixture-125-provider%2Fraw/access-token","groups":{"id":"fixture-125-id%2Fraw","provider":"fixture-125-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-126-id%2Fraw/provider-auth/fixture-126-provider%2Fraw/runtime-credential","groups":{"id":"fixture-126-id%2Fraw","provider":"fixture-126-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-127-id%2Fraw","groups":{"id":"fixture-127-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-128-id%2Fraw","groups":{"id":"fixture-128-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-129-id%2Fraw","groups":{"id":"fixture-129-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-130-id%2Fraw/repos","groups":{"id":"fixture-130-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-131-id%2Fraw/repos/fixture-131-owner%2Fraw/fixture-131-name%2Fraw","groups":{"id":"fixture-131-id%2Fraw","owner":"fixture-131-owner%2Fraw","name":"fixture-131-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"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-132-id%2Fraw/repos/fixture-132-owner%2Fraw/fixture-132-name%2Fraw","groups":{"id":"fixture-132-id%2Fraw","owner":"fixture-132-owner%2Fraw","name":"fixture-132-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-133-id%2Fraw/repos/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":"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-134-id%2Fraw/environments/fixture-134-environmentId%2Fraw","groups":{"id":"fixture-134-id%2Fraw","environmentId":"fixture-134-environmentId%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"integrations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /integration-settings/:id/environments/:environmentId","pathname":"/integration-settings/fixture-135-id%2Fraw/environments/fixture-135-environmentId%2Fraw","groups":{"id":"fixture-135-id%2Fraw","environmentId":"fixture-135-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-136-id%2Fraw/environments/fixture-136-environmentId%2Fraw","groups":{"id":"fixture-136-id%2Fraw","environmentId":"fixture-136-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-137-id%2Fraw/resolved/fixture-137-owner%2Fraw/fixture-137-name%2Fraw","groups":{"id":"fixture-137-id%2Fraw","owner":"fixture-137-owner%2Fraw","name":"fixture-137-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-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":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-139-id%2Fraw/commit-signing","groups":{"id":"fixture-139-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /sessions/:id/commit-signing","pathname":"/sessions/fixture-141-id%2Fraw/commit-signing","groups":{"id":"fixture-141-id%2Fraw"},"authentication":"sandbox","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /sessions/:id/commit-signing","pathname":"/sessions/fixture-142-id%2Fraw/commit-signing","groups":{"id":"fixture-142-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-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":"DELETE /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-145-owner%2Fraw/fixture-145-name%2Fraw","groups":{"owner":"fixture-145-owner%2Fraw","name":"fixture-145-name%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"scm_settings.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /scm-settings/repos/:owner/:name","pathname":"/scm-settings/repos/fixture-147-owner%2Fraw/fixture-147-name%2Fraw","groups":{"owner":"fixture-147-owner%2Fraw","name":"fixture-147-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-148-owner%2Fraw/fixture-148-name%2Fraw","groups":{"owner":"fixture-148-owner%2Fraw","name":"fixture-148-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-150-id%2Fraw","groups":{"id":"fixture-150-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"automations.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"PUT /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":"DELETE /automations/:id","pathname":"/automations/fixture-152-id%2Fraw","groups":{"id":"fixture-152-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-153-id%2Fraw/pause","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/resume","pathname":"/automations/fixture-154-id%2Fraw/resume","groups":{"id":"fixture-154-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-155-id%2Fraw/trigger","groups":{"id":"fixture-155-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-156-id%2Fraw/invocations","groups":{"id":"fixture-156-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-157-id%2Fraw/runs/fixture-157-runId%2Fraw","groups":{"id":"fixture-157-id%2Fraw","runId":"fixture-157-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-158-id%2Fraw/regenerate-key","groups":{"id":"fixture-158-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-153-id%2Fraw","groups":{"id":"fixture-153-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-154-id%2Fraw","groups":{"id":"fixture-154-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"DELETE /automations/:id","pathname":"/automations/fixture-155-id%2Fraw","groups":{"id":"fixture-155-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"automation","operation":"manage","automationIdParam":"id"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/pause","pathname":"/automations/fixture-156-id%2Fraw/pause","groups":{"id":"fixture-156-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-157-id%2Fraw/resume","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":null,"hasServiceActorClaims":false}", + "{"identity":"POST /automations/:id/trigger","pathname":"/automations/fixture-158-id%2Fraw/trigger","groups":{"id":"fixture-158-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-159-id%2Fraw/invocations","groups":{"id":"fixture-159-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-160-id%2Fraw/runs/fixture-160-runId%2Fraw","groups":{"id":"fixture-160-id%2Fraw","runId":"fixture-160-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-161-id%2Fraw/regenerate-key","groups":{"id":"fixture-161-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-161-id%2Fraw","groups":{"id":"fixture-161-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-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":"DELETE /mcp-servers/:id","pathname":"/mcp-servers/fixture-163-id%2Fraw","groups":{"id":"fixture-163-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /mcp-servers/:id","pathname":"/mcp-servers/fixture-164-id%2Fraw","groups":{"id":"fixture-164-id%2Fraw"},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"mcp_servers.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /mcp-servers/:id","pathname":"/mcp-servers/fixture-165-id%2Fraw","groups":{"id":"fixture-165-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-166-id%2Fraw","groups":{"id":"fixture-166-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}", @@ -177,32 +180,32 @@ 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-175-id%2Fraw","groups":{"id":"fixture-175-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-178-id%2Fraw","groups":{"id":"fixture-178-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-179-id%2Fraw/reimport/preview","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":"POST /skills/:id/reimport","pathname":"/skills/fixture-180-id%2Fraw/reimport","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":"PATCH /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":"PUT /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":"DELETE /skills/:id","pathname":"/skills/fixture-183-id%2Fraw","groups":{"id":"fixture-183-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"POST /skills/:id/reimport/preview","pathname":"/skills/fixture-182-id%2Fraw/reimport/preview","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":"POST /skills/:id/reimport","pathname":"/skills/fixture-183-id%2Fraw/reimport","groups":{"id":"fixture-183-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-184-id%2Fraw","groups":{"id":"fixture-184-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-185-id%2Fraw","groups":{"id":"fixture-185-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-186-id%2Fraw","groups":{"id":"fixture-186-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-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":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-187-id%2Fraw","groups":{"id":"fixture-187-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-189-id%2Fraw","groups":{"id":"fixture-189-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-190-id%2Fraw","groups":{"id":"fixture-190-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-192-id%2Fraw","groups":{"id":"fixture-192-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-195-id%2Fraw","groups":{"id":"fixture-195-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-194-id%2Fraw/role","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":"PUT /members/:id/status","pathname":"/members/fixture-195-id%2Fraw/status","groups":{"id":"fixture-195-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"GET /teams/:id/secrets","pathname":"/teams/fixture-196-id%2Fraw/secrets","groups":{"id":"fixture-196-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"PUT /teams/:id/secrets","pathname":"/teams/fixture-197-id%2Fraw/secrets","groups":{"id":"fixture-197-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"DELETE /teams/:id/secrets/:key","pathname":"/teams/fixture-198-id%2Fraw/secrets/fixture-198-key%2Fraw","groups":{"id":"fixture-198-id%2Fraw","key":"fixture-198-key%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", - "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-199-id%2Fraw","groups":{"id":"fixture-199-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-200-id%2Fraw","groups":{"id":"fixture-200-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"PUT /members/:id/role","pathname":"/members/fixture-197-id%2Fraw/role","groups":{"id":"fixture-197-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-198-id%2Fraw/status","groups":{"id":"fixture-198-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.members.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"GET /teams/:id/secrets","pathname":"/teams/fixture-199-id%2Fraw/secrets","groups":{"id":"fixture-199-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"PUT /teams/:id/secrets","pathname":"/teams/fixture-200-id%2Fraw/secrets","groups":{"id":"fixture-200-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"DELETE /teams/:id/secrets/:key","pathname":"/teams/fixture-201-id%2Fraw/secrets/fixture-201-key%2Fraw","groups":{"id":"fixture-201-id%2Fraw","key":"fixture-201-key%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"team","teamIdParam":"id","need":"canManageSecrets"}],"service":{"kind":"deny"},"auditAllowed":true},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", + "{"identity":"POST /webhooks/sentry/:id","pathname":"/webhooks/sentry/fixture-202-id%2Fraw","groups":{"id":"fixture-202-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-203-id%2Fraw","groups":{"id":"fixture-203-id%2Fraw"},"authentication":"handler-authenticated","authorization":{"kind":"none","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"POST /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 d8581c930a..45676797e3 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 @@ -53,6 +53,9 @@ exports[`route admission matrix > admits the workspace owner through every brows "DELETE /teams/:id/members/:userId owner=404", "POST /teams/:id/join owner=200", "GET /teams/:id/sessions owner=200", + "GET /teams/:id/repository-grants owner=200", + "PUT /teams/:id/repository-grants owner=400", + "DELETE /teams/:id/repository-grants/:grantId owner=404", "GET /settings/teams owner=200", "PATCH /settings/teams owner=400", "POST /sessions owner=201", @@ -118,8 +121,8 @@ exports[`route admission matrix > admits the workspace owner through every brows "PUT /environments/:id/secrets owner=404", "POST /environments/:id/secrets/import owner=404", "DELETE /environments/:id/secrets/:key owner=404", - "POST /image-builds/trigger/environment/:id owner=503", - "POST /image-builds/trigger/repo/:owner/:name owner=503", + "POST /image-builds/trigger/environment/:id owner=404", + "POST /image-builds/trigger/repo/:owner/:name owner=500", "PUT /image-builds/toggle/repo/:owner/:name owner=400", "GET /image-builds/status owner=200", "GET /image-builds/enabled owner=200", @@ -245,6 +248,9 @@ exports[`route admission matrix > rejects every credentialed route anonymously b "DELETE /teams/:id/members/:userId anonymous=401", "POST /teams/:id/join anonymous=401", "GET /teams/:id/sessions anonymous=401", + "GET /teams/:id/repository-grants anonymous=401", + "PUT /teams/:id/repository-grants anonymous=401", + "DELETE /teams/:id/repository-grants/:grantId anonymous=401", "GET /settings/teams anonymous=401", "PATCH /settings/teams anonymous=401", "POST /sessions anonymous=401", @@ -450,6 +456,9 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "DELETE /teams/:id/members/:userId off/nonmember=404 auditAllowed=true", "POST /teams/:id/join off/nonmember=200 auditAllowed=true", "GET /teams/:id/sessions off/nonmember=404 auditAllowed=false", + "GET /teams/:id/repository-grants off/nonmember=404 auditAllowed=false", + "PUT /teams/:id/repository-grants off/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/repository-grants/:grantId off/nonmember=403 auditAllowed=true", "GET /teams/:id/secrets off/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets off/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key off/nonmember=403 auditAllowed=true", @@ -462,6 +471,9 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "DELETE /teams/:id/members/:userId shadow/nonmember=404 auditAllowed=true", "POST /teams/:id/join shadow/nonmember=200 auditAllowed=true", "GET /teams/:id/sessions shadow/nonmember=404 auditAllowed=false", + "GET /teams/:id/repository-grants shadow/nonmember=404 auditAllowed=false", + "PUT /teams/:id/repository-grants shadow/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/repository-grants/:grantId shadow/nonmember=403 auditAllowed=true", "GET /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets shadow/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key shadow/nonmember=403 auditAllowed=true", @@ -474,6 +486,9 @@ exports[`route admission sentinel > admits team directory reads but conceals mem "DELETE /teams/:id/members/:userId on/nonmember=404 auditAllowed=true", "POST /teams/:id/join on/nonmember=200 auditAllowed=true", "GET /teams/:id/sessions on/nonmember=404 auditAllowed=false", + "GET /teams/:id/repository-grants on/nonmember=404 auditAllowed=false", + "PUT /teams/:id/repository-grants on/nonmember=403 auditAllowed=true", + "DELETE /teams/:id/repository-grants/:grantId on/nonmember=403 auditAllowed=true", "GET /teams/:id/secrets on/nonmember=403 auditAllowed=true", "PUT /teams/:id/secrets on/nonmember=403 auditAllowed=true", "DELETE /teams/:id/secrets/:key on/nonmember=403 auditAllowed=true", diff --git a/packages/control-plane/test/integration/automation-authorization.test.ts b/packages/control-plane/test/integration/automation-authorization.test.ts index f34e6e9399..a7e8cd6216 100644 --- a/packages/control-plane/test/integration/automation-authorization.test.ts +++ b/packages/control-plane/test/integration/automation-authorization.test.ts @@ -2,6 +2,9 @@ import { env } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AutomationStore, type AutomationRow } from "../../src/db/automation-store"; import { UserStore } from "../../src/db/user-store"; +import { EnvironmentStore } from "../../src/db/environments"; +import { TeamStore } from "../../src/db/teams"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; import { cleanD1Tables } from "./cleanup"; import { serviceFetch, sqlDatabase } from "./helpers"; @@ -168,6 +171,65 @@ describe("automation router authorization", () => { }); }); + it("rejects a later environment containing an ungranted repository without changing the automation", async () => { + await seedBrowser("owner"); + const team = await new TeamStore(env.DB).create({ + slug: "automation-targets", + name: "Automation targets", + joinPolicy: "invite_only", + }); + const environments = new EnvironmentStore(env.DB); + const grants = new TeamRepositoryGrantStore(env.DB); + for (const [id, name, repoId] of [ + ["env_original", "original", 1], + ["env_allowed", "web", 2], + ["env_denied", "api", 3], + ] as const) { + await environments.create( + { + id, + name: id, + owner_team_id: null, + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [{ position: 0, repo_owner: "acme", repo_name: name, repo_id: repoId, base_branch: "main" }] + ); + if (repoId !== 3) + await grants.add(team.id, { + kind: "repository", + repoExternalId: repoId, + owner: "acme", + name, + }); + } + const store = new AutomationStore(env.DB); + const row = { + ...automation("team-environment-targets", BROWSER_USER_ID), + owner_team_id: team.id, + }; + await store.create(row); + await sqlDatabase(env.DB).batch(store.bindEnvironmentInserts(row.id, ["env_original"], 1)); + + const response = await serviceFetch(`https://cp.test/automations/${row.id}`, { + method: "PUT", + body: JSON.stringify({ name: "Updated", environmentIds: ["env_allowed", "env_denied"] }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/api", + }); + expect(await store.getById(row.id)).toEqual(row); + expect( + (await store.getEnvironmentsForAutomation(row.id)).map((member) => member.environment_id) + ).toEqual(["env_original"]); + }); + it("denies bot services before handler dispatch", async () => { await seedBrowser("owner"); await new AutomationStore(env.DB).create(automation("service-target", BROWSER_USER_ID)); diff --git a/packages/control-plane/test/integration/environment-secret-import-identity.test.ts b/packages/control-plane/test/integration/environment-secret-import-identity.test.ts new file mode 100644 index 0000000000..f0bb444c03 --- /dev/null +++ b/packages/control-plane/test/integration/environment-secret-import-identity.test.ts @@ -0,0 +1,179 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnvironmentStore } from "../../src/db/environments"; +import { EnvironmentSecretsStore } from "../../src/db/environment-secrets"; +import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import { TeamStore } from "../../src/db/teams"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, serviceRequestHeaders } from "./helpers"; + +const ENVIRONMENT_ID = "env_import_identity"; + +async function importSecrets() { + const url = `https://test.local/environments/${ENVIRONMENT_ID}/secrets/import`; + const init = { + method: "POST", + body: JSON.stringify({ repoOwner: "acme", repoName: "web" }), + }; + return routeRequest( + new Request(url, { ...init, headers: await serviceRequestHeaders(url, init) }), + env, + createExecutionContext() + ); +} + +async function seedImportEnvironment(repoId: number | null, grantedRepoId: number) { + const team = await new TeamStore(env.DB).create({ + slug: "importers", + name: "Importers", + joinPolicy: "invite_only", + }); + await new TeamRepositoryGrantStore(env.DB).add(team.id, { + kind: "repository", + repoExternalId: grantedRepoId, + owner: "acme", + name: "web", + }); + const store = new EnvironmentStore(env.DB); + await store.create( + { + id: ENVIRONMENT_ID, + owner_team_id: team.id, + name: "Import identity", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [{ position: 0, repo_owner: "acme", repo_name: "web", repo_id: repoId, base_branch: "main" }] + ); + const repoSecretsStore = new RepoSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); + await repoSecretsStore.setSecrets(123, "acme", "web", { + OLD_SOURCE_KEY: "old-source-value", + }); + const secretsStore = new EnvironmentSecretsStore(env.DB, env.REPO_SECRETS_ENCRYPTION_KEY!); + await secretsStore.setSecrets(ENVIRONMENT_ID, { EXISTING_KEY: "existing-value" }); + return { store, repoSecretsStore, secretsStore }; +} + +describe("environment secret import repository identity", () => { + beforeEach(async () => { + await cleanD1Tables(); + vi.spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess").mockResolvedValue({ + repoId: 456, + repoOwner: "acme", + repoName: "web", + defaultBranch: "main", + }); + }); + afterEach(() => vi.restoreAllMocks()); + + it("rejects a stale member ID despite its old grant and encrypted source secrets", async () => { + const { store, secretsStore } = await seedImportEnvironment(123, 123); + const membersBefore = await store.getRepositoriesForEnvironment(ENVIRONMENT_ID); + const keysBefore = await secretsStore.listSecretKeys(ENVIRONMENT_ID); + const source = await env.DB.prepare( + "SELECT key, encrypted_value FROM repo_secrets WHERE repo_id = ?" + ) + .bind(123) + .all<{ key: string; encrypted_value: string }>(); + expect(source.results).toHaveLength(1); + const teamGrants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam"); + const sourceGrants = vi.spyOn(TeamRepositoryGrantStore.prototype, "listTeamsForRepository"); + const importer = vi.spyOn(EnvironmentSecretsStore.prototype, "importFromRepo"); + + const response = await importSecrets(); + + expect(response.status).toBe(409); + const raw = await response.clone().text(); + expect(await response.json()).toEqual({ + error: "Repository identity changed", + code: "repository_identity_mismatch", + repository: "acme/web", + }); + expect(raw).not.toContain("old-source-value"); + for (const row of source.results) { + expect(raw).not.toContain(row.key); + expect(raw).not.toContain(row.encrypted_value); + } + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledWith({ + owner: "acme", + name: "web", + }); + expect(teamGrants).not.toHaveBeenCalled(); + expect(sourceGrants).not.toHaveBeenCalled(); + expect(importer).not.toHaveBeenCalled(); + expect(await secretsStore.listSecretKeys(ENVIRONMENT_ID)).toEqual(keysBefore); + expect(await secretsStore.getDecryptedSecrets(ENVIRONMENT_ID)).toEqual({ + EXISTING_KEY: "existing-value", + }); + expect(await store.getRepositoriesForEnvironment(ENVIRONMENT_ID)).toEqual(membersBefore); + }); + + it("imports only the current ID for a legacy null-ID member", async () => { + const { store, repoSecretsStore, secretsStore } = await seedImportEnvironment(null, 456); + const membersBefore = await store.getRepositoriesForEnvironment(ENVIRONMENT_ID); + await repoSecretsStore.setSecrets(456, "acme", "web", { + CURRENT_SOURCE_KEY: "current-source-value", + }); + const source = await env.DB.prepare( + "SELECT key, encrypted_value FROM repo_secrets WHERE repo_id = ?" + ) + .bind(456) + .all<{ key: string; encrypted_value: string }>(); + expect(source.results).toHaveLength(1); + + const response = await importSecrets(); + + expect(response.status).toBe(200); + const raw = await response.clone().text(); + expect(await response.json()).toEqual({ + status: "imported", + environmentId: ENVIRONMENT_ID, + source: "acme/web", + keys: ["CURRENT_SOURCE_KEY"], + created: 1, + updated: 0, + }); + expect(raw).not.toContain("OLD_SOURCE_KEY"); + expect(raw).not.toContain("old-source-value"); + expect(raw).not.toContain("current-source-value"); + for (const row of source.results) expect(raw).not.toContain(row.encrypted_value); + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(await secretsStore.getDecryptedSecrets(ENVIRONMENT_ID)).toEqual({ + EXISTING_KEY: "existing-value", + CURRENT_SOURCE_KEY: "current-source-value", + }); + const copied = await env.DB.prepare( + "SELECT key, encrypted_value FROM environment_secrets WHERE environment_id = ? AND key = ?" + ) + .bind(ENVIRONMENT_ID, "CURRENT_SOURCE_KEY") + .all<{ key: string; encrypted_value: string }>(); + expect(copied.results).toEqual(source.results); + expect(await store.getRepositoriesForEnvironment(ENVIRONMENT_ID)).toEqual(membersBefore); + }); + + it("returns 404 without copying secrets when the member repository disappears", async () => { + const { store, secretsStore } = await seedImportEnvironment(123, 123); + const membersBefore = await store.getRepositoriesForEnvironment(ENVIRONMENT_ID); + const keysBefore = await secretsStore.listSecretKeys(ENVIRONMENT_ID); + vi.mocked(GitHubSourceControlProvider.prototype.checkRepositoryAccess).mockResolvedValue(null); + + const response = await importSecrets(); + + expect(response.status).toBe(404); + const raw = await response.text(); + expect(raw).not.toContain("OLD_SOURCE_KEY"); + expect(raw).not.toContain("old-source-value"); + expect(GitHubSourceControlProvider.prototype.checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(await secretsStore.listSecretKeys(ENVIRONMENT_ID)).toEqual(keysBefore); + expect(await secretsStore.getDecryptedSecrets(ENVIRONMENT_ID)).toEqual({ + EXISTING_KEY: "existing-value", + }); + expect(await store.getRepositoriesForEnvironment(ENVIRONMENT_ID)).toEqual(membersBefore); + }); +}); diff --git a/packages/control-plane/test/integration/environments-catalog.test.ts b/packages/control-plane/test/integration/environments-catalog.test.ts new file mode 100644 index 0000000000..a16908a79d --- /dev/null +++ b/packages/control-plane/test/integration/environments-catalog.test.ts @@ -0,0 +1,167 @@ +import { env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import { EnvironmentStore } from "../../src/db/environments"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { TeamStore } from "../../src/db/teams"; +import { cleanD1Tables } from "./cleanup"; +import { seedActiveUser, serviceFetch } from "./helpers"; + +const BASE = "https://test.local/environments"; +const MEMBER = "22222222222222222222222222222222"; +const OTHER = "33333333333333333333333333333333"; + +describe("team-scoped environment catalog", () => { + let teamId: string; + + beforeEach(async () => { + await cleanD1Tables(); + await seedActiveUser(MEMBER); + await seedActiveUser(OTHER); + teamId = ( + await new TeamStore(env.DB).create({ + slug: "engineering", + name: "Engineering", + joinPolicy: "invite_only", + }) + ).id; + await new TeamMembershipStore(env.DB).add(teamId, MEMBER); + const targets: [string, (number | null)[]][] = [ + ["covered", [1]], + ["denied", [2]], + ["multi", [1, 2]], + ["nullable", [null]], + ["empty", []], + ]; + for (const [name, repoIds] of targets) { + await new EnvironmentStore(env.DB).create( + { + id: `env_${name}`, + owner_team_id: name === "denied" ? teamId : null, + name, + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + repoIds.map((repoId, position) => ({ + position, + repo_owner: "acme", + repo_name: `repo-${repoId ?? 1}`, + repo_id: repoId, + base_branch: "main", + })) + ); + } + }); + + it("filters persisted refs without SCM configuration, and retains the workspace catalog", async () => { + const scopedUrl = `${BASE}?teamId=${teamId}`; + const member = { as: { userId: MEMBER, role: "member" as const } }; + const grants = new TeamRepositoryGrantStore(env.DB); + expect(await (await serviceFetch(scopedUrl, member)).json()).toEqual({ + environments: [], + total: 0, + }); + const first = await grants.add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + + const response = await serviceFetch(scopedUrl, member); + expect(response.status).toBe(200); + const covered = await response.json<{ + environments: { id: string; repositories: { repoId: number | null }[] }[]; + total: number; + }>(); + expect(covered.total).toBe(1); + expect(covered.environments.map((row) => row.id)).toEqual(["env_covered"]); + expect(covered.environments[0].repositories.map((repo) => repo.repoId)).toEqual([1]); + expect(await (await serviceFetch(BASE, member)).json()).toMatchObject({ total: 5 }); + + const second = await grants.add(teamId, { + kind: "repository", + repoExternalId: 2, + owner: "acme", + name: "repo-2", + }); + const allNumeric = await ( + await serviceFetch(scopedUrl, member) + ).json<{ + environments: { id: string }[]; + total: number; + }>(); + expect(allNumeric.total).toBe(3); + expect(allNumeric.environments.map((row) => row.id).sort()).toEqual([ + "env_covered", + "env_denied", + "env_multi", + ]); + + await grants.remove(teamId, first.id); + await grants.remove(teamId, second.id); + await grants.add(teamId, { kind: "installation" }); + const installation = await ( + await serviceFetch(scopedUrl, member) + ).json<{ + environments: { id: string }[]; + total: number; + }>(); + expect(installation.total).toBe(4); + expect(installation.environments.map((row) => row.id).sort()).toEqual([ + "env_covered", + "env_denied", + "env_multi", + "env_nullable", + ]); + }); + + it("audits concealed nonmember, missing, and archived scopes through the existing writer", async () => { + const nonmember = { as: { userId: OTHER, role: "member" as const } }; + const responses = [ + await serviceFetch(`${BASE}?teamId=${teamId}`, nonmember), + await serviceFetch(`${BASE}?teamId=team_missing`, nonmember), + ]; + await new TeamStore(env.DB).archive(teamId); + responses.push( + await serviceFetch(`${BASE}?teamId=${teamId}`, { + as: { userId: MEMBER, role: "member" }, + }), + await serviceFetch(`${BASE}?teamId=${teamId}`) + ); + for (const response of responses) { + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "Team not found" }); + } + + const audit = await env.DB.prepare( + "SELECT action, reason_code, operation_result, metadata_json, team_id FROM authorization_audit_events WHERE resource_id = '/environments' AND operation_result = 'denied'" + ).all<{ + action: string; + reason_code: string; + operation_result: string; + metadata_json: string; + team_id: string; + }>(); + expect(audit.results).toHaveLength(4); + expect(audit.results.map((row) => row.team_id).sort()).toEqual( + [teamId, "team_missing", teamId, teamId].sort() + ); + for (const row of audit.results) { + expect(row).toMatchObject({ + action: "authorization.request_denied", + reason_code: "team_not_visible", + operation_result: "denied", + }); + expect(JSON.parse(row.metadata_json)).toMatchObject({ + httpMethod: "GET", + httpPath: "/environments", + httpStatus: 404, + requirements: [{ kind: "team", teamIdParam: "teamId", need: "member" }], + }); + } + }); +}); diff --git a/packages/control-plane/test/integration/environments-routes.test.ts b/packages/control-plane/test/integration/environments-routes.test.ts index 1c8bd4b94b..b36b80ff65 100644 --- a/packages/control-plane/test/integration/environments-routes.test.ts +++ b/packages/control-plane/test/integration/environments-routes.test.ts @@ -8,12 +8,13 @@ * exist are seeded directly via EnvironmentStore (mirroring PR-4's split). */ -import { describe, it, expect, beforeEach } from "vitest"; -import { SELF, env } from "cloudflare:test"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { SELF, env, createExecutionContext } from "cloudflare:test"; import { EnvironmentStore } from "../../src/db/environments"; import { RepoSecretsStore } from "../../src/db/repo-secrets"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; import { cleanD1Tables } from "./cleanup"; -import { serviceFetch } from "./helpers"; +import { routeRequest, serviceFetch, serviceRequestHeaders } from "./helpers"; const BASE = "https://test.local"; @@ -52,6 +53,7 @@ async function seedEnvironment(opts?: { describe("Environments API (routes)", () => { beforeEach(cleanD1Tables); + afterEach(() => vi.restoreAllMocks()); describe("auth", () => { it("returns 401 without internal auth", async () => { @@ -280,11 +282,27 @@ describe("Environments API (routes)", () => { } ); - const res = await serviceFetch(`${BASE}/environments/${id}/secrets/import`, { + const checkRepositoryAccess = vi + .spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess") + .mockResolvedValue({ + repoId: 1, + repoOwner: "acme", + repoName: "web", + defaultBranch: "main", + }); + const url = `${BASE}/environments/${id}/secrets/import`; + const init = { method: "POST", body: JSON.stringify({ repoOwner: " ACME ", repoName: " WEB ", keys: ["DEPLOY_KEY"] }), - }); + }; + const res = await routeRequest( + new Request(url, { ...init, headers: await serviceRequestHeaders(url, init) }), + env, + createExecutionContext() + ); expect(res.status).toBe(200); + expect(checkRepositoryAccess).toHaveBeenCalledOnce(); + expect(checkRepositoryAccess).toHaveBeenCalledWith({ owner: "acme", name: "web" }); const raw = await res.text(); // Value-free: neither plaintext nor ciphertext leaks into the response. expect(raw).not.toContain("supersecretvalue"); 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 a4b8e91af5..64dca51239 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(203); + expect(manifest).toHaveLength(206); // 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/spawn-children.test.ts b/packages/control-plane/test/integration/spawn-children.test.ts index 0f5fad4807..6372003b23 100644 --- a/packages/control-plane/test/integration/spawn-children.test.ts +++ b/packages/control-plane/test/integration/spawn-children.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { SELF, env } from "cloudflare:test"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { SELF, env, createExecutionContext } from "cloudflare:test"; import { runInSessionDO } from "./session-do-access"; import type { SessionDO } from "../../src/cloudflare/durable-object"; import { SessionIndexStore } from "../../src/db/session-index"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; import { cleanD1Tables } from "./cleanup"; import { initNamedSessionDO, @@ -10,10 +12,12 @@ import { seedActiveUser, seedMessage, seedSandboxAuth, + routeRequest, } from "./helpers"; describe("POST /sessions/:parentId/children — spawn child", () => { beforeEach(cleanD1Tables); + afterEach(() => vi.restoreAllMocks()); /** Sets up a parent DO + sandbox auth + D1 row, returns everything needed for spawn tests. */ async function setupParent(opts?: { @@ -120,6 +124,18 @@ describe("POST /sessions/:parentId/children — spawn child", () => { await env.DB.prepare( "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES ('team_child', 'child', 'Child', 1, 1)" ).run(); + await new TeamRepositoryGrantStore(env.DB).add("team_child", { + kind: "repository", + repoExternalId: 12345, + owner: "acme", + name: "web-app", + }); + vi.spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess").mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); const { parentName, sandboxToken, store } = await setupParent({ ownerTeamId: "team_child", visibility: "workspace", @@ -129,17 +145,21 @@ describe("POST /sessions/:parentId/children — spawn child", () => { scmLogin: "acmedev", }); - const res = await SELF.fetch(`https://test.local/sessions/${parentName}/children`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sandboxToken}`, - }, - body: JSON.stringify({ - title: "Fix the tests", - prompt: "Please fix the failing tests in src/utils.ts", + const res = await routeRequest( + new Request(`https://test.local/sessions/${parentName}/children`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${sandboxToken}`, + }, + body: JSON.stringify({ + title: "Fix the tests", + prompt: "Please fix the failing tests in src/utils.ts", + }), }), - }); + env, + createExecutionContext() + ); expect(res.status).toBe(201); const body = await res.json<{ sessionId: string; status: string }>(); diff --git a/packages/control-plane/test/integration/team-grants.test.ts b/packages/control-plane/test/integration/team-grants.test.ts new file mode 100644 index 0000000000..a5a3f80016 --- /dev/null +++ b/packages/control-plane/test/integration/team-grants.test.ts @@ -0,0 +1,289 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamStore } from "../../src/db/teams"; +import { TeamMembershipStore } from "../../src/db/team-memberships"; +import { TeamRepositoryGrantStore } from "../../src/db/team-repository-grants"; +import { EnvironmentStore } from "../../src/db/environments"; +import { REPOS_CACHE_KEY, reposCacheIdentity } from "../../src/routes/repos"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; +import { cleanD1Tables } from "./cleanup"; +import { routeRequest, seedActiveUser, serviceRequestHeaders } from "./helpers"; + +const MEMBER = "22222222222222222222222222222222"; +const OTHER = "33333333333333333333333333333333"; +const repos = [1, 2].map((id) => ({ + id, + owner: "acme", + name: `repo-${id}`, + fullName: `acme/repo-${id}`, + description: null, + private: true, + archived: false, + defaultBranch: "main", +})); + +async function request(path: string, method = "GET", body?: object, userId?: string) { + const url = `https://test.local${path}`; + const payload = body ? JSON.stringify(body) : undefined; + const headers = await serviceRequestHeaders(url, { + method, + body: payload, + ...(userId ? { as: { userId, role: "member" } } : {}), + }); + return routeRequest( + new Request(url, { method, headers, body: payload }), + env, + createExecutionContext() + ); +} + +describe("team repository grants", () => { + let teamId: string; + beforeEach(async () => { + await cleanD1Tables(); + await seedActiveUser(MEMBER); + await seedActiveUser(OTHER); + await request("/me/authorization"); + teamId = ( + await new TeamStore(env.DB).create({ + slug: "engineering", + name: "Engineering", + joinPolicy: "invite_only", + }) + ).id; + await new TeamMembershipStore(env.DB).add(teamId, MEMBER); + await env.REPOS_CACHE.put( + REPOS_CACHE_KEY, + JSON.stringify({ + repos, + cachedAt: "2026-10-01T00:00:00.000Z", + freshUntil: Date.now() + 60_000, + scmIdentity: await reposCacheIdentity(env), + }) + ); + vi.spyOn(GitHubSourceControlProvider.prototype, "checkRepositoryAccess").mockImplementation( + async ({ owner, name }) => ({ + repoId: Number(name.slice("repo-".length)), + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }) + ); + }); + afterEach(() => vi.restoreAllMocks()); + + it("filters a cached catalog by numeric grants while leaving the workspace list full", async () => { + await new TeamRepositoryGrantStore(env.DB).add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + const response = await request(`/repos?teamId=${teamId}`, "GET", undefined, MEMBER); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + repos: [repos[0]], + teamHasRepositoryGrants: true, + }); + expect(await (await request("/repos")).json()).toMatchObject({ repos }); + }); + + it("serves the full installation catalog for an installation grant", async () => { + await new TeamRepositoryGrantStore(env.DB).add(teamId, { kind: "installation" }); + expect(await (await request(`/repos?teamId=${teamId}`)).json()).toMatchObject({ + repos, + teamHasRepositoryGrants: true, + }); + }); + + it("identifies a team with no grants without falling back to the installation list", async () => { + expect(await (await request(`/repos?teamId=${teamId}`)).json()).toMatchObject({ + repos: [], + teamHasRepositoryGrants: false, + }); + }); + + it.each(["/repos?teamId=", "/teams/"])("conceals a nonmember's %s grant read", async (prefix) => { + const path = + prefix === "/teams/" ? `${prefix}${teamId}/repository-grants` : `${prefix}${teamId}`; + const response = await request(path, "GET", undefined, OTHER); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ error: "Team not found" }); + const denial = + prefix === "/repos?teamId=" + ? await env.DB.prepare( + "SELECT action, reason_code FROM authorization_audit_events WHERE team_id = ? AND operation_result = 'denied'" + ) + .bind(teamId) + .all() + : await env.DB.prepare( + "SELECT action, reason_code FROM authorization_audit_events WHERE operation_result = 'denied'" + ).all(); + expect(denial.results).toContainEqual({ + action: "authorization.request_denied", + reason_code: "team_not_visible", + }); + }); + + it("audits missing teams identically to teams hidden from a nonmember", async () => { + const visible = await request(`/teams/${teamId}/repository-grants`, "GET", undefined, OTHER); + const missing = await request("/teams/team_missing/repository-grants", "GET", undefined, OTHER); + expect(missing.status).toBe(visible.status); + expect(await missing.json()).toEqual(await visible.json()); + const rows = await env.DB.prepare( + "SELECT reason_code, operation_result FROM authorization_audit_events WHERE action = 'authorization.request_denied'" + ).all(); + expect(rows.results).toHaveLength(2); + expect(rows.results).toEqual([ + { reason_code: "team_not_visible", operation_result: "denied" }, + { reason_code: "team_not_visible", operation_result: "denied" }, + ]); + }); + + it("lets a member read without audit rows but refuses grant changes", async () => { + const before = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM authorization_audit_events" + ).first(); + const response = await request(`/teams/${teamId}/repository-grants`, "GET", undefined, MEMBER); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ grants: [] }); + expect( + await env.DB.prepare("SELECT COUNT(*) AS count FROM authorization_audit_events").first() + ).toEqual(before); + expect( + (await request(`/teams/${teamId}/repository-grants`, "PUT", { kind: "installation" }, MEMBER)) + .status + ).toBe(403); + }); + + it("adds and removes grants atomically with a version bump and before/after audit", async () => { + const added = await request(`/teams/${teamId}/repository-grants`, "PUT", { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + expect(added.status).toBe(200); + const { grant } = await added.json<{ grant: { id: string } }>(); + expect((await new TeamStore(env.DB).getById(teamId))?.grantsVersion).toBe(1); + expect((await request(`/teams/${teamId}/repository-grants/${grant.id}`, "DELETE")).status).toBe( + 204 + ); + expect((await new TeamStore(env.DB).getById(teamId))?.grantsVersion).toBe(2); + const audit = await env.DB.prepare( + "SELECT action, metadata_json FROM authorization_audit_events WHERE team_id = ? AND action IN ('team.grant_added', 'team.grant_removed') ORDER BY occurred_at, id" + ) + .bind(teamId) + .all<{ action: string; metadata_json: string }>(); + expect(audit.results.map((row) => row.action)).toEqual([ + "team.grant_added", + "team.grant_removed", + ]); + expect(JSON.parse(audit.results[0].metadata_json)).toMatchObject({ + before: {}, + after: { id: grant.id }, + }); + expect(JSON.parse(audit.results[1].metadata_json)).toMatchObject({ + before: { id: grant.id }, + after: {}, + }); + }); + + it("rejects a forged external repository ID before granting it", async () => { + const response = await request(`/teams/${teamId}/repository-grants`, "PUT", { + kind: "repository", + repoExternalId: 99, + owner: "acme", + name: "repo-1", + }); + expect(response.status).toBe(409); + expect(await new TeamRepositoryGrantStore(env.DB).listForTeam(teamId)).toEqual([]); + }); + + it("lets a team lead manage grants without workspace administration", async () => { + await new TeamMembershipStore(env.DB).setRole(teamId, MEMBER, "lead"); + const response = await request( + `/teams/${teamId}/repository-grants`, + "PUT", + { kind: "installation" }, + MEMBER + ); + expect(response.status).toBe(200); + const { grant } = await response.json<{ grant: { id: string } }>(); + expect( + (await request(`/teams/${teamId}/repository-grants/${grant.id}`, "DELETE", undefined, MEMBER)) + .status + ).toBe(204); + }); + + it("refuses a 501st named grant at the API without changing the version", async () => { + await env.DB.batch( + Array.from({ length: 500 }, (_, index) => + env.DB.prepare( + "INSERT INTO team_repository_grants (id, team_id, grant_kind, repo_external_id, repo_owner, repo_name, created_at) VALUES (?, ?, 'repository', ?, 'acme', ?, 1)" + ).bind(`grant-${index}`, teamId, index + 1, `repo-${index + 1}`) + ) + ); + const response = await request(`/teams/${teamId}/repository-grants`, "PUT", { + kind: "repository", + repoExternalId: 501, + owner: "acme", + name: "repo-501", + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: "repository_grant_limit" }); + expect(await new TeamRepositoryGrantStore(env.DB).listForTeam(teamId)).toHaveLength(500); + expect((await new TeamStore(env.DB).getById(teamId))?.grantsVersion).toBe(0); + }); + + it("denies session creation with an ungranted repository before creating a session", async () => { + const response = await request( + "/sessions", + "POST", + { teamId, repoOwner: "acme", repoName: "repo-2" }, + MEMBER + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/repo-2", + }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM sessions").first()).toEqual({ + count: 0, + }); + }); + + it("denies adding an ungranted repository to a team-owned environment without changing its members", async () => { + const store = new EnvironmentStore(env.DB); + await store.create( + { + id: "env_granted", + owner_team_id: teamId, + name: "Granted", + description: null, + prebuild_enabled: 0, + channel_associations: null, + created_at: 1, + updated_at: 1, + }, + [{ position: 0, repo_owner: "acme", repo_name: "repo-1", repo_id: 1, base_branch: "main" }] + ); + await new TeamRepositoryGrantStore(env.DB).add(teamId, { + kind: "repository", + repoExternalId: 1, + owner: "acme", + name: "repo-1", + }); + const response = await request("/environments/env_granted", "PUT", { + repositories: [{ repoOwner: "acme", repoName: "repo-2" }], + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "target_team_missing_grant", + repository: "acme/repo-2", + }); + expect( + (await store.getRepositoriesForEnvironment("env_granted")).map((repo) => repo.repo_id) + ).toEqual([1]); + }); +}); diff --git a/packages/shared/src/types/audit-events.ts b/packages/shared/src/types/audit-events.ts index a43e64bfb8..4a66400ef7 100644 --- a/packages/shared/src/types/audit-events.ts +++ b/packages/shared/src/types/audit-events.ts @@ -90,6 +90,8 @@ export const AUDIT_OPERATION_ACTIONS = [ "team.member_role_changed", "team.member_removed", "team.member_joined", + "team.grant_added", + "team.grant_removed", "team.secret_set", "team.secret_deleted", ] as const; diff --git a/packages/shared/src/types/repository-catalog.ts b/packages/shared/src/types/repository-catalog.ts index d6d542720a..42482a8eef 100644 --- a/packages/shared/src/types/repository-catalog.ts +++ b/packages/shared/src/types/repository-catalog.ts @@ -62,6 +62,7 @@ export const controlPlaneReposResponseSchema = z.object({ repos: z.array(enrichedRepositorySchema), cached: z.boolean(), cachedAt: z.string(), + teamHasRepositoryGrants: z.boolean().optional(), }); export type ControlPlaneReposResponse = z.infer; diff --git a/packages/shared/src/types/teams.ts b/packages/shared/src/types/teams.ts index b9cc169426..7302289d47 100644 --- a/packages/shared/src/types/teams.ts +++ b/packages/shared/src/types/teams.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { isEnvironmentId } from "./environments"; -import { sessionListRepositorySchema } from "./repositories"; +import { repositoryPairInputSchema, sessionListRepositorySchema } from "./repositories"; export const teamRoleSchema = z.enum(["lead", "member"]); export type TeamRole = z.infer; @@ -178,3 +178,42 @@ export const teamSessionsResponseSchema = z.union([ }), ]); export type TeamSessionsResponse = z.infer; + +export const MAX_TEAM_REPOSITORY_GRANTS = 500; + +export const addTeamRepositoryGrantRequestSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("installation") }), + z.strictObject({ + kind: z.literal("repository"), + repoExternalId: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + owner: repositoryPairInputSchema.shape.repoOwner, + name: repositoryPairInputSchema.shape.repoName, + }), +]); +export type AddTeamRepositoryGrantRequest = z.infer; + +const grantFields = { + id: z.string(), + teamId: z.string(), + createdAt: z.number(), +}; +export const teamRepositoryGrantSchema = z.discriminatedUnion("kind", [ + z.object({ + ...grantFields, + kind: z.literal("installation"), + repoExternalId: z.null(), + owner: z.null(), + name: z.null(), + }), + z.object({ + ...grantFields, + kind: z.literal("repository"), + repoExternalId: z.number().int().positive(), + owner: z.string(), + name: z.string(), + }), +]); +export type TeamRepositoryGrant = z.infer; +export const teamRepositoryGrantsResponseSchema = z.object({ + grants: z.array(teamRepositoryGrantSchema), +}); diff --git a/packages/web/src/app/api/repos/route.test.ts b/packages/web/src/app/api/repos/route.test.ts index 7361f8a060..f36a0da744 100644 --- a/packages/web/src/app/api/repos/route.test.ts +++ b/packages/web/src/app/api/repos/route.test.ts @@ -11,7 +11,9 @@ describe("repositories list proxy", () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } }); - vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ repos: [] })); + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ repos: [], cached: false, cachedAt: "2026-10-01T00:00:00Z" }) + ); }); it("forwards only teamId", async () => { @@ -26,6 +28,27 @@ describe("repositories list proxy", () => { expect(controlPlaneUserFetch).toHaveBeenCalledWith("/repos"); }); + it.each([true, false])( + "preserves authoritative team grant metadata (%s)", + async (teamHasRepositoryGrants) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ + repos: [], + cached: false, + cachedAt: "2026-10-01T00:00:00Z", + teamHasRepositoryGrants, + }) + ); + const response = await GET(new NextRequest("http://localhost/api/repos?teamId=team-1")); + await expect(response.json()).resolves.toEqual({ repos: [], teamHasRepositoryGrants }); + } + ); + + it("does not invent grant metadata for the installation catalog", async () => { + const response = await GET(new NextRequest("http://localhost/api/repos")); + await expect(response.json()).resolves.toEqual({ repos: [] }); + }); + it("does not fetch without authentication", async () => { vi.mocked(getServerAuthSession).mockResolvedValue(null); expect((await GET(new NextRequest("http://localhost/api/repos?teamId=team-1"))).status).toBe( diff --git a/packages/web/src/app/api/repos/route.ts b/packages/web/src/app/api/repos/route.ts index d186dfd938..23d382b07c 100644 --- a/packages/web/src/app/api/repos/route.ts +++ b/packages/web/src/app/api/repos/route.ts @@ -30,7 +30,10 @@ export async function GET(request: NextRequest) { if (!parsed.success) throw new Error("Invalid control plane repositories response"); // The control plane returns repos in the format we need - return NextResponse.json({ repos: parsed.data.repos }); + return NextResponse.json({ + repos: parsed.data.repos, + teamHasRepositoryGrants: parsed.data.teamHasRepositoryGrants, + }); } catch (error) { console.error("Error fetching repos:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts new file mode 100644 index 0000000000..2c848b9f60 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.test.ts @@ -0,0 +1,41 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { DELETE } from "./route"; + +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); +beforeEach(() => vi.resetAllMocks()); + +describe("delete team repository grant proxy", () => { + it("encodes both IDs and preserves a bodyless 204", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(new Response(null, { status: 204 })); + const request = new NextRequest( + "http://localhost/api/teams/one/repository-grants/grant?teamId=other", + { method: "DELETE" } + ); + const response = await DELETE(request, { + params: Promise.resolve({ id: "team/one", grantId: "grant/one" }), + }); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/teams/team%2Fone/repository-grants/grant%2Fone", + { method: "DELETE" } + ); + expect(response.status).toBe(204); + expect(await response.text()).toBe(""); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it.each([401, 403, 404, 409])("preserves an upstream %s denial", async (status) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ error: "Denied" }, { status }) + ); + const response = await DELETE( + new NextRequest("http://localhost/api/teams/one/repository-grants/grant", { + method: "DELETE", + }), + { params: Promise.resolve({ id: "team-1", grantId: "grant-1" }) } + ); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual({ error: "Denied" }); + }); +}); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts new file mode 100644 index 0000000000..898e3c5e99 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/[grantId]/route.ts @@ -0,0 +1,7 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { DELETE } = settingsProxy( + ({ id, grantId }: { id: string; grantId: string }) => + `/teams/${encodeURIComponent(id)}/repository-grants/${encodeURIComponent(grantId)}`, + "team repository grant" +); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts b/packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts new file mode 100644 index 0000000000..7f4298a366 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/route.test.ts @@ -0,0 +1,81 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { GET, PUT } from "./route"; + +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); +beforeEach(() => vi.resetAllMocks()); + +const context = { params: Promise.resolve({ id: "team/one" }) }; +const grant = { + id: "grant-1", + teamId: "team/one", + kind: "installation", + repoExternalId: null, + owner: null, + name: null, + createdAt: 1, +}; + +describe("team repository grants proxy", () => { + it("encodes team IDs, strips arbitrary filters, and relays the grants list", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ grants: [grant] })); + const response = await GET( + new NextRequest("http://localhost/api/teams/one/repository-grants?teamId=other"), + context + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/teams/team%2Fone/repository-grants", + undefined + ); + await expect(response.json()).resolves.toEqual({ grants: [grant] }); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it.each([ + { kind: "installation" }, + { kind: "repository", repoExternalId: 42, owner: "group/subgroup", name: "api" }, + ])("forwards a PUT grant unchanged (%j)", async (body) => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ grant }, { status: 201 })); + const response = await PUT( + new NextRequest("http://localhost/api/teams/one/repository-grants", { + method: "PUT", + headers: { Cookie: "__Secure-openinspect.session_token=session.signature" }, + body: JSON.stringify(body), + }), + context + ); + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/teams/team%2Fone/repository-grants", { + method: "PUT", + body: JSON.stringify(body), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toEqual({ grant }); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); + + it.each([401, 403, 404, 409])( + "preserves upstream denials and conflict codes (%s)", + async (status) => { + const failure = { error: "Denied", code: "grant_conflict" }; + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json(failure, { status })); + const response = await GET( + new NextRequest("http://localhost/api/teams/one/repository-grants"), + context + ); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toEqual(failure); + } + ); + + it("does not read or forward an unauthenticated mutation", async () => { + const request = new NextRequest("http://localhost/api/teams/one/repository-grants", { + method: "PUT", + body: "{malformed", + }); + const response = await PUT(request, context); + expect(response.status).toBe(401); + expect(request.bodyUsed).toBe(false); + expect(controlPlaneUserFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/app/api/teams/[id]/repository-grants/route.ts b/packages/web/src/app/api/teams/[id]/repository-grants/route.ts new file mode 100644 index 0000000000..20c66bd959 --- /dev/null +++ b/packages/web/src/app/api/teams/[id]/repository-grants/route.ts @@ -0,0 +1,6 @@ +import { settingsProxy } from "@/lib/settings-proxy"; + +export const { GET, PUT } = settingsProxy( + ({ id }: { id: string }) => `/teams/${encodeURIComponent(id)}/repository-grants`, + "team repository grants" +); diff --git a/packages/web/src/components/session-target-picker.test.tsx b/packages/web/src/components/session-target-picker.test.tsx new file mode 100644 index 0000000000..a341fdf816 --- /dev/null +++ b/packages/web/src/components/session-target-picker.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +/// + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { SessionTargetPickerProps } from "@/hooks/use-session-target-picker"; +import { + MULTIPLE_REPOSITORIES_OPTION_VALUE, + NO_REPOSITORY_OPTION_VALUE, +} from "@/lib/session-target"; +import { SessionTargetPicker } from "./session-target-picker"; + +expect.extend(matchers); +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); +}); +afterEach(cleanup); + +describe("SessionTargetPicker empty grants", () => { + it("renders the explicit grant error alongside usable target controls", () => { + const select = vi.fn(); + const props: SessionTargetPickerProps = { + sessionTarget: null, + targetSelectValue: "", + targetOptions: [{ value: NO_REPOSITORY_OPTION_VALUE, label: "No repository" }], + displayTargetName: "Select repo", + onTargetSelectValueChange: select, + onMultiSelectionChange: vi.fn(), + selectedBranch: "", + setSelectedBranch: vi.fn(), + branches: [], + loadingBranches: false, + repos: [], + loadingRepos: false, + repositoryGrantError: "This team has no repository grants.", + selectionError: null, + }; + render(); + expect(screen.getByRole("alert")).toHaveTextContent("This team has no repository grants."); + fireEvent.click(screen.getByRole("button", { name: "Select repo" })); + fireEvent.click(screen.getByRole("option", { name: "No repository" })); + expect(select).toHaveBeenCalledWith(NO_REPOSITORY_OPTION_VALUE); + }); +}); + +describe("SessionTargetPicker invalidated selections", () => { + const props: SessionTargetPickerProps = { + sessionTarget: { kind: "repo", repoFullName: "acme/web" }, + targetSelectValue: "acme/web", + targetOptions: [{ value: NO_REPOSITORY_OPTION_VALUE, label: "No repository" }], + displayTargetName: "acme/web", + onTargetSelectValueChange: vi.fn(), + onMultiSelectionChange: vi.fn(), + selectedBranch: "feature", + setSelectedBranch: vi.fn(), + branches: [{ name: "feature" }], + loadingBranches: false, + repos: [], + loadingRepos: false, + repositoryGrantError: null, + selectionError: "Your selected target is no longer available. Choose a target again.", + }; + + it("shows the retained target and invalidation message while allowing a new target choice", () => { + const select = vi.fn(); + render(); + expect(screen.getByRole("alert")).toHaveTextContent(/choose a target again/i); + expect(screen.getByRole("button", { name: "feature" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "acme/web" })); + fireEvent.click(screen.getByRole("option", { name: "No repository" })); + expect(select).toHaveBeenCalledWith(NO_REPOSITORY_OPTION_VALUE); + }); + + it("keeps the exact multi-repository list visible and supports explicitly clearing it", () => { + const selectRepositories = vi.fn(); + render( + + ); + expect(screen.getByRole("button", { name: "Repository selection" })).toHaveTextContent( + "acme/api, acme/web" + ); + fireEvent.click(screen.getByRole("button", { name: "Choose repositories again" })); + expect(selectRepositories).toHaveBeenCalledWith([]); + }); + + it("removes the invalidation message after a new explicit choice", () => { + const { rerender } = render(); + rerender( + + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps the recovery action disabled when the composer is disabled", () => { + render( + + ); + expect(screen.getByRole("button", { name: "Choose repositories again" })).toBeDisabled(); + }); +}); diff --git a/packages/web/src/components/session-target-picker.tsx b/packages/web/src/components/session-target-picker.tsx index b8c6c11b62..55b933a69d 100644 --- a/packages/web/src/components/session-target-picker.tsx +++ b/packages/web/src/components/session-target-picker.tsx @@ -23,6 +23,8 @@ export function SessionTargetPicker({ loadingBranches, repos, loadingRepos, + repositoryGrantError, + selectionError, disabled, }: SessionTargetPickerProps & { disabled: boolean }) { return ( @@ -50,6 +52,18 @@ export function SessionTargetPicker({ + {repositoryGrantError && ( +

+ {repositoryGrantError} +

+ )} + + {selectionError && ( +

+ {selectionError} +

+ )} + {/* Ad-hoc repository set editor */} {sessionTarget?.kind === "repos" && ( )} + {selectionError && sessionTarget?.kind === "repos" && ( + + )} + {/* Branch selector */} {sessionTarget?.kind === "repo" && ( option.label.toLowerCase().includes(query)} direction="down" dropdownWidth="w-56" - disabled={disabled || loadingBranches} + disabled={disabled || loadingBranches || loadingRepos || !!selectionError} triggerClassName="flex max-w-full items-center gap-1 text-sm text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition" > diff --git a/packages/web/src/components/settings/audit-log-settings.test.tsx b/packages/web/src/components/settings/audit-log-settings.test.tsx index d0bb9b2acf..b134df8ab8 100644 --- a/packages/web/src/components/settings/audit-log-settings.test.tsx +++ b/packages/web/src/components/settings/audit-log-settings.test.tsx @@ -195,9 +195,11 @@ describe("AuditLogSettings", () => { }); it.each([ + ["team.grant_added", "Team repository grant added"], + ["team.grant_removed", "Team repository grant removed"], ["team.secret_set", "Team secret set"], ["team.secret_deleted", "Team secret deleted"], - ])("labels %s as an applied operation", (action, label) => { + ])("labels %s as an operation in the workspace audit viewer", (action, label) => { const article = renderSingle(createEvent("applied", { action })); expect(article.getByText(label)).toBeInTheDocument(); expect(article.getByText("Applied")).toBeInTheDocument(); diff --git a/packages/web/src/components/settings/audit-log-settings.tsx b/packages/web/src/components/settings/audit-log-settings.tsx index f43139f46b..ef99e105f2 100644 --- a/packages/web/src/components/settings/audit-log-settings.tsx +++ b/packages/web/src/components/settings/audit-log-settings.tsx @@ -61,6 +61,8 @@ const OPERATION_LABELS: Record = { "team.member_role_changed": "Team member role changed", "team.member_removed": "Team member removed", "team.member_joined": "Team member joined", + "team.grant_added": "Team repository grant added", + "team.grant_removed": "Team repository grant removed", "team.secret_set": "Team secret set", "team.secret_deleted": "Team secret deleted", }; diff --git a/packages/web/src/components/teams/team-page.tsx b/packages/web/src/components/teams/team-page.tsx index 0941758a39..294cea88fe 100644 --- a/packages/web/src/components/teams/team-page.tsx +++ b/packages/web/src/components/teams/team-page.tsx @@ -16,9 +16,10 @@ import { TeamDetail } from "@/components/settings/team-detail"; import { Button } from "@/components/ui/button"; import { ErrorBanner } from "@/components/ui/error-banner"; import { TeamOverview } from "./team-overview"; +import { TeamRepositories } from "./team-repositories"; import { TeamSecrets } from "./team-secrets"; -type TeamTab = "Overview" | "Members" | "Secrets" | "Settings"; +type TeamTab = "Overview" | "Members" | "Repositories" | "Secrets" | "Settings"; export function TeamPage({ slug }: { slug: string }) { const { teams, loading, error } = useTeams(); @@ -56,7 +57,7 @@ function TeamContent({ const team = currentTeam ?? initialTeam; const capabilities = useTeamCapabilities(team); const [tab, setTab] = useState("Overview"); - const tabs: TeamTab[] = canViewWork ? ["Overview", "Members"] : ["Members"]; + const tabs: TeamTab[] = canViewWork ? ["Overview", "Members", "Repositories"] : ["Members"]; if (canViewWork && capabilities.canManageSecrets) tabs.push("Secrets"); if (canViewWork && (capabilities.canEditMetadata || capabilities.canArchive)) tabs.push("Settings"); @@ -102,6 +103,7 @@ function TeamContent({ {activeTab === "Overview" && } {activeTab === "Members" && } + {activeTab === "Repositories" && } {activeTab === "Secrets" && canViewWork && capabilities.canManageSecrets && ( )} diff --git a/packages/web/src/components/teams/team-repositories.test.tsx b/packages/web/src/components/teams/team-repositories.test.tsx new file mode 100644 index 0000000000..3bc4b9fbc9 --- /dev/null +++ b/packages/web/src/components/teams/team-repositories.test.tsx @@ -0,0 +1,351 @@ +// @vitest-environment jsdom +/// + +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import type { ReactNode } from "react"; +import { SWRConfig } from "swr"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TeamRepositoryGrant } from "@open-inspect/shared/types/teams"; +import type { TeamResponse } from "@/hooks/use-teams"; +import { browserApiFetch } from "@/lib/browser-api-fetch"; +import { TeamRepositories } from "./team-repositories"; + +expect.extend(matchers); +const mocks = vi.hoisted(() => ({ + repos: vi.fn(), + authStatus: "authenticated" as "authenticated" | "loading" | "unauthenticated", + grants: [] as TeamRepositoryGrant[], +})); +vi.mock("@/lib/browser-api-fetch", () => ({ browserApiFetch: vi.fn() })); +vi.mock("@/lib/auth-session", () => ({ + useAuthSession: () => ({ + data: mocks.authStatus === "authenticated" ? { user: { id: "user-1" } } : null, + status: mocks.authStatus, + }), +})); +vi.mock("@/hooks/use-repos", () => ({ useRepos: mocks.repos })); + +const denied = { + canJoin: false, + canLeave: false, + canEditMetadata: false, + canManageMembers: false, + canManageRepositories: false, + canManageBindings: false, + canManageAutomations: false, + canManageSecrets: false, + canArchive: false, +}; +const team: TeamResponse = { + id: "team/one", + slug: "design", + name: "Design", + description: null, + joinPolicy: "invite_only", + defaultVisibility: "team", + defaultEnvironmentId: null, + grantsVersion: 0, + archivedAt: null, + createdAt: 1, + updatedAt: 1, + memberCount: 1, + capabilities: { ...denied, canManageRepositories: true }, +}; +const namedGrant: TeamRepositoryGrant = { + id: "grant/one", + teamId: team.id, + kind: "repository", + repoExternalId: 42, + owner: "group/subgroup", + name: "api", + createdAt: 1, +}; +const installationGrant: TeamRepositoryGrant = { + id: "grant-installation", + teamId: team.id, + kind: "installation", + repoExternalId: null, + owner: null, + name: null, + createdAt: 1, +}; +const catalog = [ + { + id: 42, + fullName: "group/subgroup/api", + owner: "group/subgroup", + name: "api", + description: null, + private: true, + defaultBranch: "main", + }, + { + id: 43, + fullName: "acme/web", + owner: "acme", + name: "web", + description: null, + private: false, + defaultBranch: "main", + }, +]; + +function wrapper({ children }: { children: ReactNode }) { + return ( + new Map(), dedupingInterval: 0, shouldRetryOnError: false }} + > + {children} + + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + mocks.grants = []; + mocks.authStatus = "authenticated"; + mocks.repos.mockReturnValue({ repos: catalog, loading: false, error: undefined }); + vi.mocked(browserApiFetch).mockImplementation(async (_path, init) => { + if (init?.method === "PUT") { + const input = JSON.parse(String(init.body)); + const grant = input.kind === "installation" ? installationGrant : namedGrant; + mocks.grants = [...mocks.grants, grant]; + return Response.json({ grant }); + } + if (init?.method === "DELETE") { + mocks.grants = []; + return new Response(null, { status: 204 }); + } + return Response.json({ grants: mocks.grants }); + }); +}); +afterEach(cleanup); + +describe("Team repository grants", () => { + it.each([undefined, denied, { canManageRepositories: true }])( + "shows members the list without inferring management from %j", + async (capabilities) => { + mocks.grants = [namedGrant]; + render(, { wrapper }); + expect(await screen.findByText("group/subgroup/api")).toBeInTheDocument(); + expect(screen.getByText("Named repository grant")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Remove/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add grant" })).not.toBeInTheDocument(); + expect(mocks.repos).toHaveBeenCalledWith(false); + } + ); + + it("adds a named grant from the full unscoped installation catalog", async () => { + render(, { wrapper }); + await screen.findByText("This team has no repository grants."); + expect(mocks.repos).toHaveBeenCalledWith(true); + fireEvent.change(screen.getByRole("combobox", { name: "Repository" }), { + target: { value: "42" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add grant" })); + await waitFor(() => + expect(browserApiFetch).toHaveBeenCalledWith( + "/api/teams/team%2Fone/repository-grants", + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ + kind: "repository", + repoExternalId: 42, + owner: "group/subgroup", + name: "api", + }), + }) + ) + ); + expect(await screen.findByText("Named repository grant")).toBeInTheDocument(); + const options = within(screen.getByRole("combobox", { name: "Repository" })); + expect(options.queryByRole("option", { name: "group/subgroup/api" })).not.toBeInTheDocument(); + expect(options.getByRole("option", { name: "acme/web" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "All installation repositories" })).toBeDisabled(); + }); + + it("adds installation-wide access and disables mixing named grants", async () => { + render(, { wrapper }); + await screen.findByText("This team has no repository grants."); + fireEvent.change(screen.getByRole("combobox", { name: "Grant scope" }), { + target: { value: "installation" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add grant" })); + await waitFor(() => + expect(browserApiFetch).toHaveBeenCalledWith( + "/api/teams/team%2Fone/repository-grants", + expect.objectContaining({ method: "PUT", body: JSON.stringify({ kind: "installation" }) }) + ) + ); + expect(await screen.findByText("Installation-wide grant")).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Named repositories" })).toBeDisabled(); + expect(screen.queryByRole("combobox", { name: "Repository" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add grant" })).toBeDisabled(); + }); + + it("removes a grant using encoded IDs and accepts a bodyless 204", async () => { + mocks.grants = [namedGrant]; + render(, { wrapper }); + fireEvent.click( + await screen.findByRole("button", { name: "Remove grant for group/subgroup/api" }) + ); + expect(await screen.findByText("This team has no repository grants.")).toBeInTheDocument(); + expect(browserApiFetch).toHaveBeenCalledWith( + "/api/teams/team%2Fone/repository-grants/grant%2Fone", + { method: "DELETE" } + ); + await waitFor(() => + expect( + screen.getByRole("option", { name: "All installation repositories" }) + ).not.toBeDisabled() + ); + }); + + it("treats an already-absent grant as removed and revalidates the list", async () => { + mocks.grants = [namedGrant]; + vi.mocked(browserApiFetch).mockImplementation(async (_path, init) => { + if (init?.method === "DELETE") { + mocks.grants = []; + return Response.json({ error: "Repository grant not found" }, { status: 404 }); + } + return Response.json({ grants: mocks.grants }); + }); + render(, { wrapper }); + fireEvent.click( + await screen.findByRole("button", { name: "Remove grant for group/subgroup/api" }) + ); + expect(await screen.findByText("This team has no repository grants.")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await waitFor(() => + expect( + vi + .mocked(browserApiFetch) + .mock.calls.filter(([, init]) => !init?.method || init.method === "GET") + ).toHaveLength(2) + ); + }); + + it.each([ + [404, "Team not found"], + [403, "Repository grant not found"], + [500, "Repository grant not found"], + ] as const)("keeps grants and errors for DELETE %s %s", async (status, error) => { + mocks.grants = [namedGrant]; + render(, { wrapper }); + await screen.findByText("Named repository grant"); + vi.mocked(browserApiFetch).mockResolvedValueOnce(Response.json({ error }, { status })); + fireEvent.click(screen.getByRole("button", { name: "Remove grant for group/subgroup/api" })); + expect(await screen.findByRole("alert")).toHaveTextContent(error); + expect(screen.getByText("Named repository grant")).toBeInTheDocument(); + }); + + it("does not treat an already-absent grant response as a successful addition", async () => { + render(, { wrapper }); + await screen.findByText("This team has no repository grants."); + fireEvent.change(screen.getByRole("combobox", { name: "Repository" }), { + target: { value: "42" }, + }); + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json({ error: "Repository grant not found" }, { status: 404 }) + ); + fireEvent.click(screen.getByRole("button", { name: "Add grant" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Repository grant not found"); + expect(screen.getByText("This team has no repository grants.")).toBeInTheDocument(); + }); + + it("keeps archived grants readable but disables every mutation", async () => { + mocks.grants = [namedGrant]; + render(, { wrapper }); + expect(await screen.findByText("Named repository grant")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Remove grant for group/subgroup/api" }) + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Add grant" })).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Grant scope" })).toBeDisabled(); + expect(screen.getByRole("combobox", { name: "Repository" })).toBeDisabled(); + expect(mocks.repos).toHaveBeenCalledWith(false); + }); + + it("withholds add controls while the grant list loads", () => { + vi.mocked(browserApiFetch).mockReturnValue(new Promise(() => {})); + render(, { wrapper }); + expect(screen.getByRole("status")).toHaveTextContent("Loading repository grants..."); + expect(screen.getByRole("button", { name: "Add grant" })).toBeDisabled(); + }); + + it("waits for authentication instead of presenting an authoritative empty list", () => { + mocks.authStatus = "loading"; + render(, { wrapper }); + expect(screen.getByRole("status")).toHaveTextContent("Loading repository grants..."); + expect(screen.queryByText("This team has no repository grants.")).not.toBeInTheDocument(); + expect(browserApiFetch).not.toHaveBeenCalled(); + }); + + it("rejects malformed grant responses rather than showing grants or enabling mutations", async () => { + vi.mocked(browserApiFetch).mockResolvedValue( + Response.json({ grants: [{ kind: "repository" }] }) + ); + render(, { wrapper }); + expect(await screen.findByRole("alert")).toHaveTextContent("Unable to load repository grants."); + expect(screen.queryByText("This team has no repository grants.")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add grant" })).toBeDisabled(); + }); + + it("shows grant-list errors with retry instead of treating them as zero grants", async () => { + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json({ error: "Forbidden" }, { status: 403 }) + ); + render(, { wrapper }); + expect(await screen.findByRole("alert")).toHaveTextContent("Unable to load repository grants."); + expect(screen.queryByText("This team has no repository grants.")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add grant" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("This team has no repository grants.")).toBeInTheDocument(); + }); + + it.each(["loading", "error"])( + "disables named additions while the installation catalog is %s", + async (state) => { + mocks.repos.mockReturnValue({ + repos: [], + loading: state === "loading", + error: state === "error" ? new Error("Unavailable") : undefined, + }); + render(, { wrapper }); + await screen.findByText("This team has no repository grants."); + expect(screen.getByRole("combobox", { name: "Repository" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Add grant" })).toBeDisabled(); + expect( + screen.getByText( + state === "loading" + ? "Loading installation repositories..." + : "Unable to load installation repositories." + ) + ).toBeInTheDocument(); + } + ); + + it("surfaces server conflict codes and retains existing grants on a failed removal", async () => { + mocks.grants = [namedGrant]; + render(, { wrapper }); + await screen.findByText("Named repository grant"); + vi.mocked(browserApiFetch).mockResolvedValueOnce( + Response.json({ error: "Team archived", code: "team_archived" }, { status: 409 }) + ); + fireEvent.click(screen.getByRole("button", { name: "Remove grant for group/subgroup/api" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Team archived (team_archived)"); + expect(screen.getByText("Named repository grant")).toBeInTheDocument(); + }); + + it("removes management controls when fresh server capabilities are revoked", async () => { + mocks.grants = [namedGrant]; + const view = render(, { wrapper }); + await screen.findByRole("button", { name: "Remove grant for group/subgroup/api" }); + view.rerender(); + expect(screen.getByText("Named repository grant")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Remove/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Add grant" })).not.toBeInTheDocument(); + expect(mocks.repos).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/packages/web/src/components/teams/team-repositories.tsx b/packages/web/src/components/teams/team-repositories.tsx new file mode 100644 index 0000000000..1df5555922 --- /dev/null +++ b/packages/web/src/components/teams/team-repositories.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useState } from "react"; +import useSWR, { useSWRConfig } from "swr"; +import { z } from "zod"; +import { + addTeamRepositoryGrantRequestSchema, + teamRepositoryGrantSchema, + teamRepositoryGrantsResponseSchema, + type TeamRepositoryGrant, +} from "@open-inspect/shared/types/teams"; +import { useRepos } from "@/hooks/use-repos"; +import { useTeamCapabilities } from "@/hooks/use-team-capabilities"; +import type { TeamResponse } from "@/hooks/use-teams"; +import { useAuthSession } from "@/lib/auth-session"; +import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ErrorBanner } from "@/components/ui/error-banner"; + +export function TeamRepositories({ team }: { team: TeamResponse }) { + const { data: session, status } = useAuthSession(); + const { mutate } = useSWRConfig(); + const { canManageRepositories } = useTeamCapabilities(team); + const archived = team.archivedAt !== null; + const catalog = useRepos(canManageRepositories && !archived); + const teamPath = `/api/teams/${encodeURIComponent(team.id)}` as const; + const path = `${teamPath}/repository-grants` as const; + const result = useSWR<{ grants: TeamRepositoryGrant[] }, Error>( + session?.user.id ? ([path, session.user.id] as const) : null, + async ([requestPath]: readonly [BrowserApiPath, string]) => { + const response = await browserApiFetch(requestPath); + if (!response.ok) throw new Error(`Unable to load repository grants (${response.status})`); + return teamRepositoryGrantsResponseSchema.parse(await response.json()); + }, + { keepPreviousData: false } + ); + const grants = session?.user.id && !result.error ? (result.data?.grants ?? []) : []; + const loading = status === "loading" || result.isLoading; + const [draftKind, setDraftKind] = useState<"installation" | "repository">("repository"); + const [repoId, setRepoId] = useState(""); + const [pending, setPending] = useState(false); + const [message, setMessage] = useState(null); + const installationWide = grants.some((grant) => grant.kind === "installation"); + // Existing grants fix the scope until removed; the store rejects mixed kinds. + const kind = grants[0]?.kind ?? draftKind; + const available = catalog.repos.filter( + (repo) => !grants.some((grant) => grant.repoExternalId === repo.id) + ); + const selectedRepo = available.find((repo) => String(repo.id) === repoId); + const disabled = + archived || pending || loading || !!result.error || !result.data || !session?.user.id; + + async function changeGrant( + change: { add: z.input } | { remove: string } + ) { + if (!canManageRepositories || disabled) return; + setPending(true); + setMessage(null); + try { + const adding = "add" in change; + const response = await browserApiFetch( + adding ? path : `${path}/${encodeURIComponent(change.remove)}`, + adding + ? { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(addTeamRepositoryGrantRequestSchema.parse(change.add)), + } + : { method: "DELETE" } + ); + if (!response.ok) { + const failure = await response.json().catch(() => null); + const alreadyAbsent = + !adding && response.status === 404 && failure?.error === "Repository grant not found"; + if (!alreadyAbsent) { + const error = + typeof failure?.error === "string" ? failure.error : "Repository grant update failed"; + throw new Error(typeof failure?.code === "string" ? `${error} (${failure.code})` : error); + } + } + let nextGrants: TeamRepositoryGrant[]; + if (adding) { + const { grant } = z + .object({ grant: teamRepositoryGrantSchema }) + .parse(await response.json()); + nextGrants = [...grants.filter((existing) => existing.id !== grant.id), grant]; + setRepoId(""); + } else { + nextGrants = grants.filter((grant) => grant.id !== change.remove); + } + await result.mutate({ grants: nextGrants }, { revalidate: false }); + await Promise.allSettled([ + result.mutate(), + mutate(teamPath), + mutate(`/api/repos?teamId=${encodeURIComponent(team.id)}`), + mutate(`/api/environments?teamId=${encodeURIComponent(team.id)}`), + ]); + } catch (cause) { + setMessage(cause instanceof Error ? cause.message : "Repository grant update failed"); + } finally { + setPending(false); + } + } + + return ( +
+
+

+ Repositories +

+

+ Repository grants determine which installation repositories this team can use. +

+
+ {message && {message}} + {result.error && ( + + Unable to load repository grants.{" "} + + + )} + {loading ? ( +

+ Loading repository grants... +

+ ) : ( + !result.error && + session?.user.id && + result.data && ( +
    + {grants.length === 0 && ( +
  • + This team has no repository grants. +
  • + )} + {grants.map((grant) => { + const label = + grant.kind === "installation" + ? "All installation repositories" + : `${grant.owner}/${grant.name}`; + return ( +
  • +
    +

    {label}

    + + {grant.kind === "installation" + ? "Installation-wide grant" + : "Named repository grant"} + + {grant.kind === "installation" && ( +

    + Includes current and future repositories accessible to the installation. +

    + )} +
    + {canManageRepositories && ( + + )} +
  • + ); + })} +
+ ) + )} + {canManageRepositories && ( +
+

+ Add repository grant +

+

+ Choose installation-wide access or named repositories, not both. Remove existing grants + to switch scope. +

+ {archived && ( +

+ Archived teams cannot change repository grants. +

+ )} +
+ + + {kind === "repository" && ( + <> + + + + )} + +
+ {kind === "repository" && catalog.loading && ( +

+ Loading installation repositories... +

+ )} + {kind === "repository" && catalog.error && ( + Unable to load installation repositories. + )} + {kind === "repository" && + !catalog.loading && + !catalog.error && + available.length === 0 && + !archived && ( +

+ No additional installation repositories available. +

+ )} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/teams/teams-pages.test.tsx b/packages/web/src/components/teams/teams-pages.test.tsx index 536fce68f1..528425c584 100644 --- a/packages/web/src/components/teams/teams-pages.test.tsx +++ b/packages/web/src/components/teams/teams-pages.test.tsx @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({ membershipError: null as Error | null, currentTeam: undefined as TeamResponse | undefined, join: vi.fn(), + repositories: vi.fn(), + secrets: vi.fn(), })); vi.mock("@/lib/auth-session", () => ({ @@ -49,8 +51,17 @@ vi.mock("@/components/settings/team-detail", () => ({ TeamDetail: () =>

Team settings editor

, })); vi.mock("./team-overview", () => ({ TeamOverview: () =>

Team session buckets

})); +vi.mock("./team-repositories", () => ({ + TeamRepositories: (props: { team: TeamResponse }) => { + mocks.repositories(props); + return

Team repository grants

; + }, +})); vi.mock("./team-secrets", () => ({ - TeamSecrets: ({ teamId }: { teamId: string }) =>

Team secrets editor for {teamId}

, + TeamSecrets: (props: { teamId: string; capabilities?: TeamResponse["capabilities"] }) => { + mocks.secrets(props); + return

Team secrets editor for {props.teamId}

; + }, })); const team: TeamResponse = { @@ -187,6 +198,7 @@ describe("Team page tabs", () => { expect(tabs.getByRole("button", { name: "Members" })).toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Overview" })).not.toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Activity" })).not.toBeInTheDocument(); + expect(tabs.queryByRole("button", { name: "Repositories" })).not.toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Secrets" })).not.toBeInTheDocument(); expect(tabs.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); expect(screen.getByText("Team member table")).toBeInTheDocument(); @@ -203,11 +215,13 @@ describe("Team page tabs", () => { expect(tabs.queryByRole("button", { name: "Activity" })).not.toBeInTheDocument(); }); - it("shows member Overview and Members but no Settings without capabilities", () => { + it("shows member Overview, Members, and Repositories but no Settings without capabilities", () => { mocks.mine = [team]; render(); expect(screen.getByText("Team session buckets")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Repositories" })); + expect(screen.getByText("Team repository grants")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Members" })); expect(screen.getByText("Team member table")).toBeInTheDocument(); }); @@ -219,6 +233,8 @@ describe("Team page tabs", () => { const view = render(); expect(screen.getByRole("button", { name: "Overview" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Members" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Repositories" })); + expect(screen.getByText("Team repository grants")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); mocks.teams = [{ ...team, capabilities: { ...denied, canArchive: true } }]; view.rerender(); @@ -227,6 +243,18 @@ describe("Team page tabs", () => { } ); + it("passes fresh server repository capabilities to the repository tab", () => { + mocks.mine = [team]; + mocks.teams = [{ ...team, capabilities: { ...denied, canManageRepositories: true } }]; + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Repositories" })); + expect(mocks.repositories).toHaveBeenLastCalledWith({ team: mocks.teams[0] }); + mocks.currentTeam = { ...team, capabilities: denied }; + view.rerender(); + expect(mocks.repositories).toHaveBeenLastCalledWith({ team: mocks.currentTeam }); + expect(screen.getByText("Team repository grants")).toBeInTheDocument(); + }); + it("unmounts Overview when membership disappears", () => { mocks.mine = [team]; const view = render(); @@ -238,6 +266,18 @@ describe("Team page tabs", () => { expect(screen.getByText("Team member table")).toBeInTheDocument(); }); + it("unmounts repository grants when membership disappears", () => { + mocks.mine = [team]; + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "Repositories" })); + expect(screen.getByText("Team repository grants")).toBeInTheDocument(); + mocks.mine = []; + view.rerender(); + expect(screen.queryByText("Team repository grants")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Repositories" })).not.toBeInTheDocument(); + expect(screen.getByText("Team member table")).toBeInTheDocument(); + }); + it.each(["loading", "failed"])("withholds private tabs while membership is %s", (state) => { mocks.mine = [team]; mocks.membershipLoading = state === "loading"; @@ -245,6 +285,7 @@ describe("Team page tabs", () => { render(); expect(screen.getByText("Team member table")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Overview" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Repositories" })).not.toBeInTheDocument(); }); it("unmounts Settings when the server revokes metadata and archive capabilities", () => { @@ -289,11 +330,16 @@ describe("Team page tabs", () => { expect(tabs.getAllByRole("button").map((button) => button.textContent)).toEqual([ "Overview", "Members", + "Repositories", "Secrets", ]); expect(screen.queryByText("Team secrets editor for team_design")).not.toBeInTheDocument(); fireEvent.click(tabs.getByRole("button", { name: "Secrets" })); expect(screen.getByText("Team secrets editor for team_design")).toBeInTheDocument(); + expect(mocks.secrets).toHaveBeenLastCalledWith({ + teamId: team.id, + capabilities: mocks.teams[0].capabilities, + }); } ); diff --git a/packages/web/src/hooks/use-repos.test.tsx b/packages/web/src/hooks/use-repos.test.tsx index 2c8a58f80e..403835f460 100644 --- a/packages/web/src/hooks/use-repos.test.tsx +++ b/packages/web/src/hooks/use-repos.test.tsx @@ -29,6 +29,22 @@ describe("useRepos", () => { expect(mocks.useSWR).toHaveBeenCalledWith("/api/repos"); }); + it.each([true, false, undefined])( + "returns optional server grant metadata without inferring it (%s)", + (teamHasRepositoryGrants) => { + mocks.useSWR.mockReturnValue({ + data: { + repos: [], + ...(teamHasRepositoryGrants === undefined ? {} : { teamHasRepositoryGrants }), + }, + isLoading: false, + error: undefined, + }); + const { result } = renderHook(() => useRepos(true, "team-1")); + expect(result.current.teamHasRepositoryGrants).toBe(teamHasRepositoryGrants); + } + ); + it("keys repository requests by team and returns to the workspace key", () => { const initialProps: { teamId: string | null } = { teamId: "team/one" }; const { rerender } = renderHook( diff --git a/packages/web/src/hooks/use-repos.ts b/packages/web/src/hooks/use-repos.ts index f89b4f463c..1d4d582560 100644 --- a/packages/web/src/hooks/use-repos.ts +++ b/packages/web/src/hooks/use-repos.ts @@ -13,6 +13,7 @@ export interface Repo { interface ReposResponse { repos: Repo[]; + teamHasRepositoryGrants?: boolean; } /** @@ -31,6 +32,7 @@ export function useRepos(enabled = true, teamId?: string | null) { return { repos: data?.repos ?? [], + teamHasRepositoryGrants: data?.teamHasRepositoryGrants, // The fetch is gated on the auth session, so the list is still loading // while the session itself resolves — don't report an authoritative []. loading: enabled && (status === "loading" || isLoading), diff --git a/packages/web/src/hooks/use-session-target-picker.test.ts b/packages/web/src/hooks/use-session-target-picker.test.ts index 227f217527..803b906aa8 100644 --- a/packages/web/src/hooks/use-session-target-picker.test.ts +++ b/packages/web/src/hooks/use-session-target-picker.test.ts @@ -140,6 +140,186 @@ describe("useSessionTargetPicker", () => { expect(result.current.buildRequestFields()).toEqual({ environmentId: "env-1" }); }); + it("reports authoritative zero grants without silently choosing no repository", () => { + localStorage.setItem("open-inspect-last-selected-repo", "acme/ungranted"); + mocks.repos.mockReturnValue({ repos: [], loading: false, teamHasRepositoryGrants: false }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.repositoryGrantError).toBe("This team has no repository grants."); + expect(result.current.pickerProps.repositoryGrantError).toBe( + result.current.repositoryGrantError + ); + expect(result.current.teamHasRepositoryGrants).toBe(false); + }); + + it("allows an explicit no-repository launch while still explaining zero grants", () => { + mocks.repos.mockReturnValue({ repos: [], loading: false, teamHasRepositoryGrants: false }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + act(() => result.current.pickerProps.onTargetSelectValueChange(NO_REPOSITORY_OPTION_VALUE)); + expect(result.current.isLaunchable).toBe(true); + expect(result.current.buildRequestFields()).toEqual({ repoOwner: null, repoName: null }); + expect(result.current.repositoryGrantError).toBe("This team has no repository grants."); + }); + + it("does not mistake an empty granted catalog for zero grants", () => { + mocks.repos.mockReturnValue({ repos: [], loading: false, teamHasRepositoryGrants: true }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + expect(result.current.repositoryGrantError).toBeNull(); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + }); + + it("does not infer zero grants when metadata is absent or the catalog failed", () => { + mocks.repos.mockReturnValue({ repos: [], loading: false }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result, rerender } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + expect(result.current.repositoryGrantError).toBeNull(); + expect(result.current.sessionTarget).toBeNull(); + mocks.repos.mockReturnValue({ + repos: [], + loading: false, + teamHasRepositoryGrants: false, + error: new Error("Forbidden"), + }); + rerender(); + expect(result.current.repositoryGrantError).toBeNull(); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.buildRequestFields()).toBeNull(); + }); + + it("keeps a failed scoped catalog unselected until No repository is explicitly chosen", () => { + mocks.repos.mockReturnValue({ repos: [], loading: false, error: new Error("Forbidden") }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.repositoryGrantError).toBeNull(); + act(() => result.current.pickerProps.onTargetSelectValueChange(NO_REPOSITORY_OPTION_VALUE)); + expect(result.current.buildRequestFields()).toEqual({ repoOwner: null, repoName: null }); + }); + + it.each([true, false, undefined])( + "never restores stored No repository as an implicit scoped choice (grants: %s)", + (teamHasRepositoryGrants) => { + localStorage.setItem("open-inspect-last-selected-repo", NO_REPOSITORY_OPTION_VALUE); + mocks.repos.mockReturnValue({ repos: [], loading: false, teamHasRepositoryGrants }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + act(() => result.current.pickerProps.onTargetSelectValueChange(NO_REPOSITORY_OPTION_VALUE)); + expect(result.current.isLaunchable).toBe(true); + } + ); + + it("ignores stored No repository when a scoped repository is usable", () => { + localStorage.setItem("open-inspect-last-selected-repo", NO_REPOSITORY_OPTION_VALUE); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + expect(result.current.sessionTarget).toEqual({ kind: "repo", repoFullName: "acme/web" }); + }); + + it.each([false, true])( + "preserves the unscoped no-repository fallback (catalog failed: %s)", + (failed) => { + mocks.repos.mockReturnValue({ + repos: [], + loading: false, + error: failed ? new Error("Unavailable") : undefined, + }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker()); + expect(result.current.sessionTarget).toEqual({ kind: "none" }); + expect(result.current.isLaunchable).toBe(true); + expect(result.current.repositoryGrantError).toBeNull(); + } + ); + + it("does not treat an automatic workspace fallback as an explicit No repository choice", () => { + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result, rerender } = renderHook(({ teamId }) => useSessionTargetPicker({ teamId }), { + initialProps: { teamId: null as string | null }, + }); + expect(result.current.sessionTarget).toEqual({ kind: "repo", repoFullName: "acme/web" }); + mocks.repos.mockReturnValue({ repos: [], loading: false }); + rerender({ teamId: null }); + expect(result.current.sessionTarget).toEqual({ kind: "none" }); + rerender({ teamId: "team-1" }); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.buildRequestFields()).toBeNull(); + }); + + it.each(["acme/web", MULTIPLE_REPOSITORIES_OPTION_VALUE])( + "blocks a selected scoped repository target backed by stale catalog data after an error (%s)", + (value) => { + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result, rerender } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + act(() => result.current.pickerProps.onTargetSelectValueChange(value)); + expect(result.current.isLaunchable).toBe(true); + const explicitTarget = result.current.pickerProps.sessionTarget; + mocks.repos.mockReturnValue({ + repos: [repo()], + loading: false, + error: new Error("Forbidden"), + }); + rerender(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.configKey).toBe(""); + expect(result.current.pickerProps.sessionTarget).toEqual(explicitTarget); + expect(result.current.pickerProps.selectionError).toBeNull(); + expect(result.current.repos).toEqual([]); + expect(result.current.pickerProps.repos).toEqual([]); + mocks.repos.mockReturnValue({ repos: [repo()], loading: false }); + rerender(); + expect(result.current.isLaunchable).toBe(true); + } + ); + + it("allows explicit environment selection without repository catalog permission", () => { + mocks.repos.mockReturnValue({ repos: [repo()], loading: false, error: new Error("Forbidden") }); + const { result, rerender } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + act(() => result.current.pickerProps.onTargetSelectValueChange("env:env-1")); + expect(result.current.buildRequestFields()).toEqual({ environmentId: "env-1" }); + rerender(); + expect(result.current.isLaunchable).toBe(true); + expect(result.current.buildRequestFields()).toEqual({ environmentId: "env-1" }); + }); + + it.each([true, false, undefined])( + "clears an automatic no-repository fallback when switching to a team (grants: %s)", + (teamHasRepositoryGrants) => { + mocks.repos.mockReturnValue({ repos: [], loading: false }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result, rerender } = renderHook(({ teamId }) => useSessionTargetPicker({ teamId }), { + initialProps: { teamId: null as string | null }, + }); + expect(result.current.isLaunchable).toBe(true); + mocks.repos.mockReturnValue({ repos: [], loading: false, teamHasRepositoryGrants }); + rerender({ teamId: "team-1" }); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + } + ); + + it("lists only repositories from the team-scoped catalog, never stored ungranted repositories", () => { + localStorage.setItem("open-inspect-last-selected-repo", "acme/ungranted"); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + const { result } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + expect(result.current.sessionTarget).toEqual({ kind: "repo", repoFullName: "acme/web" }); + expect(result.current.pickerProps.targetOptions).not.toEqual( + expect.arrayContaining([expect.objectContaining({ value: "acme/ungranted" })]) + ); + }); + it("waits for the default environment catalog before choosing a target", () => { mocks.environments.mockReturnValue({ environments: [], loading: true }); const { result, rerender } = renderHook(() => @@ -203,7 +383,7 @@ describe("useSessionTargetPicker", () => { }); }); - it("blocks stale targets while a new team catalog loads and reconciles when it settles", () => { + it("blocks an unavailable explicit target instead of falling back when a new team catalog settles", () => { const { result, rerender } = renderHook(({ teamId }) => useSessionTargetPicker({ teamId }), { initialProps: { teamId: "team-1" }, }); @@ -214,19 +394,27 @@ describe("useSessionTargetPicker", () => { rerender({ teamId: "team-2" }); expect(result.current.buildRequestFields()).toBeNull(); expect(result.current.isLaunchable).toBe(false); + expect(result.current.pickerProps.sessionTarget).toEqual({ + kind: "repo", + repoFullName: "acme/web", + }); + expect(result.current.pickerProps.selectionError).toBeNull(); mocks.repos.mockReturnValue({ repos: [repo({ fullName: "acme/api", name: "api", defaultBranch: "develop" })], loading: false, }); rerender({ teamId: "team-2" }); - expect(result.current.buildRequestFields()).toEqual({ - repoOwner: "acme", - repoName: "api", - branch: "develop", + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.configKey).toBe(""); + expect(result.current.pickerProps.sessionTarget).toEqual({ + kind: "repo", + repoFullName: "acme/web", }); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); }); - it("removes unavailable repositories from an explicit ad-hoc selection on team change", () => { + it("retains the exact explicit ad-hoc selection on team change instead of pruning it", () => { mocks.repos.mockReturnValue({ repos: [repo(), repo({ fullName: "acme/api", name: "api" })], loading: false, @@ -239,9 +427,254 @@ describe("useSessionTargetPicker", () => { }); mocks.repos.mockReturnValue({ repos: [repo()], loading: false }); rerender({ teamId: "team-2" }); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.pickerProps.sessionTarget).toEqual({ + kind: "repos", + repoFullNames: ["acme/web", "acme/api"], + }); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); + }); + + it.each(["repo", "repos", "environment"] as const)( + "requires user reselection after an explicit %s target disappears in the same team, even if it returns", + (kind) => { + const api = repo({ id: 2, fullName: "acme/api", name: "api" }); + mocks.repos.mockReturnValue({ repos: [repo(), api], loading: false }); + const { result, rerender } = renderHook((options) => useSessionTargetPicker(options), { + initialProps: { teamId: "team-1", defaultEnvironmentId: "env-1" }, + }); + act(() => { + if (kind === "repos") { + result.current.pickerProps.onMultiSelectionChange(["acme/api", "acme/web"]); + } else { + result.current.pickerProps.onTargetSelectValueChange( + kind === "environment" ? "env:env-1" : "acme/web" + ); + if (kind === "repo") result.current.pickerProps.setSelectedBranch("feature"); + } + }); + const explicitTarget = result.current.pickerProps.sessionTarget; + const selectValue = result.current.pickerProps.targetSelectValue; + const requestFields = result.current.buildRequestFields(); + + mocks.repos.mockReturnValue({ repos: [api], loading: false }); + mocks.environments.mockReturnValue({ + environments: [environment({ id: "env-2" })], + loading: false, + }); + rerender({ teamId: "team-1", defaultEnvironmentId: "env-2" }); + expect(result.current.sessionTarget).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.configKey).toBe(""); + expect(result.current.pickerProps.sessionTarget).toEqual(explicitTarget); + expect(result.current.pickerProps.targetSelectValue).toBe(selectValue); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); + expect(localStorage.getItem("open-inspect-last-selected-repo")).toBe( + kind === "repo" ? "acme/web" : "env:env-1" + ); + if (kind === "repo") { + expect(result.current.selectedBranch).toBe("feature"); + expect(result.current.pickerProps.displayTargetName).toBe("acme/web"); + } + if (kind === "environment") { + expect(result.current.pickerProps.displayTargetName).toContain("env-1"); + } + + mocks.repos.mockReturnValue({ repos: [repo(), api], loading: false }); + mocks.environments.mockReturnValue({ environments: [environment()], loading: false }); + rerender({ teamId: "team-1", defaultEnvironmentId: "env-1" }); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.configKey).toBe(""); + expect(result.current.pickerProps.sessionTarget).toEqual(explicitTarget); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); + rerender({ teamId: "team-2", defaultEnvironmentId: "env-1" }); + expect(result.current.isLaunchable).toBe(false); + + act(() => { + if (kind === "repos") { + result.current.pickerProps.onMultiSelectionChange(["acme/api", "acme/web"]); + } else { + result.current.pickerProps.onTargetSelectValueChange(selectValue); + } + }); + expect(result.current.isLaunchable).toBe(true); + expect(result.current.pickerProps.selectionError).toBeNull(); + expect(result.current.buildRequestFields()).toEqual( + kind === "repo" ? { repoOwner: "acme", repoName: "web", branch: "main" } : requestFields + ); + } + ); + + it("keeps a revoked explicit repository blocked until an explicit No repository choice", () => { + const { result, rerender } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + act(() => result.current.pickerProps.onTargetSelectValueChange("acme/web")); + mocks.repos.mockReturnValue({ repos: [], loading: false, teamHasRepositoryGrants: false }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + rerender(); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.pickerProps.sessionTarget).toEqual({ + kind: "repo", + repoFullName: "acme/web", + }); + expect(result.current.repositoryGrantError).toBe("This team has no repository grants."); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); + act(() => result.current.pickerProps.onTargetSelectValueChange(NO_REPOSITORY_OPTION_VALUE)); + expect(result.current.pickerProps.selectionError).toBeNull(); + expect(result.current.buildRequestFields()).toEqual({ repoOwner: null, repoName: null }); + expect(result.current.repositoryGrantError).toBe("This team has no repository grants."); + }); + + it.each(["repo", "repos", "environment"] as const)( + "preserves an explicit %s target through temporary catalog loading without invalidating it", + (kind) => { + const { result, rerender } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + act(() => { + result.current.pickerProps.onTargetSelectValueChange( + kind === "environment" + ? "env:env-1" + : kind === "repos" + ? MULTIPLE_REPOSITORIES_OPTION_VALUE + : "acme/web" + ); + if (kind === "repo") result.current.pickerProps.setSelectedBranch("feature"); + }); + const explicitTarget = result.current.pickerProps.sessionTarget; + const requestFields = result.current.buildRequestFields(); + mocks.repos.mockReturnValue({ repos: [], loading: true }); + mocks.environments.mockReturnValue({ environments: [], loading: true }); + rerender(); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.configKey).toBe(""); + expect(result.current.pickerProps.sessionTarget).toEqual(explicitTarget); + expect(result.current.pickerProps.selectionError).toBeNull(); + mocks.repos.mockReturnValue({ repos: [repo()], loading: false }); + mocks.environments.mockReturnValue({ environments: [environment()], loading: false }); + rerender(); + expect(result.current.buildRequestFields()).toEqual(requestFields); + expect(result.current.isLaunchable).toBe(true); + expect(result.current.pickerProps.selectionError).toBeNull(); + } + ); + + it.each([false, true])( + "preserves an explicit environment through catalog errors without launching stale data (cached: %s)", + (cached) => { + const { result, rerender } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + act(() => result.current.pickerProps.onTargetSelectValueChange("env:env-1")); + mocks.environments.mockReturnValue({ + environments: cached ? [environment()] : [], + loading: false, + error: new Error("Unavailable"), + }); + rerender(); + expect(result.current.isLaunchable).toBe(false); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.configKey).toBe(""); + expect(result.current.pickerProps.sessionTarget).toEqual({ + kind: "environment", + environmentId: "env-1", + }); + expect(result.current.pickerProps.selectionError).toBeNull(); + mocks.environments.mockReturnValue({ environments: [environment()], loading: false }); + rerender(); + expect(result.current.buildRequestFields()).toEqual({ environmentId: "env-1" }); + expect(result.current.pickerProps.selectionError).toBeNull(); + } + ); + + it.each([false, true])( + "does not replace an explicit workspace repository during catalog errors (cached: %s)", + (cached) => { + const { result, rerender } = renderHook(() => useSessionTargetPicker()); + act(() => result.current.pickerProps.onTargetSelectValueChange("acme/web")); + mocks.repos.mockReturnValue({ + repos: cached ? [repo()] : [], + loading: false, + error: new Error("Unavailable"), + }); + rerender(); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.pickerProps.sessionTarget).toEqual({ + kind: "repo", + repoFullName: "acme/web", + }); + expect(result.current.pickerProps.selectionError).toBeNull(); + mocks.repos.mockReturnValue({ repos: [repo()], loading: false }); + rerender(); + expect(result.current.isLaunchable).toBe(true); + } + ); + + it("treats an explicit branch choice as an explicit target, but not as target reselection", () => { + const { result, rerender } = renderHook(() => useSessionTargetPicker()); + act(() => result.current.pickerProps.setSelectedBranch("feature")); + mocks.repos.mockReturnValue({ repos: [], loading: false }); + rerender(); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.selectedBranch).toBe("feature"); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); + mocks.repos.mockReturnValue({ repos: [repo()], loading: false }); + rerender(); + act(() => result.current.pickerProps.setSelectedBranch("another-feature")); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); + }); + + it("allows an invalidated multi-repository draft to be explicitly cleared and replaced", () => { + const { result, rerender } = renderHook(() => useSessionTargetPicker({ teamId: "team-1" })); + act(() => result.current.pickerProps.onMultiSelectionChange(["acme/web"])); + mocks.repos.mockReturnValue({ + repos: [repo({ fullName: "acme/api", name: "api" })], + loading: false, + }); + rerender(); + act(() => + result.current.pickerProps.onTargetSelectValueChange(MULTIPLE_REPOSITORIES_OPTION_VALUE) + ); + expect(result.current.pickerProps.sessionTarget).toEqual({ + kind: "repos", + repoFullNames: ["acme/web"], + }); + expect(result.current.buildRequestFields()).toBeNull(); + mocks.repos.mockReturnValue({ + repos: [repo(), repo({ fullName: "acme/api", name: "api" })], + loading: false, + }); + rerender(); + expect(result.current.buildRequestFields()).toBeNull(); + expect(result.current.pickerProps.selectionError).toMatch(/choose a target again/i); + act(() => result.current.pickerProps.onMultiSelectionChange([])); + expect(result.current.pickerProps.sessionTarget).toEqual({ kind: "repos", repoFullNames: [] }); + expect(result.current.pickerProps.selectionError).toBeNull(); + expect(result.current.isLaunchable).toBe(false); + act(() => result.current.pickerProps.onMultiSelectionChange(["acme/api"])); expect(result.current.buildRequestFields()).toEqual({ - repositories: [{ repoOwner: "acme", repoName: "web" }], + repositories: [{ repoOwner: "acme", repoName: "api" }], + }); + }); + + it("still reconciles an automatic target when its catalog entry disappears", () => { + const { result, rerender } = renderHook(() => + useSessionTargetPicker({ teamId: "team-1", defaultEnvironmentId: "env-1" }) + ); + expect(result.current.buildRequestFields()).toEqual({ environmentId: "env-1" }); + mocks.environments.mockReturnValue({ environments: [], loading: false }); + rerender(); + expect(result.current.sessionTarget).toEqual({ kind: "repo", repoFullName: "acme/web" }); + mocks.repos.mockReturnValue({ + repos: [repo({ fullName: "acme/api", name: "api", defaultBranch: "develop" })], + loading: false, + }); + rerender(); + expect(result.current.buildRequestFields()).toEqual({ + repoOwner: "acme", + repoName: "api", + branch: "develop", }); + expect(result.current.pickerProps.selectionError).toBeNull(); }); it("preserves normalized multi-repository selections when the catalog uses mixed case", () => { diff --git a/packages/web/src/hooks/use-session-target-picker.ts b/packages/web/src/hooks/use-session-target-picker.ts index 420919d1e2..47f26c32cc 100644 --- a/packages/web/src/hooks/use-session-target-picker.ts +++ b/packages/web/src/hooks/use-session-target-picker.ts @@ -96,6 +96,7 @@ export function describeRepository( /** Render contract for SessionTargetPicker: the target/branch/multi-select controls. */ export interface SessionTargetPickerProps { + /** The visible draft, including an explicit target that cannot currently launch. */ sessionTarget: SessionTarget | null; targetSelectValue: string; targetOptions: ComboboxOption[] | ComboboxGroup[]; @@ -108,6 +109,8 @@ export interface SessionTargetPickerProps { loadingBranches: boolean; repos: Repo[]; loadingRepos: boolean; + repositoryGrantError: string | null; + selectionError: string | null; } /** Launch-facing selection state for the page: warming identity and request construction. */ @@ -116,6 +119,8 @@ export interface SessionTargetSelection { selectedBranch: string; repos: Repo[]; loadingRepos: boolean; + teamHasRepositoryGrants?: boolean; + repositoryGrantError: string | null; /** The selected repository's metadata when the target is a single repo. */ selectedRepo: Repo | undefined; isLaunchable: boolean; @@ -156,21 +161,59 @@ export function useSessionTargetPicker({ teamId?: string | null; defaultEnvironmentId?: string | null; } = {}): SessionTargetSelection { - const { repos, loading: loadingRepos } = useRepos(true, teamId); - const { environments, loading: loadingEnvironments } = useEnvironments(teamId); + const { + repos: catalogRepos, + loading: loadingRepos, + error: reposError, + teamHasRepositoryGrants, + } = useRepos(true, teamId); + const repos = useMemo( + () => (teamId && reposError ? [] : catalogRepos), + [teamId, reposError, catalogRepos] + ); + const noRepositoryGrants = + !!teamId && !loadingRepos && !reposError && teamHasRepositoryGrants === false; + const repositoryGrantError = noRepositoryGrants ? "This team has no repository grants." : null; + const { + environments, + loading: loadingEnvironments, + error: environmentsError, + } = useEnvironments(teamId); const [draftTarget, setSessionTarget] = useState(null); const [selectedBranch, updateSelectedBranch] = useState(""); const [selectionContext, setSelectionContext] = useState({ teamId, defaultEnvironmentId }); const [hasExplicitSelection, setHasExplicitSelection] = useState(false); + const [selectionInvalidated, setSelectionInvalidated] = useState(false); + const targetCatalogError = + draftTarget?.kind === "environment" + ? environmentsError + : draftTarget?.kind === "repo" || draftTarget?.kind === "repos" + ? reposError + : null; + const explicitTargetUnavailable = + hasExplicitSelection && + !!draftTarget && + !loadingRepos && + !loadingEnvironments && + !targetCatalogError && + !targetIsAvailable(draftTarget, repos, environments); + const selectionError = + selectionInvalidated || explicitTargetUnavailable + ? "Your selected target is no longer available. Choose a target again." + : null; // Never launch a previous team's target before its new catalogs reconcile. const sessionTarget = !loadingRepos && !loadingEnvironments && selectionContext.teamId === teamId && + !(teamId && draftTarget?.kind === "none" && !hasExplicitSelection) && (hasExplicitSelection || selectionContext.defaultEnvironmentId === defaultEnvironmentId) && + !selectionError && + !(hasExplicitSelection && targetCatalogError) && targetIsAvailable(draftTarget, repos, environments) ? draftTarget : null; + const pickerTarget = hasExplicitSelection ? draftTarget : sessionTarget; const selectedRepository = sessionTarget?.kind === "repo" ? parseRepositoryFullName(sessionTarget.repoFullName) : null; @@ -197,20 +240,20 @@ export function useSessionTargetPicker({ // Team defaults win over stored preferences, but never over a draft's explicit choice. useEffect(() => { - if (loadingRepos || loadingEnvironments || sessionTarget) return; + if (loadingRepos || loadingEnvironments) return; - let nextTarget: SessionTarget | null = null; if (hasExplicitSelection) { - if (targetIsAvailable(draftTarget, repos, environments)) nextTarget = draftTarget; - else if (draftTarget?.kind === "repos") { - nextTarget = { - kind: "repos", - repoFullNames: draftTarget.repoFullNames.filter((fullName) => - repos.some((repo) => repo.fullName.toLowerCase() === fullName.toLowerCase()) - ), - }; + // Only a successful catalog can invalidate a choice; restoration never confirms it. + if (explicitTargetUnavailable && !selectionInvalidated) setSelectionInvalidated(true); + if (!targetCatalogError && selectionContext.teamId !== teamId) { + setSelectionContext({ teamId, defaultEnvironmentId }); } - } else if ( + return; + } + if (sessionTarget) return; + + let nextTarget: SessionTarget | null = null; + if ( defaultEnvironmentId && environments.some((environment) => environment.id === defaultEnvironmentId) ) { @@ -220,14 +263,20 @@ export function useSessionTargetPicker({ if (!nextTarget) { const storedValue = localStorage.getItem(LAST_SELECTED_TARGET_STORAGE_KEY); const storedTarget = storedValue ? parseTargetSelectValue(storedValue, null) : null; - nextTarget = targetIsAvailable(storedTarget, repos, environments) - ? storedTarget - : repos[0] - ? { kind: "repo", repoFullName: repos[0].fullName } - : { kind: "none" }; + if ( + targetIsAvailable(storedTarget, repos, environments) && + !(teamId && storedTarget?.kind === "none") + ) { + nextTarget = storedTarget; + } else if (repos[0]) { + nextTarget = { kind: "repo", repoFullName: repos[0].fullName }; + } else if (!teamId) { + nextTarget = { kind: "none" }; + } } + if (!nextTarget) return; - if (nextTarget?.kind === "repo") { + if (nextTarget.kind === "repo") { if (draftTarget?.kind !== "repo" || draftTarget.repoFullName !== nextTarget.repoFullName) { updateSelectedBranch( repos.find((repo) => repo.fullName === nextTarget.repoFullName)?.defaultBranch ?? "" @@ -240,11 +289,15 @@ export function useSessionTargetPicker({ defaultEnvironmentId, draftTarget, environments, + explicitTargetUnavailable, hasExplicitSelection, loadingEnvironments, loadingRepos, repos, + selectionContext.teamId, + selectionInvalidated, sessionTarget, + targetCatalogError, teamId, ]); @@ -258,7 +311,8 @@ export function useSessionTargetPicker({ const onTargetSelectValueChange = useCallback( (value: string) => { setHasExplicitSelection(true); - const nextTarget = parseTargetSelectValue(value, sessionTarget); + setSelectionInvalidated(false); + const nextTarget = parseTargetSelectValue(value, draftTarget); setSessionTarget(nextTarget); setSelectionContext({ teamId, defaultEnvironmentId }); if (nextTarget.kind !== "repo") { @@ -268,12 +322,13 @@ export function useSessionTargetPicker({ const repo = repos.find((r) => r.fullName === nextTarget.repoFullName); if (repo) updateSelectedBranch(repo.defaultBranch); }, - [defaultEnvironmentId, repos, sessionTarget, teamId] + [defaultEnvironmentId, draftTarget, repos, teamId] ); const onMultiSelectionChange = useCallback( (repoFullNames: string[]) => { setHasExplicitSelection(true); + setSelectionInvalidated(false); setSessionTarget({ kind: "repos", repoFullNames }); setSelectionContext({ teamId, defaultEnvironmentId }); }, @@ -295,19 +350,22 @@ export function useSessionTargetPicker({ ? repos.find((r) => r.fullName === sessionTarget.repoFullName) : undefined; const selectedEnvironment = - sessionTarget?.kind === "environment" - ? environments.find((environment) => environment.id === sessionTarget.environmentId) + pickerTarget?.kind === "environment" + ? environments.find((environment) => environment.id === pickerTarget.environmentId) : undefined; const displayTargetName = (() => { - switch (sessionTarget?.kind) { + switch (pickerTarget?.kind) { case "none": return NO_REPOSITORY_LABEL; case "repo": - return selectedRepo?.name ?? sessionTarget.repoFullName; + return ( + repos.find((repo) => repo.fullName === pickerTarget.repoFullName)?.name ?? + pickerTarget.repoFullName + ); case "environment": - return selectedEnvironment?.name ?? "Environment"; + return selectedEnvironment?.name ?? `Environment (${pickerTarget.environmentId})`; case "repos": { - const count = sessionTarget.repoFullNames.length; + const count = pickerTarget.repoFullNames.length; if (count === 0) return "Select repositories"; return `${count} ${count === 1 ? "repository" : "repositories"}`; } @@ -355,12 +413,14 @@ export function useSessionTargetPicker({ repos, loadingRepos, selectedRepo, + teamHasRepositoryGrants, + repositoryGrantError, isLaunchable: isSessionTargetLaunchable(sessionTarget), configKey: getTargetConfigKey(sessionTarget), buildRequestFields, pickerProps: { - sessionTarget, - targetSelectValue: getTargetSelectValue(sessionTarget), + sessionTarget: pickerTarget, + targetSelectValue: getTargetSelectValue(pickerTarget), targetOptions, displayTargetName, onTargetSelectValueChange, @@ -371,6 +431,8 @@ export function useSessionTargetPicker({ loadingBranches, repos, loadingRepos, + repositoryGrantError, + selectionError, }, }; } diff --git a/packages/web/src/hooks/use-warm-draft-session.test.tsx b/packages/web/src/hooks/use-warm-draft-session.test.tsx index 8c5a42854b..cfe8b21236 100644 --- a/packages/web/src/hooks/use-warm-draft-session.test.tsx +++ b/packages/web/src/hooks/use-warm-draft-session.test.tsx @@ -123,6 +123,120 @@ describe("useWarmDraftSession", () => { expect(result.current.error).toBeNull(); }); + it("retries the unchanged draft explicitly after a missing repository grant is restored", async () => { + let resolveRetry: ((response: Response) => void) | undefined; + vi.mocked(browserApiFetch) + .mockResolvedValueOnce( + Response.json( + { + error: "Target team lacks repository grant", + code: "target_team_missing_grant", + repository: "group/subgroup/api", + }, + { status: 409 } + ) + ) + .mockImplementationOnce(() => new Promise((resolve) => (resolveRetry = resolve))); + const initial: WarmDraftSessionRequest = { + ...request(), + repoOwner: "group/subgroup", + repoName: "api", + teamId: "team-1", + visibility: "team", + }; + const { result, rerender } = renderHook( + ({ launchRequest }) => useWarmDraftSession(launchRequest), + { initialProps: { launchRequest: initial } } + ); + const identity = result.current.identity; + await act(async () => { + await expect(result.current.warm()).resolves.toBeNull(); + }); + expect(result.current.error).toEqual({ + message: + "This team has no repository grant for group/subgroup/api. (target_team_missing_grant)", + code: "target_team_missing_grant", + status: 409, + terminal: false, + }); + expect(result.current.sessionId).toBeNull(); + expect(result.current.isWarming).toBe(false); + + rerender({ launchRequest: { ...initial } }); + expect(result.current.identity).toBe(identity); + expect(result.current.error?.code).toBe("target_team_missing_grant"); + expect(browserApiFetch).toHaveBeenCalledOnce(); + + let retries: Promise[] = []; + act(() => { + retries = [result.current.warm(), result.current.warm(), result.current.warm()]; + }); + expect(result.current.isWarming).toBe(true); + expect(result.current.error).toBeNull(); + expect(browserApiFetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(browserApiFetch).mock.calls.map(([, options]) => options?.body)).toEqual([ + JSON.stringify(initial), + JSON.stringify(initial), + ]); + + resolveRetry?.(Response.json({ sessionId: "retried-session", status: "created" })); + await act(async () => { + await expect(Promise.all(retries)).resolves.toEqual([ + "retried-session", + "retried-session", + "retried-session", + ]); + }); + expect(result.current.identity).toBe(identity); + expect(result.current.sessionId).toBe("retried-session"); + expect(result.current.isWarming).toBe(false); + expect(result.current.error).toBeNull(); + await act(async () => { + await expect(result.current.warm()).resolves.toBe("retried-session"); + }); + expect(browserApiFetch).toHaveBeenCalledTimes(2); + expect(retireWarmDraftSession).not.toHaveBeenCalled(); + }); + + it("does not retry a still-missing grant on timers or same-identity renders", async () => { + vi.useFakeTimers(); + try { + vi.mocked(browserApiFetch).mockImplementation(async () => + Response.json( + { error: "Target team lacks repository grant", code: "target_team_missing_grant" }, + { status: 409 } + ) + ); + const initial = { ...request(), teamId: "team-1" }; + const { result, rerender } = renderHook( + ({ launchRequest }) => useWarmDraftSession(launchRequest), + { initialProps: { launchRequest: initial } } + ); + + for (const attempts of [1, 2]) { + await act(async () => { + await expect(result.current.warm()).resolves.toBeNull(); + }); + expect(result.current.error).toEqual({ + message: "Target team lacks repository grant (target_team_missing_grant)", + code: "target_team_missing_grant", + status: 409, + terminal: false, + }); + rerender({ launchRequest: { ...initial } }); + rerender({ launchRequest: { ...initial } }); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(browserApiFetch).toHaveBeenCalledTimes(attempts); + expect(result.current.sessionId).toBeNull(); + expect(result.current.isWarming).toBe(false); + } + } finally { + vi.useRealTimers(); + } + }); + it("ignores a terminal denial from a superseded request", async () => { let resolveCreate: ((response: Response) => void) | undefined; vi.mocked(browserApiFetch).mockImplementation( diff --git a/packages/web/src/hooks/use-warm-draft-session.ts b/packages/web/src/hooks/use-warm-draft-session.ts index 2d94c6a2a7..426670385f 100644 --- a/packages/web/src/hooks/use-warm-draft-session.ts +++ b/packages/web/src/hooks/use-warm-draft-session.ts @@ -122,12 +122,22 @@ export function useWarmDraftSession( if (failure && typeof failure === "object") { if ("error" in failure && typeof failure.error === "string") message = failure.error; if ("code" in failure && typeof failure.code === "string") code = failure.code; + if ( + code === "target_team_missing_grant" && + "repository" in failure && + typeof failure.repository === "string" + ) { + message = `This team has no repository grant for ${failure.repository}.`; + } } const creationError: WarmDraftSessionError = { message: code ? `${message} (${code})` : message, code, status: response.status, - terminal: [400, 403, 404, 409].includes(response.status), + // Grants can be restored without changing the draft's identity. + terminal: + code !== "target_team_missing_grant" && + [400, 403, 404, 409].includes(response.status), }; errorRef.current = creationError; setError(creationError); From 45a0b0b4935d5d0845dfa80498c217a008899d86 Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 1 Oct 2026 10:46:54 -0700 Subject: [PATCH 03/17] feat(control-plane): scope installation tokens to session repositories (#2180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary GitHub sandbox credentials now reach only the session's own repositories, for workspace-owned and team-owned sessions alike. This replaces the earlier team-grant-wide policy in this PR. - Resolve primary/member IDs from persisted D1 session membership. Environment sessions retain their copied members; environment provenance does not expand existing sessions or primary-only children. - For team sessions, retain only those candidates covered by one current `TeamRepositoryGrantStore.listForTeam` snapshot. Installation grants retain session members, never installation-wide sandbox access. - Resolve NULL IDs by structured owner/name from the identity-checked cached installation catalog. Empty, unresolved, invalid or over-500 unique final scopes fail closed before cache lookup or POST. No truncation or `all` fallback. - Repository builds mint for that repository alone, even without team grants. Environment builds use planned members intersected with the environment owner's grants and refuse changed membership during planning. - Preserve the exact scope union and ID-set v2 cache keys, 128-scope LRU cache, expiry rules, and per-key single-flight 401 recovery. - Metadata/catalog calls, access checks, branch lists, skills import and the GitHub bot's collaborator-metadata mint remain installation-wide. GitLab retains its deployment-wide PAT limitation. - Update `CHANGELOG.md` and `docs/GETTING_STARTED.md`; remove the obsolete grant-wide resolver. Closes [COL-205](https://linear.app/colemurray/issue/COL-205). ## Operator Impact Private submodules, repository-backed private dependencies and sibling-clone setup scripts now fail unless those repositories are included in the session's environment. Team sessions additionally need grants for those members. Editing an environment does not expand an existing session's snapshot. Legacy NULL IDs require an identity-matching cached repository catalog; loading the repository list populates it. Grant removal changes the next credential scope but does not revoke already-issued credentials, which remain valid until expiry. ## Checkpoint Report ### Validation | Command | Result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed; also rebuilt by every root typecheck. | | `npm run typecheck` | Final post-merge run passed in every workspace, including control-plane production, Node, unit and integration projects. | | `npm run lint:fix` | Final run and commit hooks passed. Initial two type-import failures are reproduced below. | | `npm run lint:sql-portability` | Passed: 24 baselined occurrences across 4 files, unchanged. | | Scope factory/mint boundary TDD | Initial red: 11 failed, 10 passed. Green after implementation: 21 passed. | | Focused new-scope unit run | 11 files, 190 tests passed. Final cleanup run: 7 files, 132 passed. | | `npm test -w @open-inspect/control-plane -- --maxWorkers=1` | Final post-merge run: 349 files, 5,873 tests passed. Pre-merge redesign run: 5,834 passed. | | `npm run test:integration -w @open-inspect/control-plane -- --maxWorkers=1` | Redesign full suite passed before the final concurrent-main merge: 128 files, 1,658 passed, 1 skipped; 592.00 seconds with a 1,800,000 ms timeout. | | Post-merge focused workerd run | 8 files, 111 passed, covering scoped tokens, SCM credentials, PR creation, environment snapshots, image reconciliation, session access, WebSockets and team-member privacy. | | Prettier, `git diff --check`, commit hooks | Passed. | Post-merge integration command: ```bash npm run test:integration -w @open-inspect/control-plane -- test/integration/scoped-installation-token.test.ts test/integration/scm-credentials.test.ts test/integration/create-pr.test.ts test/integration/session-from-environment.test.ts test/integration/image-build-scheduler.test.ts test/integration/session-access-routes.test.ts test/integration/websocket-session-access.test.ts test/integration/team-member-privacy.test.ts --maxWorkers=1 ``` No live credentials were used. Real D1/KV tests inspect mocked mint bodies for session isolation, grant removal, installation-grant narrowing, NULL-member/scalar-fallback resolution and repository-only builds.
Development failures, verbatim excerpts Initial boundary red: ```text TypeError: repositoryCredentialScope is not a function Test Files 2 failed (2) Tests 11 failed | 10 passed (21) ``` The first focused workerd run failed despite passing assertions because the immediately rejected nested scope promise was reported unhandled. Explicitly awaiting scope resolution fixed it; the subsequent scoped-file and full runs passed cleanly. ```text SourceControlProviderError: Cannot generate credentials: no repositories in scope ❯ repositoryCredentialScope src/source-control/credential-scope.ts:16:11 ❯ resolveRepositoryCredentialScope src/source-control/repository-scope.ts:45:10 ❯ resolveSessionCredentialScope src/source-control/session-scope.ts:23:10 ❯ test/integration/scoped-installation-token.test.ts:246:5 Test Files 7 passed (7) Tests 109 passed (109) Errors 1 error ``` Initial lint: ```text /workspace/background-agents/packages/control-plane/src/image-builds/planner.test.ts 43:35 error `import()` type annotations are forbidden @typescript-eslint/consistent-type-imports /workspace/background-agents/packages/control-plane/src/image-builds/scheduler.test.ts 19:35 error `import()` type annotations are forbidden @typescript-eslint/consistent-type-imports 2 problems (2 errors, 0 warnings) ```
### Main Verification and Drift - Integrated `main` first at `ef4f737`, then at `eb50181` after it advanced during validation, and most recently at `f1cf069` (`d4306fe`), which brought in #2178. Both changelog sides and the newer authorization/directory behavior are retained; the published branch was not rewritten. - The newer main removed ownership-move APIs and their repository reader. This PR does not restore those mutations: a focused `SessionRepositoryStore` reads ordered membership, and the credential resolver uses the already-loaded scalar identity only as the legacy fallback. - D1 has no scalar `repo_id`; member rows carry IDs. Environment members are copied on session creation, and `environment_id` is provenance rather than credential authority. - The shared reader preserves the existing `repos:list:v3` cache key, payload schema and SCM fingerprint. Refreshes, writes, freshness, metadata enrichment and metadata routing are unchanged. - The 500-ID limit applies to the final filtered, de-duplicated scope, not total team grants. Repository builds no longer enumerate teams; each nonempty team credential resolution reads grants once and filters candidates in memory. - No schema or grant-write change is added. Credential narrowing remains independent of `TEAMS_ENFORCEMENT`; the merged access-policy changes are retained. Schema assumptions remain D1 0083 and DO 56. ### Deliberately Left Out - Modal restore-token PR #2178 has since merged (`f1cf069`). Restored sandboxes now fetch credentials through the control-plane broker (`ScmCredentialsService`), so they receive the same session-scoped tokens as fresh sessions without further changes here. - Grant APIs/UI and writes remain separate; integration tests seed grants directly in D1. - Image-build secret folding is untouched. - The owner-run GitHub live gate is still pending. Immediate revocation and cross-isolate cache deletion/write fencing are not introduced. All earlier prebuild, whole-team size-limit and all-team fan-out review threads have been answered and resolved under the repository-only policy. ## Summary by CodeRabbit * **New Features** * GitHub sandbox credentials are limited to repositories included in each session; team-owned sessions are further restricted to repositories with current team grants. * Pull request operations and repository or environment image builds use credentials scoped to the relevant repositories. * Sessions with empty, unresolved, or oversized repository scopes do not receive credentials, and access is not broadened as a fallback. * Updated guidance explains repository access, installation-wide metadata and catalog operations, GitLab credentials, and legacy sessions. * **Bug Fixes** * GitHub provider requests retry once with refreshed credentials after an authorization failure. ## Grant Snapshot Review Follow-Up `b21e81e` replaces repeated coverage queries with one fresh `listForTeam` snapshot per nonempty team credential resolution. Installation grants and fail-closed empty/oversized scopes retain their existing behavior; grants are not cached across resolutions. A regression first reproduced mixed-snapshot selection (expected repository 12, received 30); it now passes and asserts exactly one grant read. Local validation passed: 6 focused unit files / 118 tests; 3 D1/KV integration files / 26 tests; all workspace typechecks; changed-file ESLint and Prettier; diff checks and commit hooks. ## Code Review Follow-Up Net change against `b21e81e` (`7988327` + `7b2e28c`), with no behavior change to credential scoping: - `coveredRepositoryIds` in `db/team-repository-grants.ts` is now the only definition of grant coverage. `TeamRepositoryGrantStore.covers` and `resolveRepositoryCredentialScope` both use it, so credential narrowing can't drift from the grant check. - `getInstallationTokenCacheKey` and `reposCacheIdentity` use the shared `sha256Hex` helper instead of hand-rolled hex encoding. - A memory token cache hit now only refreshes the entry's LRU position. Expired entries are still pruned when a token is inserted. `7988327` added a fallback that refreshed the repo catalog for NULL repository IDs. `7b2e28c` reverted it: only sessions created before migration 0032 (2026-07-07) lack IDs, and those are outside the 30-day support window. The fail-closed behavior described under Operator Impact still applies. Other review findings were left unchanged because the existing tests or this description already make them deliberate: - The planner skips the clone token instead of rejecting the build. - Members without a team grant are dropped from team scopes. - GitLab still resolves a credential scope even though its PAT ignores it. - PR refresh resolves the scope for each artifact. - Membership is not read for a missing session. Validation: control-plane typecheck (all four configs), ESLint and Prettier; full unit suite on the final code: 349 files, 5,874 tests passed; 6 related workerd integration files / 96 tests passed. ### Merge with `main` at `f1cf069` (`d4306fe`) - `image-builds/planner.ts`: kept the scoped `generateCredentialHelperAuth(tokenScope)` call and adopted #2178's `{ type: "credential_helper", token }` clone-auth shape; the Modal provider now supplies host and username. Updated the matching planner test. - `CHANGELOG.md`: kept both entries and dropped "Modal restore-token scoping remains a separate change", since restores now use the scoped broker. - No new unscoped credential mints came in from `main`; the only `{ kind: "all" }` callers are the skills import paths, which are installation-wide by design. - Validation after the merge: control-plane typecheck (all four configs) passed. The full unit suite (350 files, 5,906 tests) passed except for the one planner test asserting the pre-#2178 clone-auth shape; it passes after the update, and the image-build unit tests pass (321 tests). 6 related workerd integration files / 96 tests passed. CI runs the workspace-wide typecheck. ### Test trim (`f67a506`) Reduced the PR's test additions from about 4,300 to about 2,700 lines with no coverage loss. Line and branch coverage is unchanged for all 22 production files the PR touches, and `session-scope.ts` branch coverage goes from 75% to 100%. - Composition-layer tests (`components.credentials`, `autofix/handler`, planner, scheduler) re-ran resolver scenarios through prototype spies: team A/B grants, revoked grants, NULL-id catalog lookups, empty and over-500 scopes. They now only check that the resolved scope is passed through and that failures fail closed. The resolver logic stays covered by `repository-scope`/`session-scope`/`credential-scope` tests and the real-D1 integration test. - Removed tests that only replayed their own mock sequences or asserted incidental call counts and order. Examples: "re-reads on every call", "resolves scope again on the next delivery", and `toHaveBeenCalledBefore` checks. - Removed scope assertions that had been copied onto every GitHub provider method. One focused per-call scope test and the 401-retry test remain. - Folded `github-app.scope.test.ts` into `github-app.cache.test.ts`. The LRU and memory-cache tests now actually prove the entries come from memory and are evicted in least-recently-used order. Added a direct test of the `repository_ids` mint body. - Integration: removed cases that repeated unit-level cache mechanics (cold KV read, expiry limits, v1 keys, concurrent dedupe). Kept isolation, grant narrowing, installation-grant narrowing, NULL/scalar resolution, repository-only builds, fail-closed, and 401 recovery. Validation: control-plane typecheck and ESLint are clean. The full unit suite passed (349 files, 5,803 tests), and the 6 related workerd integration files passed (88 tests). ### Test file restore (`c814e23`) Undid the split of `pull-request-service.test.ts` into `pull-request-service.test-support.ts` and `pull-request-service.credentials.test.ts`. The helpers and the three moved tests are back in their original positions. That file's diff against `main` is now only the credential-scope additions (+31 lines): the harness scope, the push-auth scope assertions, and the fail-closed test. The session unit suite passes (90 files, 1,627 tests). --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- CHANGELOG.md | 18 + docs/GETTING_STARTED.md | 28 +- .../src/auth/github-app.cache.test.ts | 332 ++++++++++++++++++ .../control-plane/src/auth/github-app.test.ts | 50 ++- packages/control-plane/src/auth/github-app.ts | 147 ++++++-- .../control-plane/src/autofix/handler.test.ts | 94 +++++ packages/control-plane/src/autofix/handler.ts | 7 +- .../control-plane/src/autofix/service.test.ts | 94 +++-- packages/control-plane/src/autofix/service.ts | 91 +++-- .../src/db/session-repositories.ts | 20 ++ .../src/db/team-repository-grants.ts | 16 +- .../src/image-builds/credential-scope.ts | 59 ++++ .../src/image-builds/planner.test.ts | 280 +++++++++++++++ .../control-plane/src/image-builds/planner.ts | 16 +- .../src/image-builds/scheduler.test.ts | 176 ++++++++-- .../src/image-builds/scheduler.ts | 18 +- .../control-plane/src/repos/cache.test.ts | 165 +++++++++ packages/control-plane/src/repos/cache.ts | 80 +++++ .../src/routes/image-builds.trigger.test.ts | 5 +- packages/control-plane/src/routes/repos.ts | 41 +-- .../session/components.credentials.test.ts | 126 +++++++ .../control-plane/src/session/components.ts | 12 +- .../src/session/pull-request-refresh.test.ts | 89 ++++- .../src/session/pull-request-refresh.ts | 25 +- .../pull-request-service.per-branch.test.ts | 13 +- .../src/session/pull-request-service.test.ts | 30 +- .../src/session/pull-request-service.ts | 23 +- .../session/scm-credentials-service.test.ts | 43 ++- .../src/session/scm-credentials-service.ts | 8 +- .../src/skills/git-import.test.ts | 27 +- .../control-plane/src/skills/git-import.ts | 30 +- .../source-control/credential-scope.test.ts | 36 ++ .../src/source-control/credential-scope.ts | 38 ++ .../control-plane/src/source-control/index.ts | 1 + .../source-control/provider-from-env.test.ts | 4 +- .../providers/github-provider.test.ts | 332 +++++++++++++----- .../providers/github-provider.ts | 184 ++++++---- .../providers/gitlab-provider.test.ts | 109 +++--- .../providers/gitlab-provider.ts | 25 +- .../source-control/repository-scope.test.ts | 158 +++++++++ .../src/source-control/repository-scope.ts | 43 +++ .../src/source-control/session-scope.test.ts | 106 ++++++ .../src/source-control/session-scope.ts | 34 ++ .../control-plane/src/source-control/types.ts | 46 ++- .../control-plane/test/integration/helpers.ts | 21 +- .../scoped-installation-token.test.ts | 319 +++++++++++++++++ 46 files changed, 3138 insertions(+), 481 deletions(-) create mode 100644 packages/control-plane/src/auth/github-app.cache.test.ts create mode 100644 packages/control-plane/src/autofix/handler.test.ts create mode 100644 packages/control-plane/src/db/session-repositories.ts create mode 100644 packages/control-plane/src/image-builds/credential-scope.ts create mode 100644 packages/control-plane/src/image-builds/planner.test.ts create mode 100644 packages/control-plane/src/repos/cache.test.ts create mode 100644 packages/control-plane/src/repos/cache.ts create mode 100644 packages/control-plane/src/session/components.credentials.test.ts create mode 100644 packages/control-plane/src/source-control/credential-scope.test.ts create mode 100644 packages/control-plane/src/source-control/credential-scope.ts create mode 100644 packages/control-plane/src/source-control/repository-scope.test.ts create mode 100644 packages/control-plane/src/source-control/repository-scope.ts create mode 100644 packages/control-plane/src/source-control/session-scope.test.ts create mode 100644 packages/control-plane/src/source-control/session-scope.ts create mode 100644 packages/control-plane/test/integration/scoped-installation-token.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7248b0b326..088a21d786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,24 @@ remain readable. ### Changed +GitHub App sandbox credentials now reach only the session's repositories, including workspace-owned +sessions and members snapshotted from an environment. Team-owned sessions also intersect that set +with their team's current repository grants; an installation grant does not widen the credential +beyond the session's members. Sessions with no repositories or no remaining granted repositories +receive no token. Unresolved repository IDs and scopes exceeding GitHub's repository limit are +refused rather than falling back to installation-wide access. + +This breaks private submodule, repository-backed dependency, and sibling-clone setups unless those +repositories are included in the session's environment and, for team sessions, granted to its team. +Repository image builds receive a token for that repository alone; environment builds use only their +member repositories, intersected with the environment team's grants. Metadata and workspace-catalog +operations retain installation-wide access. GitLab still uses a deployment-wide PAT and does not +enforce repository-scoped credentials. + +Token cache keys cover the sorted, de-duplicated repository set, the process cache is bounded, and +overlapping refreshes share one mint per scope. Grant removal changes the next credential scope but +does not revoke already-issued tokens; sandbox helpers cache them until shortly before expiry. + **Modal snapshot restores use brokered git credentials.** Restored sandboxes now fetch git credentials from the control plane like fresh sessions, instead of receiving a token minted by Modal. The control plane now sends the VCS host and clone username with every Modal create, restore, diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index e6fbb2dcad..b9d515b0e3 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -6,9 +6,35 @@ This guide walks you through deploying your own instance of Open-Inspect using T > [SETUP_GUIDE.md](./SETUP_GUIDE.md). > **Important**: This system is designed for **single-tenant deployment only**. All users share the -> same GitHub App credentials and can access any repository the App is installed on. See the +> deployment's GitHub App installation and workspace catalog. GitHub sandbox credentials reach only +> the session's repositories, further restricted by current grants for team-owned sessions. See the > [Security Model](../README.md#security-model-single-tenant-only) for details. +### Sandbox Repository Access + +GitHub credentials issued to a sandbox cover its primary repository and session members, including +the repository snapshot copied from an environment at creation. Workspace-owned sessions are scoped +the same way. For team-owned sessions, repositories no longer covered by the team's current grants +are removed from the next credential scope. An installation grant retains all session members but +does not grant the sandbox access to other repositories in the installation. + +Private submodules, repository-backed private dependencies, and setup scripts that clone sibling +repositories now fail unless those repositories are included in the session's environment. Team +sessions also need grants for those members. Add the dependencies to the environment before creating +the session; editing an environment does not expand an existing session's repository snapshot. + +Sessions with no repositories, no remaining granted members, or unresolved repository IDs receive no +token. Scopes with more than 500 unique IDs are refused. Repository image builds use that repository +alone, while environment image builds use their member repositories and the environment owner's +current grants. Metadata and catalog calls retain installation-wide credentials. GitLab deployments +still use one deployment-wide PAT and cannot provide this credential boundary. + +Removing a grant does not revoke a credential already issued to a sandbox. Installation tokens +remain valid until expiry, and credential helpers refresh shortly before expiry. Legacy session or +environment rows without numeric repository IDs require an identity-matching cached repository +catalog; loading the repository list populates that cache. Credentials fail closed if an ID cannot +be resolved from it. + --- ## Overview diff --git a/packages/control-plane/src/auth/github-app.cache.test.ts b/packages/control-plane/src/auth/github-app.cache.test.ts new file mode 100644 index 0000000000..3eac46cb74 --- /dev/null +++ b/packages/control-plane/src/auth/github-app.cache.test.ts @@ -0,0 +1,332 @@ +import type { CacheStore } from "@open-inspect/shared/cache-store"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubAppConfig, TokenScope } from "./github-app"; +import type * as GitHubAppAuth from "./github-app"; + +class FakeCacheStore implements CacheStore { + readonly entries = new Map(); + + async get(key: string): Promise; + async get(key: string, type: "json"): Promise; + async get(key: string, type?: "json"): Promise { + const value = this.entries.get(key) ?? null; + return type === "json" && value !== null ? JSON.parse(value) : value; + } + + async put(key: string, value: string): Promise { + this.entries.set(key, value); + } + + async delete(key: string): Promise { + this.entries.delete(key); + } +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +let privateKey: string; +let auth: typeof GitHubAppAuth; + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ); + if (!("privateKey" in pair)) throw new Error("Expected an RSA key pair"); + const exported = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + if (!(exported instanceof ArrayBuffer)) throw new Error("Expected a PKCS#8 byte buffer"); + privateKey = `-----BEGIN PRIVATE KEY-----\n${btoa(String.fromCharCode(...new Uint8Array(exported)))}\n-----END PRIVATE KEY-----`; +}); + +beforeEach(async () => { + vi.resetModules(); + auth = await import("./github-app"); +}); + +afterEach(() => vi.restoreAllMocks()); + +function config(): GitHubAppConfig { + return { appId: "cache-test-app", installationId: "cache-test-installation", privateKey }; +} + +/** Config whose key cannot sign, proving a request was served without minting. */ +function cacheOnlyConfig(): GitHubAppConfig { + return { ...config(), privateKey: "invalid-key-must-not-be-used" }; +} + +function tokenEntry(token: string) { + return JSON.stringify({ + token, + cachedAtEpochMs: Date.now(), + expiresAtEpochMs: Date.now() + 60 * 60 * 1000, + }); +} + +function mintResponse(token = "fresh-token") { + return Response.json({ token, expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString() }); +} + +describe("installation token scopes", () => { + it("shares a cache key across permuted or duplicated ids and separates every other scope", async () => { + const key = (scope: TokenScope, app = config()) => + auth.getInstallationTokenCacheKey(app, scope); + const canonical = await key({ kind: "repositories", repositoryIds: [30, 2, 30] }); + expect(await key({ kind: "repositories", repositoryIds: [2, 30] })).toBe(canonical); + + const others = await Promise.all([ + key({ kind: "repositories", repositoryIds: [2] }), + key({ kind: "repositories", repositoryIds: [2, 30, 99] }), + key({ kind: "all" }), + key({ kind: "repositories", repositoryIds: [2, 30] }, { ...config(), appId: "other-app" }), + key( + { kind: "repositories", repositoryIds: [2, 30] }, + { ...config(), installationId: "other-installation" } + ), + ]); + expect(new Set([canonical, ...others]).size).toBe(others.length + 1); + }); + + it("mints repository scopes with their canonical ids and installation scopes without a body", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => mintResponse()); + await auth.getCachedInstallationToken(config(), undefined, { + scope: { kind: "repositories", repositoryIds: [30, 2, 30] }, + }); + await auth.getCachedInstallationToken(config(), undefined, { scope: { kind: "all" } }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [[, scoped], [, all]] = fetchMock.mock.calls; + expect(new Headers(scoped?.headers).get("Content-Type")).toBe("application/json"); + expect(JSON.parse(String(scoped?.body))).toEqual({ repository_ids: [2, 30] }); + expect(all?.body).toBeUndefined(); + }); + + it("refuses an empty repository scope before reading the cache or minting", async () => { + const cacheStore = new FakeCacheStore(); + const get = vi.spyOn(cacheStore, "get"); + const fetchMock = vi.spyOn(globalThis, "fetch"); + await expect( + auth.getCachedInstallationToken( + config(), + { cacheStore }, + { scope: { kind: "repositories", repositoryIds: [] } } + ) + ).rejects.toThrow("no repositories"); + expect(get).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("installation token memory cache", () => { + it("evicts the least recently used scope at the memory bound", async () => { + const cacheStore = new FakeCacheStore(); + const app = cacheOnlyConfig(); + const scope = (id: number): TokenScope => ({ kind: "repositories", repositoryIds: [id] }); + const read = (id: number) => + auth.getCachedInstallationToken(app, { cacheStore }, { scope: scope(id) }); + const max = auth.INSTALLATION_TOKEN_MEMORY_CACHE_MAX_ENTRIES; + for (let id = 1; id <= max + 1; id++) { + await cacheStore.put( + await auth.getInstallationTokenCacheKey(app, scope(id)), + tokenEntry(`token-${id}`) + ); + await read(id); + // Touch the first scope so the second becomes least recently used. + if (id === max) await read(1); + } + for (const id of [1, 2]) { + await cacheStore.put( + await auth.getInstallationTokenCacheKey(app, scope(id)), + tokenEntry("replaced") + ); + } + + expect(await read(1)).toBe("token-1"); + expect(await read(2)).toBe("replaced"); + }); + + it("serves memory hits until the cache max age, then rereads the persistent cache", async () => { + const cacheStore = new FakeCacheStore(); + const app = cacheOnlyConfig(); + const scope: TokenScope = { kind: "all" }; + const key = await auth.getInstallationTokenCacheKey(app, scope); + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + await cacheStore.put(key, tokenEntry("old-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe("old-token"); + await cacheStore.put(key, tokenEntry("new-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe("old-token"); + now += auth.INSTALLATION_TOKEN_CACHE_MAX_AGE_MS; + await cacheStore.put(key, tokenEntry("new-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe("new-token"); + }); + + it("drops the memory and persistent entries on invalidation", async () => { + const cacheStore = new FakeCacheStore(); + const app = cacheOnlyConfig(); + const scope: TokenScope = { kind: "all" }; + const key = await auth.getInstallationTokenCacheKey(app, scope); + await cacheStore.put(key, tokenEntry("rejected-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe( + "rejected-token" + ); + + await auth.invalidateInstallationTokenCache({ cacheStore }, key); + expect(cacheStore.entries.has(key)).toBe(false); + await cacheStore.put(key, tokenEntry("replacement-token")); + expect(await auth.getCachedInstallationToken(app, { cacheStore }, { scope })).toBe( + "replacement-token" + ); + }); +}); + +describe("installation token refresh single-flight", () => { + it("starts a fresh mint when a forced caller arrives after a persistent hit was selected", async () => { + const cacheStore = new FakeCacheStore(); + const app = config(); + const scope: TokenScope = { kind: "all" }; + await cacheStore.put( + await auth.getInstallationTokenCacheKey(app, scope), + tokenEntry("cached-token") + ); + const selected = deferred(); + const releaseCleanup = deferred(); + const originalFinally = Promise.prototype.finally; + vi.spyOn(Promise.prototype, "finally").mockImplementationOnce(function ( + this: Promise, + cleanup + ) { + return originalFinally.call(this, async () => { + selected.resolve(); + await releaseCleanup.promise; + cleanup?.(); + }); + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => mintResponse()); + const first = auth.getCachedInstallationToken(app, { cacheStore }, { scope }); + await selected.promise; + const forced = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + await Promise.resolve(); + releaseCleanup.resolve(); + expect(await first).toBe("cached-token"); + expect(await forced).toBe("fresh-token"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("joins overlapping forced and ordinary refreshes for the same scope", async () => { + const started = deferred(); + const release = deferred(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + started.resolve(); + await release.promise; + return mintResponse(); + }); + const scope: TokenScope = { kind: "all" }; + const first = auth.getCachedInstallationToken(config(), undefined, { + scope, + forceRefresh: true, + }); + await started.promise; + const second = auth.getCachedInstallationToken(config(), undefined, { + scope, + forceRefresh: true, + }); + const third = auth.getCachedInstallationToken(config(), undefined, { scope }); + release.resolve(); + expect(await Promise.all([first, second, third])).toEqual([ + "fresh-token", + "fresh-token", + "fresh-token", + ]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("keeps an in-flight mint registered when the scope is invalidated", async () => { + const cacheStore = new FakeCacheStore(); + const started = deferred(); + const release = deferred(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { + started.resolve(); + await release.promise; + return mintResponse(); + }); + const app = config(); + const scope: TokenScope = { kind: "all" }; + const first = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + await started.promise; + await auth.invalidateInstallationTokenCache( + { cacheStore }, + await auth.getInstallationTokenCacheKey(app, scope) + ); + const second = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + release.resolve(); + expect(await Promise.all([first, second])).toEqual(["fresh-token", "fresh-token"]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("discards a pending persistent-cache read invalidated before it completes", async () => { + const cacheStore = new FakeCacheStore(); + const readStarted = deferred(); + const releaseRead = deferred(); + vi.spyOn(cacheStore, "get").mockImplementation(async () => { + readStarted.resolve(); + await releaseRead.promise; + return JSON.parse(tokenEntry("rejected-token")); + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => mintResponse()); + const app = config(); + const scope: TokenScope = { kind: "all" }; + const first = auth.getCachedInstallationToken(app, { cacheStore }, { scope }); + await readStarted.promise; + await auth.invalidateInstallationTokenCache( + { cacheStore }, + await auth.getInstallationTokenCacheKey(app, scope) + ); + const forced = auth.getCachedInstallationToken( + app, + { cacheStore }, + { scope, forceRefresh: true } + ); + releaseRead.resolve(); + expect(await Promise.all([first, forced])).toEqual(["fresh-token", "fresh-token"]); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("cleans up a failed flight so a later request can retry", async () => { + const scope: TokenScope = { kind: "all" }; + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockRejectedValueOnce(new Error("network failure")) + .mockResolvedValueOnce(mintResponse()); + await expect( + auth.getCachedInstallationToken(config(), undefined, { scope, forceRefresh: true }) + ).rejects.toThrow("network failure"); + expect(await auth.getCachedInstallationToken(config(), undefined, { scope })).toBe( + "fresh-token" + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/control-plane/src/auth/github-app.test.ts b/packages/control-plane/src/auth/github-app.test.ts index 27766cd467..d6c6c26e4d 100644 --- a/packages/control-plane/src/auth/github-app.test.ts +++ b/packages/control-plane/src/auth/github-app.test.ts @@ -67,7 +67,7 @@ async function cacheInstallationToken( config: ReturnType ): Promise { await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "cached-token", expiresAtEpochMs: @@ -193,7 +193,7 @@ describe("github-app utilities", () => { }; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "token-from-kv", expiresAtEpochMs: @@ -202,9 +202,11 @@ describe("github-app utilities", () => { }) ); - const token = await getCachedInstallationToken(config, { - cacheStore, - }); + const token = await getCachedInstallationToken( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); expect(token).toBe("token-from-kv"); expect(fetchMock).not.toHaveBeenCalled(); @@ -227,7 +229,7 @@ describe("github-app utilities", () => { installationId: "installation-malformed-cache", }; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: 42, expiresAtEpochMs: Date.parse(expiresAt), @@ -235,7 +237,9 @@ describe("github-app utilities", () => { }) ); - await expect(getCachedInstallationToken(config, { cacheStore })).resolves.toBe("fresh-token"); + await expect( + getCachedInstallationToken(config, { cacheStore }, { scope: { kind: "all" } }) + ).resolves.toBe("fresh-token"); expect(fetchMock).toHaveBeenCalledOnce(); }); }); @@ -258,7 +262,7 @@ describe("github-app utilities", () => { const expiresAtEpochMs = Date.now() + INSTALLATION_TOKEN_CACHE_MAX_AGE_MS + INSTALLATION_TOKEN_MIN_REMAINING_MS; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "tok-with-expiry", expiresAtEpochMs, @@ -266,7 +270,11 @@ describe("github-app utilities", () => { }) ); - const result = await getCachedInstallationTokenWithExpiry(config, { cacheStore }); + const result = await getCachedInstallationTokenWithExpiry( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); expect(result).toEqual({ token: "tok-with-expiry", expiresAtEpochMs }); expect(fetchMock).not.toHaveBeenCalled(); @@ -282,7 +290,7 @@ describe("github-app utilities", () => { }; await cacheStore.put( - `github:installation-token:v1:${config.appId}:${config.installationId}`, + `github:installation-token:v2:${config.appId}:${config.installationId}:all`, JSON.stringify({ token: "shared-token", expiresAtEpochMs: @@ -291,8 +299,16 @@ describe("github-app utilities", () => { }) ); - const plain = await getCachedInstallationToken(config, { cacheStore }); - const withExpiry = await getCachedInstallationTokenWithExpiry(config, { cacheStore }); + const plain = await getCachedInstallationToken( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); + const withExpiry = await getCachedInstallationTokenWithExpiry( + config, + { cacheStore }, + { scope: { kind: "all" } } + ); expect(withExpiry.token).toBe(plain); }); @@ -314,7 +330,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-valid", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ); expect(result).toEqual({ token: "fresh-token", expiresAtEpochMs: Date.parse(expiresAt) }); @@ -337,7 +353,7 @@ describe("github-app utilities", () => { installationId: "installation-escaped-key", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ); expect(result.token).toBe("escaped-key-token"); @@ -358,7 +374,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-invalid", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ) ).rejects.toThrow("Failed to get installation token: invalid response"); }); @@ -376,7 +392,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-invalid-json", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ) ).rejects.toThrow("Failed to get installation token: invalid response"); }); @@ -398,7 +414,7 @@ describe("github-app utilities", () => { installationId: "installation-refresh-invalid-expiry", }, undefined, - { forceRefresh: true } + { scope: { kind: "all" }, forceRefresh: true } ) ).rejects.toThrow("Failed to get installation token: invalid response"); }); diff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts index 3dd9648e60..d0cdad335f 100644 --- a/packages/control-plane/src/auth/github-app.ts +++ b/packages/control-plane/src/auth/github-app.ts @@ -12,7 +12,12 @@ import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; import { DEFAULT_APP_NAME } from "@open-inspect/shared/app-name"; import type { CacheStore } from "@open-inspect/shared/cache-store"; +import { sha256Hex } from "@open-inspect/shared/service-auth"; import { z } from "zod"; +import { + repositoryCredentialScope, + type CredentialScope, +} from "../source-control/credential-scope"; import { base64UrlEncode } from "./encoding"; @@ -25,10 +30,20 @@ export const INSTALLATION_TOKEN_CACHE_MAX_AGE_MS = 50 * 60 * 1000; /** Require at least this much remaining lifetime before using a cached token (ms). */ export const INSTALLATION_TOKEN_MIN_REMAINING_MS = 5 * 60 * 1000; +/** Maximum distinct token scopes retained in the process cache. */ +export const INSTALLATION_TOKEN_MEMORY_CACHE_MAX_ENTRIES = 128; + /** Upper bound for KV cache TTL (seconds). */ const INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS = 3600; -const INSTALLATION_TOKEN_CACHE_KEY_PREFIX = "github:installation-token:v1"; +const INSTALLATION_TOKEN_CACHE_KEY_PREFIX = "github:installation-token:v2"; + +export type TokenScope = CredentialScope; + +interface InstallationTokenOptions { + scope: TokenScope; + forceRefresh?: boolean; +} interface InstallationTokenCacheBindings { cacheStore?: CacheStore; @@ -60,7 +75,10 @@ function createHttpError(message: string, status: number): GitHubHttpError { } const installationTokenMemoryCache = new Map(); -const installationTokenRefreshInFlight = new Map>(); +const installationTokenRefreshInFlight = new Map< + string, + { promise: Promise; options: { forceRefresh: boolean } } +>(); const importedPrivateKeyCache = new Map>(); /** Fetch with an AbortController timeout. */ @@ -249,7 +267,8 @@ async function generateAppJwt(appId: string, privateKey: string): Promise { const url = `https://api.github.com/app/installations/${installationId}/access_tokens`; @@ -260,7 +279,11 @@ async function getInstallationTokenWithMetadata( Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": userAgent, + ...(scope.kind === "repositories" ? { "Content-Type": "application/json" } : {}), }, + ...(scope.kind === "repositories" + ? { body: JSON.stringify({ repository_ids: scope.repositoryIds }) } + : {}), }); if (!response.ok) { @@ -287,8 +310,16 @@ async function getInstallationTokenWithMetadata( return parsed.data; } -function getInstallationTokenCacheKey(config: GitHubAppConfig): string { - return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`; +export async function getInstallationTokenCacheKey( + config: GitHubAppConfig, + scope: TokenScope +): Promise { + let scopeHash = "all"; + if (scope.kind === "repositories") { + const ids = repositoryCredentialScope(scope.repositoryIds).repositoryIds; + scopeHash = await sha256Hex(JSON.stringify(ids)); + } + return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}:${scopeHash}`; } function isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()): boolean { @@ -299,6 +330,20 @@ function isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()) return nowEpochMs < cached.expiresAtEpochMs - INSTALLATION_TOKEN_MIN_REMAINING_MS; } +function cacheInstallationTokenInMemory(cacheKey: string, cached: CachedInstallationToken): void { + const nowEpochMs = Date.now(); + for (const [key, token] of installationTokenMemoryCache) { + if (!isTokenUsable(token, nowEpochMs)) installationTokenMemoryCache.delete(key); + } + installationTokenMemoryCache.delete(cacheKey); + if (!isTokenUsable(cached, nowEpochMs)) return; + installationTokenMemoryCache.set(cacheKey, cached); + if (installationTokenMemoryCache.size > INSTALLATION_TOKEN_MEMORY_CACHE_MAX_ENTRIES) { + const oldestKey = installationTokenMemoryCache.keys().next().value; + if (oldestKey !== undefined) installationTokenMemoryCache.delete(oldestKey); + } +} + async function readInstallationTokenFromCache( env: InstallationTokenCacheBindings | undefined, cacheKey: string @@ -345,12 +390,13 @@ async function writeInstallationTokenToCache( } } -async function invalidateInstallationTokenCache( +export async function invalidateInstallationTokenCache( env: InstallationTokenCacheBindings | undefined, cacheKey: string ): Promise { installationTokenMemoryCache.delete(cacheKey); - installationTokenRefreshInFlight.delete(cacheKey); + const pending = installationTokenRefreshInFlight.get(cacheKey); + if (pending) pending.options.forceRefresh = true; if (!env?.cacheStore) { return; @@ -366,14 +412,16 @@ async function invalidateInstallationTokenCache( async function refreshInstallationToken( config: GitHubAppConfig, env: InstallationTokenCacheBindings | undefined, - cacheKey: string + cacheKey: string, + scope: TokenScope ): Promise { const nowEpochMs = Date.now(); const jwt = await generateAppJwt(config.appId, config.privateKey); const tokenData = await getInstallationTokenWithMetadata( jwt, config.installationId, - resolveUserAgent(env) + resolveUserAgent(env), + scope ); const cached: CachedInstallationToken = { token: tokenData.token, @@ -381,41 +429,64 @@ async function refreshInstallationToken( cachedAtEpochMs: nowEpochMs, }; - installationTokenMemoryCache.set(cacheKey, cached); + cacheInstallationTokenInMemory(cacheKey, cached); await writeInstallationTokenToCache(env, cacheKey, cached); return cached; } async function getOrRefreshCachedInstallationToken( config: GitHubAppConfig, - env?: InstallationTokenCacheBindings, - options?: { forceRefresh?: boolean } + env: InstallationTokenCacheBindings | undefined, + options: InstallationTokenOptions ): Promise { - const cacheKey = getInstallationTokenCacheKey(config); - const forceRefresh = options?.forceRefresh ?? false; + const scope: TokenScope = + options.scope.kind === "all" + ? options.scope + : repositoryCredentialScope(options.scope.repositoryIds); + const cacheKey = await getInstallationTokenCacheKey(config, scope); + const forceRefresh = options.forceRefresh ?? false; + + const pending = installationTokenRefreshInFlight.get(cacheKey); + if (pending) { + if (forceRefresh) pending.options.forceRefresh = true; + return pending.promise; + } if (!forceRefresh) { const memoryCached = installationTokenMemoryCache.get(cacheKey); if (memoryCached && isTokenUsable(memoryCached)) { + // Re-insert to mark most recently used; expired entries are pruned on insert. + installationTokenMemoryCache.delete(cacheKey); + installationTokenMemoryCache.set(cacheKey, memoryCached); return memoryCached; } + installationTokenMemoryCache.delete(cacheKey); + } - const persistentCached = await readInstallationTokenFromCache(env, cacheKey); - if (persistentCached && isTokenUsable(persistentCached)) { - installationTokenMemoryCache.set(cacheKey, persistentCached); - return persistentCached; + const refreshOptions = { forceRefresh }; + const refreshPromise = (async () => { + if (!refreshOptions.forceRefresh) { + const persistentCached = await readInstallationTokenFromCache(env, cacheKey); + // Invalidation or a forced caller can supersede a pending cache read. + if (!refreshOptions.forceRefresh && persistentCached && isTokenUsable(persistentCached)) { + cacheInstallationTokenInMemory(cacheKey, persistentCached); + // Forced callers must not join work that has already selected a cache hit. + if (installationTokenRefreshInFlight.get(cacheKey)?.options === refreshOptions) { + installationTokenRefreshInFlight.delete(cacheKey); + } + return persistentCached; + } } - - const inFlight = installationTokenRefreshInFlight.get(cacheKey); - if (inFlight) { - return inFlight; + return refreshInstallationToken(config, env, cacheKey, scope); + })().finally(() => { + if (installationTokenRefreshInFlight.get(cacheKey)?.promise === refreshPromise) { + installationTokenRefreshInFlight.delete(cacheKey); } - } - - const refreshPromise = refreshInstallationToken(config, env, cacheKey).finally(() => { - installationTokenRefreshInFlight.delete(cacheKey); }); - installationTokenRefreshInFlight.set(cacheKey, refreshPromise); + installationTokenRefreshInFlight.set(cacheKey, { + promise: refreshPromise, + options: refreshOptions, + }); return refreshPromise; } @@ -425,8 +496,8 @@ async function getOrRefreshCachedInstallationToken( */ export async function getCachedInstallationToken( config: GitHubAppConfig, - env?: InstallationTokenCacheBindings, - options?: { forceRefresh?: boolean } + env: InstallationTokenCacheBindings | undefined, + options: InstallationTokenOptions ): Promise { const cached = await getOrRefreshCachedInstallationToken(config, env, options); return cached.token; @@ -440,8 +511,8 @@ export async function getCachedInstallationToken( */ export async function getCachedInstallationTokenWithExpiry( config: GitHubAppConfig, - env?: InstallationTokenCacheBindings, - options?: { forceRefresh?: boolean } + env: InstallationTokenCacheBindings | undefined, + options: InstallationTokenOptions ): Promise<{ token: string; expiresAtEpochMs: number }> { const cached = await getOrRefreshCachedInstallationToken(config, env, options); return { token: cached.token, expiresAtEpochMs: cached.expiresAtEpochMs }; @@ -464,7 +535,8 @@ export async function listInstallationRepositories( env?: InstallationTokenCacheBindings ): Promise<{ repos: InstallationRepository[]; timing: ListReposTiming }> { const tokenStart = performance.now(); - let token = await getCachedInstallationToken(config, env); + const scope: TokenScope = { kind: "all" }; + let token = await getCachedInstallationToken(config, env, { scope }); const tokenGenerationMs = performance.now() - tokenStart; const perPage = 100; @@ -526,8 +598,8 @@ export async function listInstallationRepositories( throw error; } - await invalidateInstallationTokenCache(env, getInstallationTokenCacheKey(config)); - token = await getCachedInstallationToken(config, env, { forceRefresh: true }); + await invalidateInstallationTokenCache(env, await getInstallationTokenCacheKey(config, scope)); + token = await getCachedInstallationToken(config, env, { scope, forceRefresh: true }); headers.Authorization = `Bearer ${token}`; first = await fetchPage(1); } @@ -571,12 +643,13 @@ export async function getInstallationRepository( repo: string, env?: InstallationTokenCacheBindings ): Promise { - const cacheKey = getInstallationTokenCacheKey(config); + const scope: TokenScope = { kind: "all" }; + const cacheKey = await getInstallationTokenCacheKey(config, scope); let forceRefresh = false; let response!: Response; for (let attempt = 0; attempt < 2; attempt++) { - const token = await getCachedInstallationToken(config, env, { forceRefresh }); + const token = await getCachedInstallationToken(config, env, { scope, forceRefresh }); response = await fetchWithTimeout(`https://api.github.com/repos/${owner}/${repo}`, { headers: { Authorization: `Bearer ${token}`, @@ -631,7 +704,7 @@ export async function listRepositoryBranches( repo: string, env?: InstallationTokenCacheBindings ): Promise<{ name: string }[]> { - const token = await getCachedInstallationToken(config, env); + const token = await getCachedInstallationToken(config, env, { scope: { kind: "all" } }); const branches: { name: string }[] = []; let page = 1; diff --git a/packages/control-plane/src/autofix/handler.test.ts b/packages/control-plane/src/autofix/handler.test.ts new file mode 100644 index 0000000000..625341dfe9 --- /dev/null +++ b/packages/control-plane/src/autofix/handler.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubAutofixEnvelope } from "@open-inspect/shared"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import type { SqlDatabase } from "../db/sql-database"; +import type { JobDeps } from "../jobs"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import { SourceControlProviderError } from "../source-control"; +import type { Env } from "../types"; +import { handleAutofixJob } from "./handler"; +import { AutofixService } from "./service"; + +vi.mock("../repos/cache", () => ({ readCachedInstallationRepositories: vi.fn() })); +vi.mock("./service", () => ({ + AutofixService: vi.fn(function () { + return { + process: async () => ({ kind: "completed", decision: "skipped", reason: "disabled" }), + }; + }), +})); + +const ENVELOPE: GitHubAutofixEnvelope = { + version: 1, + eventType: "issue_comment", + action: "created", + deliveryId: "delivery-1", + providerObject: { kind: "pr_comment", id: "1234" }, + repository: { id: "99", owner: "acme", name: "widgets" }, + pullRequestNumber: 42, + receivedAt: "2026-07-30T05:00:00.000Z", +}; + +const WORKSPACE_SESSION = { id: "owning-public-session", ownerTeamId: null } as SessionEntry; + +describe("autofix credential scope composition", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(SessionRepositoryStore.prototype, "listRepositoryIds").mockResolvedValue([ + { repoOwner: "acme", repoName: "widgets", repoId: 99 }, + { repoOwner: "acme", repoName: "web", repoId: 123 }, + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function createHarness() { + const db = {} as SqlDatabase; + const env = { GITHUB_BOT_USERNAME: "open-inspect[bot]", TEAMS_ENFORCEMENT: "off" } as Env; + await handleAutofixJob(ENVELOPE, { attempts: 1, maxAttempts: 5 }, { + db, + env, + correlation: { trace_id: "trace-1", request_id: "request-1" }, + } as JobDeps); + return { env, resolveCredentialScope: vi.mocked(AutofixService).mock.calls[0][7] }; + } + + it("resolves the owner session's persisted repositories from D1", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(WORKSPACE_SESSION); + const h = await createHarness(); + + expect(await h.resolveCredentialScope("owning-public-session")).toEqual({ + kind: "repositories", + repositoryIds: [99, 123], + }); + expect(SessionRepositoryStore.prototype.listRepositoryIds).toHaveBeenCalledWith( + "owning-public-session" + ); + }); + + it("loads the cached catalog from env and propagates its failure rather than broadening", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(WORKSPACE_SESSION); + vi.mocked(SessionRepositoryStore.prototype.listRepositoryIds).mockResolvedValue([ + { repoOwner: "acme", repoName: "widgets", repoId: null }, + ]); + const error = new SourceControlProviderError("Cached catalog unavailable", "permanent"); + vi.mocked(readCachedInstallationRepositories).mockRejectedValue(error); + const h = await createHarness(); + + await expect(h.resolveCredentialScope("owning-public-session")).rejects.toBe(error); + expect(readCachedInstallationRepositories).toHaveBeenCalledExactlyOnceWith(h.env); + }); + + it("throws a permanent credential error rather than defaulting a missing D1 session to all", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(null); + const h = await createHarness(); + + await expect(h.resolveCredentialScope("owning-public-session")).rejects.toMatchObject({ + errorType: "permanent", + message: "Cannot resolve credential scope: session not found", + }); + }); +}); diff --git a/packages/control-plane/src/autofix/handler.ts b/packages/control-plane/src/autofix/handler.ts index 455ac7599d..65c902f355 100644 --- a/packages/control-plane/src/autofix/handler.ts +++ b/packages/control-plane/src/autofix/handler.ts @@ -9,8 +9,10 @@ import { IntegrationSettingsStore } from "../db/integration-settings"; import { PrAutofixFeedbackStore } from "../db/pr-autofix-feedback-store"; import { SessionPullRequestStore } from "../db/session-pull-request-store"; import type { JobDelivery, JobDeps, JobOutcome } from "../jobs"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { createSessionRuntimeClient } from "../session/runtime-client"; import { GitHubSourceControlProvider } from "../source-control/providers/github-provider"; +import { resolveSessionCredentialScope } from "../source-control/session-scope"; import { AutofixJobHandler } from "./job-handler"; import { AutofixService } from "./service"; @@ -48,6 +50,8 @@ export async function handleAutofixJob( userAgent: resolveAppName(env), }); const sessions = createSessionRuntimeClient(env, correlation); + const resolveCredentialScope = (sessionId: string) => + resolveSessionCredentialScope(db, sessionId, () => readCachedInstallationRepositories(env)); const service = new AutofixService( feedbackStore, new SessionPullRequestStore(db), @@ -63,7 +67,8 @@ export async function handleAutofixJob( github, sessions, env.GITHUB_BOT_USERNAME, - () => Date.now() + () => Date.now(), + resolveCredentialScope ); return new AutofixJobHandler(service, feedbackStore, () => Date.now()).handle(envelope, delivery); } diff --git a/packages/control-plane/src/autofix/service.test.ts b/packages/control-plane/src/autofix/service.test.ts index 6b519d4073..257b1f3615 100644 --- a/packages/control-plane/src/autofix/service.test.ts +++ b/packages/control-plane/src/autofix/service.test.ts @@ -7,9 +7,21 @@ import { import { AutofixService } from "./service"; import type { GitHubPullRequestFeedback } from "../source-control/providers/github-provider"; import { SourceControlProviderError } from "../source-control/errors"; +import type { CredentialScope } from "../source-control"; type ReviewFeedback = Extract; +const PR_COMMENT_ENVELOPE: GitHubAutofixEnvelope = { + version: 1, + eventType: "issue_comment", + action: "created", + deliveryId: "delivery-1", + providerObject: { kind: "pr_comment", id: "1234" }, + repository: { id: "99", owner: "acme", name: "widgets" }, + pullRequestNumber: 42, + receivedAt: "2026-07-30T05:00:00.000Z", +}; + const OPEN_INSPECT_REVIEW_ENVELOPE: GitHubAutofixEnvelope = { version: 1, eventType: "pull_request_review", @@ -101,6 +113,8 @@ function buildService() { const sessions = { fetch: vi.fn(async () => Response.json({ kind: "enqueued", messageId: "message-1" })), }; + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [99, 123] }; + const resolveCredentialScope = vi.fn(async () => credentialScope); const service = new AutofixService( feedbackStore, pullRequests, @@ -108,26 +122,27 @@ function buildService() { github, sessions, "open-inspect[bot]", - () => 2_000 + () => 2_000, + resolveCredentialScope ); - return { service, feedbackStore, pullRequests, settings, github, sessions }; + return { + service, + feedbackStore, + pullRequests, + settings, + github, + sessions, + credentialScope, + resolveCredentialScope, + }; } describe("AutofixService", () => { it("dispatches eligible human PR feedback into the owning session", async () => { const h = buildService(); - const result = await h.service.process({ - version: 1, - eventType: "issue_comment", - action: "created", - deliveryId: "delivery-1", - providerObject: { kind: "pr_comment", id: "1234" }, - repository: { id: "99", owner: "acme", name: "widgets" }, - pullRequestNumber: 42, - receivedAt: "2026-07-30T05:00:00.000Z", - }); + const result = await h.service.process(PR_COMMENT_ENVELOPE); expect(result).toEqual({ kind: "completed", @@ -135,11 +150,24 @@ describe("AutofixService", () => { reason: "enqueued", messageId: "message-1", }); - expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith({ - owner: "acme", - name: "widgets", - authorLogin: "alice", - }); + expect(h.resolveCredentialScope).toHaveBeenCalledWith("session-1"); + expect(h.github.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "widgets", number: 42, repositoryExternalId: "99" }, + h.credentialScope + ); + expect(h.github.getPullRequestFeedback).toHaveBeenCalledWith( + { + owner: "acme", + name: "widgets", + pullRequestNumber: 42, + providerObject: { kind: "pr_comment", id: "1234" }, + }, + h.credentialScope + ); + expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith( + { owner: "acme", name: "widgets", authorLogin: "alice" }, + h.credentialScope + ); expect(h.feedbackStore.markDispatchAttempted).toHaveBeenCalledBefore(h.sessions.fetch); expect(h.sessions.fetch).toHaveBeenCalledWith( "session-1", @@ -193,6 +221,20 @@ describe("AutofixService", () => { ); }); + it("fails closed before all provider content reads when owner scope cannot be resolved", async () => { + const h = buildService(); + const error = new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + h.resolveCredentialScope.mockRejectedValue(error); + + await expect(h.service.process(PR_COMMENT_ENVELOPE)).rejects.toBe(error); + + expect(h.github.getPullRequest).not.toHaveBeenCalled(); + expect(h.sessions.fetch).not.toHaveBeenCalled(); + }); + it("returns the winning queued decision when a concurrent skip loses its transition", async () => { const h = buildService(); h.settings.resolve.mockResolvedValue({ @@ -307,6 +349,15 @@ describe("AutofixService", () => { }); expect(result).toMatchObject({ decision: "queued", messageId: "message-1" }); + expect(h.github.getPullRequestFeedback).toHaveBeenCalledWith( + { + owner: "acme", + name: "widgets", + pullRequestNumber: 42, + providerObject: { kind: "review", id: "5678" }, + }, + h.credentialScope + ); expect(h.github.hasPullRequestWritePermission).not.toHaveBeenCalled(); expect(h.sessions.fetch).toHaveBeenCalledWith( "session-1", @@ -542,11 +593,10 @@ describe("AutofixService", () => { decision: "skipped", reason: "author_lacks_write_permission", }); - expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith({ - owner: "acme", - name: "widgets", - authorLogin: "Open-Inspect[bot]", - }); + expect(h.github.hasPullRequestWritePermission).toHaveBeenCalledWith( + { owner: "acme", name: "widgets", authorLogin: "Open-Inspect[bot]" }, + h.credentialScope + ); expect(h.sessions.fetch).not.toHaveBeenCalled(); }); diff --git a/packages/control-plane/src/autofix/service.ts b/packages/control-plane/src/autofix/service.ts index 9331840150..ef97ef3165 100644 --- a/packages/control-plane/src/autofix/service.ts +++ b/packages/control-plane/src/autofix/service.ts @@ -13,6 +13,7 @@ import type { GetGitHubPullRequestFeedbackConfig, } from "../source-control/providers/github-provider"; import { SourceControlProviderError } from "../source-control/errors"; +import type { CredentialScope } from "../source-control"; import { SessionInternalPaths, type SessionInternalPath } from "../session/contracts"; interface FeedbackReceipt { @@ -72,24 +73,31 @@ interface AutofixSettingsResolver { } interface GitHubAutofixProvider { - getPullRequest(config: { - owner: string; - name: string; - number: number; - repositoryExternalId: string; - }): Promise<{ + getPullRequest( + config: { + owner: string; + name: string; + number: number; + repositoryExternalId: string; + }, + scope: CredentialScope + ): Promise<{ lifecycleState: "open" | "closed" | "merged"; repoOwner: string; repoName: string; }>; getPullRequestFeedback( - config: GetGitHubPullRequestFeedbackConfig + config: GetGitHubPullRequestFeedbackConfig, + scope: CredentialScope ): Promise; - hasPullRequestWritePermission(config: { - owner: string; - name: string; - authorLogin: string; - }): Promise; + hasPullRequestWritePermission( + config: { + owner: string; + name: string; + authorLogin: string; + }, + scope: CredentialScope + ): Promise; } interface SessionClient { @@ -194,7 +202,8 @@ export class AutofixService { private readonly github: GitHubAutofixProvider, private readonly sessions: SessionClient, private readonly botUsername: string, - private readonly now: () => number + private readonly now: () => number, + private readonly resolveCredentialScope: (sessionId: string) => Promise ) {} async process(envelope: GitHubAutofixEnvelope): Promise { @@ -289,12 +298,16 @@ export class AutofixService { return this.skip(receipt.feedbackKey, "reviews_disabled", decidedAt); } - const pullRequest = await this.github.getPullRequest({ - owner: owner.repoOwner, - name: owner.repoName, - number: owner.prNumber, - repositoryExternalId: envelope.repository.id, - }); + const credentialScope = await this.resolveCredentialScope(owner.sessionId); + const pullRequest = await this.github.getPullRequest( + { + owner: owner.repoOwner, + name: owner.repoName, + number: owner.prNumber, + repositoryExternalId: envelope.repository.id, + }, + credentialScope + ); if (pullRequest.lifecycleState !== "open") { return this.skip(receipt.feedbackKey, "pull_request_not_open", decidedAt); } @@ -304,22 +317,17 @@ export class AutofixService { name: pullRequest.repoName, pullRequestNumber: owner.prNumber, }; - const feedback = + const feedbackConfig: GetGitHubPullRequestFeedbackConfig = envelope.providerObject.kind === "pr_comment" - ? await this.github.getPullRequestFeedback({ + ? { ...feedbackLocation, - providerObject: { - kind: "pr_comment", - id: envelope.providerObject.id, - }, - }) - : await this.github.getPullRequestFeedback({ + providerObject: { kind: "pr_comment", id: envelope.providerObject.id }, + } + : { ...feedbackLocation, - providerObject: { - kind: "review", - id: envelope.providerObject.id, - }, - }); + providerObject: { kind: "review", id: envelope.providerObject.id }, + }; + const feedback = await this.github.getPullRequestFeedback(feedbackConfig, credentialScope); await this.feedbackStore.attachContext(receipt.feedbackKey, { artifactId: owner.artifactId, sessionId: owner.sessionId, @@ -333,7 +341,8 @@ export class AutofixService { feedback, resolved.autofix, pullRequest.repoOwner, - pullRequest.repoName + pullRequest.repoName, + credentialScope ); if (eligibilityReason) { return this.skip(receipt.feedbackKey, eligibilityReason, decidedAt); @@ -431,7 +440,8 @@ export class AutofixService { feedback: GitHubPullRequestFeedback, settings: ResolvedGitHubAutofixSettings, owner: string, - name: string + name: string, + credentialScope: CredentialScope ): Promise { const authorType = feedback.author.type.toLowerCase(); const authorLogin = feedback.author.login.toLowerCase(); @@ -452,11 +462,14 @@ export class AutofixService { ) { return "explicit_mention"; } - const canWrite = await this.github.hasPullRequestWritePermission({ - owner, - name, - authorLogin: feedback.author.login, - }); + const canWrite = await this.github.hasPullRequestWritePermission( + { + owner, + name, + authorLogin: feedback.author.login, + }, + credentialScope + ); if (!canWrite) return "author_lacks_write_permission"; } else if (authorType === "bot") { if (feedback.kind !== "review") return "bot_pr_comment"; diff --git a/packages/control-plane/src/db/session-repositories.ts b/packages/control-plane/src/db/session-repositories.ts new file mode 100644 index 0000000000..a687014051 --- /dev/null +++ b/packages/control-plane/src/db/session-repositories.ts @@ -0,0 +1,20 @@ +import { sessionRepositoryRowSchema } from "./session-list-metadata"; +import type { SqlDatabase } from "./sql-database"; + +/** Persisted session membership, independent of mutable environment provenance. */ +export class SessionRepositoryStore { + constructor(private readonly db: SqlDatabase) {} + + async listRepositoryIds( + sessionId: string + ): Promise> { + const rows = await this.db + .prepare("SELECT * FROM session_repositories WHERE session_id = ? ORDER BY position") + .bind(sessionId) + .all(); + return rows.results.map((value) => { + const row = sessionRepositoryRowSchema.parse(value); + return { repoOwner: row.repo_owner, repoName: row.repo_name, repoId: row.repo_id }; + }); + } +} diff --git a/packages/control-plane/src/db/team-repository-grants.ts b/packages/control-plane/src/db/team-repository-grants.ts index 34bbe1c2ce..c2df127554 100644 --- a/packages/control-plane/src/db/team-repository-grants.ts +++ b/packages/control-plane/src/db/team-repository-grants.ts @@ -15,6 +15,18 @@ const grantSchema = z.object({ repo_external_id: z.number().nullable(), }); +type TeamRepositoryGrantRow = z.infer; + +/** The subset of `repoIds` a team's grants cover; an installation grant covers every id. */ +export function coveredRepositoryIds( + grants: readonly TeamRepositoryGrantRow[], + repoIds: readonly T[] +): T[] { + if (grants.some((grant) => grant.grant_kind === "installation")) return [...repoIds]; + const allowed = new Set(grants.map((grant) => grant.repo_external_id)); + return repoIds.filter((id) => id !== null && allowed.has(id)); +} + export class TeamRepositoryGrantConflictError extends Error { constructor(readonly code: "grant_kind_conflict" | "repository_grant_limit" | "team_not_active") { super( @@ -177,8 +189,6 @@ export class TeamRepositoryGrantStore { 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)); + return coveredRepositoryIds(grants, repoIds).length === repoIds.length; } } diff --git a/packages/control-plane/src/image-builds/credential-scope.ts b/packages/control-plane/src/image-builds/credential-scope.ts new file mode 100644 index 0000000000..441225e887 --- /dev/null +++ b/packages/control-plane/src/image-builds/credential-scope.ts @@ -0,0 +1,59 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { EnvironmentStore } from "../db/environments"; +import type { SqlDatabase } from "../db/sql-database"; +import { + repositoryCredentialScope, + type CredentialScope, +} from "../source-control/credential-scope"; +import { resolveRepositoryCredentialScope } from "../source-control/repository-scope"; +import { ImageBuildPlanningError, ImageBuildScopeNotFoundError } from "./errors"; +import type { ImageBuildScope } from "./model"; +import { repositoryIdentityKey } from "./provenance"; +import type { ResolvedImageBuildTarget } from "./scope"; + +/** Credentials cover only planned repositories, intersected with the environment owner's grants. */ +export async function resolveImageBuildTokenScope( + db: SqlDatabase, + scope: ImageBuildScope, + target: ResolvedImageBuildTarget, + loadCatalog: () => Promise +): Promise { + if (scope.kind !== target.kind) { + throw new ImageBuildPlanningError("Image build scope and target kinds do not match"); + } + + switch (target.kind) { + case "environment": { + const store = new EnvironmentStore(db); + const environment = await store.getById(scope.id); + if (!environment) throw new ImageBuildScopeNotFoundError(scope.kind, scope.id); + const rows = await store.getRepositoriesForEnvironment(scope.id); + const repositories = rows.map((row) => ({ + repoOwner: row.repo_owner, + repoName: row.repo_name, + repoId: row.repo_id, + })); + const currentIdentities = repositories.map(repositoryIdentityKey).sort(); + const plannedIdentities = target.repositories.map(repositoryIdentityKey).sort(); + // An edit after target resolution must not put unplanned repositories in the token. + if ( + currentIdentities.length !== plannedIdentities.length || + currentIdentities.some((identity, index) => identity !== plannedIdentities[index]) + ) { + throw new ImageBuildPlanningError(`Environment repositories changed: ${scope.id}`); + } + return await resolveRepositoryCredentialScope( + db, + repositories, + environment.owner_team_id, + loadCatalog + ); + } + case "repo": { + if (target.repositories.length !== 1) { + throw new ImageBuildPlanningError("Repository image build must contain one repository"); + } + return repositoryCredentialScope([target.repoId]); + } + } +} diff --git a/packages/control-plane/src/image-builds/planner.test.ts b/packages/control-plane/src/image-builds/planner.test.ts new file mode 100644 index 0000000000..a444ab2475 --- /dev/null +++ b/packages/control-plane/src/image-builds/planner.test.ts @@ -0,0 +1,280 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CredentialScope } from "../source-control/credential-scope"; +import { + EnvironmentStore, + type EnvironmentRepositoryRow, + type EnvironmentRow, +} from "../db/environments"; +import type { SqlDatabase } from "../db/sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import type * as ReposCacheModule from "../repos/cache"; +import { createTestEnv } from "../router.test-support"; +import type * as SourceControlModule from "../source-control"; +import { resolveImageBuildTokenScope } from "./credential-scope"; +import { ImageBuildPlanningError, ImageBuildScopeNotFoundError } from "./errors"; +import type { ImageBuildScope } from "./model"; +import { + ImageBuildPlanner, + type ImageBuildPlanRequest, + type ResolvedImageBuildTarget, +} from "./planner"; +import type * as ScopeModule from "./scope"; + +const scmProvider = vi.hoisted(() => ({ + generateCredentialHelperAuth: vi.fn(async (_scope: CredentialScope) => ({ + username: "x-access-token", + password: "clone-token", + })), +})); + +vi.mock("../source-control", async (importOriginal) => ({ + ...(await importOriginal()), + createSourceControlProviderFromEnv: vi.fn(() => scmProvider), +})); + +vi.mock("./scope", async (importOriginal) => ({ + ...(await importOriginal()), + resolveScopeSandboxSettings: vi.fn(async () => ({})), + loadScopeBuildSecrets: vi.fn(async () => undefined), +})); + +vi.mock("../repos/cache", async (importOriginal) => ({ + ...(await importOriginal()), + readCachedInstallationRepositories: vi.fn(), +})); + +const db = {} as SqlDatabase; +const REPO_SCOPE: ImageBuildScope = { kind: "repo", id: "acme/web" }; +const ENV_SCOPE: ImageBuildScope = { kind: "environment", id: "env_1" }; +const REPO_TARGET: ResolvedImageBuildTarget = { + kind: "repo", + repoId: 12, + repositories: [{ repoOwner: "acme", repoName: "web", baseBranch: "main" }], + repositoriesFingerprint: "fp-repo", +}; +const ENV_TARGET: ResolvedImageBuildTarget = { + kind: "environment", + repositories: [ + ...REPO_TARGET.repositories, + { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, + ], + repositoriesFingerprint: "fp-env", +}; +const ENVIRONMENT: EnvironmentRow = { + id: ENV_SCOPE.id, + name: "Environment", + description: null, + prebuild_enabled: 1, + channel_associations: null, + owner_team_id: null, + created_at: 1, + updated_at: 1, +}; +const ENV_REPOSITORIES: EnvironmentRepositoryRow[] = ENV_TARGET.repositories.map( + (repository, position) => ({ + environment_id: ENV_SCOPE.id, + position, + repo_owner: repository.repoOwner, + repo_name: repository.repoName, + repo_id: position === 0 ? 12 : 30, + base_branch: repository.baseBranch, + }) +); +const CATALOG: InstallationRepository[] = [ + { id: 12, name: "web" }, + { id: 30, name: "api" }, + { id: 99, name: "sibling" }, +].map((repository) => ({ + ...repository, + owner: "acme", + fullName: `acme/${repository.name}`, + description: null, + private: true, + defaultBranch: "main", + archived: false, +})); +const loadCatalog = vi.fn<() => Promise>(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue(null); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue([]); + vi.mocked(readCachedInstallationRepositories).mockResolvedValue(CATALOG); + scmProvider.generateCredentialHelperAuth.mockResolvedValue({ + username: "x-access-token", + password: "clone-token", + }); +}); + +afterEach(() => vi.restoreAllMocks()); + +function mockEnvironment( + ownerTeamId: string | null = null, + repositories: EnvironmentRepositoryRow[] = ENV_REPOSITORIES +): void { + vi.mocked(EnvironmentStore.prototype.getById).mockResolvedValue({ + ...ENVIRONMENT, + owner_team_id: ownerTeamId, + }); + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue( + repositories + ); +} + +function planRequest( + scope: ImageBuildScope, + target: ResolvedImageBuildTarget +): ImageBuildPlanRequest { + return { + buildId: "build-1", + scope, + target, + callbackUrl: "https://worker.test/image-builds/build-complete", + failureCallbackUrl: "https://worker.test/image-builds/build-failed", + correlation: { request_id: "request-1", trace_id: "trace-1" }, + callbackAuth: { token: "callback-token", tokenHash: "callback-hash", expiresAt: 1000 }, + }; +} + +describe("resolveImageBuildTokenScope", () => { + it("scopes environments to their stored member ids", async () => { + mockEnvironment(); + + expect(await resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + }); + + it("intersects environment members with the owning team's grants", async () => { + mockEnvironment("team_a"); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + { grant_kind: "repository", repo_external_id: 99 }, + ]); + + expect(await resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12], + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledExactlyOnceWith( + "team_a" + ); + }); + + it("accepts unchanged identities when member order or case differs", async () => { + mockEnvironment( + null, + [...ENV_REPOSITORIES].reverse().map((row) => ({ + ...row, + repo_owner: row.repo_owner.toUpperCase(), + repo_name: row.repo_name.toUpperCase(), + })) + ); + + expect(await resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + }); + + it.each([ + { name: "added", rows: [...ENV_REPOSITORIES, { ...ENV_REPOSITORIES[0], repo_name: "extra" }] }, + { name: "removed", rows: ENV_REPOSITORIES.slice(0, 1) }, + { + name: "replaced", + rows: [ENV_REPOSITORIES[0], { ...ENV_REPOSITORIES[1], repo_name: "sibling", repo_id: null }], + }, + ])("fails closed when environment membership is $name", async ({ rows }) => { + mockEnvironment("team_a", rows); + + await expect( + resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildPlanningError); + }); + + it("fails closed when the environment no longer exists", async () => { + await expect( + resolveImageBuildTokenScope(db, ENV_SCOPE, ENV_TARGET, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildScopeNotFoundError); + }); + + it.each([{ repositories: [] }, { repositories: ENV_TARGET.repositories }])( + "rejects repo targets without exactly one repository", + async ({ repositories }) => { + await expect( + resolveImageBuildTokenScope(db, REPO_SCOPE, { ...REPO_TARGET, repositories }, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildPlanningError); + } + ); + + it.each([ + { scope: REPO_SCOPE, target: ENV_TARGET }, + { scope: ENV_SCOPE, target: REPO_TARGET }, + ])("rejects scope/target kind mismatches before reading stores", async ({ scope, target }) => { + await expect( + resolveImageBuildTokenScope(db, scope, target, loadCatalog) + ).rejects.toBeInstanceOf(ImageBuildPlanningError); + expect(EnvironmentStore.prototype.getById).not.toHaveBeenCalled(); + }); +}); + +describe("ImageBuildPlanner clone auth", () => { + it("mints a repository build token for that repository alone", async () => { + const plan = await new ImageBuildPlanner(createTestEnv(), db).planBuild( + planRequest(REPO_SCOPE, REPO_TARGET) + ); + + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [12], + }); + expect(plan.cloneAuth).toEqual({ type: "credential_helper", token: "clone-token" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("resolves NULL environment member ids from the cached catalog", async () => { + mockEnvironment( + null, + ENV_REPOSITORIES.map((row) => ({ ...row, repo_id: null })) + ); + const env = createTestEnv(); + + const plan = await new ImageBuildPlanner(env, db).planBuild(planRequest(ENV_SCOPE, ENV_TARGET)); + + expect(readCachedInstallationRepositories).toHaveBeenCalledExactlyOnceWith(env); + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [12, 30], + }); + expect(plan.cloneAuth.type).toBe("credential_helper"); + }); + + it("does not mint a token for changed environment membership", async () => { + mockEnvironment(null, [{ ...ENV_REPOSITORIES[0], repo_name: "sibling", repo_id: null }]); + + const plan = await new ImageBuildPlanner(createTestEnv(), db).planBuild( + planRequest(ENV_SCOPE, ENV_TARGET) + ); + + expect(plan.cloneAuth).toEqual({ type: "unavailable" }); + expect(scmProvider.generateCredentialHelperAuth).not.toHaveBeenCalled(); + }); + + it("does not retry a scoped credential failure with broader auth", async () => { + scmProvider.generateCredentialHelperAuth.mockRejectedValueOnce(new Error("Token scope denied")); + + const plan = await new ImageBuildPlanner(createTestEnv(), db).planBuild( + planRequest(REPO_SCOPE, REPO_TARGET) + ); + + expect(plan.cloneAuth).toEqual({ type: "unavailable" }); + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [12], + }); + }); +}); diff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts index e170e9eedc..c82163c22b 100644 --- a/packages/control-plane/src/image-builds/planner.ts +++ b/packages/control-plane/src/image-builds/planner.ts @@ -1,5 +1,6 @@ import { resolveBuildTimeoutSeconds } from "@open-inspect/shared/types/integrations"; import { createLogger, type CorrelationContext } from "../logger"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { createSourceControlProviderFromEnv } from "../source-control"; import { prepareLegacyManagedProviderEnv } from "../sandbox/managed-provider-env"; import type { Env } from "../types"; @@ -9,6 +10,7 @@ import { hashImageBuildCallbackToken, IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS, } from "./callback-auth"; +import { resolveImageBuildTokenScope } from "./credential-scope"; import type { ImageBuildScope } from "./model"; import { loadScopeBuildSecrets, @@ -52,7 +54,7 @@ export interface ImageBuildPlannerPort { * Resolves a trigger request into a concrete provider build plan. * * The planner is the only image-build layer that loads secrets, and it leans - * on scope.ts for everything kind-specific. Split deliberately: resolveTarget + * on scope.ts for targets, settings and secrets. Split deliberately: resolveTarget * and createCallbackAuth run BEFORE the build row is registered (cheap D1 * read + pure crypto), while planBuild — which decrypts secrets — runs AFTER, * so a concurrent secret change always sees a row to supersede and the @@ -87,7 +89,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort { const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([ resolveScopeSandboxSettings(this.db, params.scope, primary), loadScopeBuildSecrets(this.env, this.db, params.scope, params.target), - this.resolveCloneAuth(params.scope), + this.resolveCloneAuth(params.scope, params.target), ]); const basePlan = { @@ -118,10 +120,16 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort { }; } - private async resolveCloneAuth(scope: ImageBuildScope): Promise { + private async resolveCloneAuth( + scope: ImageBuildScope, + target: ResolvedImageBuildTarget + ): Promise { try { + const tokenScope = await resolveImageBuildTokenScope(this.db, scope, target, () => + readCachedInstallationRepositories(this.env) + ); const provider = createSourceControlProviderFromEnv(this.env); - const auth = await provider.generateCredentialHelperAuth(); + const auth = await provider.generateCredentialHelperAuth(tokenScope); return { type: "credential_helper", token: auth.password }; } catch (e) { logger.warn("image_build.clone_token_failed", { diff --git a/packages/control-plane/src/image-builds/scheduler.test.ts b/packages/control-plane/src/image-builds/scheduler.test.ts index bb8029810a..14a557306f 100644 --- a/packages/control-plane/src/image-builds/scheduler.test.ts +++ b/packages/control-plane/src/image-builds/scheduler.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EnvironmentStore, type EnvironmentRepositoryRow } from "../db/environments"; import type { ImageBuildStore } from "../db/image-builds"; import type { SqlDatabase } from "../db/sql-database"; import type { Job } from "../jobs"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import type * as ReposCacheModule from "../repos/cache"; import { createTestEnv } from "../router.test-support"; import type { SourceControlProvider } from "../source-control"; import type { Env } from "../types"; @@ -12,6 +15,64 @@ import type { ResolvedImageBuildTarget } from "./scope"; import { COMPATIBLE_RUNTIME_VERSION } from "./test-helpers"; import type { ImageBuildWorkflow } from "./workflow"; +vi.mock("../repos/cache", async (importOriginal) => ({ + ...(await importOriginal()), + readCachedInstallationRepositories: vi.fn(), +})); + +const ENV_TARGET: ResolvedImageBuildTarget = { + kind: "environment", + repositories: [ + { repoOwner: "acme", repoName: "web", baseBranch: "main" }, + { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, + ], + repositoriesFingerprint: "fp-env", +}; +const ENV_REPOSITORIES: EnvironmentRepositoryRow[] = ENV_TARGET.repositories.map( + (repository, position) => ({ + environment_id: "env_1", + position, + repo_owner: repository.repoOwner, + repo_name: repository.repoName, + repo_id: null, + base_branch: repository.baseBranch, + }) +); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(EnvironmentStore.prototype, "getById").mockResolvedValue({ + id: "env_1", + name: "Environment", + description: null, + prebuild_enabled: 1, + channel_associations: null, + owner_team_id: null, + created_at: 1, + updated_at: 1, + }); + vi.spyOn(EnvironmentStore.prototype, "getRepositoriesForEnvironment").mockResolvedValue( + ENV_REPOSITORIES + ); + vi.mocked(readCachedInstallationRepositories).mockResolvedValue( + [ + { id: 1, name: "web" }, + { id: 2, name: "api" }, + { id: 99, name: "sibling" }, + ].map((repository) => ({ + ...repository, + owner: "acme", + fullName: `acme/${repository.name}`, + description: null, + private: true, + defaultBranch: "main", + archived: false, + })) + ); +}); + +afterEach(() => vi.restoreAllMocks()); + function harness( options: { provider?: "modal" | "daytona" | null; @@ -141,6 +202,31 @@ function harness( }; } +function mockReadyImages({ store, resolveTarget }: ReturnType): void { + store.getReconciliationStatus.mockImplementation(async (scope: ImageBuildScope) => { + const target = await resolveTarget({} as Env, {} as SqlDatabase, scope); + return [ + { + id: `build-${scope.id}`, + scopeKind: scope.kind, + scopeId: scope.id, + provider: "modal", + status: "ready", + repositoriesFingerprint: target.repositoriesFingerprint, + repositoryShas: target.repositories.map((repository) => ({ + repoOwner: repository.repoOwner, + repoName: repository.repoName, + baseSha: "abc123", + })), + runtimeVersion: COMPATIBLE_RUNTIME_VERSION, + buildDurationSeconds: 1, + errorMessage: null, + createdAt: 1, + }, + ]; + }); +} + describe("ImageBuildScheduler", () => { it("contains cleanup failures and still dispatches rebuilds", async () => { const { scheduler, store, adapter, workflow, resolveTarget } = harness(); @@ -218,9 +304,10 @@ describe("ImageBuildScheduler", () => { it("checks every enabled scope in one full scan", async () => { const getBranchHead = vi.fn(async () => "abc123"); - const { scheduler, store, resolveTarget, listScopes } = harness({ + const h = harness({ sourceControl: { getBranchHead } as unknown as SourceControlProvider, }); + const { scheduler, resolveTarget, listScopes } = h; listScopes.mockResolvedValue( Array.from({ length: 41 }, (_, index) => ({ kind: "repo" as const, @@ -243,28 +330,7 @@ describe("ImageBuildScheduler", () => { }; } ); - store.getReconciliationStatus.mockImplementation(async (scope: ImageBuildScope) => { - const target = await resolveTarget({} as Env, {} as SqlDatabase, scope); - return [ - { - id: `build-${scope.id}`, - scopeKind: scope.kind, - scopeId: scope.id, - provider: "modal", - status: "ready", - repositoriesFingerprint: target.repositoriesFingerprint, - repositoryShas: target.repositories.map((repository) => ({ - repoOwner: repository.repoOwner, - repoName: repository.repoName, - baseSha: "abc123", - })), - runtimeVersion: COMPATIBLE_RUNTIME_VERSION, - buildDurationSeconds: 1, - errorMessage: null, - createdAt: 1, - }, - ]; - }); + mockReadyImages(h); const stats = await scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); @@ -272,6 +338,68 @@ describe("ImageBuildScheduler", () => { expect(stats.branchLookups).toBe(41); }); + it("scopes every environment branch read to the resolved member ids", async () => { + const getBranchHead = vi.fn(async () => "abc123"); + const env = createTestEnv(); + const h = harness({ + env, + sourceControl: { getBranchHead } as unknown as SourceControlProvider, + }); + h.listScopes.mockResolvedValue([{ kind: "environment", id: "env_1" }]); + h.resolveTarget.mockResolvedValue(ENV_TARGET); + mockReadyImages(h); + + const stats = await h.scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); + + const expectedScope = { kind: "repositories", repositoryIds: [1, 2] }; + expect(readCachedInstallationRepositories).toHaveBeenCalledExactlyOnceWith(env); + expect(getBranchHead).toHaveBeenCalledWith( + { owner: "acme", name: "web", branch: "main" }, + expectedScope + ); + expect(getBranchHead).toHaveBeenCalledWith( + { owner: "acme", name: "api", branch: "develop" }, + expectedScope + ); + expect(stats.branchMatched).toBe(2); + }); + + it("skips branch reads and rebuilds after environment membership changes", async () => { + vi.mocked(EnvironmentStore.prototype.getRepositoriesForEnvironment).mockResolvedValue([ + ENV_REPOSITORIES[0], + { ...ENV_REPOSITORIES[1], repo_name: "sibling" }, + ]); + const getBranchHead = vi.fn(async () => "abc123"); + const h = harness({ sourceControl: { getBranchHead } as unknown as SourceControlProvider }); + h.listScopes.mockResolvedValue([{ kind: "environment", id: "env_1" }]); + h.resolveTarget.mockResolvedValue(ENV_TARGET); + mockReadyImages(h); + + const stats = await h.scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); + + expect(stats.scopesScanned).toBe(1); + expect(stats.branchLookups).toBe(0); + expect(getBranchHead).not.toHaveBeenCalled(); + expect(h.workflow.triggerBuildWithTarget).not.toHaveBeenCalled(); + }); + + it("does not broaden repository scope when the provider refuses branch auth", async () => { + const getBranchHead = vi.fn(async () => { + throw new Error("Token scope denied"); + }); + const h = harness({ sourceControl: { getBranchHead } as unknown as SourceControlProvider }); + mockReadyImages(h); + + const stats = await h.scheduler.run({ request_id: "cron-1", trace_id: "cron-1" }); + + expect(getBranchHead).toHaveBeenCalledExactlyOnceWith( + { owner: "acme", name: "web", branch: "main" }, + { kind: "repositories", repositoryIds: [1] } + ); + expect(stats.branchUnknown).toBe(1); + expect(h.workflow.triggerBuildWithTarget).not.toHaveBeenCalled(); + }); + it("starts every required build found by the full scan", async () => { const { scheduler, listScopes, workflow } = harness(); listScopes.mockResolvedValue( diff --git a/packages/control-plane/src/image-builds/scheduler.ts b/packages/control-plane/src/image-builds/scheduler.ts index 8bc6908fb6..b3e0dea610 100644 --- a/packages/control-plane/src/image-builds/scheduler.ts +++ b/packages/control-plane/src/image-builds/scheduler.ts @@ -1,9 +1,11 @@ import { ImageBuildStore } from "../db/image-builds"; import { createLogger, type CorrelationContext } from "../logger"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { createSourceControlProviderFromEnv, type SourceControlProvider } from "../source-control"; import { errorMessage } from "./errors"; import { imageBuildFinalizationJob } from "./finalization-job"; import type { ImageBuildProvider } from "./model"; +import { resolveImageBuildTokenScope } from "./credential-scope"; import { createImageBuildAdapterFactory, type ImageBuildAdapterFactory } from "./provider-factory"; import { DEFAULT_ARTIFACT_CLEANUP_MAX_AGE_MS, DEFAULT_STALE_BUILD_MAX_AGE_MS } from "./maintenance"; import { evaluateImageBuildRebuildPolicy } from "./rebuild-policy"; @@ -254,15 +256,21 @@ export class ImageBuildScheduler { let rebuild = decision.type === "rebuild"; if (decision.type === "check_branches") { + const tokenScope = await resolveImageBuildTokenScope(this.db, scope, target, () => + readCachedInstallationRepositories(this.env) + ); const heads: Array = []; for (const repository of target.repositories) { stats.branchLookups += 1; try { - const head = await sourceControl.getBranchHead({ - owner: repository.repoOwner, - name: repository.repoName, - branch: repository.baseBranch, - }); + const head = await sourceControl.getBranchHead( + { + owner: repository.repoOwner, + name: repository.repoName, + branch: repository.baseBranch, + }, + tokenScope + ); heads.push(head); if (head === null) { stats.branchMissing += 1; diff --git a/packages/control-plane/src/repos/cache.test.ts b/packages/control-plane/src/repos/cache.test.ts new file mode 100644 index 0000000000..ca7c94425b --- /dev/null +++ b/packages/control-plane/src/repos/cache.test.ts @@ -0,0 +1,165 @@ +import type { EnrichedRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SourceControlProviderError } from "../source-control/errors"; +import { + REPOS_CACHE_KEY, + readCachedInstallationRepositories, + reposCacheIdentity, + type CachedReposList, +} from "./cache"; + +type CacheEnv = Parameters[0]; + +const cacheStore = { get: vi.fn(), put: vi.fn(), delete: vi.fn() }; +const mockFetch = vi.fn(); +const repository: EnrichedRepository = { + id: 42, + owner: "acme", + name: "widgets", + fullName: "acme/widgets", + description: null, + private: true, + defaultBranch: "main", + archived: false, + language: "TypeScript", + topics: ["widgets"], + metadata: { aliases: ["widget-service"] }, +}; + +function createEnv(overrides: Partial = {}): CacheEnv { + return { + REPOS_CACHE: cacheStore, + SCM_PROVIDER: "github", + GITHUB_APP_INSTALLATION_ID: "installation-1", + ...overrides, + }; +} + +async function cachedCatalog(env: CacheEnv): Promise { + return { + repos: [repository], + cachedAt: "2026-01-01T00:00:00.000Z", + scmIdentity: await reposCacheIdentity(env), + freshUntil: 4_102_444_800_000, + }; +} + +describe("readCachedInstallationRepositories", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockFetch.mockRejectedValue(new Error("Unexpected network request")); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + expect(cacheStore.put).not.toHaveBeenCalled(); + expect(cacheStore.delete).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns the validated enriched catalog using only the existing cache key", async () => { + const env = createEnv(); + cacheStore.get.mockResolvedValue(await cachedCatalog(env)); + + await expect(readCachedInstallationRepositories(env)).resolves.toEqual([repository]); + + expect(cacheStore.get).toHaveBeenCalledExactlyOnceWith(REPOS_CACHE_KEY, "json"); + }); + + it.each([0, undefined])("reuses entries with freshUntil %s", async (freshUntil) => { + const env = createEnv(); + const cached = await cachedCatalog(env); + if (freshUntil === undefined) delete cached.freshUntil; + else cached.freshUntil = freshUntil; + cacheStore.get.mockResolvedValue(cached); + + await expect(readCachedInstallationRepositories(env)).resolves.toEqual([repository]); + }); + + it("retains nested GitLab owners and repository IDs", async () => { + const env = createEnv({ + SCM_PROVIDER: "gitlab", + GITLAB_NAMESPACE: "acme/platform/services", + GITLAB_ACCESS_TOKEN: "token-1", + }); + const nestedRepository = { + ...repository, + owner: "acme/platform/services", + fullName: "acme/platform/services/widgets", + }; + cacheStore.get.mockResolvedValue({ + ...(await cachedCatalog(env)), + repos: [nestedRepository], + }); + + await expect(readCachedInstallationRepositories(env)).resolves.toEqual([nestedRepository]); + }); + + it.each([ + { + name: "a different GitHub installation", + cachedConfig: {}, + requestedConfig: { GITHUB_APP_INSTALLATION_ID: "installation-2" }, + }, + { + name: "a different provider", + cachedConfig: {}, + requestedConfig: { SCM_PROVIDER: "gitlab" }, + }, + { + name: "a rotated GitLab token", + cachedConfig: { + SCM_PROVIDER: "gitlab", + GITLAB_NAMESPACE: "acme/platform", + GITLAB_ACCESS_TOKEN: "token-1", + }, + requestedConfig: { GITLAB_ACCESS_TOKEN: "token-2" }, + }, + ])("fails closed for $name", async ({ cachedConfig, requestedConfig }) => { + cacheStore.get.mockResolvedValue(await cachedCatalog(createEnv(cachedConfig))); + + await expect( + readCachedInstallationRepositories(createEnv({ ...cachedConfig, ...requestedConfig })) + ).rejects.toMatchObject({ + errorType: "permanent", + message: "Installation repository catalog cache does not match SCM configuration", + }); + }); + + it.each([ + { name: "a missing entry", entry: () => null }, + { + name: "an invalid repository", + entry: (valid: CachedReposList) => ({ ...valid, repos: [{ ...repository, id: "42" }] }), + }, + { + name: "a missing SCM identity", + entry: (valid: CachedReposList) => ({ ...valid, scmIdentity: undefined }), + }, + ])("fails closed for $name", async ({ entry }) => { + const env = createEnv(); + cacheStore.get.mockResolvedValue(entry(await cachedCatalog(env))); + + const read = readCachedInstallationRepositories(env); + + await expect(read).rejects.toBeInstanceOf(SourceControlProviderError); + await expect(read).rejects.toMatchObject({ + errorType: "permanent", + message: "Installation repository catalog cache is missing or malformed", + }); + }); + + it.each([new SourceControlProviderError("KV timeout", "transient"), "non-Error store failure"])( + "wraps store read failures as permanent errors (%s)", + async (cause) => { + cacheStore.get.mockRejectedValue(cause); + + await expect(readCachedInstallationRepositories(createEnv())).rejects.toMatchObject({ + errorType: "permanent", + message: "Failed to read installation repository catalog cache", + cause: cause instanceof Error ? cause : undefined, + }); + } + ); +}); diff --git a/packages/control-plane/src/repos/cache.ts b/packages/control-plane/src/repos/cache.ts new file mode 100644 index 0000000000..740bd206a7 --- /dev/null +++ b/packages/control-plane/src/repos/cache.ts @@ -0,0 +1,80 @@ +import { sha256Hex } from "@open-inspect/shared/service-auth"; +import { + enrichedRepositorySchema, + type InstallationRepository, +} from "@open-inspect/shared/types/repository-catalog"; +import { z } from "zod"; +import { resolveScmProviderFromEnv } from "../source-control/config"; +import { SourceControlProviderError } from "../source-control/errors"; +import type { Env } from "../types"; + +export const REPOS_CACHE_KEY = "repos:list:v3"; + +export async function reposCacheIdentity( + env: Pick< + Env, + "SCM_PROVIDER" | "GITHUB_APP_INSTALLATION_ID" | "GITLAB_NAMESPACE" | "GITLAB_ACCESS_TOKEN" + > +): Promise { + const provider = resolveScmProviderFromEnv(env.SCM_PROVIDER); + let identity: string[] = [provider]; + if (provider === "github") identity = [provider, env.GITHUB_APP_INSTALLATION_ID ?? ""]; + if (provider === "gitlab") { + identity = [provider, env.GITLAB_NAMESPACE ?? "", env.GITLAB_ACCESS_TOKEN ?? ""]; + } + return await sha256Hex(JSON.stringify(identity)); +} + +export const cachedReposListSchema = z.object({ + repos: z.array(enrichedRepositorySchema), + cachedAt: z.string(), + scmIdentity: z.string(), + // Missing in entries cached before this field was added. + freshUntil: z.number().optional(), +}); + +export type CachedReposList = z.infer; + +/** + * Read the installation catalog without fetching, minting credentials, or writing to cache. + * Stale entries are usable while present in KV because repository IDs are stable. + */ +export async function readCachedInstallationRepositories( + env: Pick< + Env, + | "REPOS_CACHE" + | "SCM_PROVIDER" + | "GITHUB_APP_INSTALLATION_ID" + | "GITLAB_NAMESPACE" + | "GITLAB_ACCESS_TOKEN" + > +): Promise { + const scmIdentity = await reposCacheIdentity(env); + let raw: unknown; + try { + raw = await env.REPOS_CACHE.get(REPOS_CACHE_KEY, "json"); + } catch (e) { + throw new SourceControlProviderError( + "Failed to read installation repository catalog cache", + "permanent", + undefined, + e instanceof Error ? e : undefined + ); + } + + const cached = cachedReposListSchema.safeParse(raw); + if (!cached.success) { + throw new SourceControlProviderError( + "Installation repository catalog cache is missing or malformed", + "permanent" + ); + } + if (cached.data.scmIdentity !== scmIdentity) { + throw new SourceControlProviderError( + "Installation repository catalog cache does not match SCM configuration", + "permanent" + ); + } + + return cached.data.repos; +} diff --git a/packages/control-plane/src/routes/image-builds.trigger.test.ts b/packages/control-plane/src/routes/image-builds.trigger.test.ts index 878bb68fc2..c54cf4ab52 100644 --- a/packages/control-plane/src/routes/image-builds.trigger.test.ts +++ b/packages/control-plane/src/routes/image-builds.trigger.test.ts @@ -274,7 +274,10 @@ describe("POST /image-builds/trigger/repo/:owner/:name", () => { "modal-session-1" ); expect(modalClient.startImageBuildSandbox).toHaveBeenCalledTimes(1); - expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled(); + expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalledExactlyOnceWith({ + kind: "repositories", + repositoryIds: [123], + }); // ...and is baked into the persisted fingerprint. expect(registerBuildSpy).toHaveBeenCalledWith( diff --git a/packages/control-plane/src/routes/repos.ts b/packages/control-plane/src/routes/repos.ts index 6ada5a252f..9360da6625 100644 --- a/packages/control-plane/src/routes/repos.ts +++ b/packages/control-plane/src/routes/repos.ts @@ -14,15 +14,19 @@ import { auditRouteAuthorizationDecision } from "../authorization/request-audit" import type { Env } from "../types"; import type { SqlDatabase } from "../db/sql-database"; import { - enrichedRepositorySchema, repoMetadataSchema, type EnrichedRepository, type InstallationRepository, type RepoMetadata, } from "@open-inspect/shared/types/repository-catalog"; -import { resolveScmProviderFromEnv, SourceControlProviderError } from "../source-control"; +import { + REPOS_CACHE_KEY, + cachedReposListSchema, + reposCacheIdentity, + type CachedReposList, +} from "../repos/cache"; +import { SourceControlProviderError } from "../source-control"; import { createLogger } from "../logger"; -import { z } from "zod"; import { GITHUB_USER_OR_SERVICE_ROUTE, type RequestContext, @@ -32,40 +36,13 @@ import { requirePermission, } from "./shared"; +export { REPOS_CACHE_KEY, reposCacheIdentity } from "../repos/cache"; + const logger = createLogger("router:repos"); -export const REPOS_CACHE_KEY = "repos:list:v3"; const REPOS_CACHE_FRESH_MS = 5 * 60 * 1000; const REPOS_CACHE_KV_TTL_SECONDS = 3600; -export async function reposCacheIdentity( - env: Pick< - Env, - "SCM_PROVIDER" | "GITHUB_APP_INSTALLATION_ID" | "GITLAB_NAMESPACE" | "GITLAB_ACCESS_TOKEN" - > -): Promise { - const provider = resolveScmProviderFromEnv(env.SCM_PROVIDER); - let identity: string[] = [provider]; - if (provider === "github") identity = [provider, env.GITHUB_APP_INSTALLATION_ID ?? ""]; - if (provider === "gitlab") { - identity = [provider, env.GITLAB_NAMESPACE ?? "", env.GITLAB_ACCESS_TOKEN ?? ""]; - } - const digest = new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(identity))) - ); - return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -const cachedReposListSchema = z.object({ - repos: z.array(enrichedRepositorySchema), - cachedAt: z.string(), - scmIdentity: z.string(), - // Missing in entries cached before this field was added. - freshUntil: z.number().optional(), -}); - -type CachedReposList = z.infer; - type ReposRefreshResult = | { ok: true; repos: EnrichedRepository[]; cachedAt: string } | { ok: false; reason: "not_configured" | "fetch_failed" }; diff --git a/packages/control-plane/src/session/components.credentials.test.ts b/packages/control-plane/src/session/components.credentials.test.ts new file mode 100644 index 0000000000..76895296a9 --- /dev/null +++ b/packages/control-plane/src/session/components.credentials.test.ts @@ -0,0 +1,126 @@ +import { DatabaseSync } from "node:sqlite"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import { createNodeSqlDatabase } from "../node/sqlite-database"; +import { createNodeSqlStorage } from "../node/sqlite-storage"; +import { readCachedInstallationRepositories } from "../repos/cache"; +import type { SourceControlProvider } from "../source-control"; +import type { Env } from "../types"; +import { createSessionRuntime } from "./components"; +import { buildSessionInternalRequest, SessionInternalPaths } from "./contracts"; +import { initSchema } from "./schema"; + +vi.mock("../repos/cache", () => ({ readCachedInstallationRepositories: vi.fn() })); + +describe("session credential scope composition", () => { + let sqlite: DatabaseSync; + + beforeEach(() => { + sqlite = new DatabaseSync(":memory:"); + vi.mocked(readCachedInstallationRepositories).mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + function createHarness() { + const storage = createNodeSqlStorage(sqlite); + initSchema(storage.sql); + storage.sql.exec( + `INSERT INTO session (id, session_name, repo_owner, repo_name, created_at, updated_at) + VALUES ('internal-session', 'public-session', 'acme', 'web', 1, 1)` + ); + const db = createNodeSqlDatabase(sqlite); + const env = { + REPO_SECRETS_ENCRYPTION_KEY: btoa("0".repeat(32)), + MODAL_API_SECRET: "modal-secret", + MODAL_WORKSPACE: "test-workspace", + LOG_LEVEL: "error", + } as Env; + const runtime = createSessionRuntime( + { + id: "durable-object-id", + storage, + db, + alarmStore: { + getAlarm: async () => null, + setAlarm: async () => {}, + deleteAlarm: async () => {}, + }, + sockets: { + adopt: () => {}, + tags: () => [], + sockets: () => [], + setAutoResponse: () => {}, + }, + createBackgroundTasks: () => ({ submit: () => {} }), + }, + env + ); + const generateCredentialHelperAuth = vi.fn(async () => ({ + username: "x-access-token", + password: "scoped-token", + expiresAtEpochMs: Date.now() + 60_000, + })); + runtime.internals.sourceControlProvider = { + name: "github", + generateCredentialHelperAuth, + } as unknown as SourceControlProvider; + return { + env, + generateCredentialHelperAuth, + getCredentials: () => + runtime.server.onRequest( + buildSessionInternalRequest(SessionInternalPaths.scmCredentials, { method: "POST" }) + ), + }; + } + + it("mints credentials for the scope resolved from D1 for the public session id", async () => { + const getSession = vi + .spyOn(SessionIndexStore.prototype, "get") + .mockResolvedValue({ id: "public-session", ownerTeamId: null } as SessionEntry); + vi.spyOn(SessionRepositoryStore.prototype, "listRepositoryIds").mockResolvedValue([ + { repoOwner: "acme", repoName: "web", repoId: null }, + { repoOwner: "acme", repoName: "api", repoId: 456 }, + ]); + vi.mocked(readCachedInstallationRepositories).mockResolvedValue([ + { + id: 123, + owner: "acme", + name: "web", + fullName: "acme/web", + description: null, + private: true, + defaultBranch: "main", + archived: false, + }, + ]); + const h = createHarness(); + + expect((await h.getCredentials()).status).toBe(200); + + expect(getSession).toHaveBeenCalledWith("public-session"); + expect(readCachedInstallationRepositories).toHaveBeenCalledWith(h.env); + expect(h.generateCredentialHelperAuth).toHaveBeenCalledWith({ + kind: "repositories", + repositoryIds: [123, 456], + }); + }); + + it("fails closed when the D1 session is missing despite an existing local session", async () => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(null); + const h = createHarness(); + + const response = await h.getCredentials(); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "Cannot resolve credential scope: session not found", + }); + expect(h.generateCredentialHelperAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/session/components.ts b/packages/control-plane/src/session/components.ts index 98d940bfe4..b1e71cd32e 100644 --- a/packages/control-plane/src/session/components.ts +++ b/packages/control-plane/src/session/components.ts @@ -59,6 +59,8 @@ import { SessionCollaboratorStore } from "../db/session-collaborators"; import { parsePersistedSandboxSettings } from "../sandbox/settings"; import type { SandboxSettings } from "@open-inspect/shared/types/integrations"; import { createSourceControlProviderFromEnv, type SourceControlProvider } from "../source-control"; +import { resolveSessionCredentialScope } from "../source-control/session-scope"; +import { readCachedInstallationRepositories } from "../repos/cache"; import { requireRepoSecretsEncryptionKey } from "../env-validation"; import type { Env, ClientInfo } from "../types"; import type { SessionRow } from "./types"; @@ -323,6 +325,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 resolveCredentialScope = (sessionId: string) => + resolveSessionCredentialScope(db, sessionId, () => readCachedInstallationRepositories(env)); const teamMembershipStore = new TeamMembershipStore(db); const sessionCollaboratorStore = new SessionCollaboratorStore(db); const sessionPullRequestStore = new SessionPullRequestStore(db); @@ -660,7 +664,8 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi sessionCoreRepository, artifactRepository, sourceControlProvider(), - sessionPullRequestStore + sessionPullRequestStore, + resolveCredentialScope ).then(({ updated, failures }) => { for (const artifact of updated) { messenger.broadcast({ type: "artifact_updated", artifact }); @@ -725,7 +730,9 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi return service.refresh(sessionRow); }; const getScmCredentials = (requestLog: Logger) => - new ScmCredentialsService(sourceControlProvider(), requestLog).getCredentials(); + new ScmCredentialsService(sourceControlProvider(), requestLog, () => + resolveCredentialScope(getPublicSessionId()) + ).getCredentials(); const sandboxHandler = new SandboxHandler( messageRepository, @@ -791,6 +798,7 @@ export function createSessionRuntime(platform: SessionPlatform, env: Env): Sessi artifactRepository, claims: prCreationClaims, sourceControlProvider: sourceControlProvider(), + resolveCredentialScope, log: requestLog, generateId: () => generateId(), pushBranchToRemote: (pushSpec) => pushService.pushBranchToRemote(pushSpec), diff --git a/packages/control-plane/src/session/pull-request-refresh.test.ts b/packages/control-plane/src/session/pull-request-refresh.test.ts index ac5e4f40ff..3baab3b0dc 100644 --- a/packages/control-plane/src/session/pull-request-refresh.test.ts +++ b/packages/control-plane/src/session/pull-request-refresh.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PullRequestSnapshot } from "../source-control"; +import { + SourceControlProviderError, + type CredentialScope, + type PullRequestSnapshot, +} from "../source-control"; import { refreshSessionPullRequests } from "./pull-request-refresh"; import type { ArtifactRepository } from "./artifact-repository"; import type { ArtifactRow, SessionRow } from "./types"; @@ -95,6 +99,8 @@ function createHarness(artifacts: ArtifactRow[], session: SessionRow | null = cr } as unknown as ArtifactRepository; const getPullRequest = vi.fn(async () => createSnapshot()); const upsert = vi.fn(async () => ({ applied: true })); + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [123, 9001] }; + const resolveCredentialScope = vi.fn(async () => credentialScope); return { repository, @@ -102,8 +108,16 @@ function createHarness(artifacts: ArtifactRow[], session: SessionRow | null = cr rows, getPullRequest, upsert, + credentialScope, + resolveCredentialScope, refresh: () => - refreshSessionPullRequests(repository, artifactRepository, { getPullRequest }, { upsert }), + refreshSessionPullRequests( + repository, + artifactRepository, + { getPullRequest }, + { upsert }, + resolveCredentialScope + ), }; } @@ -129,12 +143,11 @@ describe("refreshSessionPullRequests", () => { type: "pr", metadata: expect.objectContaining({ lifecycleState: "merged" }), }); - expect(harness.getPullRequest).toHaveBeenCalledWith({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: "9001", - }); + expect(harness.resolveCredentialScope).toHaveBeenCalledWith("public-session-1"); + expect(harness.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + harness.credentialScope + ); expect(harness.upsert).toHaveBeenCalledWith({ artifactId: "artifact-1", sessionId: "public-session-1", @@ -199,12 +212,10 @@ describe("refreshSessionPullRequests", () => { await harness.refresh(); - expect(harness.getPullRequest).toHaveBeenCalledWith({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: undefined, - }); + expect(harness.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "web", number: 7, repositoryExternalId: undefined }, + harness.credentialScope + ); }); it("reports artifacts whose metadata carries no PR number as not refreshable", async () => { @@ -259,6 +270,56 @@ describe("refreshSessionPullRequests", () => { expect(harness.artifactRepository.updateArtifact).toHaveBeenCalledTimes(1); }); + it("reports scope resolution failure without reading the provider", async () => { + const harness = createHarness([createPrArtifact()]); + const error = new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + harness.resolveCredentialScope.mockRejectedValue(error); + + const result = await harness.refresh(); + + expect(result).toEqual({ + updated: [], + failures: [ + { + artifactId: "artifact-1", + reason: "provider_read_failed", + prNumber: 7, + repoOwner: "acme", + repoName: "web", + error, + }, + ], + }); + expect(harness.getPullRequest).not.toHaveBeenCalled(); + }); + + it("resolves scope freshly before each artifact read", async () => { + const harness = createHarness([ + createPrArtifact(), + createPrArtifact({ id: "artifact-2", metadata: JSON.stringify({ number: 8 }) }), + ]); + const nextScope: CredentialScope = { kind: "repositories", repositoryIds: [456] }; + harness.resolveCredentialScope + .mockResolvedValueOnce(harness.credentialScope) + .mockResolvedValueOnce(nextScope); + + await harness.refresh(); + + expect(harness.getPullRequest).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ number: 7 }), + harness.credentialScope + ); + expect(harness.getPullRequest).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ number: 8 }), + nextScope + ); + }); + it("does not regress a mirror that a webhook push advanced during the pass's awaits", async () => { const harness = createHarness([createPrArtifact()]); // Simulate a webhook snapshot push interleaving with this pass: while diff --git a/packages/control-plane/src/session/pull-request-refresh.ts b/packages/control-plane/src/session/pull-request-refresh.ts index 1795242f67..cc70640e6c 100644 --- a/packages/control-plane/src/session/pull-request-refresh.ts +++ b/packages/control-plane/src/session/pull-request-refresh.ts @@ -12,7 +12,11 @@ import type { SessionArtifact } from "@open-inspect/shared/types/artifacts"; import type { SessionPullRequestStore } from "../db/session-pull-request-store"; -import type { PullRequestSnapshot, SourceControlProvider } from "../source-control"; +import type { + CredentialScope, + PullRequestSnapshot, + SourceControlProvider, +} from "../source-control"; import { parsePullRequestArtifactMetadata } from "./pull-request-snapshot"; import { applyPullRequestSnapshot } from "./pull-request-snapshot-apply"; import type { ArtifactRepository } from "./artifact-repository"; @@ -81,7 +85,8 @@ export async function refreshSessionPullRequests( repository: PullRequestRefreshRepository, artifactRepository: ArtifactRepository, sourceControlProvider: Pick, - sessionPullRequests: Pick + sessionPullRequests: Pick, + resolveCredentialScope: (sessionId: string) => Promise ): Promise { const updated: SessionArtifact[] = []; const failures: PullRequestRefreshFailure[] = []; @@ -106,12 +111,16 @@ export async function refreshSessionPullRequests( let snapshot: PullRequestSnapshot; try { - snapshot = await sourceControlProvider.getPullRequest({ - owner: target.repoOwner, - name: target.repoName, - number: target.prNumber, - repositoryExternalId: target.repositoryExternalId, - }); + const scope = await resolveCredentialScope(sessionId); + snapshot = await sourceControlProvider.getPullRequest( + { + owner: target.repoOwner, + name: target.repoName, + number: target.prNumber, + repositoryExternalId: target.repositoryExternalId, + }, + scope + ); } catch (error) { failures.push({ artifactId: artifact.id, diff --git a/packages/control-plane/src/session/pull-request-service.per-branch.test.ts b/packages/control-plane/src/session/pull-request-service.per-branch.test.ts index 9e8d62a3db..fd93ede224 100644 --- a/packages/control-plane/src/session/pull-request-service.per-branch.test.ts +++ b/packages/control-plane/src/session/pull-request-service.per-branch.test.ts @@ -7,7 +7,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Logger } from "../logger"; -import type { PullRequestSnapshot, SourceControlProvider } from "../source-control"; +import type { + CredentialScope, + PullRequestSnapshot, + SourceControlProvider, +} from "../source-control"; import { buildSessionRepositories } from "./repository-target"; import type { ArtifactRow, SessionRepositoryRow, SessionRow } from "./types"; import type { ArtifactRepository, CreateArtifactData } from "./artifact-repository"; @@ -210,6 +214,7 @@ function createTestHarness() { } as unknown as ArtifactRepository; const sessionPullRequests = { upsert: vi.fn(async () => ({ applied: true })) }; + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [123, 456] }; let idCounter = 0; const deps: PullRequestServiceDeps = { @@ -217,6 +222,7 @@ function createTestHarness() { artifactRepository, claims: new PullRequestCreationClaims(), sourceControlProvider: provider, + resolveCredentialScope: vi.fn(async () => credentialScope), log, generateId: () => `id-${++idCounter}`, pushBranchToRemote: vi.fn(async () => ({ success: true as const })), @@ -230,6 +236,7 @@ function createTestHarness() { service: new SessionPullRequestService(deps), deps, provider, + credentialScope, artifacts, sessionPullRequests, setSession: (next: SessionRow | null) => { @@ -287,6 +294,10 @@ describe("per-branch pull requests", () => { expect(harness.deps.pushBranchToRemote).toHaveBeenCalledWith( expect.objectContaining({ targetBranch: "feature-x", force: true }) ); + expect(harness.provider.getPullRequest).toHaveBeenCalledWith( + { owner: "acme", name: "web", number: 7, repositoryExternalId: undefined }, + harness.credentialScope + ); expect(harness.provider.createPullRequest).not.toHaveBeenCalled(); expect(harness.artifacts.filter((artifact) => artifact.type === "pr")).toHaveLength(1); }); diff --git a/packages/control-plane/src/session/pull-request-service.test.ts b/packages/control-plane/src/session/pull-request-service.test.ts index 5fbf2b8879..3ba9a05c48 100644 --- a/packages/control-plane/src/session/pull-request-service.test.ts +++ b/packages/control-plane/src/session/pull-request-service.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ScmSettings } from "@open-inspect/shared/types/integrations"; import type { Logger } from "../logger"; -import type { SourceControlProvider } from "../source-control"; +import { + SourceControlProviderError, + type CredentialScope, + type SourceControlProvider, +} from "../source-control"; import * as branchResolution from "../source-control/branch-resolution"; import type { SessionRepositoryRow } from "./types"; import { buildSessionRepositories } from "./repository-target"; @@ -197,6 +201,7 @@ function createTestHarness(options: { scmSettings?: ScmSettings } = {}) { } as unknown as ArtifactRepository; const sessionPullRequests = { upsert: vi.fn(async () => ({ applied: true })) }; + const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [123, 456] }; let idCounter = 0; const deps: PullRequestServiceDeps = { @@ -204,6 +209,7 @@ function createTestHarness(options: { scmSettings?: ScmSettings } = {}) { artifactRepository, claims: new PullRequestCreationClaims(), sourceControlProvider: provider, + resolveCredentialScope: vi.fn(async () => credentialScope), log, generateId: () => `id-${++idCounter}`, pushBranchToRemote: vi.fn(async () => ({ success: true as const })), @@ -219,6 +225,7 @@ function createTestHarness(options: { scmSettings?: ScmSettings } = {}) { service, deps, provider, + credentialScope, artifacts, sessionPullRequests, log, @@ -311,6 +318,8 @@ describe("SessionPullRequestService", () => { const createPrCall = (harness.provider.createPullRequest as ReturnType).mock .calls[0]; expect(createPrCall[0]).toEqual({ authType: "app", token: "app-token" }); + expect(harness.deps.resolveCredentialScope).toHaveBeenCalledWith("session-name-1"); + expect(harness.provider.generatePushAuth).toHaveBeenCalledWith(harness.credentialScope); expect(artifactCreatedBroadcasts(harness.deps)).toHaveLength(1); expect(harness.deps.repository.updateSessionBranch).toHaveBeenCalledWith( "session-1", @@ -324,6 +333,25 @@ describe("SessionPullRequestService", () => { }); }); + it("fails push auth without minting a token when the credential scope cannot be resolved", async () => { + vi.mocked(harness.deps.resolveCredentialScope).mockRejectedValueOnce( + new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ) + ); + + const result = await harness.service.createPullRequest(createInput()); + + expect(result).toEqual({ + kind: "error", + status: 500, + error: "Cannot resolve credential scope: session not found", + }); + expect(harness.provider.generatePushAuth).not.toHaveBeenCalled(); + expect(harness.deps.pushBranchToRemote).not.toHaveBeenCalled(); + }); + it("uses the sanitized branch for push, PR creation, and branch sync", async () => { const result = await harness.service.createPullRequest( createInput({ headBranch: " Feature/Test " }) diff --git a/packages/control-plane/src/session/pull-request-service.ts b/packages/control-plane/src/session/pull-request-service.ts index 8b690ecf1b..c57749fd95 100644 --- a/packages/control-plane/src/session/pull-request-service.ts +++ b/packages/control-plane/src/session/pull-request-service.ts @@ -16,6 +16,7 @@ import { SourceControlProviderError, type SourceControlProvider, type SourceControlAuthContext, + type CredentialScope, type GitPushAuthContext, type GitPushSpec, type PullRequestSnapshot, @@ -153,6 +154,7 @@ export interface PullRequestServiceDeps { /** DO-instance-scoped in-flight claims — must outlive individual requests. */ claims: PullRequestCreationClaims; sourceControlProvider: SourceControlProvider; + resolveCredentialScope: (sessionId: string) => Promise; log: Logger; generateId: () => string; pushBranchToRemote: (pushSpec: GitPushSpec) => Promise; @@ -238,8 +240,10 @@ export class SessionPullRequestService { const draft = scmSettings.alwaysUseDraftMode === true || (input.draft ?? false); let pushAuth: GitPushAuthContext; + let credentialScope: CredentialScope; try { - pushAuth = await this.deps.sourceControlProvider.generatePushAuth(); + credentialScope = await this.deps.resolveCredentialScope(sessionId); + pushAuth = await this.deps.sourceControlProvider.generatePushAuth(credentialScope); this.deps.log.info("Generated fresh push auth token"); } catch (error) { this.deps.log.error("Failed to generate push auth", { @@ -308,6 +312,7 @@ export class SessionPullRequestService { headMatches, targetRepo, sessionId, + credentialScope, { sanitizedHeadBranch, generatedHeadBranch, @@ -496,6 +501,7 @@ export class SessionPullRequestService { matches: PrArtifactHeadMatch[], targetRepo: RepoIdentity, sessionId: string, + credentialScope: CredentialScope, head: { sanitizedHeadBranch: string; generatedHeadBranch: string; @@ -533,12 +539,15 @@ export class SessionPullRequestService { // opening a duplicate is not. let live: PullRequestSnapshot | null = null; try { - live = await this.deps.sourceControlProvider.getPullRequest({ - owner: targetRepo.repoOwner, - name: targetRepo.repoName, - number: candidate.prNumber, - repositoryExternalId: candidate.repositoryExternalId ?? undefined, - }); + live = await this.deps.sourceControlProvider.getPullRequest( + { + owner: targetRepo.repoOwner, + name: targetRepo.repoName, + number: candidate.prNumber, + repositoryExternalId: candidate.repositoryExternalId ?? undefined, + }, + credentialScope + ); } catch (error) { this.deps.log.warn("Could not read live PR state; using the stored artifact's", { pr_number: candidate.prNumber, diff --git a/packages/control-plane/src/session/scm-credentials-service.test.ts b/packages/control-plane/src/session/scm-credentials-service.test.ts index 828b1186ad..39d8f82cad 100644 --- a/packages/control-plane/src/session/scm-credentials-service.test.ts +++ b/packages/control-plane/src/session/scm-credentials-service.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { Logger } from "../logger"; -import type { SourceControlProvider } from "../source-control"; +import type { CredentialScope, SourceControlProvider } from "../source-control"; import { SourceControlProviderError } from "../source-control/errors"; import { ScmCredentialsService } from "./scm-credentials-service"; +const credentialScope: CredentialScope = { kind: "repositories", repositoryIds: [99, 123] }; + function createTestLogger(): Logger { return { debug: vi.fn(), @@ -24,6 +26,14 @@ function makeProvider( } as unknown as SourceControlProvider; } +function getCredentials( + provider: SourceControlProvider, + log = createTestLogger(), + resolveCredentialScope = async () => credentialScope +) { + return new ScmCredentialsService(provider, log, resolveCredentialScope).getCredentials(); +} + describe("ScmCredentialsService", () => { it("returns credentials on success", async () => { const expiresAtEpochMs = Date.now() + 60 * 60 * 1000; @@ -36,7 +46,7 @@ describe("ScmCredentialsService", () => { }), }); - const result = await new ScmCredentialsService(provider, createTestLogger()).getCredentials(); + const result = await getCredentials(provider); expect(result).toEqual({ ok: true, @@ -44,6 +54,7 @@ describe("ScmCredentialsService", () => { password: "ghs_token", expiresAtEpochMs, }); + expect(provider.generateCredentialHelperAuth).toHaveBeenCalledWith(credentialScope); }); it("rejects invalid provider credential payloads", async () => { @@ -56,7 +67,7 @@ describe("ScmCredentialsService", () => { }), }); - const result = await new ScmCredentialsService(provider, log).getCredentials(); + const result = await getCredentials(provider, log); expect(result).toEqual({ ok: false, @@ -78,7 +89,7 @@ describe("ScmCredentialsService", () => { }), }); - const result = await new ScmCredentialsService(provider, createTestLogger()).getCredentials(); + const result = await getCredentials(provider); expect(result).toEqual({ ok: false, @@ -96,7 +107,7 @@ describe("ScmCredentialsService", () => { .mockRejectedValue(new SourceControlProviderError("App not configured", "permanent")), }); - const result = await new ScmCredentialsService(provider, log).getCredentials(); + const result = await getCredentials(provider, log); expect(result).toEqual({ ok: false, @@ -120,7 +131,7 @@ describe("ScmCredentialsService", () => { .mockRejectedValue(new SourceControlProviderError("GitHub API unavailable", "transient")), }); - const result = await new ScmCredentialsService(provider, createTestLogger()).getCredentials(); + const result = await getCredentials(provider); expect(result).toEqual({ ok: false, status: 502, error: "GitHub API unavailable" }); }); @@ -131,7 +142,7 @@ describe("ScmCredentialsService", () => { generateCredentialHelperAuth: vi.fn().mockRejectedValue(new Error("network blew up")), }); - const result = await new ScmCredentialsService(provider, log).getCredentials(); + const result = await getCredentials(provider, log); expect(result).toEqual({ ok: false, @@ -140,4 +151,22 @@ describe("ScmCredentialsService", () => { }); expect(log.error).toHaveBeenCalled(); }); + + it("maps credential scope failures without minting credentials", async () => { + const provider = makeProvider(); + + const result = await getCredentials(provider, createTestLogger(), async () => { + throw new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + }); + + expect(result).toEqual({ + ok: false, + status: 500, + error: "Cannot resolve credential scope: session not found", + }); + expect(provider.generateCredentialHelperAuth).not.toHaveBeenCalled(); + }); }); diff --git a/packages/control-plane/src/session/scm-credentials-service.ts b/packages/control-plane/src/session/scm-credentials-service.ts index 3d254fe6c9..3fd4b8fa44 100644 --- a/packages/control-plane/src/session/scm-credentials-service.ts +++ b/packages/control-plane/src/session/scm-credentials-service.ts @@ -1,4 +1,4 @@ -import type { SourceControlProvider } from "../source-control"; +import type { CredentialScope, SourceControlProvider } from "../source-control"; import { SourceControlProviderError } from "../source-control/errors"; import type { Logger } from "../logger"; @@ -22,12 +22,14 @@ export type ScmCredentialsResult = export class ScmCredentialsService { constructor( private readonly provider: SourceControlProvider, - private readonly log: Logger + private readonly log: Logger, + private readonly resolveCredentialScope: () => Promise ) {} async getCredentials(): Promise { try { - const auth = await this.provider.generateCredentialHelperAuth(); + const scope = await this.resolveCredentialScope(); + const auth = await this.provider.generateCredentialHelperAuth(scope); if ( !auth.username.trim() || !auth.password.trim() || diff --git a/packages/control-plane/src/skills/git-import.test.ts b/packages/control-plane/src/skills/git-import.test.ts index 432076039a..379b66c15a 100644 --- a/packages/control-plane/src/skills/git-import.test.ts +++ b/packages/control-plane/src/skills/git-import.test.ts @@ -102,6 +102,17 @@ const SKILL_MD = [ ].join("\n"); describe("fetchSkillImport", () => { + it("imports the workspace catalog with installation-wide content access", async () => { + const provider = fakeProvider({ "SKILL.md": { content: SKILL_MD } }); + const resolveCommit = vi.spyOn(provider, "resolveCommit"); + const listTree = vi.spyOn(provider, "listTree"); + const readBlob = vi.spyOn(provider, "readBlob"); + await fetchSkillImport(provider, source()); + for (const method of [resolveCommit, listTree, readBlob]) { + expect(method).toHaveBeenCalledWith(expect.any(Object), { kind: "all" }); + } + }); + it("reuses the repository resolution already authorized by the route", async () => { const provider = fakeProvider({ "SKILL.md": { content: SKILL_MD } }); const check = vi.spyOn(provider, "checkRepositoryAccess"); @@ -113,11 +124,14 @@ describe("fetchSkillImport", () => { defaultBranch: "develop", }); expect(check).not.toHaveBeenCalled(); - expect(resolveCommit).toHaveBeenCalledWith({ - owner: "canonical-owner", - name: "canonical-name", - ref: "develop", - }); + expect(resolveCommit).toHaveBeenCalledWith( + { + owner: "canonical-owner", + name: "canonical-name", + ref: "develop", + }, + { kind: "all" } + ); expect(result.source).toMatchObject({ repoOwner: "canonical-owner", repoName: "canonical-name", @@ -254,7 +268,8 @@ describe("fetchSkillImport", () => { }); for (const read of [resolveCommit, listTree, readBlob]) { expect(read).toHaveBeenCalledWith( - expect.objectContaining({ owner: "canonical-owner", name: "renamed-skills" }) + expect.objectContaining({ owner: "canonical-owner", name: "renamed-skills" }), + { kind: "all" } ); } }); diff --git a/packages/control-plane/src/skills/git-import.ts b/packages/control-plane/src/skills/git-import.ts index 0ecafbe219..028058e808 100644 --- a/packages/control-plane/src/skills/git-import.ts +++ b/packages/control-plane/src/skills/git-import.ts @@ -126,12 +126,15 @@ async function readBlobs( async function worker(): Promise { for (let index = next++; index < entries.length; index = next++) { const entry = entries[index]; - const bytes = await provider.readBlob({ - owner: repository.owner, - name: repository.name, - blobId: entry.blobId, - maxBytes: MAX_SKILL_FILE_BYTES, - }); + const bytes = await provider.readBlob( + { + owner: repository.owner, + name: repository.name, + blobId: entry.blobId, + maxBytes: MAX_SKILL_FILE_BYTES, + }, + { kind: "all" } + ); const path = entry.path.slice(prefix.length); // The provider refuses an oversized blob before buffering when it can // tell the size up front. GitLab's tree carries no sizes, so this is the @@ -314,7 +317,7 @@ export async function fetchSkillImport( const resolvedRef = requestedRef ?? access.defaultBranch; let commit: Awaited>; try { - commit = await provider.resolveCommit({ ...repository, ref: resolvedRef }); + commit = await provider.resolveCommit({ ...repository, ref: resolvedRef }, { kind: "all" }); } catch (error) { throw providerFailure(error, `Failed to resolve ${resolvedRef} in ${label}`); } @@ -324,11 +327,14 @@ export async function fetchSkillImport( let tree: Awaited>; try { - tree = await provider.listTree({ - ...repository, - commitSha: commit.sha, - path: source.subdirectory, - }); + tree = await provider.listTree( + { + ...repository, + commitSha: commit.sha, + path: source.subdirectory, + }, + { kind: "all" } + ); } catch (error) { throw providerFailure(error, `Failed to list ${label} at ${commit.sha}`); } diff --git a/packages/control-plane/src/source-control/credential-scope.test.ts b/packages/control-plane/src/source-control/credential-scope.test.ts new file mode 100644 index 0000000000..87edcf8e8f --- /dev/null +++ b/packages/control-plane/src/source-control/credential-scope.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { repositoryCredentialScope } from "./credential-scope"; +import { SourceControlProviderError } from "./errors"; + +describe("repositoryCredentialScope", () => { + it("sorts and de-duplicates the exact repository set", () => { + expect(repositoryCredentialScope([30, 2, 30])).toEqual({ + kind: "repositories", + repositoryIds: [2, 30], + }); + }); + + it("accepts 500 unique ids even when the input includes duplicates", () => { + const ids = Array.from({ length: 500 }, (_, i) => i + 1); + expect(repositoryCredentialScope([...ids, ...ids]).repositoryIds).toEqual(ids); + }); + + it("refuses more than 500 unique ids without truncating the set", () => { + expect(() => repositoryCredentialScope(Array.from({ length: 501 }, (_, i) => i + 1))).toThrow( + "500" + ); + }); + + it.each([[], [0], [-1], [1.5], [NaN], [Number.MAX_SAFE_INTEGER + 1]].map((ids) => ({ ids })))( + "refuses invalid or empty ids $ids with a permanent credential error", + ({ ids }) => { + try { + repositoryCredentialScope(ids); + expect.fail("Expected invalid repository scope to be refused"); + } catch (error) { + expect(error).toBeInstanceOf(SourceControlProviderError); + expect(error).toMatchObject({ errorType: "permanent" }); + } + } + ); +}); diff --git a/packages/control-plane/src/source-control/credential-scope.ts b/packages/control-plane/src/source-control/credential-scope.ts new file mode 100644 index 0000000000..a824f02e95 --- /dev/null +++ b/packages/control-plane/src/source-control/credential-scope.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { SourceControlProviderError } from "./errors"; + +/** + * Per-call scope for deployment-level credentials; providers are shared across sessions. + * GitHub honors this scope when minting installation tokens. GitLab uses a + * deployment-wide PAT and does not narrow its credentials by scope. + * GitHub refuses an empty repository scope rather than granting installation-wide access. + */ +export type CredentialScope = { kind: "all" } | { kind: "repositories"; repositoryIds: number[] }; + +export const MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS = 500; + +/** Canonical repository-only scope, including the GitHub installation-token limit. */ +export function repositoryCredentialScope( + repositoryIds: number[] +): Extract { + if (repositoryIds.length === 0) { + throw new SourceControlProviderError( + "Cannot generate credentials: no repositories in scope", + "permanent" + ); + } + if (!z.array(z.number().int().positive()).safeParse(repositoryIds).success) { + throw new SourceControlProviderError( + "Cannot generate credentials: invalid repository ids", + "permanent" + ); + } + const ids = [...new Set(repositoryIds)].sort((a, b) => a - b); + if (ids.length > MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS) { + throw new SourceControlProviderError( + `Cannot generate credentials: scope exceeds ${MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS} repositories`, + "permanent" + ); + } + return { kind: "repositories", repositoryIds: ids }; +} diff --git a/packages/control-plane/src/source-control/index.ts b/packages/control-plane/src/source-control/index.ts index 1248e7bd63..42ba03371b 100644 --- a/packages/control-plane/src/source-control/index.ts +++ b/packages/control-plane/src/source-control/index.ts @@ -10,6 +10,7 @@ export type { SourceControlProvider, SourceControlProviderName, SourceControlAuthContext, + CredentialScope, GitPushAuthContext, BuildManualPullRequestUrlConfig, BuildGitPushSpecConfig, diff --git a/packages/control-plane/src/source-control/provider-from-env.test.ts b/packages/control-plane/src/source-control/provider-from-env.test.ts index 6366cd4860..b4682d2179 100644 --- a/packages/control-plane/src/source-control/provider-from-env.test.ts +++ b/packages/control-plane/src/source-control/provider-from-env.test.ts @@ -29,7 +29,9 @@ describe("createSourceControlProviderFromEnv", () => { ); expect(provider).toBeInstanceOf(GitLabSourceControlProvider); - await expect(provider.generateCredentialHelperAuth()).resolves.toMatchObject({ + await expect( + provider.generateCredentialHelperAuth({ kind: "repositories", repositoryIds: [42] }) + ).resolves.toMatchObject({ username: "oauth2", password: "glpat-test", }); diff --git a/packages/control-plane/src/source-control/providers/github-provider.test.ts b/packages/control-plane/src/source-control/providers/github-provider.test.ts index 6576a2268c..a475c95e30 100644 --- a/packages/control-plane/src/source-control/providers/github-provider.test.ts +++ b/packages/control-plane/src/source-control/providers/github-provider.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GitHubSourceControlProvider } from "./github-provider"; import { SourceControlProviderError } from "../errors"; +import type { CredentialScope } from "../types"; // Mock the upstream GitHub App auth functions vi.mock("../../auth/github-app", () => ({ getCachedInstallationToken: vi.fn(), getCachedInstallationTokenWithExpiry: vi.fn(), + getInstallationTokenCacheKey: vi.fn(), + invalidateInstallationTokenCache: vi.fn(), getInstallationRepository: vi.fn(), listInstallationRepositories: vi.fn(), fetchWithTimeout: vi.fn(), @@ -15,6 +18,8 @@ import { fetchWithTimeout, getCachedInstallationToken, getCachedInstallationTokenWithExpiry, + getInstallationTokenCacheKey, + invalidateInstallationTokenCache, getInstallationRepository, listInstallationRepositories, } from "../../auth/github-app"; @@ -23,6 +28,8 @@ const mockGetInstallationRepository = vi.mocked(getInstallationRepository); const mockListInstallationRepositories = vi.mocked(listInstallationRepositories); const mockGetCachedInstallationTokenWithExpiry = vi.mocked(getCachedInstallationTokenWithExpiry); const mockGetCachedInstallationToken = vi.mocked(getCachedInstallationToken); +const mockGetInstallationTokenCacheKey = vi.mocked(getInstallationTokenCacheKey); +const mockInvalidateInstallationTokenCache = vi.mocked(invalidateInstallationTokenCache); const mockFetchWithTimeout = vi.mocked(fetchWithTimeout); function makeResponse(body: unknown, status = 200): Response { @@ -50,6 +57,8 @@ const fakeAppConfig = { installationId: "456", }; +const repositoryScope: CredentialScope = { kind: "repositories", repositoryIds: [9001] }; + describe("GitHubSourceControlProvider", () => { beforeEach(() => { vi.clearAllMocks(); @@ -64,7 +73,10 @@ describe("GitHubSourceControlProvider", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getBranchHead({ owner: "acme", name: "web", branch: "feature/test" }) + provider.getBranchHead( + { owner: "acme", name: "web", branch: "feature/test" }, + repositoryScope + ) ).resolves.toBe("abc123"); expect(mockFetchWithTimeout).toHaveBeenCalledWith( expect.stringContaining("heads/feature%2Ftest"), @@ -78,7 +90,7 @@ describe("GitHubSourceControlProvider", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }) + provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }, repositoryScope) ).resolves.toBeNull(); }); @@ -88,7 +100,7 @@ describe("GitHubSourceControlProvider", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getBranchHead({ owner: "acme", name: "web", branch: "main" }) + .getBranchHead({ owner: "acme", name: "web", branch: "main" }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -459,7 +471,9 @@ describe("GitHubSourceControlProvider", () => { describe("generateCredentialHelperAuth", () => { it("throws a permanent error when the App is not configured", async () => { const provider = new GitHubSourceControlProvider(); - const err = await provider.generateCredentialHelperAuth().catch((e: unknown) => e); + const err = await provider + .generateCredentialHelperAuth(repositoryScope) + .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); expect((err as SourceControlProviderError).errorType).toBe("permanent"); @@ -474,7 +488,7 @@ describe("GitHubSourceControlProvider", () => { }); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const auth = await provider.generateCredentialHelperAuth(); + const auth = await provider.generateCredentialHelperAuth(repositoryScope); expect(auth).toEqual({ username: "x-access-token", @@ -483,7 +497,8 @@ describe("GitHubSourceControlProvider", () => { }); expect(mockGetCachedInstallationTokenWithExpiry).toHaveBeenCalledWith( fakeAppConfig, - expect.objectContaining({ userAgent: expect.any(String) }) + expect.objectContaining({ userAgent: expect.any(String) }), + { scope: repositoryScope } ); }); @@ -491,7 +506,9 @@ describe("GitHubSourceControlProvider", () => { mockGetCachedInstallationTokenWithExpiry.mockRejectedValueOnce(new Error("GitHub 500")); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const err = await provider.generateCredentialHelperAuth().catch((e: unknown) => e); + const err = await provider + .generateCredentialHelperAuth(repositoryScope) + .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); expect((err as SourceControlProviderError).message).toContain("GitHub 500"); @@ -504,7 +521,9 @@ describe("GitHubSourceControlProvider", () => { mockGetCachedInstallationTokenWithExpiry.mockRejectedValueOnce(httpError); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const err = await provider.generateCredentialHelperAuth().catch((e: unknown) => e); + const err = await provider + .generateCredentialHelperAuth(repositoryScope) + .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); // Transient → the service maps this to 502, not 500. @@ -780,7 +799,7 @@ describe("getPullRequest", () => { it("throws a permanent error when the App is not configured", async () => { const provider = new GitHubSourceControlProvider(); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -793,7 +812,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot).toEqual({ number: 7, @@ -824,7 +846,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("merged"); expect(snapshot.isDraft).toBe(false); @@ -836,7 +861,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("closed"); }); @@ -854,7 +882,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.providerCreatedAt).toBe(Date.parse("2026-07-08T09:00:00Z")); expect(snapshot.mergedAt).toBe(Date.parse("2026-07-10T12:00:00Z")); @@ -872,7 +903,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.providerCreatedAt).toBe(Date.parse("2026-07-08T09:00:00Z")); expect(snapshot.mergedAt).toBeUndefined(); @@ -907,12 +941,10 @@ describe("getPullRequest", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const snapshot = await provider.getPullRequest({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: "9001", - }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ); expect(snapshot.repoName).toBe("web-renamed"); expect(mockFetchWithTimeout).toHaveBeenNthCalledWith( @@ -932,7 +964,7 @@ describe("getPullRequest", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -947,7 +979,10 @@ describe("getPullRequest", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }) + .getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -974,12 +1009,15 @@ describe("getPullRequestFeedback", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const feedback = await provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "pr_comment", id: "1234" }, - }); + const feedback = await provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "pr_comment", id: "1234" }, + }, + repositoryScope + ); expect(feedback).toEqual({ kind: "pr_comment", @@ -1009,12 +1047,15 @@ describe("getPullRequestFeedback", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "pr_comment", id: "1234" }, - }) + provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "pr_comment", id: "1234" }, + }, + repositoryScope + ) ).rejects.toMatchObject({ errorType: "permanent", message: "Pull request comment does not belong to the requested pull request", @@ -1065,12 +1106,15 @@ describe("getPullRequestFeedback", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const feedback = await provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }); + const feedback = await provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ); expect(feedback).toMatchObject({ kind: "review", @@ -1121,12 +1165,15 @@ describe("getPullRequestFeedback", () => { .mockResolvedValueOnce(makeJsonResponse([])); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const feedback = await provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }); + const feedback = await provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ); expect(feedback.kind === "review" ? feedback.comments : []).toHaveLength(100); expect(mockFetchWithTimeout).toHaveBeenNthCalledWith( @@ -1150,12 +1197,15 @@ describe("getPullRequestFeedback", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }) + provider.getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ) ).rejects.toMatchObject({ errorType: "permanent", message: "Pull request review does not belong to the requested pull request", @@ -1181,12 +1231,15 @@ describe("getPullRequestFeedback", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const error = await provider - .getPullRequestFeedback({ - owner: "acme", - name: "web", - pullRequestNumber: 7, - providerObject: { kind: "review", id: "5678" }, - }) + .getPullRequestFeedback( + { + owner: "acme", + name: "web", + pullRequestNumber: 7, + providerObject: { kind: "review", id: "5678" }, + }, + repositoryScope + ) .catch((caught: unknown) => caught); expect(error).toBeInstanceOf(SourceControlProviderError); @@ -1208,11 +1261,10 @@ describe("hasPullRequestWritePermission", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.hasPullRequestWritePermission({ - owner: "acme", - name: "web", - authorLogin: "alice", - }) + provider.hasPullRequestWritePermission( + { owner: "acme", name: "web", authorLogin: "alice" }, + repositoryScope + ) ).resolves.toBe(true); expect(mockFetchWithTimeout).toHaveBeenCalledWith( "https://api.github.com/repos/acme/web/collaborators/alice/permission", @@ -1228,11 +1280,10 @@ describe("hasPullRequestWritePermission", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.hasPullRequestWritePermission({ - owner: "acme", - name: "web", - authorLogin: "alice", - }) + provider.hasPullRequestWritePermission( + { owner: "acme", name: "web", authorLogin: "alice" }, + repositoryScope + ) ).resolves.toBe(false); } ); @@ -1242,11 +1293,10 @@ describe("hasPullRequestWritePermission", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.hasPullRequestWritePermission({ - owner: "acme", - name: "web", - authorLogin: "alice", - }) + provider.hasPullRequestWritePermission( + { owner: "acme", name: "web", authorLogin: "alice" }, + repositoryScope + ) ).resolves.toBe(false); }); @@ -1254,11 +1304,10 @@ describe("hasPullRequestWritePermission", () => { mockFetchWithTimeout.mockResolvedValueOnce(makeJsonResponse({ permission: "write" })); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - await provider.hasPullRequestWritePermission({ - owner: "acme org", - name: "web api", - authorLogin: "alice/bob", - }); + await provider.hasPullRequestWritePermission( + { owner: "acme org", name: "web api", authorLogin: "alice/bob" }, + repositoryScope + ); expect(mockFetchWithTimeout).toHaveBeenCalledWith( "https://api.github.com/repos/acme%20org/web%20api/collaborators/alice%2Fbob/permission", @@ -1354,7 +1403,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1369,7 +1418,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1386,7 +1435,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1400,7 +1449,10 @@ describe("response validation (zod boundary)", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }) + .getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1450,7 +1502,10 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.resolveCommit({ owner: "acme", name: "skills", ref: "feature/test" }) + provider.resolveCommit( + { owner: "acme", name: "skills", ref: "feature/test" }, + repositoryScope + ) ).resolves.toEqual({ sha: "abc123" }); expect(mockFetchWithTimeout).toHaveBeenCalledWith( expect.stringContaining("commits/feature%2Ftest"), @@ -1465,7 +1520,7 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.resolveCommit({ owner: "acme", name: "skills", ref: "missing" }) + provider.resolveCommit({ owner: "acme", name: "skills", ref: "missing" }, repositoryScope) ).resolves.toBeNull(); }); @@ -1482,7 +1537,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const tree = await provider.listTree({ owner: "acme", name: "skills", commitSha: "abc" }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc" }, + repositoryScope + ); expect(tree.entries.map(({ type, executable }) => ({ type, executable }))).toEqual([ { type: "file", executable: false }, @@ -1511,12 +1569,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); - const tree = await provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "skills/deploy", - }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "skills/deploy" }, + repositoryScope + ); expect(tree.entries[0]?.path).toBe("skills/deploy/SKILL.md"); expect(mockFetchWithTimeout.mock.calls.map(([url]) => String(url))).toEqual([ @@ -1531,7 +1587,10 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); await expect( - provider.listTree({ owner: "acme", name: "skills", commitSha: "abc", path: "missing" }) + provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "missing" }, + repositoryScope + ) ).resolves.toEqual({ entries: [], truncated: false }); expect(mockFetchWithTimeout).toHaveBeenCalledTimes(1); }); @@ -1551,7 +1610,7 @@ describe("managed-skill repository reads", () => { const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); const error = await provider - .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }) + .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }, repositoryScope) .catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(SourceControlProviderError); @@ -1559,3 +1618,90 @@ describe("managed-skill repository reads", () => { expect(cancelled).toBe(true); }); }); + +describe("credential scopes", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockGetCachedInstallationToken.mockResolvedValue("installation-token"); + mockGetInstallationTokenCacheKey.mockResolvedValue("scoped-cache-key"); + }); + + it("uses each call's scope on a shared provider without retaining prior scopes", async () => { + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + const scopes: CredentialScope[] = [ + repositoryScope, + { kind: "all" }, + { kind: "repositories", repositoryIds: [42, 99] }, + ]; + + for (const scope of scopes) { + await provider.generatePushAuth(scope); + } + + expect(mockGetCachedInstallationToken.mock.calls.map(([, , options]) => options)).toEqual( + scopes.map((scope) => ({ scope })) + ); + }); + + it("invalidates the scoped cache key and retries a 401 once with a refreshed token", async () => { + const env = { cacheStore: { get: vi.fn(), put: vi.fn(), delete: vi.fn() }, userAgent: "Bot" }; + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig, ...env }); + mockGetCachedInstallationToken + .mockResolvedValueOnce("expired-token") + .mockResolvedValueOnce("refreshed-token"); + mockFetchWithTimeout + .mockResolvedValueOnce(makeJsonResponse({ message: "Bad credentials" }, 401)) + .mockResolvedValueOnce(makeJsonResponse({ object: { sha: "abc123" } })); + + await expect( + provider.getBranchHead({ owner: "acme", name: "web", branch: "main" }, repositoryScope) + ).resolves.toBe("abc123"); + + expect(mockGetInstallationTokenCacheKey).toHaveBeenCalledWith(fakeAppConfig, repositoryScope); + expect(mockInvalidateInstallationTokenCache).toHaveBeenCalledWith(env, "scoped-cache-key"); + expect(mockGetCachedInstallationToken.mock.calls.map(([, , options]) => options)).toEqual([ + { scope: repositoryScope }, + { scope: repositoryScope, forceRefresh: true }, + ]); + expect(mockFetchWithTimeout.mock.calls[1][1]?.headers).toMatchObject({ + Authorization: "Bearer refreshed-token", + }); + }); + + it("surfaces a second 401 without retrying again", async () => { + mockFetchWithTimeout + .mockResolvedValueOnce(new Response("expired", { status: 401 })) + .mockResolvedValueOnce(new Response("still expired", { status: 401 })); + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + + await expect( + provider.resolveCommit({ owner: "acme", name: "web", ref: "main" }, repositoryScope) + ).rejects.toMatchObject({ httpStatus: 401, errorType: "permanent" }); + expect(mockFetchWithTimeout).toHaveBeenCalledTimes(2); + }); + + it("surfaces a 403 without refreshing credentials", async () => { + mockFetchWithTimeout.mockResolvedValueOnce(makeJsonResponse({ message: "Forbidden" }, 403)); + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + + await expect( + provider.listTree({ owner: "acme", name: "web", commitSha: "abc123" }, repositoryScope) + ).rejects.toMatchObject({ httpStatus: 403, errorType: "permanent" }); + expect(mockInvalidateInstallationTokenCache).not.toHaveBeenCalled(); + }); + + it("surfaces token refresh failures as provider errors", async () => { + mockFetchWithTimeout.mockResolvedValueOnce(new Response("expired", { status: 401 })); + mockGetCachedInstallationToken + .mockResolvedValueOnce("expired-token") + .mockRejectedValueOnce( + Object.assign(new Error("token service unavailable"), { status: 502 }) + ); + const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig }); + + await expect( + provider.getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) + ).rejects.toMatchObject({ httpStatus: 502, errorType: "transient" }); + expect(mockFetchWithTimeout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts index 1594a1cdac..ba71e17ade 100644 --- a/packages/control-plane/src/source-control/providers/github-provider.ts +++ b/packages/control-plane/src/source-control/providers/github-provider.ts @@ -24,6 +24,7 @@ import type { GitPushSpec, GitPushAuthContext, CredentialHelperAuth, + CredentialScope, ResolvedCommit, RepositoryTree, } from "../types"; @@ -36,6 +37,8 @@ import { classifyGitTreeEntry } from "./git-tree"; import { getCachedInstallationToken, getCachedInstallationTokenWithExpiry, + getInstallationTokenCacheKey, + invalidateInstallationTokenCache, getInstallationRepository, listInstallationRepositories, listRepositoryBranches, @@ -271,10 +274,11 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async getPullRequestFeedback( - config: GetGitHubPullRequestFeedbackConfig + config: GetGitHubPullRequestFeedbackConfig, + scope: CredentialScope ): Promise { if (config.providerObject.kind === "review") { - return this.getPullRequestReviewFeedback(config, config.providerObject.id); + return this.getPullRequestReviewFeedback(config, config.providerObject.id, scope); } const repositoryPath = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( @@ -282,6 +286,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { )}`; const data = await this.appJsonRequired( `${repositoryPath}/issues/comments/${encodeURIComponent(config.providerObject.id)}`, + scope, githubPullRequestCommentSchema, "get pull request comment" ); @@ -309,15 +314,19 @@ export class GitHubSourceControlProvider implements SourceControlProvider { }; } - async hasPullRequestWritePermission(config: { - owner: string; - name: string; - authorLogin: string; - }): Promise { + async hasPullRequestWritePermission( + config: { + owner: string; + name: string; + authorLogin: string; + }, + scope: CredentialScope + ): Promise { const data = await this.appJson( `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/collaborators/${encodeURIComponent(config.authorLogin)}/permission`, + scope, githubCollaboratorPermissionSchema, "get collaborator permission", true @@ -329,7 +338,8 @@ export class GitHubSourceControlProvider implements SourceControlProvider { private async getPullRequestReviewFeedback( config: GitHubPullRequestFeedbackLocation, - reviewId: string + reviewId: string, + scope: CredentialScope ): Promise> { const pullRequestPath = `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name @@ -337,6 +347,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { const reviewPath = `${pullRequestPath}/reviews/${encodeURIComponent(reviewId)}`; const review = await this.appJsonRequired( reviewPath, + scope, githubPullRequestReviewSchema, "get pull request review" ); @@ -354,6 +365,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { for (let page = 1; ; page += 1) { const pageComments = await this.appJsonRequired( `${reviewPath}/comments?per_page=${GITHUB_REVIEW_COMMENTS_PER_PAGE}&page=${page}`, + scope, z.array(githubReviewCommentSchema), "get pull request review comments" ); @@ -541,7 +553,10 @@ export class GitHubSourceControlProvider implements SourceControlProvider { * On a 404 with a known stable repo id, re-resolves the repository's * current owner/name by id and retries once (rename/transfer tolerance). */ - async getPullRequest(config: GetPullRequestConfig): Promise { + async getPullRequest( + config: GetPullRequestConfig, + scope: CredentialScope + ): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot get pull request", @@ -549,26 +564,12 @@ export class GitHubSourceControlProvider implements SourceControlProvider { ); } - let token: string; - try { - token = await getCachedInstallationToken(this.appConfig, { - cacheStore: this.cacheStore, - userAgent: this.userAgent, - }); - } catch (error) { - throw SourceControlProviderError.fromFetchError( - `Failed to generate GitHub App token: ${error instanceof Error ? error.message : String(error)}`, - error, - extractHttpStatus(error) - ); - } - - let response = await this.fetchPullRequest(token, config.owner, config.name, config.number); + let response = await this.fetchPullRequest(scope, config.owner, config.name, config.number); if (response.status === 404 && config.repositoryExternalId) { - const resolved = await this.resolveRepositoryLocationById(token, config.repositoryExternalId); + const resolved = await this.resolveRepositoryLocationById(scope, config.repositoryExternalId); if (resolved) { - response = await this.fetchPullRequest(token, resolved.owner, resolved.name, config.number); + response = await this.fetchPullRequest(scope, resolved.owner, resolved.name, config.number); } } @@ -612,18 +613,17 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } private fetchPullRequest( - token: string, + scope: CredentialScope, owner: string, name: string, number: number ): Promise { - return fetchWithTimeout(`${GITHUB_API_BASE}/repos/${owner}/${name}/pulls/${number}`, { - headers: { - Accept: "application/vnd.github.v3+json", - Authorization: `Bearer ${token}`, - "User-Agent": this.userAgent, - }, - }); + return this.appFetch( + `/repos/${owner}/${name}/pulls/${number}`, + scope, + "get pull request", + "application/vnd.github.v3+json" + ); } /** @@ -636,18 +636,14 @@ export class GitHubSourceControlProvider implements SourceControlProvider { * and the caller surfaces the original 404. */ private async resolveRepositoryLocationById( - token: string, + scope: CredentialScope, repositoryExternalId: string ): Promise<{ owner: string; name: string } | null> { - const response = await fetchWithTimeout( - `${GITHUB_API_BASE}/repositories/${encodeURIComponent(repositoryExternalId)}`, - { - headers: { - Accept: "application/vnd.github.v3+json", - Authorization: `Bearer ${token}`, - "User-Agent": this.userAgent, - }, - } + const response = await this.appFetch( + `/repositories/${encodeURIComponent(repositoryExternalId)}`, + scope, + "resolve repository location", + "application/vnd.github.v3+json" ); if (!response.ok) { @@ -753,7 +749,10 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } } - async getBranchHead(config: GetRepositoryConfig & { branch: string }): Promise { + async getBranchHead( + config: GetRepositoryConfig & { branch: string }, + scope: CredentialScope + ): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot resolve branch head", @@ -761,21 +760,13 @@ export class GitHubSourceControlProvider implements SourceControlProvider { ); } try { - const token = await getCachedInstallationToken(this.appConfig, { - cacheStore: this.cacheStore, - userAgent: this.userAgent, - }); - const response = await fetchWithTimeout( - `${GITHUB_API_BASE}/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( + const response = await this.appFetch( + `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/ref/heads/${encodeURIComponent(config.branch)}`, - { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "User-Agent": this.userAgent, - }, - } + scope, + "resolve branch head", + "application/vnd.github+json" ); if (response.status === 404) return null; if (!response.ok) { @@ -803,13 +794,15 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async resolveCommit( - config: GetRepositoryConfig & { ref: string } + config: GetRepositoryConfig & { ref: string }, + scope: CredentialScope ): Promise { try { const response = await this.appFetch( `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/commits/${encodeURIComponent(config.ref)}`, + scope, "resolve commit", "application/vnd.github.sha" ); @@ -829,7 +822,8 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async listTree( - config: GetRepositoryConfig & { commitSha: string; path?: string | null } + config: GetRepositoryConfig & { commitSha: string; path?: string | null }, + scope: CredentialScope ): Promise { const scopedPath = config.path?.trim() || null; let treeSha = config.commitSha; @@ -839,6 +833,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/trees/${encodeURIComponent(treeSha)}`, + scope, githubTreeSchema, "resolve repository subtree" ); @@ -851,6 +846,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, + scope, githubTreeSchema, "list repository tree" ); @@ -870,13 +866,15 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } async readBlob( - config: GetRepositoryConfig & { blobId: string; maxBytes: number } + config: GetRepositoryConfig & { blobId: string; maxBytes: number }, + scope: CredentialScope ): Promise { try { const response = await this.appFetch( `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent( config.name )}/git/blobs/${encodeURIComponent(config.blobId)}`, + scope, "read blob", "application/vnd.github.raw" ); @@ -893,24 +891,52 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } /** Issue an installation-authenticated GitHub API request. */ - private async appFetch(path: string, operation: string, accept: string): Promise { + private async appFetch( + path: string, + scope: CredentialScope, + operation: string, + accept: string + ): Promise { if (!this.appConfig) { throw new SourceControlProviderError( `GitHub App not configured - cannot ${operation}`, "permanent" ); } - const token = await getCachedInstallationToken(this.appConfig, { + const env = { cacheStore: this.cacheStore, userAgent: this.userAgent, - }); - return fetchWithTimeout(`${GITHUB_API_BASE}${path}`, { - headers: { + }; + try { + const token = await getCachedInstallationToken(this.appConfig, env, { scope }); + const headers = { Accept: accept, Authorization: `Bearer ${token}`, "User-Agent": this.userAgent, - }, - }); + }; + let response = await fetchWithTimeout(`${GITHUB_API_BASE}${path}`, { headers }); + if (response.status === 401) { + await invalidateInstallationTokenCache( + env, + await getInstallationTokenCacheKey(this.appConfig, scope) + ); + const refreshedToken = await getCachedInstallationToken(this.appConfig, env, { + scope, + forceRefresh: true, + }); + response = await fetchWithTimeout(`${GITHUB_API_BASE}${path}`, { + headers: { ...headers, Authorization: `Bearer ${refreshedToken}` }, + }); + } + return response; + } catch (error) { + if (error instanceof SourceControlProviderError) throw error; + throw SourceControlProviderError.fromFetchError( + `Failed to ${operation}: ${error instanceof Error ? error.message : String(error)}`, + error, + extractHttpStatus(error) + ); + } } /** @@ -919,12 +945,13 @@ export class GitHubSourceControlProvider implements SourceControlProvider { */ private async appJson( path: string, + scope: CredentialScope, schema: z.ZodType, operation: string, notFoundIsAbsence: boolean ): Promise { try { - const response = await this.appFetch(path, operation, "application/vnd.github+json"); + const response = await this.appFetch(path, scope, operation, "application/vnd.github+json"); if (notFoundIsAbsence && response.status === 404) return null; if (!response.ok) throw await githubResponseError(response, operation); return await parseProviderResponse(response, schema, `Failed to ${operation}`); @@ -940,10 +967,11 @@ export class GitHubSourceControlProvider implements SourceControlProvider { private async appJsonRequired( path: string, + scope: CredentialScope, schema: z.ZodType, operation: string ): Promise { - const data = await this.appJson(path, schema, operation, false); + const data = await this.appJson(path, scope, schema, operation, false); if (data === null) throw new SourceControlProviderError(`Failed to ${operation}`, "permanent"); return data; } @@ -951,7 +979,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { /** * Generate authentication for git push operations using GitHub App. */ - async generatePushAuth(): Promise { + async generatePushAuth(scope: CredentialScope): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot generate push auth", @@ -960,10 +988,11 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } try { - const token = await getCachedInstallationToken(this.appConfig, { - cacheStore: this.cacheStore, - userAgent: this.userAgent, - }); + const token = await getCachedInstallationToken( + this.appConfig, + { cacheStore: this.cacheStore, userAgent: this.userAgent }, + { scope } + ); return { authType: "app", token, @@ -976,7 +1005,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider { } } - async generateCredentialHelperAuth(): Promise { + async generateCredentialHelperAuth(scope: CredentialScope): Promise { if (!this.appConfig) { throw new SourceControlProviderError( "GitHub App not configured - cannot generate credential helper auth", @@ -990,7 +1019,8 @@ export class GitHubSourceControlProvider implements SourceControlProvider { { cacheStore: this.cacheStore, userAgent: this.userAgent, - } + }, + { scope } ); return { username: "x-access-token", diff --git a/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts b/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts index e5677f4d50..f1c659ca81 100644 --- a/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts +++ b/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { GitLabSourceControlProvider, deriveGitLabMergeRequestStatus } from "./gitlab-provider"; import { SourceControlProviderError } from "../errors"; +import type { CredentialScope } from "../types"; // Mock global fetch const mockFetch = vi.fn(); @@ -17,6 +18,7 @@ function makeResponse(body: unknown, status = 200, headers: HeadersInit = {}): R } const fakeConfig = { accessToken: "glpat-test-token" }; +const repositoryScope: CredentialScope = { kind: "repositories", repositoryIds: [9001] }; describe("GitLabSourceControlProvider", () => { beforeEach(() => { @@ -29,11 +31,10 @@ describe("GitLabSourceControlProvider", () => { const provider = new GitLabSourceControlProvider(fakeConfig); await expect( - provider.getBranchHead({ - owner: "acme/platform", - name: "web", - branch: "feature/test", - }) + provider.getBranchHead( + { owner: "acme/platform", name: "web", branch: "feature/test" }, + repositoryScope + ) ).resolves.toBe("def456"); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining( @@ -48,7 +49,7 @@ describe("GitLabSourceControlProvider", () => { const provider = new GitLabSourceControlProvider(fakeConfig); await expect( - provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }) + provider.getBranchHead({ owner: "acme", name: "web", branch: "missing" }, repositoryScope) ).resolves.toBeNull(); }); @@ -57,7 +58,7 @@ describe("GitLabSourceControlProvider", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getBranchHead({ owner: "acme", name: "web", branch: "main" }) + .getBranchHead({ owner: "acme", name: "web", branch: "main" }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -864,9 +865,9 @@ describe("GitLabSourceControlProvider", () => { }); describe("generatePushAuth", () => { - it("returns PAT-type auth context with configured token", async () => { + it("returns the deployment-wide PAT even for a narrowed credential scope", async () => { const provider = new GitLabSourceControlProvider({ accessToken: "glpat-abc123" }); - const auth = await provider.generatePushAuth(); + const auth = await provider.generatePushAuth(repositoryScope); expect(auth).toEqual({ authType: "pat", token: "glpat-abc123" }); }); @@ -879,7 +880,7 @@ describe("GitLabSourceControlProvider", () => { try { const provider = new GitLabSourceControlProvider({ accessToken: "glpat-abc123" }); - const auth = await provider.generateCredentialHelperAuth(); + const auth = await provider.generateCredentialHelperAuth(repositoryScope); expect(auth).toEqual({ username: "oauth2", @@ -1079,7 +1080,10 @@ describe("getPullRequest", () => { mockFetch.mockResolvedValueOnce(makeResponse(baseMrResponse)); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot).toEqual({ number: 7, @@ -1113,7 +1117,10 @@ describe("getPullRequest", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.providerCreatedAt).toBe(Date.parse("2026-07-08T09:00:00.000Z")); expect(snapshot.mergedAt).toBe(Date.parse("2026-07-10T12:00:00.000Z")); @@ -1124,7 +1131,10 @@ describe("getPullRequest", () => { mockFetch.mockResolvedValueOnce(makeResponse({ ...baseMrResponse, state: "merged" })); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("merged"); expect(snapshot.isDraft).toBe(false); @@ -1144,12 +1154,10 @@ describe("getPullRequest", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ - owner: "acme", - name: "web", - number: 7, - repositoryExternalId: "9001", - }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ); expect(snapshot.repoName).toBe("web-renamed"); expect(mockFetch.mock.calls[1][0]).toBe("https://gitlab.com/api/v4/projects/9001"); @@ -1163,7 +1171,7 @@ describe("getPullRequest", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1234,7 +1242,10 @@ describe("response validation (zod boundary)", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const snapshot = await provider.getPullRequest({ owner: "acme", name: "web", number: 7 }); + const snapshot = await provider.getPullRequest( + { owner: "acme", name: "web", number: 7 }, + repositoryScope + ); expect(snapshot.lifecycleState).toBe("open"); expect(snapshot.isDraft).toBe(false); @@ -1255,7 +1266,7 @@ describe("response validation (zod boundary)", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7 }) + .getPullRequest({ owner: "acme", name: "web", number: 7 }, repositoryScope) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1300,7 +1311,10 @@ describe("response validation (zod boundary)", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const err = await provider - .getPullRequest({ owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }) + .getPullRequest( + { owner: "acme", name: "web", number: 7, repositoryExternalId: "9001" }, + repositoryScope + ) .catch((e: unknown) => e); expect(err).toBeInstanceOf(SourceControlProviderError); @@ -1314,6 +1328,18 @@ describe("managed-skill repository reads", () => { vi.resetAllMocks(); }); + it("resolves commits with the deployment PAT", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ id: "abc123" })); + const provider = new GitLabSourceControlProvider(fakeConfig); + + await expect( + provider.resolveCommit({ owner: "acme", name: "skills", ref: "main" }, repositoryScope) + ).resolves.toEqual({ sha: "abc123" }); + expect(mockFetch.mock.calls[0][1].headers).toMatchObject({ + Authorization: `Bearer ${fakeConfig.accessToken}`, + }); + }); + it("classifies symlinks and submodules as unsupported tree entries", async () => { mockFetch.mockResolvedValueOnce( new Response( @@ -1328,7 +1354,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const tree = await provider.listTree({ owner: "acme", name: "skills", commitSha: "abc" }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc" }, + repositoryScope + ); expect(tree.entries.map(({ type, executable }) => ({ type, executable }))).toEqual([ { type: "file", executable: false }, @@ -1351,12 +1380,10 @@ describe("managed-skill repository reads", () => { ); const provider = new GitLabSourceControlProvider(fakeConfig); - const tree = await provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "skills/deploy", - }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "skills/deploy" }, + repositoryScope + ); expect(tree.entries[0]?.path).toBe("skills/deploy/SKILL.md"); const requestUrl = String(mockFetch.mock.calls[0]?.[0]); @@ -1382,12 +1409,10 @@ describe("managed-skill repository reads", () => { .mockResolvedValueOnce(makeResponse([])); const provider = new GitLabSourceControlProvider(fakeConfig); - const tree = await provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "skills/deploy", - }); + const tree = await provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "skills/deploy" }, + repositoryScope + ); expect(tree).toMatchObject({ truncated: false }); expect(tree.entries).toHaveLength(100); @@ -1400,12 +1425,10 @@ describe("managed-skill repository reads", () => { const provider = new GitLabSourceControlProvider(fakeConfig); await expect( - provider.listTree({ - owner: "acme", - name: "skills", - commitSha: "abc", - path: "missing", - }) + provider.listTree( + { owner: "acme", name: "skills", commitSha: "abc", path: "missing" }, + repositoryScope + ) ).resolves.toEqual({ entries: [], truncated: false }); }); @@ -1424,7 +1447,7 @@ describe("managed-skill repository reads", () => { const provider = new GitLabSourceControlProvider(fakeConfig); const error = await provider - .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }) + .readBlob({ owner: "acme", name: "skills", blobId: "big", maxBytes: 4 }, repositoryScope) .catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(SourceControlProviderError); diff --git a/packages/control-plane/src/source-control/providers/gitlab-provider.ts b/packages/control-plane/src/source-control/providers/gitlab-provider.ts index 5634032bcf..3e0191f04d 100644 --- a/packages/control-plane/src/source-control/providers/gitlab-provider.ts +++ b/packages/control-plane/src/source-control/providers/gitlab-provider.ts @@ -23,6 +23,7 @@ import type { GitPushSpec, GitPushAuthContext, CredentialHelperAuth, + CredentialScope, ResolvedCommit, RepositoryTree, RepositoryTreeEntry, @@ -203,6 +204,7 @@ function parseProviderTimestamp(value: string | null | undefined): number | unde * Uses Personal Access Tokens for all API calls. The PAT must have * `read_api` scope for read operations and `api` scope for write operations * (creating merge requests, push). + * Credential scopes are ignored: the deployment-wide PAT cannot be narrowed per call. */ export class GitLabSourceControlProvider implements SourceControlProvider { readonly name = "gitlab"; @@ -353,7 +355,10 @@ export class GitLabSourceControlProvider implements SourceControlProvider { * On a 404 with a known stable project id, re-resolves the project's * current path by id and retries once (rename/transfer tolerance). */ - async getPullRequest(config: GetPullRequestConfig): Promise { + async getPullRequest( + config: GetPullRequestConfig, + _scope: CredentialScope + ): Promise { let owner = config.owner; let name = config.name; let response = await this.fetchMergeRequest(owner, name, config.number); @@ -578,7 +583,10 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } } - async getBranchHead(config: GetRepositoryConfig & { branch: string }): Promise { + async getBranchHead( + config: GetRepositoryConfig & { branch: string }, + _scope: CredentialScope + ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); try { const response = await fetchWithTimeout( @@ -612,7 +620,8 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } async resolveCommit( - config: GetRepositoryConfig & { ref: string } + config: GetRepositoryConfig & { ref: string }, + _scope: CredentialScope ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); const response = await this.patFetch( @@ -630,7 +639,8 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } async listTree( - config: GetRepositoryConfig & { commitSha: string; path?: string | null } + config: GetRepositoryConfig & { commitSha: string; path?: string | null }, + _scope: CredentialScope ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); const entries: RepositoryTreeEntry[] = []; @@ -674,7 +684,8 @@ export class GitLabSourceControlProvider implements SourceControlProvider { } async readBlob( - config: GetRepositoryConfig & { blobId: string; maxBytes: number } + config: GetRepositoryConfig & { blobId: string; maxBytes: number }, + _scope: CredentialScope ): Promise { const projectPath = encodeProjectPath(config.owner, config.name); const response = await this.patFetch( @@ -702,14 +713,14 @@ export class GitLabSourceControlProvider implements SourceControlProvider { /** * Generate authentication for git push operations using the provider PAT. */ - async generatePushAuth(): Promise { + async generatePushAuth(_scope: CredentialScope): Promise { return { authType: "pat", token: this.accessToken, }; } - async generateCredentialHelperAuth(): Promise { + async generateCredentialHelperAuth(_scope: CredentialScope): Promise { return { username: "oauth2", password: this.accessToken, diff --git a/packages/control-plane/src/source-control/repository-scope.test.ts b/packages/control-plane/src/source-control/repository-scope.test.ts new file mode 100644 index 0000000000..074dc326d3 --- /dev/null +++ b/packages/control-plane/src/source-control/repository-scope.test.ts @@ -0,0 +1,158 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SqlDatabase } from "../db/sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS } from "./credential-scope"; +import { resolveRepositoryCredentialScope } from "./repository-scope"; + +const db = {} as SqlDatabase; +const loadCatalog = vi.fn<() => Promise>(); + +function repository(repoId: number | null, repoOwner = "acme", repoName = "web") { + return { repoOwner, repoName, repoId }; +} + +function catalogRepository(id: number, owner: string, name: string): InstallationRepository { + return { + id, + owner, + name, + fullName: `${owner}/${name}`, + description: null, + private: true, + defaultBranch: "main", + archived: false, + }; +} + +describe("resolveRepositoryCredentialScope", () => { + beforeEach(() => { + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + loadCatalog.mockReset().mockResolvedValue([]); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("sorts and deduplicates known ids without loading the catalog or grants", async () => { + const repositories = [repository(30), repository(12, "acme", "api"), repository(30)]; + + expect(await resolveRepositoryCredentialScope(db, repositories, null, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + expect(loadCatalog).not.toHaveBeenCalled(); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("resolves NULL ids from the catalog by nested owner and name case-insensitively", async () => { + loadCatalog.mockResolvedValue([ + catalogRepository(12, "Group/Subgroup", "Web"), + catalogRepository(30, "group/subgroup", "API"), + catalogRepository(99, "other", "unrelated"), + ]); + + expect( + await resolveRepositoryCredentialScope( + db, + [ + repository(null, "GROUP/SUBGROUP", "web"), + repository(null, "group/subgroup", "api"), + repository(50, "acme", "known"), + ], + null, + loadCatalog + ) + ).toEqual({ kind: "repositories", repositoryIds: [12, 30, 50] }); + expect(loadCatalog).toHaveBeenCalledTimes(1); + }); + + it("refuses the entire scope if any NULL id is unresolved, ignoring catalog fullName", async () => { + loadCatalog.mockResolvedValue([ + catalogRepository(12, "acme", "web"), + { ...catalogRepository(99, "other", "missing"), fullName: "acme/missing" }, + ]); + + await expect( + resolveRepositoryCredentialScope( + db, + [repository(null), repository(null, "acme", "missing")], + "team-a", + loadCatalog + ) + ).rejects.toMatchObject({ errorType: "permanent" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it.each([ + { source: "stored", repositories: [repository(0)], catalog: [] }, + { + source: "catalog", + repositories: [repository(null)], + catalog: [catalogRepository(Number.NaN, "acme", "web")], + }, + ])("refuses an invalid $source id before reading grants", async ({ repositories, catalog }) => { + loadCatalog.mockResolvedValue(catalog); + + await expect( + resolveRepositoryCredentialScope(db, repositories, "team-a", loadCatalog) + ).rejects.toMatchObject({ errorType: "permanent" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("refuses an empty repository set without reading grants", async () => { + await expect( + resolveRepositoryCredentialScope(db, [], "team-a", loadCatalog) + ).rejects.toMatchObject({ errorType: "permanent" }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).not.toHaveBeenCalled(); + }); + + it("filters candidates to the owning team's repository grants", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + { grant_kind: "repository", repo_external_id: 99 }, + ]); + + expect( + await resolveRepositoryCredentialScope( + db, + [repository(30), repository(12, "acme", "api")], + "team-a", + loadCatalog + ) + ).toEqual({ kind: "repositories", repositoryIds: [12] }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("team-a"); + }); + + it("keeps all candidates, and only candidates, under an installation grant", async () => { + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "installation", repo_external_id: null }, + ]); + loadCatalog.mockResolvedValue([ + catalogRepository(12, "acme", "web"), + catalogRepository(99, "other", "not-in-session"), + ]); + + expect( + await resolveRepositoryCredentialScope( + db, + [repository(null), repository(30, "acme", "api")], + "team-a", + loadCatalog + ) + ).toEqual({ kind: "repositories", repositoryIds: [12, 30] }); + }); + + it("applies the scope limit to granted candidates, not the pre-filter repository count", async () => { + const repositories = Array.from( + { length: MAX_CREDENTIAL_SCOPE_REPOSITORY_IDS + 1 }, + (_, index) => repository(index + 1, "acme", `repo-${index}`) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + ]); + + expect(await resolveRepositoryCredentialScope(db, repositories, "team-a", loadCatalog)).toEqual( + { kind: "repositories", repositoryIds: [12] } + ); + }); +}); diff --git a/packages/control-plane/src/source-control/repository-scope.ts b/packages/control-plane/src/source-control/repository-scope.ts new file mode 100644 index 0000000000..2ee162766e --- /dev/null +++ b/packages/control-plane/src/source-control/repository-scope.ts @@ -0,0 +1,43 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import type { SqlDatabase } from "../db/sql-database"; +import { coveredRepositoryIds, TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { repositoryCredentialScope, type CredentialScope } from "./credential-scope"; +import { SourceControlProviderError } from "./errors"; + +/** Resolve only the supplied repositories, intersecting current team grants when owned. */ +export async function resolveRepositoryCredentialScope( + db: SqlDatabase, + repositories: readonly { repoOwner: string; repoName: string; repoId: number | null }[], + ownerTeamId: string | null, + loadInstallationRepositories: () => Promise +): Promise { + const catalog = repositories.some((repository) => repository.repoId === null) + ? await loadInstallationRepositories() + : []; + const candidateIds = [ + ...new Set( + repositories.map((repository) => { + const repoId = + repository.repoId ?? + catalog.find( + (entry) => + entry.owner.toLowerCase() === repository.repoOwner.toLowerCase() && + entry.name.toLowerCase() === repository.repoName.toLowerCase() + )?.id; + if (repoId === undefined || !Number.isSafeInteger(repoId) || repoId <= 0) { + throw new SourceControlProviderError( + `Cannot resolve credential scope: repository id unavailable or invalid for ${repository.repoOwner}/${repository.repoName}`, + "permanent" + ); + } + return repoId; + }) + ), + ]; + + if (ownerTeamId !== null && candidateIds.length > 0) { + const grants = await new TeamRepositoryGrantStore(db).listForTeam(ownerTeamId); + return repositoryCredentialScope(coveredRepositoryIds(grants, candidateIds)); + } + return repositoryCredentialScope(candidateIds); +} diff --git a/packages/control-plane/src/source-control/session-scope.test.ts b/packages/control-plane/src/source-control/session-scope.test.ts new file mode 100644 index 0000000000..bd6b8a4771 --- /dev/null +++ b/packages/control-plane/src/source-control/session-scope.test.ts @@ -0,0 +1,106 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SessionIndexStore, type SessionEntry } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import type { SqlDatabase } from "../db/sql-database"; +import { TeamRepositoryGrantStore } from "../db/team-repository-grants"; +import { SourceControlProviderError } from "./errors"; +import { resolveSessionCredentialScope } from "./session-scope"; + +const db = {} as SqlDatabase; +const SESSION_ID = "public-session"; +const loadCatalog = vi.fn<() => Promise>(); + +function indexSession(overrides: Partial = {}): SessionEntry { + return { + id: SESSION_ID, + ownerTeamId: null, + repoOwner: null, + repoName: null, + ...overrides, + } as SessionEntry; +} + +describe("resolveSessionCredentialScope", () => { + beforeEach(() => { + vi.spyOn(SessionIndexStore.prototype, "get").mockResolvedValue(indexSession()); + vi.spyOn(SessionRepositoryStore.prototype, "listRepositoryIds").mockResolvedValue([ + { repoOwner: "acme", repoName: "web", repoId: 30 }, + { repoOwner: "acme", repoName: "api", repoId: 12 }, + ]); + vi.spyOn(TeamRepositoryGrantStore.prototype, "listForTeam").mockResolvedValue([]); + loadCatalog.mockReset().mockResolvedValue([]); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("fails closed without reading membership when the session is missing", async () => { + vi.mocked(SessionIndexStore.prototype.get).mockResolvedValue(null); + + const error = await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog).catch( + (caught: unknown) => caught + ); + + expect(error).toBeInstanceOf(SourceControlProviderError); + expect(error).toMatchObject({ + message: "Cannot resolve credential scope: session not found", + errorType: "permanent", + }); + expect(SessionRepositoryStore.prototype.listRepositoryIds).not.toHaveBeenCalled(); + }); + + it("scopes credentials to the persisted session members", async () => { + expect(await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12, 30], + }); + expect(SessionRepositoryStore.prototype.listRepositoryIds).toHaveBeenCalledWith(SESSION_ID); + }); + + it("falls back to the session's scalar repository via the catalog when there are no members", async () => { + vi.mocked(SessionIndexStore.prototype.get).mockResolvedValue( + indexSession({ repoOwner: "acme", repoName: "web" }) + ); + vi.mocked(SessionRepositoryStore.prototype.listRepositoryIds).mockResolvedValue([]); + loadCatalog.mockResolvedValue([ + { + id: 12, + owner: "ACME", + name: "Web", + fullName: "ACME/Web", + description: null, + private: true, + defaultBranch: "main", + archived: false, + }, + ]); + + expect(await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12], + }); + }); + + it("refuses a session with neither members nor a scalar repository", async () => { + vi.mocked(SessionRepositoryStore.prototype.listRepositoryIds).mockResolvedValue([]); + + await expect(resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).rejects.toMatchObject({ + errorType: "permanent", + }); + }); + + it("intersects members with the owning team's current grants", async () => { + vi.mocked(SessionIndexStore.prototype.get).mockResolvedValue( + indexSession({ ownerTeamId: "team-a" }) + ); + vi.mocked(TeamRepositoryGrantStore.prototype.listForTeam).mockResolvedValue([ + { grant_kind: "repository", repo_external_id: 12 }, + ]); + + expect(await resolveSessionCredentialScope(db, SESSION_ID, loadCatalog)).toEqual({ + kind: "repositories", + repositoryIds: [12], + }); + expect(TeamRepositoryGrantStore.prototype.listForTeam).toHaveBeenCalledWith("team-a"); + }); +}); diff --git a/packages/control-plane/src/source-control/session-scope.ts b/packages/control-plane/src/source-control/session-scope.ts new file mode 100644 index 0000000000..7bbca600b3 --- /dev/null +++ b/packages/control-plane/src/source-control/session-scope.ts @@ -0,0 +1,34 @@ +import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; +import { SessionIndexStore } from "../db/session-index"; +import { SessionRepositoryStore } from "../db/session-repositories"; +import type { SqlDatabase } from "../db/sql-database"; +import type { CredentialScope } from "./credential-scope"; +import { SourceControlProviderError } from "./errors"; +import { resolveRepositoryCredentialScope } from "./repository-scope"; + +/** Resolve fresh ownership and persisted session members, never environment provenance. */ +export async function resolveSessionCredentialScope( + db: SqlDatabase, + sessionId: string, + loadInstallationRepositories: () => Promise +): Promise { + const session = await new SessionIndexStore(db).get(sessionId); + if (!session) { + throw new SourceControlProviderError( + "Cannot resolve credential scope: session not found", + "permanent" + ); + } + const members = await new SessionRepositoryStore(db).listRepositoryIds(sessionId); + const repositories = members.length + ? members + : session.repoOwner && session.repoName + ? [{ repoOwner: session.repoOwner, repoName: session.repoName, repoId: null }] + : []; + return await resolveRepositoryCredentialScope( + db, + repositories, + session.ownerTeamId, + loadInstallationRepositories + ); +} diff --git a/packages/control-plane/src/source-control/types.ts b/packages/control-plane/src/source-control/types.ts index 69b57c1ddf..70cf066496 100644 --- a/packages/control-plane/src/source-control/types.ts +++ b/packages/control-plane/src/source-control/types.ts @@ -6,6 +6,9 @@ import type { InstallationRepository } from "@open-inspect/shared/types/repository-catalog"; import type { PullRequestLifecycleState } from "@open-inspect/shared/types/artifacts"; +import type { CredentialScope } from "./credential-scope"; + +export type { CredentialScope } from "./credential-scope"; /** * Repository information. @@ -305,6 +308,10 @@ export interface PullRequestSnapshot { * Defines the contract for source control platform operations. * Implementations wrap provider-specific APIs (GitHub, GitLab, Bitbucket). * + * App-level credential scope: + * - GitHub honors CredentialScope when minting installation tokens. + * - GitLab uses a deployment-wide PAT and does not narrow it by scope. + * * Error handling: * - Methods should throw SourceControlProviderError with appropriate errorType * - "transient" errors (network issues) can be retried @@ -407,7 +414,10 @@ export interface SourceControlProvider { * Resolve one branch tip with app-level credentials. A confirmed 404 is * absence; authentication, throttling, and transport failures throw. */ - getBranchHead(config: GetRepositoryConfig & { branch: string }): Promise; + getBranchHead( + config: GetRepositoryConfig & { branch: string }, + scope: CredentialScope + ): Promise; /** * Resolve a branch, tag, or commit-ish to the commit it names. @@ -419,7 +429,10 @@ export interface SourceControlProvider { * @returns The resolved commit, or null when the ref does not exist * @throws SourceControlProviderError */ - resolveCommit(config: GetRepositoryConfig & { ref: string }): Promise; + resolveCommit( + config: GetRepositoryConfig & { ref: string }, + scope: CredentialScope + ): Promise; /** * List every entry reachable from a commit, recursively. @@ -432,7 +445,8 @@ export interface SourceControlProvider { * @throws SourceControlProviderError */ listTree( - config: GetRepositoryConfig & { commitSha: string; path?: string | null } + config: GetRepositoryConfig & { commitSha: string; path?: string | null }, + scope: CredentialScope ): Promise; /** @@ -451,21 +465,28 @@ export interface SourceControlProvider { * largest body the caller is willing to accept * @throws SourceControlProviderError, including when the blob is too large */ - readBlob(config: GetRepositoryConfig & { blobId: string; maxBytes: number }): Promise; + readBlob( + config: GetRepositoryConfig & { blobId: string; maxBytes: number }, + scope: CredentialScope + ): Promise; /** * Read the current state of a pull request. * - * App-authenticated: credentials come from provider-level configuration - * (matching listRepositories), never a caller token — the webhook and - * read-through freshness paths run with no user in the loop. + * App-authenticated: credentials come from provider-level configuration, + * never a caller token — the webhook and read-through freshness paths run + * with no user in the loop. GitHub honors the caller's scope; GitLab's + * deployment-wide PAT is not narrowed by it. * * @param config - PR identifier; include repositoryExternalId when known * so a 404 triggers a resolve-by-id + single retry (rename tolerance) * @returns Current PR snapshot * @throws SourceControlProviderError */ - getPullRequest(config: GetPullRequestConfig): Promise; + getPullRequest( + config: GetPullRequestConfig, + scope: CredentialScope + ): Promise; /** * Generate authentication for git push operations. @@ -477,7 +498,7 @@ export interface SourceControlProvider { * @returns Git push authentication context with app token * @throws SourceControlProviderError */ - generatePushAuth(): Promise; + generatePushAuth(scope: CredentialScope): Promise; /** * Generate credentials for the sandbox's git credential helper. @@ -485,12 +506,13 @@ export interface SourceControlProvider { * Called per request from inside the sandbox via * `POST /sessions/:id/scm-credentials`. The returned `username` is the * provider-specific basic-auth username (e.g. `x-access-token` for GitHub), - * and `password` is a freshly minted token. `expiresAtEpochMs` lets the - * client side cache the credentials until shortly before they expire. + * and `password` is a scoped installation token for GitHub or the + * deployment-wide PAT for GitLab. `expiresAtEpochMs` lets the client side + * cache the credentials until shortly before they expire. * * @throws SourceControlProviderError on configuration or upstream errors */ - generateCredentialHelperAuth(): Promise; + generateCredentialHelperAuth(scope: CredentialScope): Promise; /** * Build provider-specific URL for manual pull request creation. diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 0094a7cc5a..0ce549e050 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -267,7 +267,14 @@ export async function initSession(overrides?: { model: defaults.model ?? "anthropic/claude-haiku-4-5", reasoningEffort: defaults.reasoningEffort ?? null, baseBranch: defaults.defaultBranch ?? "main", - repositories: defaults.repositories, + repositories: defaults.repositories ?? [ + { + repoOwner: defaults.repoOwner, + repoName: defaults.repoName, + repoId: defaults.repoId, + baseBranch: defaults.defaultBranch ?? "main", + }, + ], environmentId: defaults.environmentId ?? null, status: "created", userId: defaults.userId, @@ -424,6 +431,18 @@ export async function initNamedSession( model: defaults.model ?? "anthropic/claude-haiku-4-5", reasoningEffort: defaults.reasoningEffort ?? null, baseBranch: defaults.defaultBranch ?? "main", + repositories: + defaults.repositories ?? + (defaults.repoOwner && defaults.repoName && defaults.repoId !== null + ? [ + { + repoOwner: defaults.repoOwner, + repoName: defaults.repoName, + repoId: defaults.repoId, + baseBranch: defaults.defaultBranch ?? "main", + }, + ] + : []), status: "created", parentSessionId: defaults.parentSessionId ?? null, spawnSource: defaults.spawnSource ?? "user", diff --git a/packages/control-plane/test/integration/scoped-installation-token.test.ts b/packages/control-plane/test/integration/scoped-installation-token.test.ts new file mode 100644 index 0000000000..8aea8b8f22 --- /dev/null +++ b/packages/control-plane/test/integration/scoped-installation-token.test.ts @@ -0,0 +1,319 @@ +import { env } from "cloudflare:test"; +import { createKvCacheStore } from "@open-inspect/shared/cache-store"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getCachedInstallationToken, + getInstallationTokenCacheKey, + type GitHubAppConfig, + type TokenScope, +} from "../../src/auth/github-app"; +import { resolveSessionCredentialScope } from "../../src/source-control/session-scope"; +import { SessionIndexStore } from "../../src/db/session-index"; +import { resolveImageBuildTokenScope } from "../../src/image-builds/credential-scope"; +import { + readCachedInstallationRepositories, + REPOS_CACHE_KEY, + reposCacheIdentity, +} from "../../src/repos/cache"; +import { GitHubSourceControlProvider } from "../../src/source-control/providers/github-provider"; +import { cleanD1Tables } from "./cleanup"; + +let privateKey: string; +const cacheStore = createKvCacheStore(env.REPOS_CACHE); +const cacheBindings = { cacheStore, userAgent: "scoped-token-test" }; +const catalogEnv = { REPOS_CACHE: cacheStore, GITHUB_APP_INSTALLATION_ID: "installation-1" }; +const loadCatalog = () => readCachedInstallationRepositories(catalogEnv); + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"] + ); + if (!("privateKey" in pair)) throw new Error("Expected an RSA key pair"); + const exported = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + if (!(exported instanceof ArrayBuffer)) throw new Error("Expected a PKCS#8 byte buffer"); + const bytes = new Uint8Array(exported); + privateKey = `-----BEGIN PRIVATE KEY-----\n${btoa(String.fromCharCode(...bytes))}\n-----END PRIVATE KEY-----`; +}); + +beforeEach(cleanD1Tables); +afterEach(() => vi.restoreAllMocks()); + +function config(testName: string): GitHubAppConfig { + return { appId: `scope-${testName}`, installationId: "installation-1", privateKey }; +} + +async function seedTeam(id: string): Promise { + await env.DB.prepare( + "INSERT INTO teams (id, slug, name, created_at, updated_at) VALUES (?, ?, ?, 1, 1)" + ) + .bind(id, id, id) + .run(); +} + +async function grant(teamId: string, repositoryId: number): Promise { + 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', ?, 'acme', ?, 1)` + ) + .bind(`${teamId}-${repositoryId}`, teamId, repositoryId, `repo-${repositoryId}`) + .run(); +} + +async function seedSession( + id: string, + ids: number[], + ownerTeamId: string | null = null +): Promise { + const repositories = ids.map((repoId) => ({ + repoOwner: "acme", + repoName: `repo-${repoId}`, + repoId, + baseBranch: "main", + })); + await new SessionIndexStore(env.DB).create({ + id, + ownerTeamId, + visibility: "workspace", + title: null, + repoOwner: repositories[0]?.repoOwner ?? null, + repoName: repositories[0]?.repoName ?? null, + repositories, + model: "anthropic/claude-haiku-4-5", + reasoningEffort: null, + baseBranch: repositories.length ? "main" : null, + status: "active", + createdAt: 1, + updatedAt: 1, + }); +} + +const sessionScope = (sessionId: string) => + resolveSessionCredentialScope(env.DB, sessionId, loadCatalog); + +function mockMint() { + let count = 0; + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + if (typeof input !== "string" || !input.endsWith("/access_tokens")) { + throw new Error("Unexpected outbound request in scoped-token test"); + } + expect(init?.method).toBe("POST"); + return Response.json({ + token: `scoped-token-${++count}`, + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + }); +} + +describe("session repository installation tokens over D1 and KV", () => { + it.each([null, "team_a"])( + "never shares tokens between different session repositories for owner %s", + async (ownerTeamId) => { + await seedTeam("team_a"); + await grant("team_a", 12); + await grant("team_a", 2); + await grant("team_a", 99); + await grant("team_a", 77); + await seedSession("session-a", [12, 2], ownerTeamId); + await seedSession("session-b", [99], ownerTeamId); + const mint = mockMint(); + const app = config(`different-sessions-${ownerTeamId ?? "workspace"}`); + const scopeA = await sessionScope("session-a"); + const scopeB = await sessionScope("session-b"); + + const tokenA = await getCachedInstallationToken(app, cacheBindings, { scope: scopeA }); + const tokenB = await getCachedInstallationToken(app, cacheBindings, { scope: scopeB }); + expect(tokenB).not.toBe(tokenA); + expect(mint.mock.calls.map(([, init]) => JSON.parse(String(init?.body)))).toEqual([ + { repository_ids: [2, 12] }, + { repository_ids: [99] }, + ]); + const keyA = await getInstallationTokenCacheKey(app, scopeA); + const keyB = await getInstallationTokenCacheKey(app, scopeB); + expect(await cacheStore.get(keyA, "json")).toMatchObject({ token: tokenA }); + expect(await cacheStore.get(keyB, "json")).toMatchObject({ token: tokenB }); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeA })).toBe(tokenA); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeB })).toBe(tokenB); + expect(mint).toHaveBeenCalledTimes(2); + } + ); + + it("mints once for a newly added grant and never reuses a broader token after removal", async () => { + await seedTeam("team_a"); + await grant("team_a", 12); + await seedSession("session-a", [12, 99], "team_a"); + const mint = mockMint(); + const app = config("grant-changes"); + const original = await sessionScope("session-a"); + const tokenOriginal = await getCachedInstallationToken(app, cacheBindings, { scope: original }); + + await grant("team_a", 99); + const expanded = await sessionScope("session-a"); + const tokenExpanded = await getCachedInstallationToken(app, cacheBindings, { scope: expanded }); + expect(tokenExpanded).not.toBe(tokenOriginal); + expect(await getInstallationTokenCacheKey(app, expanded)).not.toBe( + await getInstallationTokenCacheKey(app, original) + ); + expect(mint).toHaveBeenCalledTimes(2); + + await env.DB.prepare( + "DELETE FROM team_repository_grants WHERE team_id = ? AND repo_external_id = ?" + ) + .bind("team_a", 12) + .run(); + const narrowed = await sessionScope("session-a"); + const tokenNarrowed = await getCachedInstallationToken(app, cacheBindings, { scope: narrowed }); + expect(tokenNarrowed).not.toBe(tokenExpanded); + expect(mint).toHaveBeenCalledTimes(3); + expect(JSON.parse(String(mint.mock.calls[2][1]?.body))).toEqual({ repository_ids: [99] }); + }); + + it("returns no token for a team with no grants even when an all-scope token is cached", async () => { + await seedTeam("team_empty"); + await seedSession("session-empty-grants", [12], "team_empty"); + const mint = mockMint(); + const app = config("empty-team"); + await getCachedInstallationToken(app, cacheBindings, { scope: { kind: "all" } }); + expect(mint.mock.calls[0][1]?.body).toBeUndefined(); + mint.mockClear(); + await expect(sessionScope("session-empty-grants")).rejects.toThrow("no repositories"); + expect(mint).not.toHaveBeenCalled(); + }); + + it("installation grants retain only session members, never other installation repositories", async () => { + await seedTeam("team_all"); + await env.DB.prepare( + "INSERT INTO team_repository_grants (id, team_id, grant_kind, created_at) VALUES (?, ?, 'installation', 1)" + ) + .bind("grant-all", "team_all") + .run(); + await seedSession("session-installation-grant", [12], "team_all"); + const mint = mockMint(); + await getCachedInstallationToken(config("installation-grant-session"), cacheBindings, { + scope: await sessionScope("session-installation-grant"), + }); + expect(JSON.parse(String(mint.mock.calls[0][1]?.body))).toEqual({ repository_ids: [12] }); + }); + + it.each(["NULL member", "scalar fallback"])( + "resolves a legacy %s from the cached catalog and refuses an unresolved identity", + async (kind) => { + await seedSession("session-null-id", [12]); + if (kind === "scalar fallback") { + await env.DB.prepare("DELETE FROM session_repositories WHERE session_id = ?") + .bind("session-null-id") + .run(); + } else { + await env.DB.prepare("UPDATE session_repositories SET repo_id = NULL WHERE session_id = ?") + .bind("session-null-id") + .run(); + } + await cacheStore.put( + REPOS_CACHE_KEY, + JSON.stringify({ + scmIdentity: await reposCacheIdentity(catalogEnv), + cachedAt: new Date().toISOString(), + repos: [12, 99].map((id) => ({ + id, + owner: "acme", + name: `repo-${id}`, + fullName: `acme/repo-${id}`, + description: null, + private: true, + archived: false, + defaultBranch: "main", + })), + }) + ); + const mint = mockMint(); + await getCachedInstallationToken(config(`legacy-session-${kind}`), cacheBindings, { + scope: await sessionScope("session-null-id"), + }); + expect(JSON.parse(String(mint.mock.calls[0][1]?.body))).toEqual({ repository_ids: [12] }); + await env.DB.prepare( + kind === "scalar fallback" + ? "UPDATE sessions SET repo_name = ? WHERE id = ?" + : "UPDATE session_repositories SET repo_name = ? WHERE session_id = ?" + ) + .bind("missing", "session-null-id") + .run(); + mint.mockClear(); + await expect(sessionScope("session-null-id")).rejects.toThrow("repository id unavailable"); + expect(mint).not.toHaveBeenCalled(); + } + ); + + it("mints a repository image-build token for that repository alone with no team grants", async () => { + const mint = mockMint(); + const scope = await resolveImageBuildTokenScope( + env.DB, + { kind: "repo", id: "acme/repo-12" }, + { + kind: "repo", + repoId: 12, + repositories: [{ repoOwner: "acme", repoName: "repo-12", baseBranch: "main" }], + repositoriesFingerprint: "test-fingerprint", + }, + loadCatalog + ); + await getCachedInstallationToken(config("repository-build"), cacheBindings, { scope }); + expect(JSON.parse(String(mint.mock.calls[0][1]?.body))).toEqual({ repository_ids: [12] }); + }); + + it("recovers a provider 401 through the real scoped caches without evicting another scope", async () => { + const app = config("provider-401"); + const scopeA: TokenScope = { kind: "repositories", repositoryIds: [12] }; + const scopeB: TokenScope = { kind: "repositories", repositoryIds: [99] }; + const keyA = await getInstallationTokenCacheKey(app, scopeA); + const keyB = await getInstallationTokenCacheKey(app, scopeB); + for (const [key, token] of [ + [keyA, "rejected-token"], + [keyB, "unaffected-token"], + ]) { + await cacheStore.put( + key, + JSON.stringify({ + token, + expiresAtEpochMs: Date.now() + 60 * 60 * 1000, + cachedAtEpochMs: Date.now(), + }) + ); + } + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + if (typeof input !== "string") throw new Error("Unexpected request input"); + if (input.endsWith("/access_tokens")) { + expect(JSON.parse(String(init?.body))).toEqual({ repository_ids: [12] }); + return Response.json({ + token: "replacement-token", + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + } + expect(input).toBe("https://api.github.com/repos/acme/repo-12/git/ref/heads/main"); + if (new Headers(init?.headers).get("Authorization") === "Bearer rejected-token") { + return new Response("Unauthorized", { status: 401 }); + } + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer replacement-token"); + return Response.json({ object: { sha: "branch-sha" } }); + }); + const provider = new GitHubSourceControlProvider({ appConfig: app, cacheStore }); + expect( + await provider.getBranchHead({ owner: "acme", name: "repo-12", branch: "main" }, scopeA) + ).toBe("branch-sha"); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeA })).toBe( + "replacement-token" + ); + expect(await getCachedInstallationToken(app, cacheBindings, { scope: scopeB })).toBe( + "unaffected-token" + ); + expect(await cacheStore.get(keyA, "json")).toMatchObject({ token: "replacement-token" }); + expect(await cacheStore.get(keyB, "json")).toMatchObject({ token: "unaffected-token" }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); From 5015e10086a3d0bde3d32c250529236729c5506a Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 1 Oct 2026 10:47:48 -0700 Subject: [PATCH 04/17] feat(web): move media into Info as collapsible Artifacts (#2199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reorganizes the session inspector sidebar: - **Info is now the first tab** (order: Info, Changes, Tasks, Tools) and is the default tab for viewers with no stored tab choice. Viewers who already picked a tab still reopen on it. - **Captured media moved from Changes into Info**, renamed from "Media (n)" to **"Artifacts (n)"**. It uses the existing `CollapsibleSection` (expanded by default, toggle exposes `aria-expanded`). It sits right after Run information / Repository. - The Changes tab now only shows file changes. Opening a diff still switches the inspector to Changes via `showTab("changes")`, so the diff flow is unchanged. ## Notes for review - Making Info the **default** tab, not just the first in order, is my reading of "move info to be the first tab". If Changes should stay the default, revert the one-line change to `DEFAULT_SESSION_INSPECTOR_TAB`. - The "Media (n)" pill in the desktop action bar and mobile actions menu is unchanged. It's outside the sidebar. ## Testing - `npm test -w @open-inspect/web`: 263 files / 2672 tests pass - Web `tsc --noEmit` is clean; ESLint + Prettier pass - Updated tests: tab order and keyboard navigation with Info first, the Info default in the hook, the Changes-panel tests now open Changes first, and a new test that checks Artifacts renders in Info and collapses/expands. ## Visual verification Checked in a local dev server using a temporary, uncommitted preview page that renders `SessionRightSidebar` with fixture data. Viewport was 1512×982. - Info first and selected, with Artifacts (2) expanded: screenshot artifact `c13d29d7a5b7b08722d457e9e131887f` - Artifacts collapsed: screenshot artifact `040c3fcfbf1ea80f6805990012796179` - Collapse → expand → switch to Changes (no media there) → back to Info: video artifact `ffd194fe2a181537433d85240f7b8a30` The media thumbnails show as blank placeholders because the preview had no media backend. The Changes tab showed "Unable to load changes." because the fixture had no diff. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/eee2433c00c3516de6af9e1b0550cba3)* ## Summary by CodeRabbit * **Updates** * The session inspector now opens to the Info tab by default, with Info appearing before Changes in the tab order. * Captured media appears in an expandable Artifacts section in Info instead of the Changes panel. * The Changes panel focuses on checkout changes. * Opening media from the mobile session header shows the Info tab without changing the remembered inspector tab. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../(sidebar)/session/[id]/page.test.tsx | 16 +++++- .../app/(app)/(sidebar)/session/[id]/page.tsx | 7 +++ .../src/components/session-header.test.tsx | 34 ++++++++++++- .../web/src/components/session-header.tsx | 5 +- .../components/session-right-sidebar.test.tsx | 51 +++++++++++++------ .../src/components/session-right-sidebar.tsx | 25 +++++---- .../hooks/use-session-inspector-tab.test.tsx | 6 +-- .../src/hooks/use-session-inspector-tab.ts | 4 +- 8 files changed, 112 insertions(+), 36 deletions(-) diff --git a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx index 6b095c037f..1b7cc88739 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { Component, type PropsWithChildren, type ReactNode } from "react"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { SessionSnapshot } from "@open-inspect/shared/types/server-messages"; import { resolveSessionCapabilities } from "@/lib/session-capabilities"; @@ -153,6 +153,7 @@ class NotFoundBoundary extends Component { cleanup(); + localStorage.clear(); vi.restoreAllMocks(); }); @@ -171,6 +172,19 @@ it("renders the existing not-found path before cached session content or action expect(mocks.prompt).not.toHaveBeenCalled(); }); +it("opens phone media on Info without replacing the remembered inspector tab", async () => { + localStorage.setItem("open-inspect-session-inspector-tab", "changes"); + mocks.mobile = true; + render(); + await waitFor(() => expect(mocks.overlay.mock.lastCall?.[0].activeTab).toBe("changes")); + expect(mocks.overlay.mock.lastCall?.[0].open).toBe(false); + + act(() => mocks.header.mock.lastCall?.[0].onOpenMobileMedia()); + + expect(mocks.overlay.mock.lastCall?.[0]).toMatchObject({ open: true, activeTab: "info" }); + expect(localStorage.getItem("open-inspect-session-inspector-tab")).toBe("changes"); +}); + it("keeps desktop actions available without collaboration and refreshes sidebar and overlay scope", () => { const { rerender } = render(); expect(mocks.composer).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 a9e6b68ccc..c3893021f0 100644 --- a/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx +++ b/packages/web/src/app/(app)/(sidebar)/session/[id]/page.tsx @@ -267,6 +267,12 @@ function SessionContent({ const openMobileDetails = useCallback(() => { setIsDetailsOpen(true); }, []); + const openMobileMedia = useCallback(() => { + setIsDetailsOpen(true); + // Media lives in Info's Artifacts section. Showing it is navigation, so the + // viewer's remembered tab stays as it was. + showInspectorTab("info"); + }, [showInspectorTab]); const focusDetailsTrigger = useCallback( () => focusSessionDetailsTrigger(isPhone, actionsButtonRef.current, detailsButtonRef.current), [isPhone] @@ -451,6 +457,7 @@ function SessionContent({ onToggleDetails={toggleDetails} onToggleDesktopDetails={toggleDesktopDetails} onOpenMobileDetails={openMobileDetails} + onOpenMobileMedia={openMobileMedia} actions={{ sessionId, sessionStatus: sessionState?.status ?? DEFAULT_SESSION_STATUS, diff --git a/packages/web/src/components/session-header.test.tsx b/packages/web/src/components/session-header.test.tsx index 40783eb919..8d4bae8c99 100644 --- a/packages/web/src/components/session-header.test.tsx +++ b/packages/web/src/components/session-header.test.tsx @@ -103,6 +103,7 @@ describe("SessionHeader", () => { onToggleDetails: vi.fn(), onToggleDesktopDetails: vi.fn(), onOpenMobileDetails: vi.fn(), + onOpenMobileMedia: vi.fn(), actions, renameSession: vi.fn(), }; @@ -130,6 +131,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -161,6 +163,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={{ ...actions, capabilities: { ...FULL_CAPABILITIES, lifecycle: false } }} renameSession={vi.fn()} capabilities={{ @@ -199,6 +202,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={onToggleDesktopDetails} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -229,6 +233,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={onToggleDesktopDetails} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -254,6 +259,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -267,6 +273,7 @@ describe("SessionHeader", () => { it("replaces the phone Details control with the unified actions menu", () => { const onToggleDetails = vi.fn(); const onOpenMobileDetails = vi.fn(); + const onOpenMobileMedia = vi.fn(); render( { onToggleDetails={onToggleDetails} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={onOpenMobileDetails} - actions={actions} + onOpenMobileMedia={onOpenMobileMedia} + actions={{ + ...actions, + artifacts: [{ id: "shot-1", type: "screenshot", url: null, createdAt: 1 }], + }} renameSession={vi.fn()} /> ); @@ -297,6 +308,11 @@ describe("SessionHeader", () => { fireEvent.click(screen.getByRole("menuitem", { name: "Details" })); expect(onOpenMobileDetails).toHaveBeenCalledOnce(); expect(onToggleDetails).not.toHaveBeenCalled(); + + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + fireEvent.click(screen.getByRole("menuitem", { name: "Media (1)" })); + expect(onOpenMobileMedia).toHaveBeenCalledOnce(); + expect(onOpenMobileDetails).toHaveBeenCalledOnce(); }); it("renders separate status icons and reveals the connection label on hover", async () => { @@ -313,6 +329,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -344,6 +361,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -372,6 +390,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -405,6 +424,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -441,6 +461,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -475,6 +496,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -507,6 +529,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -537,6 +560,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -565,6 +589,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -593,6 +618,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -618,6 +644,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -638,6 +665,7 @@ describe("SessionHeader", () => { onToggleDetails: vi.fn(), onToggleDesktopDetails: vi.fn(), onOpenMobileDetails: vi.fn(), + onOpenMobileMedia: vi.fn(), actions, renameSession: vi.fn(), }; @@ -664,6 +692,7 @@ describe("SessionHeader", () => { onToggleDetails: vi.fn(), onToggleDesktopDetails: vi.fn(), onOpenMobileDetails: vi.fn(), + onOpenMobileMedia: vi.fn(), actions, renameSession: vi.fn(), }; @@ -691,6 +720,7 @@ describe("SessionHeader", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -736,6 +766,7 @@ describe("SessionHeader mobile presentation", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> @@ -865,6 +896,7 @@ describe("SessionHeader mobile presentation", () => { onToggleDetails={vi.fn()} onToggleDesktopDetails={vi.fn()} onOpenMobileDetails={vi.fn()} + onOpenMobileMedia={vi.fn()} actions={actions} renameSession={vi.fn()} /> diff --git a/packages/web/src/components/session-header.tsx b/packages/web/src/components/session-header.tsx index 578d5cbd2e..f8a664f645 100644 --- a/packages/web/src/components/session-header.tsx +++ b/packages/web/src/components/session-header.tsx @@ -140,6 +140,8 @@ export type SessionHeaderProps = { onToggleDetails: () => void; onToggleDesktopDetails: () => void; onOpenMobileDetails: () => void; + /** Opens the details overlay on the section that lists captured media. */ + onOpenMobileMedia: () => void; actions: SessionActionProps; optimisticTitle?: string; renameSession: (title: string) => Promise; @@ -162,6 +164,7 @@ export function SessionHeader({ onToggleDetails, onToggleDesktopDetails, onOpenMobileDetails, + onOpenMobileMedia, actions, optimisticTitle, renameSession, @@ -288,7 +291,7 @@ export function SessionHeader({ sandbox={sandbox} triggerRef={actionsButtonRef} onOpenDetails={onOpenMobileDetails} - onOpenMedia={onOpenMobileDetails} + onOpenMedia={onOpenMobileMedia} />
{capabilities.read && ( diff --git a/packages/web/src/components/session-right-sidebar.test.tsx b/packages/web/src/components/session-right-sidebar.test.tsx index a12ecddb9f..4f76d1461f 100644 --- a/packages/web/src/components/session-right-sidebar.test.tsx +++ b/packages/web/src/components/session-right-sidebar.test.tsx @@ -591,37 +591,40 @@ describe("SessionRightSidebar", () => { it("uses linked, keyboard-accessible tabs with one visible panel", async () => { const user = userEvent.setup(); render(inspector()); - const changes = screen.getByRole("tab", { name: "Changes" }); - expect(changes).toHaveAttribute("aria-selected", "true"); + const tabs = screen.getAllByRole("tab").map((tab) => tab.textContent?.trim()); + expect(tabs).toEqual(["Info", "Changes", "Tasks", "Tools"]); + const info = screen.getByRole("tab", { name: "Info" }); + expect(info).toHaveAttribute("aria-selected", "true"); expect(screen.getAllByRole("tabpanel")).toHaveLength(1); + expect(screen.getByText("Run information")).toBeVisible(); - await user.click(changes); + await user.click(info); await user.keyboard("{ArrowRight}"); - const info = screen.getByRole("tab", { name: "Info" }); - await waitFor(() => expect(info).toHaveFocus()); - expect(info).toHaveAttribute("aria-selected", "true"); - expect(changes).toHaveAttribute("tabindex", "-1"); - expect(screen.getByRole("tabpanel", { name: "Info" })).toHaveAttribute( + const changes = screen.getByRole("tab", { name: "Changes" }); + await waitFor(() => expect(changes).toHaveFocus()); + expect(changes).toHaveAttribute("aria-selected", "true"); + expect(info).toHaveAttribute("tabindex", "-1"); + expect(screen.getByRole("tabpanel", { name: "Changes" })).toHaveAttribute( "id", - info.getAttribute("aria-controls") + changes.getAttribute("aria-controls") ); - expect(screen.getByText("Run information")).toBeVisible(); const tools = screen.getByRole("tab", { name: "Tools" }); await user.keyboard("{End}"); await waitFor(() => expect(tools).toHaveFocus()); await user.keyboard("{ArrowRight}"); - await waitFor(() => expect(changes).toHaveFocus()); + await waitFor(() => expect(info).toHaveFocus()); await user.keyboard("{ArrowLeft}"); await waitFor(() => expect(tools).toHaveFocus()); await user.keyboard("{Home}"); - await waitFor(() => expect(changes).toHaveFocus()); + await waitFor(() => expect(info).toHaveFocus()); }); it("preserves the file filter when switching panels and passes canonical selection", async () => { const user = userEvent.setup(); const onOpenDiff = vi.fn(); render(inspector({ diffState: READY_DIFF, onOpenDiff })); + selectTab("Changes 1"); const filter = screen.getByRole("searchbox", { name: "Filter changed files" }); await user.type(filter, "navigation"); await user.click(screen.getByRole("tab", { name: "Info" })); @@ -641,6 +644,7 @@ describe("SessionRightSidebar", () => { it("totals the latest changes in the Changes header", () => { render(inspector({ diffState: READY_DIFF })); + selectTab("Changes 1"); const panel = screen.getByRole("tabpanel", { name: "Changes 1" }); expect(panel).toHaveTextContent("+2"); @@ -681,6 +685,7 @@ describe("SessionRightSidebar", () => { "preserves diff lifecycle state %#", (props, message) => { render(inspector(props)); + selectTab(/^Changes/); expect(screen.getByRole("tabpanel", { name: /^Changes/ })).toHaveTextContent(message); } ); @@ -692,18 +697,34 @@ describe("SessionRightSidebar", () => { capabilities: { ...FULL_CAPABILITIES, lifecycle: false }, }) ); + selectTab("Changes"); expect(screen.getByText("Capture failed")).toBeVisible(); expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); }); - it("lists captured media with the changes", () => { + it("lists captured media as collapsible artifacts in Info", () => { render( inspector({ - artifacts: [{ id: "shot-1", type: "screenshot", url: null, createdAt: 1 }], + artifacts: [ + { + id: "shot-1", + type: "screenshot", + url: null, + createdAt: 1, + metadata: { caption: "Login page" }, + }, + ], }) ); - expect(screen.getByRole("tabpanel", { name: /^Changes/ })).toHaveTextContent("Media (1)"); + const toggle = screen.getByRole("button", { name: "Artifacts (1)" }); + expect(screen.getByRole("tabpanel", { name: "Info" })).toContainElement(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: "Login page" })).toBeVisible(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: "Login page" })).not.toBeInTheDocument(); }); it("shows honest task and tool empty states", () => { diff --git a/packages/web/src/components/session-right-sidebar.tsx b/packages/web/src/components/session-right-sidebar.tsx index b131574f3e..c6f2a29281 100644 --- a/packages/web/src/components/session-right-sidebar.tsx +++ b/packages/web/src/components/session-right-sidebar.tsx @@ -71,8 +71,8 @@ const DEFAULT_CAN_MANAGE_BUDGET = false; const TRACE_DOWNLOAD_TIMEOUT_MS = 60_000; const INSPECTOR_TAB_LABELS: Record = { - changes: "Changes", info: "Info", + changes: "Changes", tasks: "Tasks", tools: "Tools", }; @@ -226,7 +226,7 @@ export function SessionRightSidebarContent({ ))} - {/* Canonical durable checkout changes, plus media the agent captured */} + {/* Canonical durable checkout changes */}

Files changed

@@ -288,17 +288,6 @@ export function SessionRightSidebarContent({ /> )}
- {mediaArtifacts.length > 0 && ( -
- - - -
- )}
@@ -330,6 +319,16 @@ export function SessionRightSidebarContent({ canManageBudget={canManageBudget} /> + {/* Media the agent captured */} + {mediaArtifacts.length > 0 && ( + + + + )} {scope && capabilities.changeVisibility && ( { }); describe("useSessionInspectorTab", () => { - it("opens on Changes by default", () => { + it("opens on Info by default", () => { const { result } = renderHook(() => useSessionInspectorTab()); - expect(result.current.tab).toBe("changes"); + expect(result.current.tab).toBe("info"); }); it("persists and restores the chosen tab", async () => { @@ -43,7 +43,7 @@ describe("useSessionInspectorTab", () => { localStorage.setItem("open-inspect-session-inspector-tab", "pull-requests"); const { result } = renderHook(() => useSessionInspectorTab()); - await waitFor(() => expect(result.current.tab).toBe("changes")); + await waitFor(() => expect(result.current.tab).toBe("info")); }); it("keeps working when browser storage is unavailable", () => { diff --git a/packages/web/src/hooks/use-session-inspector-tab.ts b/packages/web/src/hooks/use-session-inspector-tab.ts index 3426915ccb..6a0643fedd 100644 --- a/packages/web/src/hooks/use-session-inspector-tab.ts +++ b/packages/web/src/hooks/use-session-inspector-tab.ts @@ -2,10 +2,10 @@ import { useCallback, useEffect, useState } from "react"; -export const SESSION_INSPECTOR_TABS = ["changes", "info", "tasks", "tools"] as const; +export const SESSION_INSPECTOR_TABS = ["info", "changes", "tasks", "tools"] as const; export type SessionInspectorTab = (typeof SESSION_INSPECTOR_TABS)[number]; -const DEFAULT_SESSION_INSPECTOR_TAB: SessionInspectorTab = "changes"; +const DEFAULT_SESSION_INSPECTOR_TAB: SessionInspectorTab = "info"; const SESSION_INSPECTOR_TAB_STORAGE_KEY = "open-inspect-session-inspector-tab"; export function isSessionInspectorTab(value: unknown): value is SessionInspectorTab { From b98a378bdbe2dba1237953da9162e8a85a7e926a Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 1 Oct 2026 10:48:28 -0700 Subject: [PATCH 05/17] refactor: extract sandbox watchdog effects (COL-246) (#2172) ## Summary Implements COL-246, increment 6 of the COL-240 lifecycle-manager refactor. - Extract `failConnectTimeout`, `terminateStaleHeartbeat`, `snapshotAndStopStaleSandbox`, and `stopForInactivity` into stateless `watchdog-effects.ts` functions with per-function dependency subsets. - Keep the complete boot-budget effect, alarm context capture, policy dispatch, healthy/warning scheduling, failure accounting/reporting, public policies, and the single termination flag in `SandboxLifecycleManager`. - Add assembled-manager ordering/interleaving coverage, extend existing ESLint consumer boundaries, and update the repository ownership design. ## Ownership and Ordering The effects receive narrowed storage/socket/broadcast/shutdown/access ports and named checkpoint, failure, and generation-targeted stop operations. They have no manager reference, flag setter, scheduler, timer, or separate lifecycle state. For non-held boot budget, shutdown send, generation fence, failed state/breaker/access retirement, status/error publication and persistence, and socket detachment all precede guard acquisition. Only explicit provider stop runs inside the manager's `failBootBudget` try/finally. Fatal/unresponsive termination remains in the manager; other watchdogs intentionally do not acquire this guard. Existing retained-source holds, unfenced no-stop late-bridge self-heal, half-boot versus ready-workspace preservation, provider-managed preserve-stop, detached heartbeat capture, shutdown-first inactivity, post-await abandonment checks, log/user messages, and `SandboxAlarmResult` meanings remain unchanged. The shared alarm scheduler and session-handler ordering are untouched. This extraction does not add broader generation hardening or fix the separately documented baseline safety gaps. ## Coverage Retains all existing assembled-manager and Workerd suites. Strengthens the exact boot-budget failure/access/publication/persistence trace, actual spawn exclusion during stop and successful spawn after failed stop, retained-source no-send/no-detach and duplicate-accounting behavior. Continuation tests use fresh row snapshots and prove that non-identity field changes do not abandon the same generation. Adds deferred stop coverage for independent ID/timestamp replacements with legacy/resumable heartbeat and inactivity paths, checkpoint uncertainty blocking competing teardown, and inactivity waiting for held/owned shutdown before any legacy effects. ## Related Work Starting checkout: `60930ce`, with COL-245 integrated via #2170 after launch/access/VM extraction. COL-238 remains In Progress and #2141 is open/unmerged; its proposed heartbeat confirmation is not imported. Existing alarm policy and boot-phase helpers are reused unchanged. ## Verification Node `v24.20.0`, npm `11.19.0`; dependencies were already installed. Checks ran sequentially, with one Vitest worker to respect sandbox resources. | Command | Final Result | | --- | --- | | `npm run build -w @open-inspect/shared` | Passed | | `npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/session/alarm src/session/sandbox-shutdown --maxWorkers=1` | Passed: 26 files, 785 tests | | `npm run test:integration -w @open-inspect/control-plane -- session-lifecycle-alarm-recovery sandbox-early-connect sandbox-shutdown sandbox-state-retention --maxWorkers=1` | Passed: 4 files, 57 tests | | `npm run typecheck -w @open-inspect/control-plane` | Passed: Worker, Node, unit, integration configurations | | `npm run lint -w @open-inspect/control-plane` | Passed | | `npm run test:lint-sandbox-boundaries` | Passed: 2 tests | | `git diff --check` | Passed; committed base diff also passed | Touched-file formatting passed: ```bash npx prettier --check docs/plans/sandbox-lifecycle-manager-refactor.md eslint.config.js scripts/lint-sandbox-boundaries.test.mjs packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts packages/control-plane/src/sandbox/lifecycle/manager.ts packages/control-plane/src/sandbox/lifecycle/alarm-boot-budget-effects.test.ts packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts ``` An initial targeted run failed two new assertions because its fixture stubs startup decisions as unmanaged despite real checkpoint ownership. The new tests were narrowed to competing teardown; existing real-coordinator tests retain startup-hold coverage. The corrected targeted run passed 79 tests. Initial typecheck found an overly narrow mock return type, corrected to the existing `StopResult`; the focused unit suite and typecheck were rerun successfully. No remaining verification blockers or production behavior fixes are claimed. Full-package/full-story tests and bundle builds remain COL-247's scope. Workerd uses provider substitutes; no deployment or live-provider verification was performed. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/d1b90cca7d20fef75c0f94573635dada)* ## Summary by CodeRabbit * **Bug Fixes** * Improved sandbox lifecycle reliability during connection-timeout, stale-heartbeat, and inactivity handling. If a sandbox is replaced while shutdown is underway, the replacement remains unaffected, and shutdown already owned by another operation is not repeated. * Improved handling of uncertain checkpoint outcomes so competing alarm, failure-reporting, and teardown actions do not proceed. * **Refactor** * Updated internal lifecycle handling while preserving existing timeout, shutdown, and recovery behavior. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- .../sandbox-lifecycle-manager-refactor.md | 86 +++++- eslint.config.js | 5 + .../alarm-boot-budget-effects.test.ts | 66 ++++- .../sandbox/lifecycle/alarm-effects.test.ts | 164 +++++++++++ .../alarm-inactivity-effects.test.ts | 43 +++ .../lifecycle/manager-shutdown.test.ts | 11 +- .../src/sandbox/lifecycle/manager.ts | 260 +++-------------- .../src/sandbox/lifecycle/watchdog-effects.ts | 265 ++++++++++++++++++ scripts/lint-sandbox-boundaries.test.mjs | 6 + 9 files changed, 670 insertions(+), 236 deletions(-) create mode 100644 packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts diff --git a/docs/plans/sandbox-lifecycle-manager-refactor.md b/docs/plans/sandbox-lifecycle-manager-refactor.md index 052f305573..e5e4f92d48 100644 --- a/docs/plans/sandbox-lifecycle-manager-refactor.md +++ b/docs/plans/sandbox-lifecycle-manager-refactor.md @@ -30,6 +30,10 @@ Paths are relative to `packages/control-plane/src/`. admission flags and prior-generation replacement retirement remain in the manager. - `sandbox/lifecycle/provider-stop.ts` owns the shared local stop bound, abort/timer mechanics and explicit `confirmed`/`not_stopped` outcome contract, not either caller's retirement policy. +- `sandbox/lifecycle/watchdog-effects.ts` owns stateless connect-timeout, stale-heartbeat, stale + snapshot/stop and inactivity effects. The manager retains the complete boot-budget effect, + captures alarm facts, evaluates/dispatches policy, schedules healthy/warning checks and owns the + termination guard. - `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. @@ -37,9 +41,10 @@ Paths are relative to `packages/control-plane/src/`. ports remain in `sandbox/lifecycle/ports.ts`; consumers do not gain launch, access or shutdown internals. Session access readers retain authentication/read eligibility and decryption rechecks. -Watchdog effects remain manager responsibilities until their serial extraction increment lands. Each -increment must integrate before its successor; the manager is not intended to become a tiny facade -or lose lifecycle arbitration. +Each increment must integrate before its successor; the manager is not intended to become a tiny +facade or lose lifecycle arbitration. Fatal/unresponsive termination, generic startup claims, +failure accounting and public checkpoint/admission/recovery policies remain manager +responsibilities. ## Launch Contract @@ -210,6 +215,61 @@ These inherited semantics, along with prior-generation unscoped clearing and acc failure boundaries recorded in the baseline, are unchanged; no stronger retirement/exactly-once guarantee is claimed. +## Watchdog Contract + +`failConnectTimeout`, `terminateStaleHeartbeat`, `snapshotAndStopStaleSandbox` and +`stopForInactivity` are stateless functions. Their dependencies are narrowed storage, broadcast, +socket, shutdown and access ports plus named failure accounting/reporting, checkpoint triggering, +provider capability checks and generation-targeted best-effort stop operations. Each function takes +only its dependency subset; there is no manager reference, flag setter, scheduler or lifecycle +class. Construction does not call dependencies, and logger resolution remains lazy, including +detached snapshot rejection logging. Existing boot-phase parsing and failure text are reused without +new vocabulary, defaults or messages. + +Review follow-up keeps `failBootBudget` entirely in the manager, alongside its termination/admission +state. This replaces the effect-to-manager `stopBootBudgetSandbox` callback and private guard +wrapper; the complete ordering and guarded stop are visible in one method. Moving termination +ownership or splitting this operation behind a one-off reverse callback is not part of this +increment. + +`handleAlarm` retains the initial shutdown hold check, pre-await sandbox/time/client/provider-handle +capture, ID-plus-reservation-timestamp generation predicate, `evaluateAlarmPolicy` dispatch, +healthy/warning scheduling and `SandboxAlarmResult`. `session/alarm/handler.ts` still prioritizes +shutdown before and after other alarm work and uses those same results to fail/redrive the queue. +The shared `AlarmScheduler` remains the only alarm/deadline owner. + +- Connect timeout fails/counts/clears access before explicit stop, fences only when stop is + available and the retained source is not held, then publishes status/error after stop. Without + explicit stop, a late bridge may still self-heal the unfenced failed boot. +- Heartbeat retires incomplete boots without snapshotting, preserves provider-managed resumable + state, and delegates shutdown-required ready-workspace capture to shutdown in emergency mode. + Legacy ready-workspace capture precedes explicit destroy-stop, then runtime shutdown and final + detach. Without explicit stop, capture stays detached. Existing hold/generation checks after + awaited capture and generation checks after stop remain where they were; no new confirmation + episode is introduced. +- Boot budget holds a failed retained source without runtime shutdown, fencing, detach or provider + destruction. Otherwise it sends runtime shutdown, fences, fails/counts/retires access, publishes + and persists the failure, then detaches. Within the same manager method, only explicit + destroy-stop runs inside the `isTerminatingSandbox` try/finally interval. Concurrent spawn is + excluded during that stop, not during earlier publication/detachment. Other watchdog paths + intentionally do not acquire this guard; fatal termination keeps its own interval. +- Inactivity awaits shutdown ownership first and falls back only when unmanaged. Access retirement + retains capability-based URL/secret rules. Resumable stop skips snapshot/runtime shutdown; legacy + capture precedes runtime shutdown, explicit destroy-stop and final detach/warning. Existing + post-capture hold/generation and post-stop generation checks remain unchanged. + +Stops receive the observed provider handle and generation timestamp, not a replacement row's target. +This does not strengthen every current publication or ownership handoff: connect-timeout publication +after stop, legacy effects after unmanaged shutdown ownership, and retirement exceptions keep their +existing boundaries. The baseline's separate gaps remain separate behavior work, not extraction +fixes or exhaustive interleaving guarantees. + +Related-work reconciliation at T6's starting `60930ce`: COL-245 is integrated via PR #2170, after +the launch/access/VM increments. COL-238 remains In Progress and PR #2141 is open/unmerged; its +proposed heartbeat confirmation is absent from this checkout and is not imported. COL-150's alarm +policy and the current COL-151 boot-phase helpers are reused unchanged. Recheck independently landed +heartbeat work when integrating subsequent increments. + ## Verification Keep direct narrow-dependency tests for input resolution and lookup effects. Keep assembled tests @@ -243,6 +303,22 @@ hold, exercises the actual rehydration hook/shared deadline storage, and confirm failed/successful/repeated cleanup preserves the hold and recovery receipt. ESLint keeps cleanup internals unavailable to public consumers. +Watchdog coverage retains all assembled manager/policy/effect suites and real-storage/Workerd alarm +recovery and shutdown checks. The boot-budget trace names failure accounting, access retirement, +status/error publication and persistence before detachment and guarded stop; actual spawn remains +blocked during stop and resumes after stop failure. Retained-source tests assert no send/detach and +no duplicate failure charge under the hold. Deferred heartbeat/inactivity stop tests independently +replace ID or timestamp for legacy and resumable providers and assert post-stop abandonment without +successor mutation, detachment or new publication. Reads return fresh row snapshots rather than +aliases of the persisted fixture row; the captured alarm row remains unchanged across writes. +Same-ID/timestamp continuations vary status, provider handle, heartbeat/activity and access URL to +prove non-identity changes do not abandon the generation. Deferred ordinary captures prove in-flight +and uncertain ownership blocks competing generic teardown and subsequent stop; inactivity ownership +tests pin the absence of fallback effects before a held/owned decision. These extend rather than +replace the existing half-boot/ready, destructive-snapshot, detached-capture, late-bridge, +checkpoint-replacement, queue-result, duplicate-alarm and shared-deadline tests. ESLint keeps +watchdog internals unavailable to public consumers. + `manager-shutdown.test.ts` isolates the assembled shutdown/recovery cases, including the committed access/publication failure matrix, from the manager's orchestration suite. It retains real manager/access/shutdown composition with test storage/provider ports; real SQLite and Workerd checks @@ -254,8 +330,8 @@ Build shared first, then run sequentially from the repository root: ```bash npm run build -w @open-inspect/shared -npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/sandbox/providers src/session/sandbox-access src/session/sandbox-repository src/session/sandbox-shutdown -npm run test:integration -w @open-inspect/control-plane -- sandbox-early-connect sandbox-shutdown sandbox-state-retention session-components +npm test -w @open-inspect/control-plane -- src/sandbox/lifecycle src/session/alarm src/session/sandbox-shutdown +npm run test:integration -w @open-inspect/control-plane -- session-lifecycle-alarm-recovery sandbox-early-connect sandbox-shutdown sandbox-state-retention 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/eslint.config.js b/eslint.config.js index 83a7a31725..ef42d6f8c5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -37,6 +37,11 @@ const sandboxImplementationImports = [ message: "Bounded provider-stop mechanics are internal to lifecycle. Consumers use lifecycle ports.", }, + { + regex: "(?:^|/)lifecycle/watchdog-effects(?:\\.[cm]?[jt]sx?)?$", + message: + "Watchdog effects are internal to the lifecycle manager. Consumers use lifecycle ports.", + }, ]; export default tseslint.config( 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 8b25ace946..abf5cb7fed 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 @@ -4,7 +4,10 @@ import type { StopResult } from "../provider"; import { createAlarmFixture, createMockSandbox, createMockProvider } from "./test-helpers"; describe("boot budget alarm effects", () => { - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); it("shuts down before fencing and failing the generation, then publishes the persisted phase error", async () => { const sandbox = createMockSandbox({ @@ -127,6 +130,8 @@ describe("boot budget alarm effects", () => { }); it("holds the termination guard only for provider stop, after publishing and detaching", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); const sandbox = createMockSandbox({ status: "connecting", created_at: Date.now() - DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs, @@ -151,11 +156,34 @@ describe("boot budget alarm effects", () => { sandbox.fenced = 1; }); vi.spyOn(h.storage, "updateSandboxStatus").mockImplementation((status) => { - observations.push(`status:${h.manager.isSpawning()}`); + observations.push(`status:${status}:${h.manager.isSpawning()}`); sandbox.status = status; }); - vi.spyOn(h.broadcaster, "broadcast").mockImplementation(() => { - observations.push(`broadcast:${h.manager.isSpawning()}`); + const countFailure = vi + .mocked(h.storage.incrementCircuitBreakerFailure) + .getMockImplementation()!; + vi.mocked(h.storage.incrementCircuitBreakerFailure).mockImplementation((timestamp) => { + observations.push(`failure:${h.manager.isSpawning()}`); + countFailure(timestamp); + }); + const clearAccess = vi.mocked(h.storage.clearSandboxAccess).getMockImplementation()!; + vi.mocked(h.storage.clearSandboxAccess).mockImplementation((kind) => { + observations.push(`clear:${kind}:${h.manager.isSpawning()}`); + clearAccess(kind); + }); + const clearTunnels = vi.mocked(h.storage.clearSandboxTunnelUrls).getMockImplementation()!; + vi.mocked(h.storage.clearSandboxTunnelUrls).mockImplementation(() => { + observations.push(`clear:tunnels:${h.manager.isSpawning()}`); + clearTunnels(); + }); + const persistError = vi.mocked(h.storage.setLastSpawnError).getMockImplementation()!; + vi.mocked(h.storage.setLastSpawnError).mockImplementation((reason, timestamp) => { + observations.push(`persist:error:${h.manager.isSpawning()}`); + persistError(reason, timestamp); + }); + vi.spyOn(h.broadcaster, "broadcast").mockImplementation((message) => { + observations.push(`broadcast:${message.type}:${h.manager.isSpawning()}`); + h.broadcaster.messages.push(message); }); vi.spyOn(h.wsManager, "detachSandboxWebSocket").mockImplementation(() => { observations.push(`detach:${h.manager.isSpawning()}`); @@ -167,13 +195,26 @@ describe("boot budget alarm effects", () => { expect(observations).toEqual([ "send:false", "fence:false", - "status:false", - "broadcast:false", - "broadcast:false", - "broadcast:false", + "status:failed:false", + "failure:false", + "clear:codeServer:false", + "clear:vnc:false", + "clear:ttyd:false", + "clear:tunnels:false", + "broadcast:sandbox_access_changed:false", + "broadcast:sandbox_status:false", + "persist:error:false", + "broadcast:sandbox_error:false", "detach:false", "stop:true", ]); + expect(h.broadcaster.messages).toEqual([ + { type: "sandbox_access_changed" }, + { type: "sandbox_status", status: "failed" }, + { type: "sandbox_error", error: sandbox.last_spawn_error }, + ]); + expect(sandbox.spawn_failure_count).toBe(1); + expect(sandbox.last_spawn_error_at).toBe(Date.now()); expect(h.manager.isSpawning()).toBe(false); }); @@ -185,7 +226,7 @@ describe("boot budget alarm effects", () => { created_at: Date.now() - DEFAULT_LIFECYCLE_CONFIG.bootBudget.timeoutMs, code_server_url: "https://code.test", }); - const stopSandbox = vi.fn(async () => { + const stopSandbox = vi.fn(async (): Promise => { if (failure === "rejected") throw new Error("provider stop unavailable"); return { success: false, error: "provider stop unavailable" }; }); @@ -223,6 +264,13 @@ describe("boot budget alarm effects", () => { 1000, "Boot budget exceeded" ); + + stopSandbox.mockResolvedValue({ success: true }); + await h.manager.spawnSandbox(); + expect(h.storage.updateSandboxForSpawn).toHaveBeenCalledOnce(); + expect(h.provider.createSandbox).toHaveBeenCalledOnce(); + expect(sandbox.status).toBe("connecting"); + expect(h.manager.isSpawning()).toBe(false); } ); diff --git a/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts b/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts index 5f7e1498fb..e0527badd9 100644 --- a/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/alarm-effects.test.ts @@ -169,6 +169,170 @@ describe("cross-path alarm effects", () => { } ); + describe.each(["heartbeat", "inactivity"] as const)("%s continuation", (trigger) => { + it.each([ + { resumable: false, change: "id" }, + { resumable: false, change: "timestamp" }, + { resumable: false, change: "metadata" }, + { resumable: true, change: "id" }, + { resumable: true, change: "timestamp" }, + { resumable: true, change: "metadata" }, + ] as const)( + "revalidates $change changes during stop using fresh rows (resumable=$resumable)", + async ({ resumable, change }) => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); + const now = Date.now(); + const sandbox = createMockSandbox({ + last_heartbeat: trigger === "heartbeat" ? now - 100_000 : now, + last_activity: now - DEFAULT_LIFECYCLE_CONFIG.inactivity.timeoutMs - 1, + }); + const providerObjectId = sandbox.modal_object_id; + const createdAt = sandbox.created_at; + const initialRow = { ...sandbox }; + let stopStarted!: () => void; + const started = new Promise((resolve) => { + stopStarted = resolve; + }); + let releaseStop!: () => void; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + const stopSandbox = vi.fn(async () => { + stopStarted(); + await stopGate; + return { success: true }; + }); + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true, supportsPersistentResume: resumable }, + stopSandbox, + }) + ); + // Repository reads are snapshots; writes only mutate the persisted fixture row. + vi.mocked(h.storage.getSandbox).mockImplementation(() => ({ ...sandbox })); + const pending = h.manager.handleAlarm(); + try { + await started; + const alarmRow = vi.mocked(h.storage.getSandbox).mock.results[0].value; + expect(alarmRow).not.toBe(sandbox); + expect(alarmRow).toEqual(initialRow); + expect(sandbox.status).toBe(trigger === "heartbeat" ? "stale" : "stopped"); + // These paths intentionally don't share the boot-budget termination guard. + expect(h.manager.isSpawning()).toBe(false); + expect(stopSandbox).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + providerObjectId, + generationCreatedAtMs: createdAt, + intent: resumable ? "preserve" : "destroy", + }) + ); + Object.assign(sandbox, { + modal_sandbox_id: change === "id" ? "replacement" : sandbox.modal_sandbox_id, + created_at: change === "timestamp" ? createdAt + 1 : createdAt, + modal_object_id: "updated-provider-object", + status: "connecting", + last_heartbeat: now, + last_activity: now, + code_server_url: "https://code.test/updated", + }); + const updatedRow = { ...sandbox }; + const publications = [...h.broadcaster.messages]; + releaseStop(); + + const continues = change === "metadata"; + await expect(pending).resolves.toBe(continues ? "sandbox_terminated" : "no_action"); + + expect(alarmRow).toEqual(initialRow); + expect(sandbox).toEqual(updatedRow); + expect(h.storage.getSandbox()).not.toBe(alarmRow); + expect(h.broadcaster.messages).toEqual([ + ...publications, + ...(continues && trigger === "inactivity" + ? [ + { + type: "sandbox_warning", + message: resumable + ? "Sandbox stopped due to inactivity" + : "Sandbox stopped due to inactivity, snapshot saved", + }, + ] + : []), + ]); + if (continues) { + expect(h.wsManager.detachSandboxWebSocket).toHaveBeenCalledExactlyOnceWith( + 1000, + trigger === "heartbeat" ? "Heartbeat stale" : "Inactivity timeout" + ); + } else { + expect(h.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + } + expect(h.wsManager.sendToSandbox).toHaveBeenCalledTimes( + !resumable && (trigger === "inactivity" || continues) ? 1 : 0 + ); + expect(h.provider.takeSnapshot).toHaveBeenCalledTimes(resumable ? 0 : 1); + } finally { + releaseStop(); + await pending; + } + } + ); + + it("abandons teardown when checkpoint uncertainty takes ownership", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); + const now = Date.now(); + const sandbox = createMockSandbox({ + last_heartbeat: trigger === "heartbeat" ? now - 100_000 : now, + last_activity: now - DEFAULT_LIFECYCLE_CONFIG.inactivity.timeoutMs - 1, + }); + let captureStarted!: () => void; + const started = new Promise((resolve) => { + captureStarted = resolve; + }); + let releaseCapture!: () => void; + const captureGate = new Promise((resolve) => { + releaseCapture = resolve; + }); + const takeSnapshot = vi.fn(async () => { + captureStarted(); + await captureGate; + throw new Error("checkpoint response lost"); + }); + const stopSandbox = vi.fn(async () => ({ success: true })); + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true }, + takeSnapshot, + stopSandbox, + }) + ); + const pending = h.manager.handleAlarm(); + try { + await started; + expect(h.shutdown.isHolding()).toBe(true); + await expect(h.manager.handleAlarm()).resolves.toBe("no_action"); + await expect(h.manager.terminateFailedSandbox("competing fatal report")).resolves.toBe( + false + ); + await h.manager.terminateUnresponsiveSandbox("stop_send_failed"); + } finally { + releaseCapture(); + await pending; + } + + await expect(pending).resolves.toBe("no_action"); + expect(h.shutdown.isHolding()).toBe(true); + await expect(h.manager.handleAlarm()).resolves.toBe("no_action"); + expect(takeSnapshot).toHaveBeenCalledOnce(); + expect(stopSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.sendToSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + }); + }); + it.each(["heartbeat", "budget"] as const)( "continues the failure streak and blocks replacement after a long %s boot", async (trigger) => { diff --git a/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts b/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts index 02a74e5c25..61697ece2a 100644 --- a/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/alarm-inactivity-effects.test.ts @@ -54,6 +54,49 @@ describe("inactivity alarm effects", () => { ); }); + it.each(["owned", "held"] as const)( + "waits for shutdown ownership and avoids legacy effects when %s", + async (ownership) => { + const sandbox = createMockSandbox({ + last_activity: Date.now() - DEFAULT_LIFECYCLE_CONFIG.inactivity.timeoutMs - 1, + code_server_url: "https://code.test", + }); + const original = { ...sandbox }; + const h = createAlarmFixture( + sandbox, + createMockProvider({ + capabilities: { supportsExplicitStop: true }, + stopSandbox: vi.fn(async () => ({ success: true })), + }) + ); + let releaseOwnership!: (result: "owned" | "held") => void; + const decision = new Promise<"owned" | "held">((resolve) => { + releaseOwnership = resolve; + }); + vi.spyOn(h.shutdown, "requestShutdown").mockReturnValue(decision); + const pending = h.manager.handleAlarm(); + + try { + expect(h.shutdown.requestShutdown).toHaveBeenCalledExactlyOnceWith("inactivity_timeout"); + expect(sandbox).toEqual(original); + expect(h.broadcaster.messages).toEqual([]); + } finally { + releaseOwnership(ownership); + await pending; + } + + await expect(pending).resolves.toBe("no_action"); + expect(sandbox).toEqual(original); + expect(h.storage.updateSandboxStatus).not.toHaveBeenCalled(); + expect(h.storage.clearSandboxAccess).not.toHaveBeenCalled(); + expect(h.provider.takeSnapshot).not.toHaveBeenCalled(); + expect(h.provider.stopSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.sendToSandbox).not.toHaveBeenCalled(); + expect(h.wsManager.detachSandboxWebSocket).not.toHaveBeenCalled(); + expect(h.broadcaster.messages).toEqual([]); + } + ); + describe.each(["rejected", "unsuccessful"] as const)("%s provider stop", (failure) => { it.each([false, true])("still retires and warns (resumable=%s)", async (resumable) => { const sandbox = createMockSandbox({ diff --git a/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts b/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts index 2054cc8ce7..c305d95e0d 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager-shutdown.test.ts @@ -417,7 +417,13 @@ describe("final graceful shutdown lifecycle integration", () => { ); if (failure === "boot budget") row.last_heartbeat = Date.now(); expect(await f.manager.handleShutdownAlarm()).toBe("continue"); - await f.manager.handleAlarm(); + const result = await f.manager.handleAlarm(); + if (failure === "boot budget") { + expect(result).toEqual({ kind: "boot_budget_exceeded", reason: row.last_spawn_error }); + expect(f.sockets.sendToSandbox).not.toHaveBeenCalled(); + expect(f.sockets.detachSandboxWebSocket).not.toHaveBeenCalled(); + expect(f.manager.isSpawning()).toBe(false); + } } // Neither deleted nor fenced: the source is the only copy of the workspace. @@ -431,6 +437,9 @@ describe("final graceful shutdown lifecycle integration", () => { phase: "unknown", receipt: { kind: "retained", artifactId: "retained-source" }, }); + await expect(f.manager.handleAlarm()).resolves.toBe("no_action"); + expect(f.storage.incrementCircuitBreakerFailure).toHaveBeenCalledOnce(); + expect(f.provider.takeSnapshot).not.toHaveBeenCalled(); await f.manager.spawnSandbox(); expect(f.provider.createSandbox).not.toHaveBeenCalled(); diff --git a/packages/control-plane/src/sandbox/lifecycle/manager.ts b/packages/control-plane/src/sandbox/lifecycle/manager.ts index 38db24e5f7..a76f486798 100644 --- a/packages/control-plane/src/sandbox/lifecycle/manager.ts +++ b/packages/control-plane/src/sandbox/lifecycle/manager.ts @@ -92,6 +92,13 @@ import { type AllocationCleanupStorage, } from "./allocation-cleanup"; import { boundedProviderStop, type ProviderStopOutcome } from "./provider-stop"; +import { + failConnectTimeout, + terminateStaleHeartbeat, + stopForInactivity, + type WatchdogContext, + type WatchdogEffectsDependencies, +} from "./watchdog-effects"; export type { SandboxGeneration, SandboxAlarmResult } from "./ports"; export type { AlarmScheduler } from "../../platform-ports"; @@ -312,14 +319,8 @@ export interface IdGenerator { * can yield long enough for a replacement spawn to install a new row, and a * stop aimed at `getSandbox()` afterwards would kill the replacement instead. */ -interface AlarmContext { - sandbox: SandboxRow; - now: number; +interface AlarmContext extends WatchdogContext { connectedClients: number; - /** Provider handle of the generation the alarm observed, if it has one. */ - providerObjectId: string | undefined; - /** Whether the persisted row still holds that same generation. */ - isCurrentGeneration: () => boolean; } // ==================== Configuration ==================== @@ -411,6 +412,7 @@ export class SandboxLifecycleManager private readonly launchContext: SandboxLaunchContext; private readonly vmStartup: VmStartupReconciliation; private readonly allocationCleanup: AllocationCleanupDependencies; + private readonly watchdogEffects: WatchdogEffectsDependencies; /** * Session-scoped logger. Falls back to the module-level logger if no @@ -478,6 +480,21 @@ export class SandboxLifecycleManager this.stopProviderSandbox("startup_superseded", "destroy", signal, providerObjectId), getLogger: () => this.log, }; + this.watchdogEffects = { + storage, + broadcaster, + sockets: wsManager, + shutdown, + access, + canStopProviderSandbox: () => this.canStopProviderSandbox(), + usesProviderManagedStop: () => this.usesProviderManagedStop(), + snapshotRequiresShutdown: () => !!provider.capabilities.snapshotRequiresShutdown, + recordSpawnFailure: (now, attemptStartedAt) => this.recordSpawnFailure(now, attemptStartedAt), + reportSandboxError: (reason) => this.reportSandboxError(reason), + triggerSnapshot: (reason) => this.triggerSnapshot(reason), + stopProviderSandboxSafely: (options) => this.stopProviderSandboxSafely(options), + getLogger: () => this.log, + }; } /** @@ -1521,16 +1538,27 @@ export class SandboxLifecycleManager return "no_action"; case "connecting_timeout": - return this.failConnectTimeout(finding.elapsedMs, context); + return failConnectTimeout( + this.watchdogEffects, + finding.elapsedMs, + this.config.connectingTimeout.timeoutMs, + context + ); case "heartbeat_stale": - return this.terminateStaleHeartbeat(finding.ageMs, finding.isBooting, context); + return terminateStaleHeartbeat( + this.watchdogEffects, + finding.ageMs, + finding.isBooting, + this.config.heartbeat.timeoutMs, + context + ); case "boot_budget_exceeded": return this.failBootBudget(finding.elapsedMs, context); case "inactivity_timeout": - return this.stopForInactivity(context); + return stopForInactivity(this.watchdogEffects, this.config.inactivity.timeoutMs, context); case "inactivity_warning": this.log.info("Inactivity extended", { @@ -1566,160 +1594,7 @@ export class SandboxLifecycleManager }); } - /** - * Give up on a generation whose bridge never arrived. The row is failed and - * the breaker charged before the provider stop, so a prompt landing mid-stop - * learns the spawn died instead of waiting on the provider to confirm it. - */ - private async failConnectTimeout( - elapsedMs: number, - ctx: AlarmContext - ): Promise { - this.log.warn("Connecting timeout", { - event: "sandbox.connecting_timeout", - elapsed_ms: elapsedMs, - timeout_ms: this.config.connectingTimeout.timeoutMs, - }); - this.storage.updateSandboxStatus("failed"); - this.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); - this.access.clearAccess(); - const held = this.holdFailedRetainedBoot( - ctx.sandbox, - "Sandbox failed to connect within the allowed time" - ); - if (!held && this.canStopProviderSandbox()) { - // Fenced before the stop: a bridge arriving while the stop is in - // flight is refused at the door instead of self-healing into a - // container being killed. Where the provider cannot be stopped the - // row stays unfenced, so a boot that outlives the watchdog (#1905) - // can still connect and serve the session. - this.storage.fenceSandboxGeneration(); - await this.stopProviderSandboxSafely({ - reason: "connecting_timeout", - intent: "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after connecting timeout", - }); - } - this.broadcaster.broadcast({ type: "sandbox_status", status: "failed" }); - this.reportSandboxError( - held - ? "Sandbox failed to connect within the allowed time." - : "Sandbox failed to connect within the allowed time. It will be retried on your next message." - ); - return "sandbox_failed"; - } - - /** - * Terminate a generation that stopped heartbeating. What the sandbox was - * doing chooses the recovery: a provider that owns its state gets a - * preserving stop, a boot that died mid-flight is destroyed with no - * snapshot, and a ready sandbox is snapshotted so the session can resume. - */ - private async terminateStaleHeartbeat( - ageMs: number, - isBooting: boolean, - ctx: AlarmContext - ): Promise { - this.log.warn("Heartbeat stale", { - event: "sandbox.heartbeat_stale", - last_heartbeat_ms: ageMs, - threshold_ms: this.config.heartbeat.timeoutMs, - sandbox_status: ctx.sandbox.status, - }); - if (!isBooting && this.provider.capabilities.snapshotRequiresShutdown) { - // These providers save only on the way down, and a runtime that stopped - // heartbeating cannot take part in a graceful drain. The coordinator - // captures the source without it, then stops it. - const ownership = await this.shutdown.requestShutdown("heartbeat_timeout", "emergency"); - if (ownership !== "unmanaged") return "no_action"; - } - this.storage.updateSandboxStatus("stale"); - // A bridge that connected and then died mid-boot is a boot failure - // 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.access.clearAccess(); - this.broadcaster.broadcast({ type: "sandbox_status", status: "stale" }); - - const preservesProviderState = this.usesProviderManagedStop(); - if (preservesProviderState || isBooting) { - // `usesProviderManagedStop()` already implies `canStopProviderSandbox()`, - // so this guard only screens the booting case. Never snapshot a - // half-booted filesystem: it would be recorded as the restore point, and - // the next spawn would boot from it and skip the setup it never - // finished. No shutdown either — the row is already `stale`, which the - // send path refuses, and a bridge that stopped heartbeating is not there - // to receive it. - if (this.canStopProviderSandbox()) { - await this.stopProviderSandboxSafely({ - reason: "heartbeat_timeout", - intent: preservesProviderState ? "preserve" : "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after heartbeat timeout", - }); - } - } else { - if ((await this.snapshotAndStopStaleSandbox(ctx)) === "abandoned") return "no_action"; - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.sendToSandbox({ type: "shutdown" }); - } - - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.detachSandboxWebSocket(1000, "Heartbeat stale"); - return "sandbox_terminated"; - } - - /** - * Preserve a ready sandbox that stopped heartbeating, then stop it. - * - * Where the provider can be stopped the snapshot is awaited first, because - * the stop would otherwise race it; where it cannot, the snapshot runs - * detached so the status broadcast is not held behind it. Resolves - * "abandoned" when a shutdown or a replacement generation took over while - * the snapshot was in flight, which is the caller's cue to touch nothing - * further. - */ - private async snapshotAndStopStaleSandbox(ctx: AlarmContext): Promise<"stopped" | "abandoned"> { - if (!this.canStopProviderSandbox()) { - // Fire-and-forget snapshot so status broadcast isn't delayed. - this.triggerSnapshot("heartbeat_timeout").catch((e) => - this.log.error("Heartbeat snapshot failed", { - error: e instanceof Error ? e : String(e), - }) - ); - return "stopped"; - } - - await this.triggerSnapshot("heartbeat_timeout"); - if (this.shutdown.isHolding()) return "abandoned"; - if (!ctx.isCurrentGeneration()) return "abandoned"; - await this.stopProviderSandboxSafely({ - reason: "heartbeat_timeout", - intent: "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after heartbeat timeout", - }); - return "stopped"; - } - - /** - * Give up on a boot that outlived its budget. Order matters: the `shutdown` - * goes out first, while the socket is still adoptable (the lifecycle send - * path refuses a failed row); the generation is then fenced so a runtime - * that ignores the shutdown, or reconnects, is refused at the door and its - * supervisor exits — which is how a provider with no explicit stop is - * stopped; only then is the row failed. The failure is published and - * persisted before the provider stop yields, and the spawn guard is held - * across it, so a prompt arriving mid-stop neither waits to learn the boot - * died nor reserves a replacement that inherits this failure. A boot of the - * retained source is held instead, and its runtime and sandbox are left for - * the recovery. Returns the failure text so the alarm handler can fail the - * pending prompt with the same words. - */ + /** Publish and detach before guarding explicit stop; leave a held retained source intact. */ private async failBootBudget(elapsedMs: number, ctx: AlarmContext): Promise { const bootPhase = parseStoredSandboxBootPhase(ctx.sandbox.boot_phase); const reason = formatBootBudgetFailure( @@ -1761,63 +1636,6 @@ export class SandboxLifecycleManager return { kind: "boot_budget_exceeded", reason }; } - /** - * Stop an idle sandbox. A provider that can resume in place keeps its own - * state; otherwise the filesystem is snapshotted first so the next prompt - * restores rather than rebuilds. - */ - private async stopForInactivity(ctx: AlarmContext): Promise { - const ownership = await this.shutdown.requestShutdown("inactivity_timeout"); - if (ownership !== "unmanaged") return "no_action"; - - this.log.info("Inactivity timeout", { - event: "sandbox.timeout", - last_activity: ctx.sandbox.last_activity, - timeout_ms: this.config.inactivity.timeoutMs, - }); - // Set status to stopped FIRST to block reconnection attempts - this.storage.updateSandboxStatus("stopped"); - this.access.clearAccess(); - this.broadcaster.broadcast({ type: "sandbox_status", status: "stopped" }); - - const preservesProviderState = this.usesProviderManagedStop(); - if (preservesProviderState) { - await this.stopProviderSandboxSafely({ - reason: "inactivity_timeout", - intent: "preserve", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after inactivity timeout", - level: "error", - }); - } else { - await this.triggerSnapshot("inactivity_timeout"); - if (this.shutdown.isHolding()) return "no_action"; - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.sendToSandbox({ type: "shutdown" }); - if (this.canStopProviderSandbox()) { - await this.stopProviderSandboxSafely({ - reason: "inactivity_timeout", - intent: "destroy", - providerObjectId: ctx.providerObjectId, - generationCreatedAtMs: ctx.sandbox.created_at, - failureMessage: "Provider stop failed after inactivity timeout", - level: "error", - }); - } - } - - if (!ctx.isCurrentGeneration()) return "no_action"; - this.wsManager.detachSandboxWebSocket(1000, "Inactivity timeout"); - this.broadcaster.broadcast({ - type: "sandbox_warning", - message: preservesProviderState - ? "Sandbox stopped due to inactivity" - : "Sandbox stopped due to inactivity, snapshot saved", - }); - return "sandbox_terminated"; - } - private isCurrentSandboxState(expected: SandboxRow): boolean { const current = this.storage.getSandbox(); return ( diff --git a/packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts b/packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts new file mode 100644 index 0000000000..b36f5295f0 --- /dev/null +++ b/packages/control-plane/src/sandbox/lifecycle/watchdog-effects.ts @@ -0,0 +1,265 @@ +import type { ServerMessage } from "@open-inspect/shared/types/server-messages"; +import type { SandboxStatus } from "@open-inspect/shared/types/sessions"; +import type { Logger } from "../../logger"; +import type { SandboxRow } from "../../session/types"; +import type { StopConfig } from "../provider"; +import type { SandboxAlarmResult, SandboxGeneration } from "./ports"; +import type { SandboxAccess } from "./sandbox-access"; + +/** Generation-pinned facts captured by the manager before alarm effects can yield. */ +export interface WatchdogContext { + sandbox: SandboxRow; + now: number; + providerObjectId: string | undefined; + isCurrentGeneration: () => boolean; +} + +export interface WatchdogEffectsDependencies { + storage: { + updateSandboxStatus(status: SandboxStatus): void; + fenceSandboxGeneration(): void; + }; + broadcaster: { broadcast(message: ServerMessage): void }; + sockets: { + sendToSandbox(message: object): boolean; + detachSandboxWebSocket(code: number, reason: string): void; + }; + shutdown: { + isHolding(): boolean; + requestShutdown( + reason: string, + mode?: "graceful" | "emergency" + ): Promise<"owned" | "unmanaged" | "held">; + holdFailedRetainedBoot(reason: string, generation: SandboxGeneration): boolean; + }; + access: Pick; + canStopProviderSandbox: () => boolean; + usesProviderManagedStop: () => boolean; + snapshotRequiresShutdown: () => boolean; + recordSpawnFailure: (now: number, attemptStartedAt: number) => void; + reportSandboxError: (reason: string) => void; + triggerSnapshot: (reason: string) => Promise; + stopProviderSandboxSafely: (options: { + reason: string; + intent: StopConfig["intent"]; + providerObjectId: string | undefined; + generationCreatedAtMs: number; + failureMessage: string; + level?: "warn" | "error"; + }) => Promise; + getLogger: () => Pick; +} + +/** Fail and charge the boot before stop; providers without stop permit late bridge self-heal. */ +export async function failConnectTimeout( + deps: Pick< + WatchdogEffectsDependencies, + | "storage" + | "broadcaster" + | "shutdown" + | "access" + | "canStopProviderSandbox" + | "recordSpawnFailure" + | "reportSandboxError" + | "stopProviderSandboxSafely" + | "getLogger" + >, + elapsedMs: number, + timeoutMs: number, + ctx: WatchdogContext +): Promise { + deps.getLogger().warn("Connecting timeout", { + event: "sandbox.connecting_timeout", + elapsed_ms: elapsedMs, + timeout_ms: timeoutMs, + }); + deps.storage.updateSandboxStatus("failed"); + deps.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); + deps.access.clearAccess(); + const held = deps.shutdown.holdFailedRetainedBoot( + "Sandbox failed to connect within the allowed time", + { sandboxId: ctx.sandbox.modal_sandbox_id, createdAt: ctx.sandbox.created_at } + ); + if (!held && deps.canStopProviderSandbox()) { + // Refuse a bridge arriving during stop, but don't fence an unstoppable late boot. + deps.storage.fenceSandboxGeneration(); + await deps.stopProviderSandboxSafely({ + reason: "connecting_timeout", + intent: "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after connecting timeout", + }); + } + deps.broadcaster.broadcast({ type: "sandbox_status", status: "failed" }); + deps.reportSandboxError( + held + ? "Sandbox failed to connect within the allowed time." + : "Sandbox failed to connect within the allowed time. It will be retried on your next message." + ); + return "sandbox_failed"; +} + +/** Preserve a ready workspace, but never turn an incomplete boot into a restore point. */ +export async function terminateStaleHeartbeat( + deps: Pick< + WatchdogEffectsDependencies, + | "storage" + | "broadcaster" + | "sockets" + | "shutdown" + | "access" + | "canStopProviderSandbox" + | "usesProviderManagedStop" + | "snapshotRequiresShutdown" + | "recordSpawnFailure" + | "triggerSnapshot" + | "stopProviderSandboxSafely" + | "getLogger" + >, + ageMs: number, + isBooting: boolean, + timeoutMs: number, + ctx: WatchdogContext +): Promise { + deps.getLogger().warn("Heartbeat stale", { + event: "sandbox.heartbeat_stale", + last_heartbeat_ms: ageMs, + threshold_ms: timeoutMs, + sandbox_status: ctx.sandbox.status, + }); + if (!isBooting && deps.snapshotRequiresShutdown()) { + // A missing runtime cannot drain; the coordinator captures and retires the source. + const ownership = await deps.shutdown.requestShutdown("heartbeat_timeout", "emergency"); + if (ownership !== "unmanaged") return "no_action"; + } + deps.storage.updateSandboxStatus("stale"); + if (isBooting) deps.recordSpawnFailure(ctx.now, ctx.sandbox.created_at); + deps.access.clearAccess(); + deps.broadcaster.broadcast({ type: "sandbox_status", status: "stale" }); + + const preservesProviderState = deps.usesProviderManagedStop(); + if (preservesProviderState || isBooting) { + // No snapshot of a half-booted filesystem or shutdown to an absent bridge. + if (deps.canStopProviderSandbox()) { + await deps.stopProviderSandboxSafely({ + reason: "heartbeat_timeout", + intent: preservesProviderState ? "preserve" : "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after heartbeat timeout", + }); + } + } else { + if ((await snapshotAndStopStaleSandbox(deps, ctx)) === "abandoned") return "no_action"; + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.sendToSandbox({ type: "shutdown" }); + } + + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.detachSandboxWebSocket(1000, "Heartbeat stale"); + return "sandbox_terminated"; +} + +/** Await capture before explicit stop; otherwise leave capture detached as before. */ +export async function snapshotAndStopStaleSandbox( + deps: Pick< + WatchdogEffectsDependencies, + | "canStopProviderSandbox" + | "triggerSnapshot" + | "shutdown" + | "stopProviderSandboxSafely" + | "getLogger" + >, + ctx: WatchdogContext +): Promise<"stopped" | "abandoned"> { + if (!deps.canStopProviderSandbox()) { + deps.triggerSnapshot("heartbeat_timeout").catch((e) => + deps.getLogger().error("Heartbeat snapshot failed", { + error: e instanceof Error ? e : String(e), + }) + ); + return "stopped"; + } + + await deps.triggerSnapshot("heartbeat_timeout"); + if (deps.shutdown.isHolding()) return "abandoned"; + if (!ctx.isCurrentGeneration()) return "abandoned"; + await deps.stopProviderSandboxSafely({ + reason: "heartbeat_timeout", + intent: "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after heartbeat timeout", + }); + return "stopped"; +} + +/** Shutdown owns inactivity first; only unmanaged work uses the legacy fallback. */ +export async function stopForInactivity( + deps: Pick< + WatchdogEffectsDependencies, + | "storage" + | "broadcaster" + | "sockets" + | "shutdown" + | "access" + | "canStopProviderSandbox" + | "usesProviderManagedStop" + | "triggerSnapshot" + | "stopProviderSandboxSafely" + | "getLogger" + >, + timeoutMs: number, + ctx: WatchdogContext +): Promise { + const ownership = await deps.shutdown.requestShutdown("inactivity_timeout"); + if (ownership !== "unmanaged") return "no_action"; + + deps.getLogger().info("Inactivity timeout", { + event: "sandbox.timeout", + last_activity: ctx.sandbox.last_activity, + timeout_ms: timeoutMs, + }); + // Block reconnect before retiring access or yielding to capture/stop. + deps.storage.updateSandboxStatus("stopped"); + deps.access.clearAccess(); + deps.broadcaster.broadcast({ type: "sandbox_status", status: "stopped" }); + + const preservesProviderState = deps.usesProviderManagedStop(); + if (preservesProviderState) { + await deps.stopProviderSandboxSafely({ + reason: "inactivity_timeout", + intent: "preserve", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after inactivity timeout", + level: "error", + }); + } else { + await deps.triggerSnapshot("inactivity_timeout"); + if (deps.shutdown.isHolding()) return "no_action"; + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.sendToSandbox({ type: "shutdown" }); + if (deps.canStopProviderSandbox()) { + await deps.stopProviderSandboxSafely({ + reason: "inactivity_timeout", + intent: "destroy", + providerObjectId: ctx.providerObjectId, + generationCreatedAtMs: ctx.sandbox.created_at, + failureMessage: "Provider stop failed after inactivity timeout", + level: "error", + }); + } + } + + if (!ctx.isCurrentGeneration()) return "no_action"; + deps.sockets.detachSandboxWebSocket(1000, "Inactivity timeout"); + deps.broadcaster.broadcast({ + type: "sandbox_warning", + message: preservesProviderState + ? "Sandbox stopped due to inactivity" + : "Sandbox stopped due to inactivity, snapshot saved", + }); + return "sandbox_terminated"; +} diff --git a/scripts/lint-sandbox-boundaries.test.mjs b/scripts/lint-sandbox-boundaries.test.mjs index f134bcd241..f940e3c399 100644 --- a/scripts/lint-sandbox-boundaries.test.mjs +++ b/scripts/lint-sandbox-boundaries.test.mjs @@ -19,6 +19,7 @@ test("new consumers and platform adapters cannot import sandbox implementations" ["VmStartupReconciliation", "../sandbox/lifecycle/vm-startup-reconciliation"], ["AllocationCleanupDependencies", "../sandbox/lifecycle/allocation-cleanup"], ["ProviderStopOutcome", "../sandbox/lifecycle/provider-stop"], + ["WatchdogEffectsDependencies", "../sandbox/lifecycle/watchdog-effects"], ]) { const [result] = await eslint.lintText( `import type { ${name} } from "${source}"; export type Dependency = ${name};`, @@ -58,4 +59,9 @@ test("focused ports remain usable by consumers and extracted lifecycle modules", { filePath: "packages/control-plane/src/sandbox/lifecycle/manager.ts" } ); assert.equal(cleanup.errorCount, 0, JSON.stringify(cleanup.messages)); + const [watchdog] = await eslint.lintText( + 'import { terminateStaleHeartbeat } from "./watchdog-effects"; export const effect = terminateStaleHeartbeat;', + { filePath: "packages/control-plane/src/sandbox/lifecycle/manager.ts" } + ); + assert.equal(watchdog.errorCount, 0, JSON.stringify(watchdog.messages)); }); From 499f348a259698a8b5ade15f5ba5ce90ce0cfbbc Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 1 Oct 2026 17:18:13 -0700 Subject: [PATCH 06/17] docs: correct modal-vm provider and recovery guidance (#2200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Addresses COL-234 with documentation-only changes. - Correct the build-allocation recovery claim: a lost create response marks the build failed, and its VM remains until the provider timeout (40 minutes by default, up to 70 minutes including finalization grace). A re-triggered build uses a new build ID and allocation name. - Add `modal-vm` to the pre-built image provider list and explain that it builds Modal images on the VM backend, separately from `modal` images. - Add a linked `modal-vm` entry to the `SANDBOX_PROVIDER` deployment-settings row, preserving Prettier's table alignment. - Require a web redeployment after changing `sandbox_provider` with Terraform. For Vercel, operators must manually run **Deploy Web** after the apply so the build-time provider value and Pre-Built Images filtering are updated. This applies to every provider switch. ## Verification - Checked the documentation against the current build workflow, Modal client and build lifecycle, provider registry, Terraform configuration, Deploy Web workflow, and image-builds API filtering. - `npx prettier --check docs/MODAL_DOCKER.md docs/IMAGE_PREBUILD.md docs/GETTING_STARTED.md` passes. - `git diff --check origin/main...HEAD` passes. - Only `docs/MODAL_DOCKER.md`, `docs/IMAGE_PREBUILD.md`, and `docs/GETTING_STARTED.md` changed. No application code changes. --- *Created with [Open-Inspect](https://open-inspect-prod.vercel.app/session/9f7eb3c5e11650155e8b1e71abd13700)* ## Summary by CodeRabbit * **Documentation** * Added `modal-vm` as a supported sandbox provider in the configuration and getting-started guides. * Clarified that `modal-vm` sessions use images built on the VM backend, not images built under `modal`. * Updated build-retry guidance: after a lost create response, the VM may remain running until its provider timeout; retries use a new build and allocation. * Clarified provider-switch deployment steps for Cloudflare and Vercel, including that Vercel’s image-build list may show the previous provider until a new production deployment is live. --------- Co-authored-by: Cole Murray <2492022+ColeMurray@users.noreply.github.com> Co-authored-by: waclaude --- docs/GETTING_STARTED.md | 163 +++++++++--------- docs/IMAGE_PREBUILD.md | 8 +- docs/MODAL_DOCKER.md | 28 ++- .../docs/configure/prebuilt-images.mdx | 3 +- 4 files changed, 113 insertions(+), 89 deletions(-) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b9d515b0e3..143e0780ca 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -378,7 +378,7 @@ modal_workspace = "your-modal-workspace" modal_environment = "your-modal-environment" modal_environment_web_suffix = "your-modal-web-suffix" # Lowercase letters, digits, dashes; empty for https://workspace--... endpoints -# Sandbox provider: "modal" (default), "daytona", "vercel", "opencomputer", or "e2b" +# Sandbox provider: "modal" (default), "modal-vm", "daytona", "vercel", "opencomputer", or "e2b" # sandbox_provider = "modal" # Daytona (only required when sandbox_provider = "daytona") @@ -737,8 +737,9 @@ Your core deployment is complete. Everything below is optional; follow only the The core path uses Modal, the default `sandbox_provider`. To run sessions on another provider, set `sandbox_provider` in `terraform.tfvars` and follow the matching section below instead of the -[Modal](#modal) credentials in Step 2. Terraform accepts `modal`, `daytona`, `vercel`, -`opencomputer`, or `e2b`. +[Modal](#modal) credentials in Step 2. Terraform accepts `modal`, `modal-vm`, `daytona`, `vercel`, +`opencomputer`, or `e2b`. The `modal-vm` backend uses the same Modal credentials; see +[Modal VM backend](MODAL_DOCKER.md). ### Daytona @@ -1249,84 +1250,84 @@ may contain personal information; leave them in secrets if you prefer masking in The table below describes deployment settings and credentials; use Variables for the names above and Secrets for credentials: -| Setting Name | Value | -| ---------------------------------- | ------------------------------------------------------------------------------------------- | -| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token | -| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | -| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain | -| `R2_MEDIA_LOCATION` | R2 location hint for the media bucket (defaults to `ENAM`) | -| `R2_MEDIA_BUCKET_NAME` | Optional media bucket name override for a pre-created bucket | -| `DEPLOYMENT_NAME` | Your deployment name | -| `R2_ACCESS_KEY_ID` | R2 access key ID | -| `R2_SECRET_ACCESS_KEY` | R2 secret access key | -| `WEB_PLATFORM` | `vercel` or `cloudflare` | -| `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 | -| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) | -| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty | -| `SANDBOX_PROVIDER` | `modal` (default), `daytona`, `vercel`, `opencomputer`, or `e2b` | -| `SANDBOX_INACTIVITY_TIMEOUT_MS` | Idle milliseconds before a sandbox is snapshotted and stopped (defaults to `600000`) | -| `SANDBOX_BOOT_TIMEOUT_MS` | Milliseconds a connected sandbox may keep booting before it fails (defaults to `1800000`) | -| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name prefix _(only if `sandbox_provider = "daytona"`)_ | -| `DAYTONA_BASE_SNAPSHOT_MEMORY_GIB` | Base snapshot memory in GiB (defaults to `2`) | -| `DAYTONA_TARGET` | Optional Daytona target name | -| `DAYTONA_TOOLBOX_API_URL` | Optional Daytona toolbox proxy override; empty uses the proxy each sandbox reports | -| `DAYTONA_PREBUILDS_ENABLED` | `true` to admit new Daytona prebuilt-image builds and boot from them (default: `false`) | -| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ | -| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ | -| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions | -| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds | -| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) | -| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) | -| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override | -| `OPENCOMPUTER_API_KEY` | OpenComputer API key _(only if `sandbox_provider = "opencomputer"`)_ | -| `E2B_API_KEY` | E2B API key _(only if `sandbox_provider = "e2b"`)_ | -| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` | -| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` | -| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` | -| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` | -| `GH_APP_ID` | Required GitHub App repository-access ID | -| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) | -| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID | -| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) | -| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) | -| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) | -| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) | -| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) | -| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) | -| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) | -| `LINEAR_API_KEY` | Optional Linear API key used as a comment-posting fallback | -| `ANTHROPIC_API_KEY` | Optional; reaches Modal and OpenComputer sandboxes; classifier fallback | -| `CLASSIFICATION_ANTHROPIC_API_KEY` | Optional classifier-only Anthropic key; never reaches sandboxes | -| `CLASSIFICATION_OPENAI_API_KEY` | Classifier OpenAI key (required when `classification_model` is an OpenAI id) | -| `OPENAI_API_KEY` | Optional OpenAI API key used when a session selects API-key authentication | -| `XAI_API_KEY` | Optional xAI API key used when a session selects API-key authentication | -| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) | -| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | -| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | -| `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` | Optional existing provider-account key override; Terraform generates one when omitted | -| `MODAL_API_SECRET` | Generated Modal API secret | -| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | -| `ALLOWED_USERS` | Comma-separated GitHub usernames (empty = list unused; see note below) | -| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (empty = list unused; see note below) | -| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | -| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in | -| `UNSAFE_ALLOW_ALL_USERS` | `true` to allow any authenticated user when every allowlist is empty (defaults to `false`) | -| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) | -| `ENABLE_SERVICE_BINDINGS` | Optional Terraform CI flag for service-binding phase 1 (defaults to `true`) | -| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) | -| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) | -| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) | -| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) | -| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) | +| Setting Name | Value | +| ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `CLOUDFLARE_API_TOKEN` | Your Cloudflare API token | +| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | +| `CLOUDFLARE_WORKER_SUBDOMAIN` | Your workers.dev subdomain | +| `R2_MEDIA_LOCATION` | R2 location hint for the media bucket (defaults to `ENAM`) | +| `R2_MEDIA_BUCKET_NAME` | Optional media bucket name override for a pre-created bucket | +| `DEPLOYMENT_NAME` | Your deployment name | +| `R2_ACCESS_KEY_ID` | R2 access key ID | +| `R2_SECRET_ACCESS_KEY` | R2 secret access key | +| `WEB_PLATFORM` | `vercel` or `cloudflare` | +| `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 | +| `MODAL_ENVIRONMENT` | Modal environment name (defaults to `main`) | +| `MODAL_ENVIRONMENT_WEB_SUFFIX` | Modal environment web suffix for endpoint URLs; lowercase letters, digits, dashes, or empty | +| `SANDBOX_PROVIDER` | `modal` (default), [`modal-vm`](MODAL_DOCKER.md), `daytona`, `vercel`, `opencomputer`, or `e2b` | +| `SANDBOX_INACTIVITY_TIMEOUT_MS` | Idle milliseconds before a sandbox is snapshotted and stopped (defaults to `600000`) | +| `SANDBOX_BOOT_TIMEOUT_MS` | Milliseconds a connected sandbox may keep booting before it fails (defaults to `1800000`) | +| `DAYTONA_API_URL` | Daytona API URL _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_API_KEY` | Daytona API key _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_BASE_SNAPSHOT` | Daytona base snapshot name prefix _(only if `sandbox_provider = "daytona"`)_ | +| `DAYTONA_BASE_SNAPSHOT_MEMORY_GIB` | Base snapshot memory in GiB (defaults to `2`) | +| `DAYTONA_TARGET` | Optional Daytona target name | +| `DAYTONA_TOOLBOX_API_URL` | Optional Daytona toolbox proxy override; empty uses the proxy each sandbox reports | +| `DAYTONA_PREBUILDS_ENABLED` | `true` to admit new Daytona prebuilt-image builds and boot from them (default: `false`) | +| `VERCEL_SANDBOX_TOKEN` | Vercel API token _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_PROJECT_ID` | Vercel project ID for sandbox sessions _(only if `sandbox_provider = "vercel"`)_ | +| `VERCEL_SANDBOX_TEAM_ID` | Optional Vercel team/account ID for sandbox sessions | +| `VERCEL_BASE_SNAPSHOT_ID` | Optional manual Vercel base-runtime snapshot; skips Terraform-managed snapshot builds | +| `VERCEL_SANDBOX_RUNTIME` | Optional Vercel Sandbox runtime (defaults to `node24`) | +| `VERCEL_SNAPSHOT_EXPIRATION_MS` | Optional Vercel runtime snapshot expiration in milliseconds (`0` means no expiration) | +| `VERCEL_SANDBOX_API_BASE_URL` | Optional advanced Vercel Sandbox API base URL override | +| `OPENCOMPUTER_API_KEY` | OpenComputer API key _(only if `sandbox_provider = "opencomputer"`)_ | +| `E2B_API_KEY` | E2B API key _(only if `sandbox_provider = "e2b"`)_ | +| `GH_OAUTH_CLIENT_ID` | Optional GitHub sign-in client ID; set with `GH_OAUTH_CLIENT_SECRET` | +| `GH_OAUTH_CLIENT_SECRET` | Optional GitHub sign-in client secret; set with `GH_OAUTH_CLIENT_ID` | +| `GOOGLE_CLIENT_ID` | Optional Google sign-in client ID; set with `GOOGLE_CLIENT_SECRET` | +| `GOOGLE_CLIENT_SECRET` | Optional Google sign-in client secret; set with `GOOGLE_CLIENT_ID` | +| `GH_APP_ID` | Required GitHub App repository-access ID | +| `GH_APP_PRIVATE_KEY` | Required GitHub App repository-access private key (PKCS#8 format) | +| `GH_APP_INSTALLATION_ID` | Required GitHub App repository-access installation ID | +| `ENABLE_SLACK_BOT` | `true` to deploy Slack bot, `false` to skip (default: `true`) | +| `SLACK_BOT_TOKEN` | Slack bot token (required if enabled) | +| `SLACK_SIGNING_SECRET` | Slack signing secret (required if enabled) | +| `ENABLE_LINEAR_BOT` | `true` to deploy Linear bot, `false` to skip (default: `false`) | +| `LINEAR_CLIENT_ID` | Linear OAuth application client ID (required if Linear enabled) | +| `LINEAR_CLIENT_SECRET` | Linear OAuth application client secret (required if Linear enabled) | +| `LINEAR_WEBHOOK_SECRET` | Linear webhook signing secret (required if Linear enabled) | +| `LINEAR_API_KEY` | Optional Linear API key used as a comment-posting fallback | +| `ANTHROPIC_API_KEY` | Optional; reaches Modal and OpenComputer sandboxes; classifier fallback | +| `CLASSIFICATION_ANTHROPIC_API_KEY` | Optional classifier-only Anthropic key; never reaches sandboxes | +| `CLASSIFICATION_OPENAI_API_KEY` | Classifier OpenAI key (required when `classification_model` is an OpenAI id) | +| `OPENAI_API_KEY` | Optional OpenAI API key used when a session selects API-key authentication | +| `XAI_API_KEY` | Optional xAI API key used when a session selects API-key authentication | +| `DEEPSEEK_API_KEY` | DeepSeek API key (optional, required only for DeepSeek models) | +| `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | +| `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | +| `PROVIDER_ACCOUNTS_ENCRYPTION_KEY` | Optional existing provider-account key override; Terraform generates one when omitted | +| `MODAL_API_SECRET` | Generated Modal API secret | +| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | +| `ALLOWED_USERS` | Comma-separated GitHub usernames (empty = list unused; see note below) | +| `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (empty = list unused; see note below) | +| `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | +| `ALLOWED_GITHUB_ORGS` | Comma-separated GitHub orgs whose active members can sign in | +| `UNSAFE_ALLOW_ALL_USERS` | `true` to allow any authenticated user when every allowlist is empty (defaults to `false`) | +| `ENABLE_DURABLE_OBJECT_BINDINGS` | Optional Terraform CI flag for Durable Object phase 1 (defaults to `true`) | +| `ENABLE_SERVICE_BINDINGS` | Optional Terraform CI flag for service-binding phase 1 (defaults to `true`) | +| `ENABLE_GITHUB_BOT` | `true` to deploy GitHub bot worker (or empty to skip) | +| `GH_WEBHOOK_SECRET` | GitHub webhook secret (required if GitHub bot enabled) | +| `GH_BOT_USERNAME` | GitHub App bot username, e.g., `my-app[bot]` (required if GitHub bot enabled) | +| `APP_NAME` | Optional display name for whitelabeling (default: `Open-Inspect`) | +| `APP_ICON_URL` | Optional URL to a custom logo/favicon (default: built-in icon) | An empty allowlist only means that list is not used; it does not admit everyone. Terraform fails the plan unless at least one of `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, `ALLOWED_EMAILS`, or diff --git a/docs/IMAGE_PREBUILD.md b/docs/IMAGE_PREBUILD.md index 86cc4237a9..df48742664 100644 --- a/docs/IMAGE_PREBUILD.md +++ b/docs/IMAGE_PREBUILD.md @@ -34,9 +34,11 @@ few minutes of changes. ## Getting Started Pre-built images are available when the deployment uses `sandbox_provider = "modal"`, -`sandbox_provider = "vercel"`, `sandbox_provider = "opencomputer"`, `sandbox_provider = "e2b"`, or -`sandbox_provider = "daytona"`. The artifact is stored per provider as a Modal image, Vercel -snapshot, OpenComputer checkpoint, or E2B/Daytona snapshot. +`sandbox_provider = "modal-vm"`, `sandbox_provider = "vercel"`, `sandbox_provider = "opencomputer"`, +`sandbox_provider = "e2b"`, or `sandbox_provider = "daytona"`. The artifact is stored per provider +as a Modal image, Vercel snapshot, OpenComputer checkpoint, or E2B/Daytona snapshot. With +`modal-vm`, the artifact is a Modal image built on the VM backend. Images built under `modal` are +not used by `modal-vm` sessions. Daytona additionally requires an operator to open admission (`daytona_prebuilds_enabled`, default off) — see [Daytona prebuilds](#daytona-prebuilds) below. While admission is closed the settings diff --git a/docs/MODAL_DOCKER.md b/docs/MODAL_DOCKER.md index 7c21617f42..521ad32914 100644 --- a/docs/MODAL_DOCKER.md +++ b/docs/MODAL_DOCKER.md @@ -55,10 +55,12 @@ Containers must use appropriate persistence and restart policies. Live Docker pa provided. Raw daemon logs are truncated after clean preparation before reusable image capture. Retried VM launches adopt only an exactly owned allocation and recover its original interactive -credentials. A predecessor must be confirmed terminated before launching a replacement. Build -allocations have deterministic backend/build names, so a retried create adopts the allocation an -earlier lost response created. Returned build handles are persisted for cleanup before backend -validation; incompatible builds never start and cannot publish prepared images. +credentials. A predecessor must be confirmed terminated before launching a replacement. A build +whose create response is lost is marked failed; its VM runs until the provider's build sandbox +timeout (40 minutes by default, up to 70 minutes, including the 10-minute finalization grace). +Re-triggering the build uses a new build ID and allocation name, not the previous allocation. +Returned build handles are persisted for cleanup before backend validation; incompatible builds +never start and cannot publish prepared images. Before create or restore returns, the control plane records a pending VM reference of the form `modal-vm-session:["sessionId","sandboxId"]`. The session id selects the named allocation; the @@ -122,6 +124,24 @@ credentials for pending cleanup, and rebuild images under the selected backend. artifacts. Rollback to `modal` does not transparently resume VM sessions or prove old VMs have stopped. +After changing `sandbox_provider`, apply Terraform and ensure the web app is deployed with the new +provider. The deployment step depends on `web_platform`: + +- **Cloudflare**: `terraform apply` rebuilds and deploys the web Worker with the new provider. No + separate redeploy step is needed. +- **Vercel**: after the apply, create a new production deployment through your configured CLI, + Git-linked, or GitHub Actions deployment path. If using Actions, manually run **Deploy Web**; + Terraform-only changes do not trigger it, and it skips deployment unless `VERCEL_API_TOKEN` and + `VERCEL_PROJECT_ID` are configured in GitHub. See + [Deploy the Web App](GETTING_STARTED.md#step-7-deploy-the-web-app) for the CLI and Git-linked + paths. + +Terraform updates `NEXT_PUBLIC_SANDBOX_PROVIDER`, but Vercel environment changes apply only to new +deployments and `NEXT_PUBLIC_*` values are fixed at build time. Until the new Vercel deployment is +live, `GET /api/image-builds` still filters by the old provider, so the Pre-Built Images page shows +the old provider's builds. This applies to any provider switch, not only switches to or from +`modal-vm`. + PR #2007's earlier per-session Docker/variant design was not deployed. Its schema additions and settings are not part of this implementation, so no variant migration is required. diff --git a/packages/docs/content/docs/configure/prebuilt-images.mdx b/packages/docs/content/docs/configure/prebuilt-images.mdx index 9c82db2ba7..7f56c8479b 100644 --- a/packages/docs/content/docs/configure/prebuilt-images.mdx +++ b/packages/docs/content/docs/configure/prebuilt-images.mdx @@ -4,7 +4,7 @@ description: Enable prebuilt images for a repository or an environment so sessio audience: team-owner owner: platform status: published -lastReviewed: "2026-09-28" +lastReviewed: "2026-10-01" relatedCode: - docs/IMAGE_PREBUILD.md - docs/HOW_IT_WORKS.md @@ -53,6 +53,7 @@ The artifact is stored by the deployment's sandbox provider: | Provider | Artifact | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Modal | Modal image | +| `modal-vm` | Modal image built on the VM backend. Images built under `modal` are not used by `modal-vm` sessions. | | Vercel Sandboxes | Vercel snapshot | | OpenComputer | Checkpoint | | E2B | Snapshot | From 9b398dc8bc563930b0bf3ed186e9176ca4d1afcd Mon Sep 17 00:00:00 2001 From: Cole Murray Date: Thu, 1 Oct 2026 19:34:48 -0700 Subject: [PATCH 07/17] fix(web): use shared Select component on team page dropdowns (#2204) ## Summary The team page had five dropdowns rendered as native ` + onValueChange={(value) => setEditor((current) => ({ ...current, draft: { ...current.draft, - joinPolicy: event.target.value === "open" ? "open" : "invite_only", + joinPolicy: value === "open" ? "open" : "invite_only", }, })) } - className="w-full rounded border border-border bg-background px-2 py-2 text-sm disabled:opacity-50" > - - - + + + + + Invite only + Open + +
- + + + + + Workspace + Team + Private + +
+ ); + const activeTab = access.tabs.includes(tab) ? tab : access.tabs[0]; + if (!activeTab) return backButton; + + return ( +
+

{environment.name}

+

+ {environment.description || "Edit this environment."} +

+ +
+ {access.tabs.map((entry) => ( + + ))} +
+ + {error && {error}} + + {activeTab === "configuration" ? ( + + ) : activeTab === "secrets" ? ( +
+

+ Sessions launched from this environment get global secrets plus these — repository + secrets do not carry over automatically. Changing secrets invalidates prebuilt images + and triggers a rebuild. +

+ + {access.canImportRepoSecrets && ( + + )} +
{backButton}
+
+ ) : ( +
+ +
{backButton}
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/settings/environment-form.test.tsx b/packages/web/src/components/settings/environment-form.test.tsx index b70be9b743..527dc0abec 100644 --- a/packages/web/src/components/settings/environment-form.test.tsx +++ b/packages/web/src/components/settings/environment-form.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom /// -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import { MAX_TARGET_REPOSITORIES } from "@open-inspect/shared/types/repositories"; @@ -14,6 +14,8 @@ expect.extend(matchers); afterEach(cleanup); const mocks = vi.hoisted(() => ({ + useRepos: vi.fn(), + allowWorkspace: true, reposValue: [] as Array<{ id: number; fullName: string; @@ -26,7 +28,22 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/hooks/use-repos", () => ({ - useRepos: () => ({ repos: mocks.reposValue, loading: false }), + useRepos: (enabled: boolean, teamId: string | null) => { + mocks.useRepos(enabled, teamId); + return { repos: mocks.reposValue, loading: false }; + }, +})); +vi.mock("@/hooks/use-resource-teams", () => ({ + useResourceTeams: () => ({ + teams: [ + { id: "team-1", name: "Engineering" }, + { id: "team-2", name: "Design" }, + ], + allTeams: [], + loading: false, + error: null, + allowWorkspace: mocks.allowWorkspace, + }), })); vi.mock("@/hooks/use-branches", () => ({ @@ -34,6 +51,9 @@ vi.mock("@/hooks/use-branches", () => ({ })); beforeAll(() => { + // Radix Select uses pointer capture, which jsdom lacks. + Element.prototype.hasPointerCapture = () => false; + Element.prototype.releasePointerCapture = () => {}; Element.prototype.scrollIntoView = vi.fn(); // Radix Switch measures itself via ResizeObserver, which jsdom lacks. vi.stubGlobal( @@ -46,6 +66,10 @@ beforeAll(() => { ); }); +beforeEach(() => { + mocks.allowWorkspace = true; +}); + function repo(owner: string, name: string, id: number) { return { id, @@ -79,6 +103,85 @@ function environment( } describe("EnvironmentForm", () => { + it.each(["team-1", null])("validates owner %s and discards stale selections", async (teamId) => { + mocks.allowWorkspace = teamId !== null; + mocks.reposValue = [repo("acme", "web", 1)]; + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + + ); + expect(mocks.useRepos).toHaveBeenLastCalledWith(true, teamId); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent( + teamId ? "Engineering" : "Select a team" + ); + fireEvent.submit(container.querySelector("form")!); + if (!teamId) { + expect(screen.getByRole("button", { name: "Create environment" })).toBeDisabled(); + expect(onSubmit).not.toHaveBeenCalled(); + return; + } + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ teamId })); + // Creating from a team's page keeps the environment on that page. + expect(screen.getByRole("combobox", { name: "Team" })).toBeDisabled(); + await user.click(screen.getByRole("combobox", { name: "Team" })); + expect(screen.queryByRole("option", { name: "Design" })).not.toBeInTheDocument(); + }); + + it("discards stale selections when an unscoped creation changes owner", async () => { + mocks.allowWorkspace = true; + mocks.reposValue = [repo("acme", "web", 1)]; + const onSubmit = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + + ); + await user.click(screen.getByRole("combobox", { name: "Team" })); + await user.click(screen.getByRole("option", { name: "Design" })); + expect(mocks.useRepos).toHaveBeenLastCalledWith(true, "team-2"); + expect(screen.queryByTitle("acme/web")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create environment" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("shows existing team ownership read-only and excludes it from edit submissions", () => { + mocks.reposValue = [repo("acme", "web", 1)]; + const onSubmit = vi.fn(); + const { container } = render( + + ); + expect(screen.getByRole("combobox", { name: "Team" })).toHaveTextContent("Engineering"); + expect(screen.getByRole("combobox", { name: "Team" })).toBeDisabled(); + fireEvent.submit(container.querySelector("form")!); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("teamId"); + expect(onSubmit.mock.calls[0][0].repositories).toEqual([ + { repoOwner: "acme", repoName: "web", baseBranch: "main" }, + ]); + }); + it("preserves a nested owner namespace when saving", async () => { mocks.reposValue = [repo("group/subgroup", "web", 1)]; const onSubmit = vi.fn(); diff --git a/packages/web/src/components/settings/environment-form.tsx b/packages/web/src/components/settings/environment-form.tsx index 8946f5ea1f..e204beea3d 100644 --- a/packages/web/src/components/settings/environment-form.tsx +++ b/packages/web/src/components/settings/environment-form.tsx @@ -18,11 +18,14 @@ import { Combobox } from "@/components/ui/combobox"; import { BranchIcon, ChevronDownIcon, RepoIcon } from "@/components/ui/icons"; import { useBranches } from "@/hooks/use-branches"; import { useRepos, type Repo } from "@/hooks/use-repos"; +import { useResourceTeams } from "@/hooks/use-resource-teams"; +import { ResourceTeamField } from "@/components/resource-team-field"; import { RepositoryMultiSelect } from "@/components/repository-multi-select"; import { repositorySelectionKey } from "@/lib/repository-selection"; import { getRepoImageProviders, supportsRepoImages } from "@/lib/sandbox-provider"; export interface EnvironmentFormValues { + teamId?: string | null; name: string; description: string | null; prebuildEnabled: boolean; @@ -42,14 +45,25 @@ export function EnvironmentForm({ onSubmit, onCancel, submitting, + teamId: initialTeamId, }: { mode: "create" | "edit"; initialValues?: Environment; onSubmit: (values: EnvironmentFormValues) => void; onCancel: () => void; submitting: boolean; + teamId?: string | null; }) { - const { repos, loading: loadingRepos } = useRepos(); + const [teamId, setTeamId] = useState(initialValues?.ownerTeamId ?? initialTeamId ?? null); + // Owner is fixed when editing, or when creating from a team's page. + const ownerLocked = mode === "edit" || !!initialTeamId; + const scope = useResourceTeams("environment"); + const scopeValid = + mode === "edit" || + (!scope.loading && + !scope.error && + (teamId ? scope.teams.some((team) => team.id === teamId) : scope.allowWorkspace)); + const { repos, loading: loadingRepos } = useRepos(true, teamId); const prebuildsSupported = supportsRepoImages(); const [name, setName] = useState(initialValues?.name ?? ""); @@ -107,12 +121,13 @@ export function EnvironmentForm({ setSelectedKeys((current) => current.filter((entry) => entry !== key)); }; - const canSubmit = name.trim().length > 0 && selectedKeys.length > 0 && !submitting; + const canSubmit = name.trim().length > 0 && selectedKeys.length > 0 && !submitting && scopeValid; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) return; onSubmit({ + ...(mode === "create" ? { teamId } : {}), name: name.trim(), description: description.trim() ? description.trim() : null, prebuildEnabled, @@ -137,6 +152,18 @@ export function EnvironmentForm({ return (
+ { + if (submitting || ownerLocked) return; + setTeamId(nextTeamId); + setSelectedKeys([]); + setBranchByKey({}); + }} + />