Ran Python test suites and independent probes.
VerifyExecuted focused TypeScript tests with mocked responses.
VerifyReviewed credential scope and multi-repo access handling.
UnderstandConfirmed all original issues fixed and no new critical problems found.
VerifyIMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. Stay focused on repository code only.
You have a full read-write shell in this git worktree (branch feat/scoped-sandbox-credentials, already committed, dependencies already installed at the repo root via npm install). This is a disposable copy — you may run any command, including tests, freely.
Context: you previously reviewed a diff (read-only) that narrowed a GitHub App installation token handed to sandboxes, and found three real issues:
I claim all three are now fixed on this branch. Do NOT trust that claim — verify it yourself by actually running commands in this worktree. Specifically:
cd packages/control-plane && npm run test and confirm it passes.cd packages/modal-infra && uv run pytest tests/test_clone_token.py -q and cd packages/sandbox-runtime && uv run pytest tests/test_github_app_auth.py -q and confirm they pass.Report your findings as [P1]/[P2] as before, plus a plain statement of whether each of the three original findings is CONFIRMED FIXED, PARTIALLY FIXED, or NOT FIXED, based on what you actually ran — not what the code merely appears to say.
I’ll inspect the four files, run the requested tests, and probe the empty-permissions case independently. I’ll also check for remaining credential-scope gaps, staying within repository code and avoiding the directories you listed.
>git status --short; git branch --show-current; sed -n '1,260p' packages/control-plane/src/session/http/handlers/sandbox.handler.ts; sed -n '1,280p' packages/control-plane/src/image-builds/planner.ts{"chunk_id":"82e716","wall_time_seconds":0.051958958,"exit_code":0,"original_token_count":3780,"output":"feat/scoped-sandbox-credentials\nimport type { Logger } from \"../../../logger\";\nimport {\n createMediaArtifactRequestSchema,\n type CreateMediaArtifactRequest,\n} from \"@open-inspect/shared/types/session-api\";\nimport type { SessionArtifact } from \"@open-inspect/shared/types/artifacts\";\nimport { sandboxEventSchema, type SandboxEvent } from \"@open-inspect/shared/types/sandbox-events\";\nimport { isDeadSandboxStatus } from \"../../../sandbox/lifecycle/decisions\";\nimport {\n OpenAITokenNotConfiguredError,\n OpenAITokenStorageError,\n OpenAITokenUnauthorizedError,\n OpenAITokenUpstreamError,\n type OpenAIToken,\n} from \"../../openai-token-refresh-service\";\nimport type { XaiTokenRefreshResult } from \"../../xai-token-refresh-service\";\nimport type { ScmCredentialsResult } from \"../../scm-credentials-service\";\nimport type { SessionMessenger } from \"../../messenger\";\nimport type { MessageRepository } from \"../../message-repository\";\nimport type { ArtifactRepository } from \"../../artifact-repository\";\nimport type { EventRepository } from \"../../event-repository\";\nimport type { SessionCoreRepository } from \"../../session-core-repository\";\nimport type { SandboxRepository } from \"../../sandbox-repository\";\nimport type { SessionSandboxEventProcessor } from \"../../sandbox-events/processor\";\nimport type { SandboxRow, SessionRow } from \"../../types\";\nimport { assertArtifactType } from \"../../artifacts\";\nimport { parseTunnelUrls } from \"../../tunnel-urls\";\nimport { z } from \"zod\";\n\nconst sandboxErrorRequestSchema = z.object({\n error: z.string().trim().min(1).max(1000),\n});\n\n/**\n * HTTP boundary for the sandbox-facing endpoints: event ingestion, media\n * artifacts, token verification, and the\n * credential/token refresh routes the in-sandbox tooling calls.\n */\nexport class SandboxHandler {\n /** Create the sandbox HTTP handler with its repositories and service dependencies. */\n constructor(\n private readonly messageRepository: MessageRepository,\n private readonly eventRepository: EventRepository,\n private readonly artifactRepository: ArtifactRepository,\n private readonly sessionCoreRepository: SessionCoreRepository,\n private readonly sandboxRepository: SandboxRepository,\n private readonly sandboxEventProcessor: SessionSandboxEventProcessor,\n private readonly messenger: SessionMessenger,\n private readonly refreshOpenAIToken: (session: SessionRow, log: Logger) => Promise<OpenAIToken>,\n private readonly refreshXaiToken: (\n session: SessionRow,\n log: Logger\n ) => Promise<XaiTokenRefreshResult>,\n private readonly getScmCredentials: (\n repos: Array<{ owner: string; name: string }>,\n log: Logger\n ) => Promise<ScmCredentialsResult>,\n private readonly isValidSandboxToken: (\n token: string | null,\n sandbox: SandboxRow | null\n ) => Promise<boolean>,\n private readonly failSandbox: (reason: string) => Promise<void>,\n private readonly generateId: () => string,\n private readonly now: () => number = Date.now\n ) {}\n\n async sandboxEvent(request: Request): Promise<Response> {\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ error: \"Invalid request body\" }, { status: 400 });\n }\n\n const result = sandboxEventSchema.safeParse(raw);\n if (!result.success) {\n return Response.json({ error: \"Invalid sandbox event\" }, { status: 400 });\n }\n\n const event: SandboxEvent = result.data;\n await this.sandboxEventProcessor.processSandboxEvent(event);\n return Response.json({ status: \"ok\" });\n }\n\n async sandboxError(request: Request): Promise<Response> {\n const authHeader = request.headers.get(\"Authorization\");\n const token = authHeader?.startsWith(\"Bearer \") ? authHeader.slice(\"Bearer \".length) : null;\n const sandboxId = request.headers.get(\"X-Sandbox-ID\");\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox || !token) {\n return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n if (sandbox.modal_sandbox_id && sandboxId !== sandbox.modal_sandbox_id) {\n return Response.json({ error: \"Wrong sandbox\" }, { status: 403 });\n }\n\n if (!(await this.isValidSandboxToken(token, sandbox))) {\n return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ error: \"Invalid request body\" }, { status: 400 });\n }\n const result = sandboxErrorRequestSchema.safeParse(raw);\n if (!result.success) {\n return Response.json({ error: \"Invalid sandbox error\" }, { status: 400 });\n }\n\n const currentSandbox = this.sandboxRepository.getSandbox();\n if (\n currentSandbox?.modal_sandbox_id !== sandbox.modal_sandbox_id ||\n currentSandbox?.auth_token_hash !== sandbox.auth_token_hash ||\n currentSandbox?.auth_token !== sandbox.auth_token\n ) {\n return Response.json({ error: \"Sandbox credentials changed\" }, { status: 403 });\n }\n if (currentSandbox.status === \"stopped\" || currentSandbox.status === \"stale\") {\n return Response.json({ status: \"ignored\" });\n }\n\n await this.failSandbox(result.data.error);\n return Response.json({ status: \"ok\" });\n }\n\n async createMediaArtifact(request: Request): Promise<Response> {\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ error: \"Invalid request body\" }, { status: 400 });\n }\n\n const result = createMediaArtifactRequestSchema.safeParse(raw);\n if (!result.success) {\n return Response.json({ error: \"Invalid media artifact body\" }, { status: 400 });\n }\n\n const body: CreateMediaArtifactRequest = result.data;\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n return Response.json({ error: \"No sandbox\" }, { status: 404 });\n }\n\n if (!body.artifactId || !body.objectKey) {\n return Response.json({ error: \"artifactId and objectKey are required\" }, { status: 400 });\n }\n\n const processingMessage = this.messageRepository.getProcessingMessage();\n if (!processingMessage) {\n return Response.json({ error: \"No active prompt\" }, { status: 409 });\n }\n\n const artifactType = assertArtifactType(body.artifactType);\n const now = this.now();\n const timestampSeconds = now / 1000;\n const artifact: SessionArtifact = {\n id: body.artifactId,\n type: artifactType,\n url: body.objectKey,\n metadata: body.metadata ?? null,\n createdAt: now,\n updatedAt: now,\n };\n\n this.artifactRepository.createArtifact({\n id: artifact.id,\n type: artifact.type,\n url: artifact.url,\n metadata: artifact.metadata ? JSON.stringify(artifact.metadata) : null,\n createdAt: now,\n });\n\n const event: Extract<SandboxEvent, { type: \"artifact\" }> = {\n type: \"artifact\",\n artifactType: artifact.type,\n artifactId: artifact.id,\n url: body.objectKey,\n metadata: artifact.metadata ?? undefined,\n messageId: processingMessage.id,\n sandboxId: sandbox.modal_sandbox_id ?? sandbox.id,\n timestamp: timestampSeconds,\n };\n\n this.eventRepository.createEvent({\n id: this.generateId(),\n type: event.type,\n data: JSON.stringify(event),\n messageId: processingMessage.id,\n createdAt: now,\n });\n\n this.messenger.broadcast({ type: \"artifact_created\", artifact });\n this.messenger.broadcast({ type: \"sandbox_event\", event });\n\n return Response.json({ status: \"ok\", artifactId: artifact.id });\n }\n\n async verifySandboxToken(request: Request, log: Logger): Promise<Response> {\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ valid: false, error: \"Missing token\" }, { status: 400 });\n }\n\n const body = raw && typeof raw === \"object\" ? raw : null;\n const token = body && \"token\" in body ? body.token : undefined;\n\n if (typeof token !== \"string\" || !token) {\n return Response.json({ valid: false, error: \"Missing token\" }, { status: 400 });\n }\n\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n log.warn(\"Sandbox token verification failed: no sandbox\");\n return Response.json({ valid: false, error: \"No sandbox\" }, { status: 404 });\n }\n\n // Boot-time states (spawning/connecting) must authenticate — the git\n // credential broker is already called during the initial clone, before\n // the WebSocket connect flips the status to ready.\n if (isDeadSandboxStatus(sandbox.status)) {\n log.warn(\"Sandbox token verification failed: sandbox is dead\", {\n status: sandbox.status,\n });\n return Response.json({ valid: false, error: \"Sandbox not active\" }, { status: 410 });\n }\n\n const isTokenValid = await this.isValidSandboxToken(token, sandbox);\n if (!isTokenValid) {\n log.warn(\"Sandbox token verification failed: token mismatch\");\n return Response.json({ valid: false, error: \"Invalid token\" }, { status: 401 });\n }\n\n log.info(\"Sandbox token verified successfully\");\n return Response.json(\n { valid: true, sandboxId: sandbox.modal_sandbox_id ?? sandbox.id },\n { status: 200 }\n );\n }\n\n async openaiTokenRefresh(log: Logger): Promise<Response> {\n const session = this.sessionCoreRepository.getSession();\n if (!session) {\n return Response.json({ error: \"No session\" }, { status: 404 });\n }\n\n let token: OpenAIToken;\n try {\n token = await this.refreshOpenAIToken(session, log);\n } catch (error) {\n if (error instanceof OpenAITokenNotConfiguredError) {\n return Response.json({ error: error.message }, { status: 404 });\n }\n if (error instanceof OpenAITokenUnauthorizedError) {\n return Response.json({ error: error.message }, { status: 401 });\n }\nimport { resolveBuildTimeoutSeconds } from \"@open-inspect/shared/types/integrations\";\nimport { createLogger, type CorrelationContext } from \"../logger\";\nimport { createSourceControlProviderFromEnv, resolveScmProviderFromEnv } from \"../source-control\";\nimport { scmCloneIdentity } from \"../sandbox/sandbox-env\";\nimport { prepareLegacyManagedProviderEnv } from \"../sandbox/managed-provider-env\";\nimport type { Env } from \"../types\";\nimport type { SqlDatabase } from \"../db/sql-database\";\nimport {\n generateImageBuildCallbackToken,\n hashImageBuildCallbackToken,\n IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS,\n} from \"./callback-auth\";\nimport type { ImageBuildScope } from \"./model\";\nimport {\n loadScopeBuildSecrets,\n resolveScopeSandboxSettings,\n resolveScopeTarget,\n type ResolvedImageBuildTarget,\n} from \"./scope\";\nimport type { ImageBuildCloneAuth, ImageBuildPlan, ImageBuildRepository } from \"./types\";\n\nconst logger = createLogger(\"image-builds:planner\");\nconst MS_PER_SECOND = 1000;\n\n/** The single-use callback token every build authenticates with (planner mints, workflow verifies). */\nexport interface PlannedCallbackAuth {\n token: string;\n tokenHash: string;\n expiresAt: number;\n}\n\nexport type { ResolvedImageBuildTarget } from \"./scope\";\n\n/** Inputs for planBuild; the target is resolved before registration, secrets after. */\nexport interface ImageBuildPlanRequest {\n buildId: string;\n scope: ImageBuildScope;\n callbackUrl: string;\n failureCallbackUrl: string;\n correlation: CorrelationContext;\n target: ResolvedImageBuildTarget;\n callbackAuth: PlannedCallbackAuth;\n}\n\n/** The planning operations the workflow sequences a build through. */\nexport interface ImageBuildPlannerPort {\n resolveTarget(scope: ImageBuildScope): Promise<ResolvedImageBuildTarget>;\n createCallbackAuth(): Promise<PlannedCallbackAuth>;\n planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan>;\n}\n\n/**\n * Resolves a trigger request into a concrete provider build plan.\n *\n * The planner is the only image-build layer that loads secrets, and it leans\n * on scope.ts for everything kind-specific. Split deliberately: resolveTarget\n * and createCallbackAuth run BEFORE the build row is registered (cheap D1\n * read + pure crypto), while planBuild — which decrypts secrets — runs AFTER,\n * so a concurrent secret change always sees a row to supersede and the\n * build's now-stale secrets can never reach a still-selectable image.\n * Build-time secrets are the same set the scope's sessions get, and the build\n * timeout honors the primary repository's sandbox settings with the scope's\n * own overrides layered on top.\n */\nexport class ImageBuildPlanner implements ImageBuildPlannerPort {\n constructor(\n private readonly env: Env,\n private readonly db: SqlDatabase\n ) {}\n\n async resolveTarget(scope: ImageBuildScope): Promise<ResolvedImageBuildTarget> {\n return resolveScopeTarget(this.env, this.db, scope);\n }\n\n async createCallbackAuth(): Promise<PlannedCallbackAuth> {\n const token = generateImageBuildCallbackToken();\n return {\n token,\n tokenHash: await hashImageBuildCallbackToken(token, this.env),\n expiresAt: Date.now() + IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS,\n };\n }\n\n async planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan> {\n const { repositories, repositoriesFingerprint } = params.target;\n const primary = repositories[0];\n\n const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n resolveScopeSandboxSettings(this.db, params.scope, primary),\n loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n this.resolveCloneAuth(params.scope, repositories),\n ]);\n\n const basePlan = {\n buildId: params.buildId,\n scope: params.scope,\n repositories,\n repositoriesFingerprint,\n callbackUrl: params.callbackUrl,\n failureCallbackUrl: params.failureCallbackUrl,\n buildTimeoutMs: resolveBuildTimeoutSeconds(sandboxSettings) * MS_PER_SECOND,\n userEnvVars: userEnvVars\n ? prepareLegacyManagedProviderEnv({\n exposedSecrets: userEnvVars,\n brokerSecrets: userEnvVars,\n })\n : undefined,\n correlation: {\n trace_id: params.correlation.trace_id,\n request_id: params.correlation.request_id,\n },\n };\n\n return {\n ...basePlan,\n callbackToken: params.callbackAuth.token,\n cloneAuth,\n };\n }\n\n private async resolveCloneAuth(\n scope: ImageBuildScope,\n repositories: ImageBuildRepository[]\n ): Promise<ImageBuildCloneAuth> {\n try {\n const provider = createSourceControlProviderFromEnv(this.env);\n const auth = await provider.generateCredentialHelperAuth(\n repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n );\n return {\n type: \"credential_helper\",\n host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\n username: auth.username,\n token: auth.password,\n };\n } catch (e) {\n logger.warn(\"image_build.clone_token_failed\", {\n error: e instanceof Error ? e.message : String(e),\n scope_kind: scope.kind,\n scope_id: scope.id,\n });\n return { type: \"unavailable\" };\n }\n }\n}\n"}
{"chunk_id":"9cdadf","wall_time_seconds":0.000002666,"exit_code":0,"original_token_count":1865,"output":"\"\"\"Resolve VCS clone tokens for Modal sandbox git operations.\"\"\"\n\nimport os\n\nfrom .log_config import get_logger\n\nlog = get_logger(\"clone_token\")\n\n\ndef resolve_clone_token(repo_owner: str | None = None, repo_name: str | None = None) -> str | None:\n \"\"\"Return a provider-specific clone token, or None when credentials are unavailable.\n\n For GitHub, the minted token is always narrowed to ``repo_name`` with\n git-only permissions (contents:write, metadata:read) — this token is\n injected directly into a sandbox's environment, so it must never carry\n more than git operations need. Missing repo context is treated as \"no\n token available\" rather than minting an unnarrowed, installation-wide\n token: this resolver exists specifically to produce sandbox-bound\n credentials, so it must fail closed on its own, independent of whether\n every caller already guards the missing-context case. A narrowing\n failure is likewise NOT retried unnarrowed: it is logged and treated\n the same as \"no token available\" (fail closed), never silently widened\n to the full installation grant.\n \"\"\"\n from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\n\n scm_provider = os.environ.get(\"SCM_PROVIDER\", \"github\")\n\n if scm_provider == \"gitlab\":\n token = os.environ.get(\"GITLAB_ACCESS_TOKEN\")\n if not token:\n log.warn(\"gitlab.token_missing\")\n return token\n\n if not repo_name:\n log.warn(\"github.repo_context_missing\", repo_owner=repo_owner, repo_name=repo_name)\n return None\n\n try:\n app_id = os.environ.get(\"GITHUB_APP_ID\")\n private_key = os.environ.get(\"GITHUB_APP_PRIVATE_KEY\")\n installation_id = os.environ.get(\"GITHUB_APP_INSTALLATION_ID\")\n\n if app_id and private_key and installation_id:\n return generate_installation_token(\n app_id=app_id,\n private_key=[REDACTED] installation_id=installation_id,\n repository=repo_name,\n permissions=SANDBOX_SCOPED_PERMISSIONS,\n )\n except Exception as e:\n log.warn(\"github.token_error\", exc=e, repo_owner=repo_owner, repo_name=repo_name)\n\n return None\n\"\"\"\nGitHub App token generation for git operations.\n\nGenerates short-lived installation access tokens for:\n- Cloning private repositories during image builds\n- Git fetch/sync at sandbox startup\n- Git push when creating pull requests\n\nTokens are valid for ~1 hour.\n\"\"\"\n\nimport time\n\nimport httpx\nimport jwt\n\n\ndef generate_jwt(app_id: str, private_key: str) -> str:\n \"\"\"\n Generate a JWT for GitHub App authentication.\n\n Args:\n app_id: The GitHub App's ID\n private_key: The App's private key (PEM format)\n\n Returns:\n Signed JWT valid for 10 minutes\n \"\"\"\n now = int(time.time())\n payload = {\n \"iat\": now - 60, # Issued 60 seconds ago (clock skew tolerance)\n \"exp\": now + 600, # Expires in 10 minutes\n \"iss\": app_id,\n }\n return jwt.encode(payload, private_key, algorithm=\"RS256\")\n\n\n# Default permission set for tokens handed to a sandbox: git push only,\n# never pull_requests/issues write. See generate_installation_token.\nSANDBOX_SCOPED_PERMISSIONS: dict[str, str] = {\"contents\": \"write\", \"metadata\": \"read\"}\n\n\ndef get_installation_token(\n jwt_token: str,\n installation_id: str,\n *,\n repository: str | None = None,\n permissions: dict[str, str] | None = None,\n) -> str:\n \"\"\"\n Exchange a JWT for an installation access token.\n\n Args:\n jwt_token: The signed JWT\n installation_id: The GitHub App installation ID\n repository: If given (not None), narrow the minted token to this\n single repository (name only, not \"owner/repo\") via GitHub's\n optional `repositories` request field.\n permissions: If given (not None), narrow the minted token to this\n permission subset via GitHub's optional `permissions` request\n field. Requires `repository` — GitHub's `permissions` field\n without `repositories` narrows nothing (still installation-wide).\n\n Returns:\n Installation access token (valid for 1 hour), narrowed to\n `repository`/`permissions` when supplied. GitHub rejects a\n malformed or over-broad narrowing request outright (raises here) —\n there is no fallback to an unnarrowed token.\n\n Raises:\n ValueError: if `permissions` is given without `repository`, or if\n either is an empty container — an empty `permissions={}` would\n make GitHub omit the field entirely and return the installation's\n full, unnarrowed permission set, which is exactly the silent\n widening this function must never do.\n httpx.HTTPStatusError: If the GitHub API request fails\n \"\"\"\n if permissions is not None and repository is None:\n raise ValueError(\"permissions requires repository — it narrows nothing on its own\")\n if repository is not None and not repository:\n raise ValueError(\"repository must be non-empty when provided\")\n if permissions is not None and not permissions:\n raise ValueError(\"permissions must be non-empty when provided\")\n\n url = f\"https://api.github.com/app/installations/{installation_id}/access_tokens\"\n headers = {\n \"Authorization\": f\"Bearer {jwt_token}\",\n \"Accept\": \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n }\n body: dict[str, object] = {}\n if repository is not None:\n body[\"repositories\"] = [repository]\n if permissions is not None:\n body[\"permissions\"] = permissions\n\n with httpx.Client() as client:\n response = client.post(url, headers=headers, json=body or None)\n response.raise_for_status()\n return response.json()[\"token\"]\n\n\ndef generate_installation_token(\n app_id: str,\n private_key: str,\n installation_id: str,\n *,\n repository: str | None = None,\n permissions: dict[str, str] | None = None,\n) -> str:\n \"\"\"\n Generate a fresh GitHub App installation token.\n\n This is the main entry point for token generation. It:\n 1. Creates a JWT signed with the App's private key\n 2. Exchanges it for an installation access token\n\n Args:\n app_id: The GitHub App's ID\n private_key: The App's private key (PEM format)\n installation_id: The GitHub App installation ID\n repository: If given, narrow the token to this single repository\n (see get_installation_token).\n permissions: If given, narrow the token's permissions (see\n get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\n when `repository` is given and this is omitted, since every\n current caller of the narrowed path is minting a\n sandbox-reachable credential.\n\n Returns:\n Installation access token (valid for 1 hour)\n\n Raises:\n httpx.HTTPStatusError: If the GitHub API request fails\n jwt.PyJWTError: If JWT encoding fails\n \"\"\"\n jwt_token = generate_jwt(app_id, private_key)\n resolved_permissions = permissions\n if repository and resolved_permissions is None:\n resolved_permissions = SANDBOX_SCOPED_PERMISSIONS\n return get_installation_token(\n jwt_token,\n installation_id,\n repository=repository,\n permissions=resolved_permissions,\n )\n"}>cat packages/modal-infra/src/clone_token.py; cat packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py{"chunk_id":"82e716","wall_time_seconds":0.051958958,"exit_code":0,"original_token_count":3780,"output":"feat/scoped-sandbox-credentials\nimport type { Logger } from \"../../../logger\";\nimport {\n createMediaArtifactRequestSchema,\n type CreateMediaArtifactRequest,\n} from \"@open-inspect/shared/types/session-api\";\nimport type { SessionArtifact } from \"@open-inspect/shared/types/artifacts\";\nimport { sandboxEventSchema, type SandboxEvent } from \"@open-inspect/shared/types/sandbox-events\";\nimport { isDeadSandboxStatus } from \"../../../sandbox/lifecycle/decisions\";\nimport {\n OpenAITokenNotConfiguredError,\n OpenAITokenStorageError,\n OpenAITokenUnauthorizedError,\n OpenAITokenUpstreamError,\n type OpenAIToken,\n} from \"../../openai-token-refresh-service\";\nimport type { XaiTokenRefreshResult } from \"../../xai-token-refresh-service\";\nimport type { ScmCredentialsResult } from \"../../scm-credentials-service\";\nimport type { SessionMessenger } from \"../../messenger\";\nimport type { MessageRepository } from \"../../message-repository\";\nimport type { ArtifactRepository } from \"../../artifact-repository\";\nimport type { EventRepository } from \"../../event-repository\";\nimport type { SessionCoreRepository } from \"../../session-core-repository\";\nimport type { SandboxRepository } from \"../../sandbox-repository\";\nimport type { SessionSandboxEventProcessor } from \"../../sandbox-events/processor\";\nimport type { SandboxRow, SessionRow } from \"../../types\";\nimport { assertArtifactType } from \"../../artifacts\";\nimport { parseTunnelUrls } from \"../../tunnel-urls\";\nimport { z } from \"zod\";\n\nconst sandboxErrorRequestSchema = z.object({\n error: z.string().trim().min(1).max(1000),\n});\n\n/**\n * HTTP boundary for the sandbox-facing endpoints: event ingestion, media\n * artifacts, token verification, and the\n * credential/token refresh routes the in-sandbox tooling calls.\n */\nexport class SandboxHandler {\n /** Create the sandbox HTTP handler with its repositories and service dependencies. */\n constructor(\n private readonly messageRepository: MessageRepository,\n private readonly eventRepository: EventRepository,\n private readonly artifactRepository: ArtifactRepository,\n private readonly sessionCoreRepository: SessionCoreRepository,\n private readonly sandboxRepository: SandboxRepository,\n private readonly sandboxEventProcessor: SessionSandboxEventProcessor,\n private readonly messenger: SessionMessenger,\n private readonly refreshOpenAIToken: (session: SessionRow, log: Logger) => Promise<OpenAIToken>,\n private readonly refreshXaiToken: (\n session: SessionRow,\n log: Logger\n ) => Promise<XaiTokenRefreshResult>,\n private readonly getScmCredentials: (\n repos: Array<{ owner: string; name: string }>,\n log: Logger\n ) => Promise<ScmCredentialsResult>,\n private readonly isValidSandboxToken: (\n token: string | null,\n sandbox: SandboxRow | null\n ) => Promise<boolean>,\n private readonly failSandbox: (reason: string) => Promise<void>,\n private readonly generateId: () => string,\n private readonly now: () => number = Date.now\n ) {}\n\n async sandboxEvent(request: Request): Promise<Response> {\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ error: \"Invalid request body\" }, { status: 400 });\n }\n\n const result = sandboxEventSchema.safeParse(raw);\n if (!result.success) {\n return Response.json({ error: \"Invalid sandbox event\" }, { status: 400 });\n }\n\n const event: SandboxEvent = result.data;\n await this.sandboxEventProcessor.processSandboxEvent(event);\n return Response.json({ status: \"ok\" });\n }\n\n async sandboxError(request: Request): Promise<Response> {\n const authHeader = request.headers.get(\"Authorization\");\n const token = authHeader?.startsWith(\"Bearer \") ? authHeader.slice(\"Bearer \".length) : null;\n const sandboxId = request.headers.get(\"X-Sandbox-ID\");\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox || !token) {\n return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n if (sandbox.modal_sandbox_id && sandboxId !== sandbox.modal_sandbox_id) {\n return Response.json({ error: \"Wrong sandbox\" }, { status: 403 });\n }\n\n if (!(await this.isValidSandboxToken(token, sandbox))) {\n return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ error: \"Invalid request body\" }, { status: 400 });\n }\n const result = sandboxErrorRequestSchema.safeParse(raw);\n if (!result.success) {\n return Response.json({ error: \"Invalid sandbox error\" }, { status: 400 });\n }\n\n const currentSandbox = this.sandboxRepository.getSandbox();\n if (\n currentSandbox?.modal_sandbox_id !== sandbox.modal_sandbox_id ||\n currentSandbox?.auth_token_hash !== sandbox.auth_token_hash ||\n currentSandbox?.auth_token !== sandbox.auth_token\n ) {\n return Response.json({ error: \"Sandbox credentials changed\" }, { status: 403 });\n }\n if (currentSandbox.status === \"stopped\" || currentSandbox.status === \"stale\") {\n return Response.json({ status: \"ignored\" });\n }\n\n await this.failSandbox(result.data.error);\n return Response.json({ status: \"ok\" });\n }\n\n async createMediaArtifact(request: Request): Promise<Response> {\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ error: \"Invalid request body\" }, { status: 400 });\n }\n\n const result = createMediaArtifactRequestSchema.safeParse(raw);\n if (!result.success) {\n return Response.json({ error: \"Invalid media artifact body\" }, { status: 400 });\n }\n\n const body: CreateMediaArtifactRequest = result.data;\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n return Response.json({ error: \"No sandbox\" }, { status: 404 });\n }\n\n if (!body.artifactId || !body.objectKey) {\n return Response.json({ error: \"artifactId and objectKey are required\" }, { status: 400 });\n }\n\n const processingMessage = this.messageRepository.getProcessingMessage();\n if (!processingMessage) {\n return Response.json({ error: \"No active prompt\" }, { status: 409 });\n }\n\n const artifactType = assertArtifactType(body.artifactType);\n const now = this.now();\n const timestampSeconds = now / 1000;\n const artifact: SessionArtifact = {\n id: body.artifactId,\n type: artifactType,\n url: body.objectKey,\n metadata: body.metadata ?? null,\n createdAt: now,\n updatedAt: now,\n };\n\n this.artifactRepository.createArtifact({\n id: artifact.id,\n type: artifact.type,\n url: artifact.url,\n metadata: artifact.metadata ? JSON.stringify(artifact.metadata) : null,\n createdAt: now,\n });\n\n const event: Extract<SandboxEvent, { type: \"artifact\" }> = {\n type: \"artifact\",\n artifactType: artifact.type,\n artifactId: artifact.id,\n url: body.objectKey,\n metadata: artifact.metadata ?? undefined,\n messageId: processingMessage.id,\n sandboxId: sandbox.modal_sandbox_id ?? sandbox.id,\n timestamp: timestampSeconds,\n };\n\n this.eventRepository.createEvent({\n id: this.generateId(),\n type: event.type,\n data: JSON.stringify(event),\n messageId: processingMessage.id,\n createdAt: now,\n });\n\n this.messenger.broadcast({ type: \"artifact_created\", artifact });\n this.messenger.broadcast({ type: \"sandbox_event\", event });\n\n return Response.json({ status: \"ok\", artifactId: artifact.id });\n }\n\n async verifySandboxToken(request: Request, log: Logger): Promise<Response> {\n let raw: unknown;\n try {\n raw = await request.json();\n } catch {\n return Response.json({ valid: false, error: \"Missing token\" }, { status: 400 });\n }\n\n const body = raw && typeof raw === \"object\" ? raw : null;\n const token = body && \"token\" in body ? body.token : undefined;\n\n if (typeof token !== \"string\" || !token) {\n return Response.json({ valid: false, error: \"Missing token\" }, { status: 400 });\n }\n\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n log.warn(\"Sandbox token verification failed: no sandbox\");\n return Response.json({ valid: false, error: \"No sandbox\" }, { status: 404 });\n }\n\n // Boot-time states (spawning/connecting) must authenticate — the git\n // credential broker is already called during the initial clone, before\n // the WebSocket connect flips the status to ready.\n if (isDeadSandboxStatus(sandbox.status)) {\n log.warn(\"Sandbox token verification failed: sandbox is dead\", {\n status: sandbox.status,\n });\n return Response.json({ valid: false, error: \"Sandbox not active\" }, { status: 410 });\n }\n\n const isTokenValid = await this.isValidSandboxToken(token, sandbox);\n if (!isTokenValid) {\n log.warn(\"Sandbox token verification failed: token mismatch\");\n return Response.json({ valid: false, error: \"Invalid token\" }, { status: 401 });\n }\n\n log.info(\"Sandbox token verified successfully\");\n return Response.json(\n { valid: true, sandboxId: sandbox.modal_sandbox_id ?? sandbox.id },\n { status: 200 }\n );\n }\n\n async openaiTokenRefresh(log: Logger): Promise<Response> {\n const session = this.sessionCoreRepository.getSession();\n if (!session) {\n return Response.json({ error: \"No session\" }, { status: 404 });\n }\n\n let token: OpenAIToken;\n try {\n token = await this.refreshOpenAIToken(session, log);\n } catch (error) {\n if (error instanceof OpenAITokenNotConfiguredError) {\n return Response.json({ error: error.message }, { status: 404 });\n }\n if (error instanceof OpenAITokenUnauthorizedError) {\n return Response.json({ error: error.message }, { status: 401 });\n }\nimport { resolveBuildTimeoutSeconds } from \"@open-inspect/shared/types/integrations\";\nimport { createLogger, type CorrelationContext } from \"../logger\";\nimport { createSourceControlProviderFromEnv, resolveScmProviderFromEnv } from \"../source-control\";\nimport { scmCloneIdentity } from \"../sandbox/sandbox-env\";\nimport { prepareLegacyManagedProviderEnv } from \"../sandbox/managed-provider-env\";\nimport type { Env } from \"../types\";\nimport type { SqlDatabase } from \"../db/sql-database\";\nimport {\n generateImageBuildCallbackToken,\n hashImageBuildCallbackToken,\n IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS,\n} from \"./callback-auth\";\nimport type { ImageBuildScope } from \"./model\";\nimport {\n loadScopeBuildSecrets,\n resolveScopeSandboxSettings,\n resolveScopeTarget,\n type ResolvedImageBuildTarget,\n} from \"./scope\";\nimport type { ImageBuildCloneAuth, ImageBuildPlan, ImageBuildRepository } from \"./types\";\n\nconst logger = createLogger(\"image-builds:planner\");\nconst MS_PER_SECOND = 1000;\n\n/** The single-use callback token every build authenticates with (planner mints, workflow verifies). */\nexport interface PlannedCallbackAuth {\n token: string;\n tokenHash: string;\n expiresAt: number;\n}\n\nexport type { ResolvedImageBuildTarget } from \"./scope\";\n\n/** Inputs for planBuild; the target is resolved before registration, secrets after. */\nexport interface ImageBuildPlanRequest {\n buildId: string;\n scope: ImageBuildScope;\n callbackUrl: string;\n failureCallbackUrl: string;\n correlation: CorrelationContext;\n target: ResolvedImageBuildTarget;\n callbackAuth: PlannedCallbackAuth;\n}\n\n/** The planning operations the workflow sequences a build through. */\nexport interface ImageBuildPlannerPort {\n resolveTarget(scope: ImageBuildScope): Promise<ResolvedImageBuildTarget>;\n createCallbackAuth(): Promise<PlannedCallbackAuth>;\n planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan>;\n}\n\n/**\n * Resolves a trigger request into a concrete provider build plan.\n *\n * The planner is the only image-build layer that loads secrets, and it leans\n * on scope.ts for everything kind-specific. Split deliberately: resolveTarget\n * and createCallbackAuth run BEFORE the build row is registered (cheap D1\n * read + pure crypto), while planBuild — which decrypts secrets — runs AFTER,\n * so a concurrent secret change always sees a row to supersede and the\n * build's now-stale secrets can never reach a still-selectable image.\n * Build-time secrets are the same set the scope's sessions get, and the build\n * timeout honors the primary repository's sandbox settings with the scope's\n * own overrides layered on top.\n */\nexport class ImageBuildPlanner implements ImageBuildPlannerPort {\n constructor(\n private readonly env: Env,\n private readonly db: SqlDatabase\n ) {}\n\n async resolveTarget(scope: ImageBuildScope): Promise<ResolvedImageBuildTarget> {\n return resolveScopeTarget(this.env, this.db, scope);\n }\n\n async createCallbackAuth(): Promise<PlannedCallbackAuth> {\n const token = generateImageBuildCallbackToken();\n return {\n token,\n tokenHash: await hashImageBuildCallbackToken(token, this.env),\n expiresAt: Date.now() + IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS,\n };\n }\n\n async planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan> {\n const { repositories, repositoriesFingerprint } = params.target;\n const primary = repositories[0];\n\n const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n resolveScopeSandboxSettings(this.db, params.scope, primary),\n loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n this.resolveCloneAuth(params.scope, repositories),\n ]);\n\n const basePlan = {\n buildId: params.buildId,\n scope: params.scope,\n repositories,\n repositoriesFingerprint,\n callbackUrl: params.callbackUrl,\n failureCallbackUrl: params.failureCallbackUrl,\n buildTimeoutMs: resolveBuildTimeoutSeconds(sandboxSettings) * MS_PER_SECOND,\n userEnvVars: userEnvVars\n ? prepareLegacyManagedProviderEnv({\n exposedSecrets: userEnvVars,\n brokerSecrets: userEnvVars,\n })\n : undefined,\n correlation: {\n trace_id: params.correlation.trace_id,\n request_id: params.correlation.request_id,\n },\n };\n\n return {\n ...basePlan,\n callbackToken: params.callbackAuth.token,\n cloneAuth,\n };\n }\n\n private async resolveCloneAuth(\n scope: ImageBuildScope,\n repositories: ImageBuildRepository[]\n ): Promise<ImageBuildCloneAuth> {\n try {\n const provider = createSourceControlProviderFromEnv(this.env);\n const auth = await provider.generateCredentialHelperAuth(\n repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n );\n return {\n type: \"credential_helper\",\n host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\n username: auth.username,\n token: auth.password,\n };\n } catch (e) {\n logger.warn(\"image_build.clone_token_failed\", {\n error: e instanceof Error ? e.message : String(e),\n scope_kind: scope.kind,\n scope_id: scope.id,\n });\n return { type: \"unavailable\" };\n }\n }\n}\n"}
{"chunk_id":"9cdadf","wall_time_seconds":0.000002666,"exit_code":0,"original_token_count":1865,"output":"\"\"\"Resolve VCS clone tokens for Modal sandbox git operations.\"\"\"\n\nimport os\n\nfrom .log_config import get_logger\n\nlog = get_logger(\"clone_token\")\n\n\ndef resolve_clone_token(repo_owner: str | None = None, repo_name: str | None = None) -> str | None:\n \"\"\"Return a provider-specific clone token, or None when credentials are unavailable.\n\n For GitHub, the minted token is always narrowed to ``repo_name`` with\n git-only permissions (contents:write, metadata:read) — this token is\n injected directly into a sandbox's environment, so it must never carry\n more than git operations need. Missing repo context is treated as \"no\n token available\" rather than minting an unnarrowed, installation-wide\n token: this resolver exists specifically to produce sandbox-bound\n credentials, so it must fail closed on its own, independent of whether\n every caller already guards the missing-context case. A narrowing\n failure is likewise NOT retried unnarrowed: it is logged and treated\n the same as \"no token available\" (fail closed), never silently widened\n to the full installation grant.\n \"\"\"\n from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\n\n scm_provider = os.environ.get(\"SCM_PROVIDER\", \"github\")\n\n if scm_provider == \"gitlab\":\n token = os.environ.get(\"GITLAB_ACCESS_TOKEN\")\n if not token:\n log.warn(\"gitlab.token_missing\")\n return token\n\n if not repo_name:\n log.warn(\"github.repo_context_missing\", repo_owner=repo_owner, repo_name=repo_name)\n return None\n\n try:\n app_id = os.environ.get(\"GITHUB_APP_ID\")\n private_key = os.environ.get(\"GITHUB_APP_PRIVATE_KEY\")\n installation_id = os.environ.get(\"GITHUB_APP_INSTALLATION_ID\")\n\n if app_id and private_key and installation_id:\n return generate_installation_token(\n app_id=app_id,\n private_key=[REDACTED] installation_id=installation_id,\n repository=repo_name,\n permissions=SANDBOX_SCOPED_PERMISSIONS,\n )\n except Exception as e:\n log.warn(\"github.token_error\", exc=e, repo_owner=repo_owner, repo_name=repo_name)\n\n return None\n\"\"\"\nGitHub App token generation for git operations.\n\nGenerates short-lived installation access tokens for:\n- Cloning private repositories during image builds\n- Git fetch/sync at sandbox startup\n- Git push when creating pull requests\n\nTokens are valid for ~1 hour.\n\"\"\"\n\nimport time\n\nimport httpx\nimport jwt\n\n\ndef generate_jwt(app_id: str, private_key: str) -> str:\n \"\"\"\n Generate a JWT for GitHub App authentication.\n\n Args:\n app_id: The GitHub App's ID\n private_key: The App's private key (PEM format)\n\n Returns:\n Signed JWT valid for 10 minutes\n \"\"\"\n now = int(time.time())\n payload = {\n \"iat\": now - 60, # Issued 60 seconds ago (clock skew tolerance)\n \"exp\": now + 600, # Expires in 10 minutes\n \"iss\": app_id,\n }\n return jwt.encode(payload, private_key, algorithm=\"RS256\")\n\n\n# Default permission set for tokens handed to a sandbox: git push only,\n# never pull_requests/issues write. See generate_installation_token.\nSANDBOX_SCOPED_PERMISSIONS: dict[str, str] = {\"contents\": \"write\", \"metadata\": \"read\"}\n\n\ndef get_installation_token(\n jwt_token: str,\n installation_id: str,\n *,\n repository: str | None = None,\n permissions: dict[str, str] | None = None,\n) -> str:\n \"\"\"\n Exchange a JWT for an installation access token.\n\n Args:\n jwt_token: The signed JWT\n installation_id: The GitHub App installation ID\n repository: If given (not None), narrow the minted token to this\n single repository (name only, not \"owner/repo\") via GitHub's\n optional `repositories` request field.\n permissions: If given (not None), narrow the minted token to this\n permission subset via GitHub's optional `permissions` request\n field. Requires `repository` — GitHub's `permissions` field\n without `repositories` narrows nothing (still installation-wide).\n\n Returns:\n Installation access token (valid for 1 hour), narrowed to\n `repository`/`permissions` when supplied. GitHub rejects a\n malformed or over-broad narrowing request outright (raises here) —\n there is no fallback to an unnarrowed token.\n\n Raises:\n ValueError: if `permissions` is given without `repository`, or if\n either is an empty container — an empty `permissions={}` would\n make GitHub omit the field entirely and return the installation's\n full, unnarrowed permission set, which is exactly the silent\n widening this function must never do.\n httpx.HTTPStatusError: If the GitHub API request fails\n \"\"\"\n if permissions is not None and repository is None:\n raise ValueError(\"permissions requires repository — it narrows nothing on its own\")\n if repository is not None and not repository:\n raise ValueError(\"repository must be non-empty when provided\")\n if permissions is not None and not permissions:\n raise ValueError(\"permissions must be non-empty when provided\")\n\n url = f\"https://api.github.com/app/installations/{installation_id}/access_tokens\"\n headers = {\n \"Authorization\": f\"Bearer {jwt_token}\",\n \"Accept\": \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n }\n body: dict[str, object] = {}\n if repository is not None:\n body[\"repositories\"] = [repository]\n if permissions is not None:\n body[\"permissions\"] = permissions\n\n with httpx.Client() as client:\n response = client.post(url, headers=headers, json=body or None)\n response.raise_for_status()\n return response.json()[\"token\"]\n\n\ndef generate_installation_token(\n app_id: str,\n private_key: str,\n installation_id: str,\n *,\n repository: str | None = None,\n permissions: dict[str, str] | None = None,\n) -> str:\n \"\"\"\n Generate a fresh GitHub App installation token.\n\n This is the main entry point for token generation. It:\n 1. Creates a JWT signed with the App's private key\n 2. Exchanges it for an installation access token\n\n Args:\n app_id: The GitHub App's ID\n private_key: The App's private key (PEM format)\n installation_id: The GitHub App installation ID\n repository: If given, narrow the token to this single repository\n (see get_installation_token).\n permissions: If given, narrow the token's permissions (see\n get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\n when `repository` is given and this is omitted, since every\n current caller of the narrowed path is minting a\n sandbox-reachable credential.\n\n Returns:\n Installation access token (valid for 1 hour)\n\n Raises:\n httpx.HTTPStatusError: If the GitHub API request fails\n jwt.PyJWTError: If JWT encoding fails\n \"\"\"\n jwt_token = generate_jwt(app_id, private_key)\n resolved_permissions = permissions\n if repository and resolved_permissions is None:\n resolved_permissions = SANDBOX_SCOPED_PERMISSIONS\n return get_installation_token(\n jwt_token,\n installation_id,\n repository=repository,\n permissions=resolved_permissions,\n )\n"}>npm run test{"status":"fulfilled","value":{"chunk_id":"6f647d","wall_time_seconds":1.001835416,"session_id":3079,"original_token_count":36,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c168a3","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"0b4871","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"fcf2af","wall_time_seconds":0.019506791,"exit_code":0,"original_token_count":3532,"output":" { access_token: result.accessToken, expires_in: result.expiresIn },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n /**\n * Return the sandbox's resolved tunnel URLs as a `{ [port]: url }` map.\n *\n * `sandbox.tunnel_urls` is a JSON-encoded `{ [port: string]: string }`\n * stored by `SandboxLifecycleManager#storeAndBroadcastTunnelUrls`. When the\n * control plane has resolved Modal tunnel URLs but the in-sandbox file write\n * (`sandbox.open` from outside) hasn't propagated to the sandbox's own\n * filesystem view — a real failure mode on the Modal provider — this\n * endpoint is the in-sandbox fallback for retrieving them via\n * `SANDBOX_AUTH_TOKEN`.\n *\n * Responses:\n * - `404` when no sandbox exists for the session.\n * - `500` when the stored value is malformed — invalid JSON, not a plain\n * object, or holding a non-string value — so the in-sandbox setup hard-\n * fails on corrupt data instead of writing a garbage `.tunnels.env`. Note\n * a not-yet-resolved sandbox still returns `200` with an empty map, so the\n * client must tolerate an empty result and retry until ports appear.\n * - `200` with `{ tunnelUrls }` otherwise (empty map when none are stored).\n */\n async tunnelUrls(log: Logger): Promise<Response> {\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n return Response.json({ error: \"No sandbox\" }, { status: 404 });\n }\n\n let urls: Record<string, string> = {};\n if (sandbox.tunnel_urls) {\n const parsed = parseTunnelUrls(sandbox.tunnel_urls);\n if (!parsed) {\n log.warn(\"Invalid stored tunnel_urls\");\n return Response.json({ error: \"Invalid stored tunnel URLs\" }, { status: 500 });\n }\n urls = parsed;\n }\n\n return Response.json(\n { tunnelUrls: urls },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n async scmCredentials(log: Logger): Promise<Response> {\n const session = this.sessionCoreRepository.getSession();\n if (!session) {\n return Response.json({ error: \"No session\" }, { status: 404 });\n }\n if (!session.repo_owner || !session.repo_name) {\n return Response.json(\n { error: \"SCM credentials require a repository context\" },\n { status: 400 }\n );\n }\n\n // The full member repository set, not just the primary — a session's\n // sandbox may need to clone/push/fetch sibling repositories, and a\n // credential scoped to the primary alone would deny those operations.\n const repos = this.sessionCoreRepository\n .getSessionRepositories()\n .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n\n const result = await this.getScmCredentials(repos, log);\n if (!result.ok) {\n return Response.json({ error: result.error }, { status: result.status });\n }\n\n return Response.json(\n {\n username: result.username,\n password: result.password,\n expires_at_epoch_ms: result.expiresAtEpochMs,\n },\n {\n status: 200,\n headers: { \"Cache-Control\": \"no-store\" },\n }\n );\n }\n}\n7cf3fdd5 test: assert the mint function is never called, not just that None is returned\na7983425 feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n7cef37e6 docs: credential isolation audit — sandbox token capable of review-approval scope\n4bdb0472 docs: record repeat adversarial audit results, close backlog item #2\n16a0ccdc docs: correct overstated CODEOWNERS claim, close #3b with the acceptance-suite fix\npackages/sandbox-runtime/tests/test_github_app_auth.py:11: SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:30: get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\npackages/sandbox-runtime/tests/test_github_app_auth.py:58: \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/tests/test_github_app_auth.py:64: \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:118: assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\npackages/modal-infra/tests/test_clone_token.py:7:from src.clone_token import resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:22:def test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:26: assert resolve_clone_token() == \"glpat-token\"\npackages/modal-infra/tests/test_clone_token.py:29:def test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:32: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:35:def test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:38: Uses a plain recording mock rather than a raising stub: resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:53: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:54: assert resolve_clone_token(\"acme\", None) is None\npackages/modal-infra/tests/test_clone_token.py:55: assert resolve_clone_token(\"acme\", \"\") is None\npackages/modal-infra/tests/test_clone_token.py:59:def test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:74: assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\npackages/modal-infra/tests/test_clone_token.py:87:def test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:94: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:98:def test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:108: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:111:def test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:124: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/src/clone_token.py:10:def resolve_clone_token(repo_owner: str | None = None, repo_name: str | None = None) -> str | None:\npackages/modal-infra/src/clone_token.py:25: from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/modal-infra/src/clone_token.py:50: permissions=SANDBOX_SCOPED_PERMISSIONS,\npackages/modal-infra/src/web_api.py:36:from .clone_token import resolve_clone_token\npackages/modal-infra/src/web_api.py:637: resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:3:from .github_app import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:11: \"SANDBOX_SCOPED_PERMISSIONS\",\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:40:SANDBOX_SCOPED_PERMISSIONS: dict[str, str] = {\"contents\": \"write\", \"metadata\": \"read\"}\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:125: get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:140: resolved_permissions = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/auth/github-app.ts:344:export const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\npackages/control-plane/src/auth/github-app.ts:371: permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/session/repo-id-resolution.test.ts:29: generateCredentialHelperAuth: () => notUsedHere(\"generateCredentialHelperAuth\"),\npackages/control-plane/src/image-builds/planner.ts:127: const auth = await provider.generateCredentialHelperAuth(\npackages/control-plane/src/source-control/provider-from-env.test.ts:33: provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"repo\" }])\npackages/control-plane/src/session/scm-credentials-service.test.ts:22: generateCredentialHelperAuth: overrides.generateCredentialHelperAuth ?? vi.fn(),\npackages/control-plane/src/session/scm-credentials-service.test.ts:32: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:54: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:78: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:100: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:126: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:141: generateCredentialHelperAuth: vi.fn().mockRejectedValue(new Error(\"network blew up\")),\npackages/control-plane/src/source-control/types.ts:507: generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.test.ts:462: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:466: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:476: const err = await provider.generateCredentialHelperAuth([]).catch((e: unknown) => e);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:487: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:503: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:527: await provider.generateCredentialHelperAuth([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:545: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:562: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:875: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:882: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/session/scm-credentials-service.ts:18: * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\npackages/control-plane/src/session/scm-credentials-service.ts:32: const auth = await this.provider.generateCredentialHelperAuth(repos);\npackages/control-plane/src/source-control/providers/gitlab-provider.ts:718: async generateCredentialHelperAuth(\npackages/control-plane/src/session/components.ts:631: const getScmCredentials = (repos: Array<{ owner: string; name: string }>, requestLog: Logger) =>\npackages/control-plane/src/session/components.ts:644: getScmCredentials,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:31: const getScmCredentials = vi.fn();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:56: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:87: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:632: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:639: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:643: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:650: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:663: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:676: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:680: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:688: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:707: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:717: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:726: expect(getScmCredentials).toHaveBeenCalledWith(\npackages/control-plane/src/routes/image-builds.trigger.test.ts:43: generateCredentialHelperAuth: vi.fn(),\npackages/control-plane/src/routes/image-builds.trigger.test.ts:231: scmProvider.generateCredentialHelperAuth.mockResolvedValue({\npackages/control-plane/src/routes/image-builds.trigger.test.ts:275: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/routes/image-builds.trigger.test.ts:317: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:54: private readonly getScmCredentials: (\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:356: const result = await this.getScmCredentials(repos, log);\n"}}>uv run pytest tests/test_clone_token.py -q{"status":"fulfilled","value":{"chunk_id":"6f647d","wall_time_seconds":1.001835416,"session_id":3079,"original_token_count":36,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c168a3","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"0b4871","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"fcf2af","wall_time_seconds":0.019506791,"exit_code":0,"original_token_count":3532,"output":" { access_token: result.accessToken, expires_in: result.expiresIn },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n /**\n * Return the sandbox's resolved tunnel URLs as a `{ [port]: url }` map.\n *\n * `sandbox.tunnel_urls` is a JSON-encoded `{ [port: string]: string }`\n * stored by `SandboxLifecycleManager#storeAndBroadcastTunnelUrls`. When the\n * control plane has resolved Modal tunnel URLs but the in-sandbox file write\n * (`sandbox.open` from outside) hasn't propagated to the sandbox's own\n * filesystem view — a real failure mode on the Modal provider — this\n * endpoint is the in-sandbox fallback for retrieving them via\n * `SANDBOX_AUTH_TOKEN`.\n *\n * Responses:\n * - `404` when no sandbox exists for the session.\n * - `500` when the stored value is malformed — invalid JSON, not a plain\n * object, or holding a non-string value — so the in-sandbox setup hard-\n * fails on corrupt data instead of writing a garbage `.tunnels.env`. Note\n * a not-yet-resolved sandbox still returns `200` with an empty map, so the\n * client must tolerate an empty result and retry until ports appear.\n * - `200` with `{ tunnelUrls }` otherwise (empty map when none are stored).\n */\n async tunnelUrls(log: Logger): Promise<Response> {\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n return Response.json({ error: \"No sandbox\" }, { status: 404 });\n }\n\n let urls: Record<string, string> = {};\n if (sandbox.tunnel_urls) {\n const parsed = parseTunnelUrls(sandbox.tunnel_urls);\n if (!parsed) {\n log.warn(\"Invalid stored tunnel_urls\");\n return Response.json({ error: \"Invalid stored tunnel URLs\" }, { status: 500 });\n }\n urls = parsed;\n }\n\n return Response.json(\n { tunnelUrls: urls },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n async scmCredentials(log: Logger): Promise<Response> {\n const session = this.sessionCoreRepository.getSession();\n if (!session) {\n return Response.json({ error: \"No session\" }, { status: 404 });\n }\n if (!session.repo_owner || !session.repo_name) {\n return Response.json(\n { error: \"SCM credentials require a repository context\" },\n { status: 400 }\n );\n }\n\n // The full member repository set, not just the primary — a session's\n // sandbox may need to clone/push/fetch sibling repositories, and a\n // credential scoped to the primary alone would deny those operations.\n const repos = this.sessionCoreRepository\n .getSessionRepositories()\n .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n\n const result = await this.getScmCredentials(repos, log);\n if (!result.ok) {\n return Response.json({ error: result.error }, { status: result.status });\n }\n\n return Response.json(\n {\n username: result.username,\n password: result.password,\n expires_at_epoch_ms: result.expiresAtEpochMs,\n },\n {\n status: 200,\n headers: { \"Cache-Control\": \"no-store\" },\n }\n );\n }\n}\n7cf3fdd5 test: assert the mint function is never called, not just that None is returned\na7983425 feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n7cef37e6 docs: credential isolation audit — sandbox token capable of review-approval scope\n4bdb0472 docs: record repeat adversarial audit results, close backlog item #2\n16a0ccdc docs: correct overstated CODEOWNERS claim, close #3b with the acceptance-suite fix\npackages/sandbox-runtime/tests/test_github_app_auth.py:11: SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:30: get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\npackages/sandbox-runtime/tests/test_github_app_auth.py:58: \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/tests/test_github_app_auth.py:64: \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:118: assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\npackages/modal-infra/tests/test_clone_token.py:7:from src.clone_token import resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:22:def test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:26: assert resolve_clone_token() == \"glpat-token\"\npackages/modal-infra/tests/test_clone_token.py:29:def test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:32: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:35:def test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:38: Uses a plain recording mock rather than a raising stub: resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:53: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:54: assert resolve_clone_token(\"acme\", None) is None\npackages/modal-infra/tests/test_clone_token.py:55: assert resolve_clone_token(\"acme\", \"\") is None\npackages/modal-infra/tests/test_clone_token.py:59:def test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:74: assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\npackages/modal-infra/tests/test_clone_token.py:87:def test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:94: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:98:def test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:108: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:111:def test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:124: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/src/clone_token.py:10:def resolve_clone_token(repo_owner: str | None = None, repo_name: str | None = None) -> str | None:\npackages/modal-infra/src/clone_token.py:25: from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/modal-infra/src/clone_token.py:50: permissions=SANDBOX_SCOPED_PERMISSIONS,\npackages/modal-infra/src/web_api.py:36:from .clone_token import resolve_clone_token\npackages/modal-infra/src/web_api.py:637: resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:3:from .github_app import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:11: \"SANDBOX_SCOPED_PERMISSIONS\",\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:40:SANDBOX_SCOPED_PERMISSIONS: dict[str, str] = {\"contents\": \"write\", \"metadata\": \"read\"}\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:125: get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:140: resolved_permissions = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/auth/github-app.ts:344:export const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\npackages/control-plane/src/auth/github-app.ts:371: permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/session/repo-id-resolution.test.ts:29: generateCredentialHelperAuth: () => notUsedHere(\"generateCredentialHelperAuth\"),\npackages/control-plane/src/image-builds/planner.ts:127: const auth = await provider.generateCredentialHelperAuth(\npackages/control-plane/src/source-control/provider-from-env.test.ts:33: provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"repo\" }])\npackages/control-plane/src/session/scm-credentials-service.test.ts:22: generateCredentialHelperAuth: overrides.generateCredentialHelperAuth ?? vi.fn(),\npackages/control-plane/src/session/scm-credentials-service.test.ts:32: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:54: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:78: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:100: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:126: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:141: generateCredentialHelperAuth: vi.fn().mockRejectedValue(new Error(\"network blew up\")),\npackages/control-plane/src/source-control/types.ts:507: generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.test.ts:462: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:466: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:476: const err = await provider.generateCredentialHelperAuth([]).catch((e: unknown) => e);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:487: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:503: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:527: await provider.generateCredentialHelperAuth([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:545: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:562: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:875: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:882: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/session/scm-credentials-service.ts:18: * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\npackages/control-plane/src/session/scm-credentials-service.ts:32: const auth = await this.provider.generateCredentialHelperAuth(repos);\npackages/control-plane/src/source-control/providers/gitlab-provider.ts:718: async generateCredentialHelperAuth(\npackages/control-plane/src/session/components.ts:631: const getScmCredentials = (repos: Array<{ owner: string; name: string }>, requestLog: Logger) =>\npackages/control-plane/src/session/components.ts:644: getScmCredentials,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:31: const getScmCredentials = vi.fn();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:56: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:87: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:632: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:639: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:643: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:650: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:663: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:676: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:680: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:688: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:707: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:717: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:726: expect(getScmCredentials).toHaveBeenCalledWith(\npackages/control-plane/src/routes/image-builds.trigger.test.ts:43: generateCredentialHelperAuth: vi.fn(),\npackages/control-plane/src/routes/image-builds.trigger.test.ts:231: scmProvider.generateCredentialHelperAuth.mockResolvedValue({\npackages/control-plane/src/routes/image-builds.trigger.test.ts:275: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/routes/image-builds.trigger.test.ts:317: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:54: private readonly getScmCredentials: (\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:356: const result = await this.getScmCredentials(repos, log);\n"}}>uv run pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"6f647d","wall_time_seconds":1.001835416,"session_id":3079,"original_token_count":36,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c168a3","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"0b4871","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"fcf2af","wall_time_seconds":0.019506791,"exit_code":0,"original_token_count":3532,"output":" { access_token: result.accessToken, expires_in: result.expiresIn },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n /**\n * Return the sandbox's resolved tunnel URLs as a `{ [port]: url }` map.\n *\n * `sandbox.tunnel_urls` is a JSON-encoded `{ [port: string]: string }`\n * stored by `SandboxLifecycleManager#storeAndBroadcastTunnelUrls`. When the\n * control plane has resolved Modal tunnel URLs but the in-sandbox file write\n * (`sandbox.open` from outside) hasn't propagated to the sandbox's own\n * filesystem view — a real failure mode on the Modal provider — this\n * endpoint is the in-sandbox fallback for retrieving them via\n * `SANDBOX_AUTH_TOKEN`.\n *\n * Responses:\n * - `404` when no sandbox exists for the session.\n * - `500` when the stored value is malformed — invalid JSON, not a plain\n * object, or holding a non-string value — so the in-sandbox setup hard-\n * fails on corrupt data instead of writing a garbage `.tunnels.env`. Note\n * a not-yet-resolved sandbox still returns `200` with an empty map, so the\n * client must tolerate an empty result and retry until ports appear.\n * - `200` with `{ tunnelUrls }` otherwise (empty map when none are stored).\n */\n async tunnelUrls(log: Logger): Promise<Response> {\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n return Response.json({ error: \"No sandbox\" }, { status: 404 });\n }\n\n let urls: Record<string, string> = {};\n if (sandbox.tunnel_urls) {\n const parsed = parseTunnelUrls(sandbox.tunnel_urls);\n if (!parsed) {\n log.warn(\"Invalid stored tunnel_urls\");\n return Response.json({ error: \"Invalid stored tunnel URLs\" }, { status: 500 });\n }\n urls = parsed;\n }\n\n return Response.json(\n { tunnelUrls: urls },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n async scmCredentials(log: Logger): Promise<Response> {\n const session = this.sessionCoreRepository.getSession();\n if (!session) {\n return Response.json({ error: \"No session\" }, { status: 404 });\n }\n if (!session.repo_owner || !session.repo_name) {\n return Response.json(\n { error: \"SCM credentials require a repository context\" },\n { status: 400 }\n );\n }\n\n // The full member repository set, not just the primary — a session's\n // sandbox may need to clone/push/fetch sibling repositories, and a\n // credential scoped to the primary alone would deny those operations.\n const repos = this.sessionCoreRepository\n .getSessionRepositories()\n .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n\n const result = await this.getScmCredentials(repos, log);\n if (!result.ok) {\n return Response.json({ error: result.error }, { status: result.status });\n }\n\n return Response.json(\n {\n username: result.username,\n password: result.password,\n expires_at_epoch_ms: result.expiresAtEpochMs,\n },\n {\n status: 200,\n headers: { \"Cache-Control\": \"no-store\" },\n }\n );\n }\n}\n7cf3fdd5 test: assert the mint function is never called, not just that None is returned\na7983425 feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n7cef37e6 docs: credential isolation audit — sandbox token capable of review-approval scope\n4bdb0472 docs: record repeat adversarial audit results, close backlog item #2\n16a0ccdc docs: correct overstated CODEOWNERS claim, close #3b with the acceptance-suite fix\npackages/sandbox-runtime/tests/test_github_app_auth.py:11: SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:30: get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\npackages/sandbox-runtime/tests/test_github_app_auth.py:58: \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/tests/test_github_app_auth.py:64: \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:118: assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\npackages/modal-infra/tests/test_clone_token.py:7:from src.clone_token import resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:22:def test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:26: assert resolve_clone_token() == \"glpat-token\"\npackages/modal-infra/tests/test_clone_token.py:29:def test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:32: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:35:def test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:38: Uses a plain recording mock rather than a raising stub: resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:53: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:54: assert resolve_clone_token(\"acme\", None) is None\npackages/modal-infra/tests/test_clone_token.py:55: assert resolve_clone_token(\"acme\", \"\") is None\npackages/modal-infra/tests/test_clone_token.py:59:def test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:74: assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\npackages/modal-infra/tests/test_clone_token.py:87:def test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:94: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:98:def test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:108: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:111:def test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:124: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/src/clone_token.py:10:def resolve_clone_token(repo_owner: str | None = None, repo_name: str | None = None) -> str | None:\npackages/modal-infra/src/clone_token.py:25: from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/modal-infra/src/clone_token.py:50: permissions=SANDBOX_SCOPED_PERMISSIONS,\npackages/modal-infra/src/web_api.py:36:from .clone_token import resolve_clone_token\npackages/modal-infra/src/web_api.py:637: resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:3:from .github_app import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:11: \"SANDBOX_SCOPED_PERMISSIONS\",\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:40:SANDBOX_SCOPED_PERMISSIONS: dict[str, str] = {\"contents\": \"write\", \"metadata\": \"read\"}\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:125: get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:140: resolved_permissions = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/auth/github-app.ts:344:export const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\npackages/control-plane/src/auth/github-app.ts:371: permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/session/repo-id-resolution.test.ts:29: generateCredentialHelperAuth: () => notUsedHere(\"generateCredentialHelperAuth\"),\npackages/control-plane/src/image-builds/planner.ts:127: const auth = await provider.generateCredentialHelperAuth(\npackages/control-plane/src/source-control/provider-from-env.test.ts:33: provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"repo\" }])\npackages/control-plane/src/session/scm-credentials-service.test.ts:22: generateCredentialHelperAuth: overrides.generateCredentialHelperAuth ?? vi.fn(),\npackages/control-plane/src/session/scm-credentials-service.test.ts:32: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:54: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:78: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:100: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:126: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:141: generateCredentialHelperAuth: vi.fn().mockRejectedValue(new Error(\"network blew up\")),\npackages/control-plane/src/source-control/types.ts:507: generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.test.ts:462: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:466: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:476: const err = await provider.generateCredentialHelperAuth([]).catch((e: unknown) => e);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:487: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:503: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:527: await provider.generateCredentialHelperAuth([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:545: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:562: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:875: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:882: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/session/scm-credentials-service.ts:18: * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\npackages/control-plane/src/session/scm-credentials-service.ts:32: const auth = await this.provider.generateCredentialHelperAuth(repos);\npackages/control-plane/src/source-control/providers/gitlab-provider.ts:718: async generateCredentialHelperAuth(\npackages/control-plane/src/session/components.ts:631: const getScmCredentials = (repos: Array<{ owner: string; name: string }>, requestLog: Logger) =>\npackages/control-plane/src/session/components.ts:644: getScmCredentials,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:31: const getScmCredentials = vi.fn();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:56: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:87: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:632: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:639: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:643: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:650: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:663: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:676: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:680: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:688: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:707: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:717: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:726: expect(getScmCredentials).toHaveBeenCalledWith(\npackages/control-plane/src/routes/image-builds.trigger.test.ts:43: generateCredentialHelperAuth: vi.fn(),\npackages/control-plane/src/routes/image-builds.trigger.test.ts:231: scmProvider.generateCredentialHelperAuth.mockResolvedValue({\npackages/control-plane/src/routes/image-builds.trigger.test.ts:275: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/routes/image-builds.trigger.test.ts:317: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:54: private readonly getScmCredentials: (\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:356: const result = await this.getScmCredentials(repos, log);\n"}}>sed -n '290,390p' packages/control-plane/src/session/http/handlers/sandbox.handler.ts; git log -5 --oneline; rg -n 'generateCredentialHelperAuth|SANDBOX_SCOPED_PERMISSIONS|getScmCredentials|resolve_clone_token' packages/control-plane/src packages/modal-infra/src packages/modal-infra/tests/test_clone_token.py packages/sandbox-runtime/src packages/sandbox-runtime/tests/test_github_app_auth.py{"status":"fulfilled","value":{"chunk_id":"6f647d","wall_time_seconds":1.001835416,"session_id":3079,"original_token_count":36,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c168a3","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"0b4871","wall_time_seconds":0,"exit_code":2,"original_token_count":46,"output":"error: Failed to initialize cache at `/Users/gaganarora/.cache/uv`\n Caused by: failed to open file `/Users/gaganarora/.cache/uv/sdists-v9/.git`: Operation not permitted (os error 1)\n"}}
{"status":"fulfilled","value":{"chunk_id":"fcf2af","wall_time_seconds":0.019506791,"exit_code":0,"original_token_count":3532,"output":" { access_token: result.accessToken, expires_in: result.expiresIn },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n /**\n * Return the sandbox's resolved tunnel URLs as a `{ [port]: url }` map.\n *\n * `sandbox.tunnel_urls` is a JSON-encoded `{ [port: string]: string }`\n * stored by `SandboxLifecycleManager#storeAndBroadcastTunnelUrls`. When the\n * control plane has resolved Modal tunnel URLs but the in-sandbox file write\n * (`sandbox.open` from outside) hasn't propagated to the sandbox's own\n * filesystem view — a real failure mode on the Modal provider — this\n * endpoint is the in-sandbox fallback for retrieving them via\n * `SANDBOX_AUTH_TOKEN`.\n *\n * Responses:\n * - `404` when no sandbox exists for the session.\n * - `500` when the stored value is malformed — invalid JSON, not a plain\n * object, or holding a non-string value — so the in-sandbox setup hard-\n * fails on corrupt data instead of writing a garbage `.tunnels.env`. Note\n * a not-yet-resolved sandbox still returns `200` with an empty map, so the\n * client must tolerate an empty result and retry until ports appear.\n * - `200` with `{ tunnelUrls }` otherwise (empty map when none are stored).\n */\n async tunnelUrls(log: Logger): Promise<Response> {\n const sandbox = this.sandboxRepository.getSandbox();\n if (!sandbox) {\n return Response.json({ error: \"No sandbox\" }, { status: 404 });\n }\n\n let urls: Record<string, string> = {};\n if (sandbox.tunnel_urls) {\n const parsed = parseTunnelUrls(sandbox.tunnel_urls);\n if (!parsed) {\n log.warn(\"Invalid stored tunnel_urls\");\n return Response.json({ error: \"Invalid stored tunnel URLs\" }, { status: 500 });\n }\n urls = parsed;\n }\n\n return Response.json(\n { tunnelUrls: urls },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n async scmCredentials(log: Logger): Promise<Response> {\n const session = this.sessionCoreRepository.getSession();\n if (!session) {\n return Response.json({ error: \"No session\" }, { status: 404 });\n }\n if (!session.repo_owner || !session.repo_name) {\n return Response.json(\n { error: \"SCM credentials require a repository context\" },\n { status: 400 }\n );\n }\n\n // The full member repository set, not just the primary — a session's\n // sandbox may need to clone/push/fetch sibling repositories, and a\n // credential scoped to the primary alone would deny those operations.\n const repos = this.sessionCoreRepository\n .getSessionRepositories()\n .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n\n const result = await this.getScmCredentials(repos, log);\n if (!result.ok) {\n return Response.json({ error: result.error }, { status: result.status });\n }\n\n return Response.json(\n {\n username: result.username,\n password: result.password,\n expires_at_epoch_ms: result.expiresAtEpochMs,\n },\n {\n status: 200,\n headers: { \"Cache-Control\": \"no-store\" },\n }\n );\n }\n}\n7cf3fdd5 test: assert the mint function is never called, not just that None is returned\na7983425 feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n7cef37e6 docs: credential isolation audit — sandbox token capable of review-approval scope\n4bdb0472 docs: record repeat adversarial audit results, close backlog item #2\n16a0ccdc docs: correct overstated CODEOWNERS claim, close #3b with the acceptance-suite fix\npackages/sandbox-runtime/tests/test_github_app_auth.py:11: SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:30: get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\npackages/sandbox-runtime/tests/test_github_app_auth.py:58: \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/tests/test_github_app_auth.py:64: \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\npackages/sandbox-runtime/tests/test_github_app_auth.py:118: assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\npackages/modal-infra/tests/test_clone_token.py:7:from src.clone_token import resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:22:def test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:26: assert resolve_clone_token() == \"glpat-token\"\npackages/modal-infra/tests/test_clone_token.py:29:def test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:32: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:35:def test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:38: Uses a plain recording mock rather than a raising stub: resolve_clone_token\npackages/modal-infra/tests/test_clone_token.py:53: assert resolve_clone_token() is None\npackages/modal-infra/tests/test_clone_token.py:54: assert resolve_clone_token(\"acme\", None) is None\npackages/modal-infra/tests/test_clone_token.py:55: assert resolve_clone_token(\"acme\", \"\") is None\npackages/modal-infra/tests/test_clone_token.py:59:def test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:74: assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\npackages/modal-infra/tests/test_clone_token.py:87:def test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:94: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:98:def test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:108: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/tests/test_clone_token.py:111:def test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\npackages/modal-infra/tests/test_clone_token.py:124: assert resolve_clone_token(\"acme\", \"repo\") is None\npackages/modal-infra/src/clone_token.py:10:def resolve_clone_token(repo_owner: str | None = None, repo_name: str | None = None) -> str | None:\npackages/modal-infra/src/clone_token.py:25: from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/modal-infra/src/clone_token.py:50: permissions=SANDBOX_SCOPED_PERMISSIONS,\npackages/modal-infra/src/web_api.py:36:from .clone_token import resolve_clone_token\npackages/modal-infra/src/web_api.py:637: resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:3:from .github_app import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:11: \"SANDBOX_SCOPED_PERMISSIONS\",\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:40:SANDBOX_SCOPED_PERMISSIONS: dict[str, str] = {\"contents\": \"write\", \"metadata\": \"read\"}\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:125: get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:140: resolved_permissions = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/auth/github-app.ts:344:export const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\npackages/control-plane/src/auth/github-app.ts:371: permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\npackages/control-plane/src/session/repo-id-resolution.test.ts:29: generateCredentialHelperAuth: () => notUsedHere(\"generateCredentialHelperAuth\"),\npackages/control-plane/src/image-builds/planner.ts:127: const auth = await provider.generateCredentialHelperAuth(\npackages/control-plane/src/source-control/provider-from-env.test.ts:33: provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"repo\" }])\npackages/control-plane/src/session/scm-credentials-service.test.ts:22: generateCredentialHelperAuth: overrides.generateCredentialHelperAuth ?? vi.fn(),\npackages/control-plane/src/session/scm-credentials-service.test.ts:32: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:54: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:78: generateCredentialHelperAuth: vi.fn().mockResolvedValue({\npackages/control-plane/src/session/scm-credentials-service.test.ts:100: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:126: generateCredentialHelperAuth: vi\npackages/control-plane/src/session/scm-credentials-service.test.ts:141: generateCredentialHelperAuth: vi.fn().mockRejectedValue(new Error(\"network blew up\")),\npackages/control-plane/src/source-control/types.ts:507: generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.test.ts:462: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:466: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:476: const err = await provider.generateCredentialHelperAuth([]).catch((e: unknown) => e);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:487: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:503: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/source-control/providers/github-provider.test.ts:527: await provider.generateCredentialHelperAuth([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:545: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/github-provider.test.ts:562: .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:875: describe(\"generateCredentialHelperAuth\", () => {\npackages/control-plane/src/source-control/providers/gitlab-provider.test.ts:882: const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\npackages/control-plane/src/session/scm-credentials-service.ts:18: * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\npackages/control-plane/src/session/scm-credentials-service.ts:32: const auth = await this.provider.generateCredentialHelperAuth(repos);\npackages/control-plane/src/source-control/providers/gitlab-provider.ts:718: async generateCredentialHelperAuth(\npackages/control-plane/src/session/components.ts:631: const getScmCredentials = (repos: Array<{ owner: string; name: string }>, requestLog: Logger) =>\npackages/control-plane/src/session/components.ts:644: getScmCredentials,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:31: const getScmCredentials = vi.fn();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:56: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:87: getScmCredentials,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:632: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:639: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:643: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:650: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:663: const { handler, getSession, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:676: expect(getScmCredentials).not.toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:680: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:688: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:707: const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:717: getScmCredentials.mockResolvedValue({\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:726: expect(getScmCredentials).toHaveBeenCalledWith(\npackages/control-plane/src/routes/image-builds.trigger.test.ts:43: generateCredentialHelperAuth: vi.fn(),\npackages/control-plane/src/routes/image-builds.trigger.test.ts:231: scmProvider.generateCredentialHelperAuth.mockResolvedValue({\npackages/control-plane/src/routes/image-builds.trigger.test.ts:275: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/routes/image-builds.trigger.test.ts:317: expect(scmProvider.generateCredentialHelperAuth).toHaveBeenCalled();\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:54: private readonly getScmCredentials: (\npackages/control-plane/src/session/http/handlers/sandbox.handler.ts:356: const result = await this.getScmCredentials(repos, log);\n"}}>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv uv run pytest tests/test_clone_token.py -q{"status":"fulfilled","value":{"chunk_id":"645a9a","wall_time_seconds":0.32365525,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926967) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926958) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"315e5a","wall_time_seconds":0.336015,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926969) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926954) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"f1f9d7","wall_time_seconds":0.227511416,"exit_code":0,"original_token_count":3513,"output":"commit a7983425691f1a97de508158b45ae08becd289c6\nAuthor: gagan114662 <[REDACTED]>\nDate: Sat Sep 12 15:56:45 2026 -0400\n\n feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n \n The GitHub App installation token handed to a sandbox (via the git\n credential helper and the gh CLI wrapper) previously carried the App's\n full grant, including pull_requests:write and issues:write. Because\n this is the same credential type the review-submission path uses, a\n sandbox's own agent-readable token could call the reviews endpoint on\n any PR the installation covers, not just perform git operations.\n \n Mint a token narrowed to contents:write + metadata:read, scoped to\n every repository a session or image build actually needs (not just a\n \"primary\" one, preserving multi-repo/sibling-repo support), fetched\n fresh with no caching and no fallback to the full-grant token on\n failure. Applies to the control-plane's sandbox-facing credential\n endpoint and image-build clone auth (TypeScript), and the Modal\n restore-path static token injection (Python), both independently.\n \n The brokered push+PR-creation path and the github-bot Worker's own\n review-submission minting are untouched — neither is sandbox-reachable\n and both legitimately need the App's full grant.\n \n Co-Authored-By: Claude Sonnet 5 <[REDACTED]>\n\n packages/control-plane/src/auth/github-app.ts | 99 ++++++++++++++++\n packages/control-plane/src/image-builds/planner.ts | 13 ++-\n packages/control-plane/src/session/components.ts | 4 +-\n .../session/http/handlers/sandbox.handler.test.ts | 39 ++++++-\n .../src/session/http/handlers/sandbox.handler.ts | 14 ++-\n .../src/session/scm-credentials-service.test.ts | 24 +++-\n .../src/session/scm-credentials-service.ts | 6 +-\n .../src/source-control/provider-from-env.test.ts | 4 +-\n .../providers/github-provider.test.ts | 86 +++++++++++---\n .../source-control/providers/github-provider.ts | 25 +++-\n .../providers/gitlab-provider.test.ts | 2 +-\n .../source-control/providers/gitlab-provider.ts | 10 +-\n packages/control-plane/src/source-control/types.ts | 20 +++-\n packages/modal-infra/src/clone_token.py | 27 ++++-\n packages/modal-infra/src/web_api.py | 4 +-\n packages/modal-infra/tests/test_clone_token.py | 51 +++++++-\n .../tests/test_web_api_create_sandbox.py | 8 +-\n .../src/sandbox_runtime/auth/__init__.py | 3 +-\n .../src/sandbox_runtime/auth/github_app.py | 64 ++++++++++-\n .../sandbox-runtime/tests/test_github_app_auth.py | 128 +++++++++++++++++++++\n 20 files changed, 571 insertions(+), 60 deletions(-)\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/** Default permission set for sandbox-reachable credentials: git push only. */\nexport const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\n contents: \"write\",\n metadata: \"read\",\n};\n\n/**\n * Mint a fresh installation token scoped to a set of repositories and a\n * minimal permission set (default: `contents:write` + `metadata:read` —\n * enough for git clone/fetch/push, nothing else).\n *\n * Intentionally uncached and never falls back to the full-grant token on\n * failure: a rejected narrowing request must propagate as an error so the\n * caller denies the credential rather than silently widening its scope.\n * Every mint hits GitHub fresh, trading a small amount of latency for the\n * guarantee that a scoped-credential caller can never receive a broader\n * grant than requested.\n *\n * Fails closed on malformed input rather than silently minting a broader\n * grant: an empty `repoNames` array, or an empty `permissions` object,\n * would each cause GitHub's API to omit the corresponding narrowing field\n * and return the installation's full, unnarrowed permission set — so both\n * are rejected here before any request is made.\n */\nexport async function getScopedInstallationTokenWithExpiry(\n config: GitHubAppConfig,\n repoNames: string[],\n env?: InstallationTokenCacheBindings,\n permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\n): Promise<{ token: string; expiresAtEpochMs: number }> {\n if (repoNames.length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no repositories\");\n }\n if (Object.keys(permissions).length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no permissions\");\n }\n const jwt = await generateAppJwt(config.appId, config.privateKey);\n return getScopedInstallationTokenWithMetadata(\n jwt,\n config.installationId,\n resolveUserAgent(env),\n repoNames,\n permissions\n );\n}\n\nfunction getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n}\n\nfunction isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()): boolean {\n const cacheAgeMs = nowEpochMs - cached.cachedAtEpochMs;\n if (cacheAgeMs >= INSTALLATION_TOKEN_CACHE_MAX_AGE_MS) {\n return false;\n }\n return nowEpochMs < cached.expiresAtEpochMs - INSTALLATION_TOKEN_MIN_REMAINING_MS;\n}\n\nasync function readInstallationTokenFromCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<CachedInstallationToken | null> {\n if (!env?.cacheStore) {\n return null;\n }\n\n try {\n const result = cachedInstallationTokenSchema.safeParse(\n await env.cacheStore.get(cacheKey, \"json\")\n );\n return result.success ? result.data : null;\n } catch {\n return null;\n }\n}\n\nasync function writeInstallationTokenToCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string,\n cached: CachedInstallationToken\n): Promise<void> {\n if (!env?.cacheStore) {\n return;\n }\n\n const nowEpochMs = Date.now();\n const remainingLifetimeMs = cached.expiresAtEpochMs - nowEpochMs;\n if (remainingLifetimeMs <= 0) {\n return;\n }\n\n const cacheBoundLifetimeMs = Math.min(remainingLifetimeMs, INSTALLATION_TOKEN_CACHE_MAX_AGE_MS);\n const ttlSeconds = Math.max(\n 1,\n Math.min(INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS, Math.floor(cacheBoundLifetimeMs / 1000))\n );\n\n try {\n await env.cacheStore.put(cacheKey, JSON.stringify(cached), { expirationTtl: ttlSeconds });\n } catch {\n // Cache failures are non-fatal.\n }\n}\n\nasync function invalidateInstallationTokenCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<void> {\n installationTokenMemoryCache.delete(cacheKey);\n installationTokenRefreshInFlight.delete(cacheKey);\n\n if (!env?.cacheStore) {\n return;\n }\n\n try {\n await env.cacheStore.delete(cacheKey);\n } catch {\n // Cache invalidation failures are non-fatal.\n }\n}\n\nasync function refreshInstallationToken(\n });\n return {\n authType: \"app\",\n token,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate GitHub App token: ${error instanceof Error ? error.message : String(error)}`,\n error\n );\n }\n }\n\n async generateCredentialHelperAuth(\n repos: Array<{ owner: string; name: string }>\n ): Promise<CredentialHelperAuth> {\n if (!this.appConfig) {\n throw new SourceControlProviderError(\n \"GitHub App not configured - cannot generate credential helper auth\",\n \"permanent\"\n );\n }\n const repoNames = [\n ...new Set(repos.map((r) => r.name.trim()).filter((name): name is string => name.length > 0)),\n ];\n if (repoNames.length === 0) {\n throw new SourceControlProviderError(\n \"Cannot generate a repo-scoped credential without a repository\",\n \"permanent\"\n );\n }\n\n // Scoped to every repository the caller needs, with git-only\n // permissions — this credential is directly reachable by the\n // sandbox's own shell (git credential helper, gh CLI wrapper). No\n // fallback to the full-grant token on failure: a rejected narrowing\n // must deny the credential, not silently widen it.\n try {\n const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n }\n );\n return {\n username: \"x-access-token\",\n password: token,\n expiresAtEpochMs,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate scoped GitHub credential helper auth for ${repoNames.join(\", \")}: ${error instanceof Error ? error.message : String(error)}`,\n error,\n extractHttpStatus(error)\n );\n }\n }\n\n buildManualPullRequestUrl(config: BuildManualPullRequestUrlConfig): string {\nimport type { SourceControlProvider } from \"../source-control\";\nimport { SourceControlProviderError } from \"../source-control/errors\";\nimport type { Logger } from \"../logger\";\n\nexport type ScmCredentialsResult =\n | {\n ok: true;\n username: string;\n password: string;\n expiresAtEpochMs: number;\n }\n | { ok: false; status: number; error: string };\n\n/**\n * Service that mints short-lived SCM credentials for the sandbox-side git\n * credential helper.\n *\n * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\n * and adapts upstream errors into a discriminated union that the HTTP handler\n * can map directly to a response. Never logs the returned password.\n */\nexport class ScmCredentialsService {\n constructor(\n private readonly provider: SourceControlProvider,\n private readonly log: Logger\n ) {}\n\n async getCredentials(\n repos: Array<{ owner: string; name: string }>\n ): Promise<ScmCredentialsResult> {\n try {\n const auth = await this.provider.generateCredentialHelperAuth(repos);\n if (\n !auth.username.trim() ||\n !auth.password.trim() ||\n !Number.isFinite(auth.expiresAtEpochMs) ||\n auth.expiresAtEpochMs <= Date.now()\n ) {\n this.log.error(\"Provider returned invalid SCM credential helper auth\", {\n scm_provider: this.provider.name,\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n\n return {\n ok: true,\n username: auth.username,\n password: auth.password,\n expiresAtEpochMs: auth.expiresAtEpochMs,\n };\n } catch (e) {\n if (e instanceof SourceControlProviderError) {\n // Permanent → 500 (config error, retrying won't help).\n // Transient → 502 (upstream/network blip, the helper exits 1 and\n // the next git op will retry).\n const status = e.errorType === \"permanent\" ? 500 : 502;\n this.log.warn(\"SCM credential helper auth failed\", {\n scm_provider: this.provider.name,\n error_type: e.errorType,\n error: e.message,\n });\n return { ok: false, status, error: e.message };\n }\n\n this.log.error(\"Unexpected error generating SCM credentials\", {\n scm_provider: this.provider.name,\n error: e instanceof Error ? e.message : String(e),\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n }\n}\n246: getSessionRepositories(): SessionRepositoryEntry[] {\n[project]\nname = \"open-inspect-sandbox-runtime\"\nversion = \"0.1.0\"\ndescription = \"Provider-agnostic sandbox runtime for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"cryptography>=44.0.0\",\n \"httpx>=0.27.0\",\n \"websockets>=13.0\",\n \"pydantic>=2.0\",\n \"PyJWT[crypto]>=2.9.0\",\n # Exact pin: the wheel bundles the `claude` binary and its message shapes\n # are what harness/claude.py translates.\n \"claude-agent-sdk==0.2.152\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=8.0\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/sandbox_runtime\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n[project]\nname = \"open-inspect-modal\"\nversion = \"0.1.0\"\ndescription = \"Modal sandbox infrastructure for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"open-inspect-sandbox-runtime\", # sibling package, resolved via [tool.uv.sources]\n \"modal>=1.4.3\", # Function.with_options() (per-call timeout override) requires >=1.4.3\n \"httpx>=0.27.0\",\n \"pydantic>=2.0\",\n \"fastapi>=0.110.0\",\n \"PyJWT[crypto]>=2.9.0\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=9.0.3\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[tool.uv.sources]\nopen-inspect-sandbox-runtime = { path = \"../sandbox-runtime\", editable = true }\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\npythonpath = [\"../sandbox-images/src\"]\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"src\", \"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n"}}
{"chunk_id":"d34bfd","wall_time_seconds":5.002878208,"session_id":3079,"original_token_count":834,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 42ms\n × adopts sockets under their tags and enumerates them by tag 27ms\n × returns no tags for a socket it never accepted 6ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 0ms\n × forwards text frames as strings and binary frames as ArrayBuffers 0ms\n × answers the exact keepalive request without delivering it 0ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 0ms\n × reports a lost connection as an unclean 1006 close 1ms\n × delivers one socket's events in order, one at a time, close last 2ms\n × logs a failed delivery and keeps delivering 0ms\n × forwards socket errors to the runtime 0ms\n × reports an incomplete closing handshake as unclean even with a normal code 1ms\n × pauses a flooding peer while a delivery is in flight and loses nothing 1ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 0ms\n × satisfies the core's open check without the ambient WebSocket global 1ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 38ms\n × answers /healthz itself, 200 while serving and 503 while draining 24ms\n × hands every other request to the app as a fetch Request 10ms\n × routes an upgrade to the upgrade handler 1ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 1ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/host.test.ts (10 tests | 10 failed) 2293ms\n × boots over the migrated global store and answers the health check and the route table 414ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 255ms\n × closes the cache database on a normal shutdown, not only on a failed boot 170ms\n × reports draining once a shutdown begins and stops listening when it ends 197ms\n × waits for a request in flight before closing the stores, and answers it 192ms\n × gives up a request that outlives the budget and reports it 208ms\n × marks a stop that abandoned nothing as clean 171ms\n × arms a deadline a previous process left only in the session file 188ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 265ms\n × releases what it acquired when a later boot step fails 233ms\n ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 26ms\n × answers 400 on any path but a session's 15ms\n × answers 400 to an upgrade whose Host makes no URL 5ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 0ms\n × answers 404 for a session the index does not know, without opening a runtime 0ms\n × answers 404 when the index knows the session but nothing is behind it 0ms\n × writes the session's rejection as the handshake's status 0ms\n × hands the session the upgrade as a request with its URL and headers 0ms\n × completes an accepted upgrade and the runtime exchanges messages on the socket 0ms\n × holds a frame sent on the 101 until the runtime has attached 1ms\n × closes the socket with 1011 when attachment fails 1ms\n"}>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv uv run pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"645a9a","wall_time_seconds":0.32365525,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926967) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926958) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"315e5a","wall_time_seconds":0.336015,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926969) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926954) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"f1f9d7","wall_time_seconds":0.227511416,"exit_code":0,"original_token_count":3513,"output":"commit a7983425691f1a97de508158b45ae08becd289c6\nAuthor: gagan114662 <[REDACTED]>\nDate: Sat Sep 12 15:56:45 2026 -0400\n\n feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n \n The GitHub App installation token handed to a sandbox (via the git\n credential helper and the gh CLI wrapper) previously carried the App's\n full grant, including pull_requests:write and issues:write. Because\n this is the same credential type the review-submission path uses, a\n sandbox's own agent-readable token could call the reviews endpoint on\n any PR the installation covers, not just perform git operations.\n \n Mint a token narrowed to contents:write + metadata:read, scoped to\n every repository a session or image build actually needs (not just a\n \"primary\" one, preserving multi-repo/sibling-repo support), fetched\n fresh with no caching and no fallback to the full-grant token on\n failure. Applies to the control-plane's sandbox-facing credential\n endpoint and image-build clone auth (TypeScript), and the Modal\n restore-path static token injection (Python), both independently.\n \n The brokered push+PR-creation path and the github-bot Worker's own\n review-submission minting are untouched — neither is sandbox-reachable\n and both legitimately need the App's full grant.\n \n Co-Authored-By: Claude Sonnet 5 <[REDACTED]>\n\n packages/control-plane/src/auth/github-app.ts | 99 ++++++++++++++++\n packages/control-plane/src/image-builds/planner.ts | 13 ++-\n packages/control-plane/src/session/components.ts | 4 +-\n .../session/http/handlers/sandbox.handler.test.ts | 39 ++++++-\n .../src/session/http/handlers/sandbox.handler.ts | 14 ++-\n .../src/session/scm-credentials-service.test.ts | 24 +++-\n .../src/session/scm-credentials-service.ts | 6 +-\n .../src/source-control/provider-from-env.test.ts | 4 +-\n .../providers/github-provider.test.ts | 86 +++++++++++---\n .../source-control/providers/github-provider.ts | 25 +++-\n .../providers/gitlab-provider.test.ts | 2 +-\n .../source-control/providers/gitlab-provider.ts | 10 +-\n packages/control-plane/src/source-control/types.ts | 20 +++-\n packages/modal-infra/src/clone_token.py | 27 ++++-\n packages/modal-infra/src/web_api.py | 4 +-\n packages/modal-infra/tests/test_clone_token.py | 51 +++++++-\n .../tests/test_web_api_create_sandbox.py | 8 +-\n .../src/sandbox_runtime/auth/__init__.py | 3 +-\n .../src/sandbox_runtime/auth/github_app.py | 64 ++++++++++-\n .../sandbox-runtime/tests/test_github_app_auth.py | 128 +++++++++++++++++++++\n 20 files changed, 571 insertions(+), 60 deletions(-)\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/** Default permission set for sandbox-reachable credentials: git push only. */\nexport const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\n contents: \"write\",\n metadata: \"read\",\n};\n\n/**\n * Mint a fresh installation token scoped to a set of repositories and a\n * minimal permission set (default: `contents:write` + `metadata:read` —\n * enough for git clone/fetch/push, nothing else).\n *\n * Intentionally uncached and never falls back to the full-grant token on\n * failure: a rejected narrowing request must propagate as an error so the\n * caller denies the credential rather than silently widening its scope.\n * Every mint hits GitHub fresh, trading a small amount of latency for the\n * guarantee that a scoped-credential caller can never receive a broader\n * grant than requested.\n *\n * Fails closed on malformed input rather than silently minting a broader\n * grant: an empty `repoNames` array, or an empty `permissions` object,\n * would each cause GitHub's API to omit the corresponding narrowing field\n * and return the installation's full, unnarrowed permission set — so both\n * are rejected here before any request is made.\n */\nexport async function getScopedInstallationTokenWithExpiry(\n config: GitHubAppConfig,\n repoNames: string[],\n env?: InstallationTokenCacheBindings,\n permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\n): Promise<{ token: string; expiresAtEpochMs: number }> {\n if (repoNames.length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no repositories\");\n }\n if (Object.keys(permissions).length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no permissions\");\n }\n const jwt = await generateAppJwt(config.appId, config.privateKey);\n return getScopedInstallationTokenWithMetadata(\n jwt,\n config.installationId,\n resolveUserAgent(env),\n repoNames,\n permissions\n );\n}\n\nfunction getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n}\n\nfunction isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()): boolean {\n const cacheAgeMs = nowEpochMs - cached.cachedAtEpochMs;\n if (cacheAgeMs >= INSTALLATION_TOKEN_CACHE_MAX_AGE_MS) {\n return false;\n }\n return nowEpochMs < cached.expiresAtEpochMs - INSTALLATION_TOKEN_MIN_REMAINING_MS;\n}\n\nasync function readInstallationTokenFromCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<CachedInstallationToken | null> {\n if (!env?.cacheStore) {\n return null;\n }\n\n try {\n const result = cachedInstallationTokenSchema.safeParse(\n await env.cacheStore.get(cacheKey, \"json\")\n );\n return result.success ? result.data : null;\n } catch {\n return null;\n }\n}\n\nasync function writeInstallationTokenToCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string,\n cached: CachedInstallationToken\n): Promise<void> {\n if (!env?.cacheStore) {\n return;\n }\n\n const nowEpochMs = Date.now();\n const remainingLifetimeMs = cached.expiresAtEpochMs - nowEpochMs;\n if (remainingLifetimeMs <= 0) {\n return;\n }\n\n const cacheBoundLifetimeMs = Math.min(remainingLifetimeMs, INSTALLATION_TOKEN_CACHE_MAX_AGE_MS);\n const ttlSeconds = Math.max(\n 1,\n Math.min(INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS, Math.floor(cacheBoundLifetimeMs / 1000))\n );\n\n try {\n await env.cacheStore.put(cacheKey, JSON.stringify(cached), { expirationTtl: ttlSeconds });\n } catch {\n // Cache failures are non-fatal.\n }\n}\n\nasync function invalidateInstallationTokenCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<void> {\n installationTokenMemoryCache.delete(cacheKey);\n installationTokenRefreshInFlight.delete(cacheKey);\n\n if (!env?.cacheStore) {\n return;\n }\n\n try {\n await env.cacheStore.delete(cacheKey);\n } catch {\n // Cache invalidation failures are non-fatal.\n }\n}\n\nasync function refreshInstallationToken(\n });\n return {\n authType: \"app\",\n token,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate GitHub App token: ${error instanceof Error ? error.message : String(error)}`,\n error\n );\n }\n }\n\n async generateCredentialHelperAuth(\n repos: Array<{ owner: string; name: string }>\n ): Promise<CredentialHelperAuth> {\n if (!this.appConfig) {\n throw new SourceControlProviderError(\n \"GitHub App not configured - cannot generate credential helper auth\",\n \"permanent\"\n );\n }\n const repoNames = [\n ...new Set(repos.map((r) => r.name.trim()).filter((name): name is string => name.length > 0)),\n ];\n if (repoNames.length === 0) {\n throw new SourceControlProviderError(\n \"Cannot generate a repo-scoped credential without a repository\",\n \"permanent\"\n );\n }\n\n // Scoped to every repository the caller needs, with git-only\n // permissions — this credential is directly reachable by the\n // sandbox's own shell (git credential helper, gh CLI wrapper). No\n // fallback to the full-grant token on failure: a rejected narrowing\n // must deny the credential, not silently widen it.\n try {\n const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n }\n );\n return {\n username: \"x-access-token\",\n password: token,\n expiresAtEpochMs,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate scoped GitHub credential helper auth for ${repoNames.join(\", \")}: ${error instanceof Error ? error.message : String(error)}`,\n error,\n extractHttpStatus(error)\n );\n }\n }\n\n buildManualPullRequestUrl(config: BuildManualPullRequestUrlConfig): string {\nimport type { SourceControlProvider } from \"../source-control\";\nimport { SourceControlProviderError } from \"../source-control/errors\";\nimport type { Logger } from \"../logger\";\n\nexport type ScmCredentialsResult =\n | {\n ok: true;\n username: string;\n password: string;\n expiresAtEpochMs: number;\n }\n | { ok: false; status: number; error: string };\n\n/**\n * Service that mints short-lived SCM credentials for the sandbox-side git\n * credential helper.\n *\n * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\n * and adapts upstream errors into a discriminated union that the HTTP handler\n * can map directly to a response. Never logs the returned password.\n */\nexport class ScmCredentialsService {\n constructor(\n private readonly provider: SourceControlProvider,\n private readonly log: Logger\n ) {}\n\n async getCredentials(\n repos: Array<{ owner: string; name: string }>\n ): Promise<ScmCredentialsResult> {\n try {\n const auth = await this.provider.generateCredentialHelperAuth(repos);\n if (\n !auth.username.trim() ||\n !auth.password.trim() ||\n !Number.isFinite(auth.expiresAtEpochMs) ||\n auth.expiresAtEpochMs <= Date.now()\n ) {\n this.log.error(\"Provider returned invalid SCM credential helper auth\", {\n scm_provider: this.provider.name,\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n\n return {\n ok: true,\n username: auth.username,\n password: auth.password,\n expiresAtEpochMs: auth.expiresAtEpochMs,\n };\n } catch (e) {\n if (e instanceof SourceControlProviderError) {\n // Permanent → 500 (config error, retrying won't help).\n // Transient → 502 (upstream/network blip, the helper exits 1 and\n // the next git op will retry).\n const status = e.errorType === \"permanent\" ? 500 : 502;\n this.log.warn(\"SCM credential helper auth failed\", {\n scm_provider: this.provider.name,\n error_type: e.errorType,\n error: e.message,\n });\n return { ok: false, status, error: e.message };\n }\n\n this.log.error(\"Unexpected error generating SCM credentials\", {\n scm_provider: this.provider.name,\n error: e instanceof Error ? e.message : String(e),\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n }\n}\n246: getSessionRepositories(): SessionRepositoryEntry[] {\n[project]\nname = \"open-inspect-sandbox-runtime\"\nversion = \"0.1.0\"\ndescription = \"Provider-agnostic sandbox runtime for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"cryptography>=44.0.0\",\n \"httpx>=0.27.0\",\n \"websockets>=13.0\",\n \"pydantic>=2.0\",\n \"PyJWT[crypto]>=2.9.0\",\n # Exact pin: the wheel bundles the `claude` binary and its message shapes\n # are what harness/claude.py translates.\n \"claude-agent-sdk==0.2.152\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=8.0\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/sandbox_runtime\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n[project]\nname = \"open-inspect-modal\"\nversion = \"0.1.0\"\ndescription = \"Modal sandbox infrastructure for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"open-inspect-sandbox-runtime\", # sibling package, resolved via [tool.uv.sources]\n \"modal>=1.4.3\", # Function.with_options() (per-call timeout override) requires >=1.4.3\n \"httpx>=0.27.0\",\n \"pydantic>=2.0\",\n \"fastapi>=0.110.0\",\n \"PyJWT[crypto]>=2.9.0\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=9.0.3\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[tool.uv.sources]\nopen-inspect-sandbox-runtime = { path = \"../sandbox-runtime\", editable = true }\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\npythonpath = [\"../sandbox-images/src\"]\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"src\", \"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n"}}
{"chunk_id":"d34bfd","wall_time_seconds":5.002878208,"session_id":3079,"original_token_count":834,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 42ms\n × adopts sockets under their tags and enumerates them by tag 27ms\n × returns no tags for a socket it never accepted 6ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 0ms\n × forwards text frames as strings and binary frames as ArrayBuffers 0ms\n × answers the exact keepalive request without delivering it 0ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 0ms\n × reports a lost connection as an unclean 1006 close 1ms\n × delivers one socket's events in order, one at a time, close last 2ms\n × logs a failed delivery and keeps delivering 0ms\n × forwards socket errors to the runtime 0ms\n × reports an incomplete closing handshake as unclean even with a normal code 1ms\n × pauses a flooding peer while a delivery is in flight and loses nothing 1ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 0ms\n × satisfies the core's open check without the ambient WebSocket global 1ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 38ms\n × answers /healthz itself, 200 while serving and 503 while draining 24ms\n × hands every other request to the app as a fetch Request 10ms\n × routes an upgrade to the upgrade handler 1ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 1ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/host.test.ts (10 tests | 10 failed) 2293ms\n × boots over the migrated global store and answers the health check and the route table 414ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 255ms\n × closes the cache database on a normal shutdown, not only on a failed boot 170ms\n × reports draining once a shutdown begins and stops listening when it ends 197ms\n × waits for a request in flight before closing the stores, and answers it 192ms\n × gives up a request that outlives the budget and reports it 208ms\n × marks a stop that abandoned nothing as clean 171ms\n × arms a deadline a previous process left only in the session file 188ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 265ms\n × releases what it acquired when a later boot step fails 233ms\n ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 26ms\n × answers 400 on any path but a session's 15ms\n × answers 400 to an upgrade whose Host makes no URL 5ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 0ms\n × answers 404 for a session the index does not know, without opening a runtime 0ms\n × answers 404 when the index knows the session but nothing is behind it 0ms\n × writes the session's rejection as the handshake's status 0ms\n × hands the session the upgrade as a request with its URL and headers 0ms\n × completes an accepted upgrade and the runtime exchanges messages on the socket 0ms\n × holds a frame sent on the 101 until the runtime has attached 1ms\n × closes the socket with 1011 when attachment fails 1ms\n"}>git show --stat a7983425; sed -n '330,465p' packages/control-plane/src/auth/github-app.ts; sed -n '960,1020p' packages/control-plane/src/source-control/providers/github-provider.ts; cat packages/control-plane/src/session/scm-credentials-service.ts; rg -n 'getSessionRepositories' packages/control-plane/src/session/session-core-repository.ts; cat packages/sandbox-runtime/pyproject.toml; cat packages/modal-infra/pyproject.toml{"status":"fulfilled","value":{"chunk_id":"645a9a","wall_time_seconds":0.32365525,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926967) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926958) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"315e5a","wall_time_seconds":0.336015,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926969) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926954) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"f1f9d7","wall_time_seconds":0.227511416,"exit_code":0,"original_token_count":3513,"output":"commit a7983425691f1a97de508158b45ae08becd289c6\nAuthor: gagan114662 <[REDACTED]>\nDate: Sat Sep 12 15:56:45 2026 -0400\n\n feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n \n The GitHub App installation token handed to a sandbox (via the git\n credential helper and the gh CLI wrapper) previously carried the App's\n full grant, including pull_requests:write and issues:write. Because\n this is the same credential type the review-submission path uses, a\n sandbox's own agent-readable token could call the reviews endpoint on\n any PR the installation covers, not just perform git operations.\n \n Mint a token narrowed to contents:write + metadata:read, scoped to\n every repository a session or image build actually needs (not just a\n \"primary\" one, preserving multi-repo/sibling-repo support), fetched\n fresh with no caching and no fallback to the full-grant token on\n failure. Applies to the control-plane's sandbox-facing credential\n endpoint and image-build clone auth (TypeScript), and the Modal\n restore-path static token injection (Python), both independently.\n \n The brokered push+PR-creation path and the github-bot Worker's own\n review-submission minting are untouched — neither is sandbox-reachable\n and both legitimately need the App's full grant.\n \n Co-Authored-By: Claude Sonnet 5 <[REDACTED]>\n\n packages/control-plane/src/auth/github-app.ts | 99 ++++++++++++++++\n packages/control-plane/src/image-builds/planner.ts | 13 ++-\n packages/control-plane/src/session/components.ts | 4 +-\n .../session/http/handlers/sandbox.handler.test.ts | 39 ++++++-\n .../src/session/http/handlers/sandbox.handler.ts | 14 ++-\n .../src/session/scm-credentials-service.test.ts | 24 +++-\n .../src/session/scm-credentials-service.ts | 6 +-\n .../src/source-control/provider-from-env.test.ts | 4 +-\n .../providers/github-provider.test.ts | 86 +++++++++++---\n .../source-control/providers/github-provider.ts | 25 +++-\n .../providers/gitlab-provider.test.ts | 2 +-\n .../source-control/providers/gitlab-provider.ts | 10 +-\n packages/control-plane/src/source-control/types.ts | 20 +++-\n packages/modal-infra/src/clone_token.py | 27 ++++-\n packages/modal-infra/src/web_api.py | 4 +-\n packages/modal-infra/tests/test_clone_token.py | 51 +++++++-\n .../tests/test_web_api_create_sandbox.py | 8 +-\n .../src/sandbox_runtime/auth/__init__.py | 3 +-\n .../src/sandbox_runtime/auth/github_app.py | 64 ++++++++++-\n .../sandbox-runtime/tests/test_github_app_auth.py | 128 +++++++++++++++++++++\n 20 files changed, 571 insertions(+), 60 deletions(-)\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/** Default permission set for sandbox-reachable credentials: git push only. */\nexport const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\n contents: \"write\",\n metadata: \"read\",\n};\n\n/**\n * Mint a fresh installation token scoped to a set of repositories and a\n * minimal permission set (default: `contents:write` + `metadata:read` —\n * enough for git clone/fetch/push, nothing else).\n *\n * Intentionally uncached and never falls back to the full-grant token on\n * failure: a rejected narrowing request must propagate as an error so the\n * caller denies the credential rather than silently widening its scope.\n * Every mint hits GitHub fresh, trading a small amount of latency for the\n * guarantee that a scoped-credential caller can never receive a broader\n * grant than requested.\n *\n * Fails closed on malformed input rather than silently minting a broader\n * grant: an empty `repoNames` array, or an empty `permissions` object,\n * would each cause GitHub's API to omit the corresponding narrowing field\n * and return the installation's full, unnarrowed permission set — so both\n * are rejected here before any request is made.\n */\nexport async function getScopedInstallationTokenWithExpiry(\n config: GitHubAppConfig,\n repoNames: string[],\n env?: InstallationTokenCacheBindings,\n permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\n): Promise<{ token: string; expiresAtEpochMs: number }> {\n if (repoNames.length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no repositories\");\n }\n if (Object.keys(permissions).length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no permissions\");\n }\n const jwt = await generateAppJwt(config.appId, config.privateKey);\n return getScopedInstallationTokenWithMetadata(\n jwt,\n config.installationId,\n resolveUserAgent(env),\n repoNames,\n permissions\n );\n}\n\nfunction getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n}\n\nfunction isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()): boolean {\n const cacheAgeMs = nowEpochMs - cached.cachedAtEpochMs;\n if (cacheAgeMs >= INSTALLATION_TOKEN_CACHE_MAX_AGE_MS) {\n return false;\n }\n return nowEpochMs < cached.expiresAtEpochMs - INSTALLATION_TOKEN_MIN_REMAINING_MS;\n}\n\nasync function readInstallationTokenFromCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<CachedInstallationToken | null> {\n if (!env?.cacheStore) {\n return null;\n }\n\n try {\n const result = cachedInstallationTokenSchema.safeParse(\n await env.cacheStore.get(cacheKey, \"json\")\n );\n return result.success ? result.data : null;\n } catch {\n return null;\n }\n}\n\nasync function writeInstallationTokenToCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string,\n cached: CachedInstallationToken\n): Promise<void> {\n if (!env?.cacheStore) {\n return;\n }\n\n const nowEpochMs = Date.now();\n const remainingLifetimeMs = cached.expiresAtEpochMs - nowEpochMs;\n if (remainingLifetimeMs <= 0) {\n return;\n }\n\n const cacheBoundLifetimeMs = Math.min(remainingLifetimeMs, INSTALLATION_TOKEN_CACHE_MAX_AGE_MS);\n const ttlSeconds = Math.max(\n 1,\n Math.min(INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS, Math.floor(cacheBoundLifetimeMs / 1000))\n );\n\n try {\n await env.cacheStore.put(cacheKey, JSON.stringify(cached), { expirationTtl: ttlSeconds });\n } catch {\n // Cache failures are non-fatal.\n }\n}\n\nasync function invalidateInstallationTokenCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<void> {\n installationTokenMemoryCache.delete(cacheKey);\n installationTokenRefreshInFlight.delete(cacheKey);\n\n if (!env?.cacheStore) {\n return;\n }\n\n try {\n await env.cacheStore.delete(cacheKey);\n } catch {\n // Cache invalidation failures are non-fatal.\n }\n}\n\nasync function refreshInstallationToken(\n });\n return {\n authType: \"app\",\n token,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate GitHub App token: ${error instanceof Error ? error.message : String(error)}`,\n error\n );\n }\n }\n\n async generateCredentialHelperAuth(\n repos: Array<{ owner: string; name: string }>\n ): Promise<CredentialHelperAuth> {\n if (!this.appConfig) {\n throw new SourceControlProviderError(\n \"GitHub App not configured - cannot generate credential helper auth\",\n \"permanent\"\n );\n }\n const repoNames = [\n ...new Set(repos.map((r) => r.name.trim()).filter((name): name is string => name.length > 0)),\n ];\n if (repoNames.length === 0) {\n throw new SourceControlProviderError(\n \"Cannot generate a repo-scoped credential without a repository\",\n \"permanent\"\n );\n }\n\n // Scoped to every repository the caller needs, with git-only\n // permissions — this credential is directly reachable by the\n // sandbox's own shell (git credential helper, gh CLI wrapper). No\n // fallback to the full-grant token on failure: a rejected narrowing\n // must deny the credential, not silently widen it.\n try {\n const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n }\n );\n return {\n username: \"x-access-token\",\n password: token,\n expiresAtEpochMs,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate scoped GitHub credential helper auth for ${repoNames.join(\", \")}: ${error instanceof Error ? error.message : String(error)}`,\n error,\n extractHttpStatus(error)\n );\n }\n }\n\n buildManualPullRequestUrl(config: BuildManualPullRequestUrlConfig): string {\nimport type { SourceControlProvider } from \"../source-control\";\nimport { SourceControlProviderError } from \"../source-control/errors\";\nimport type { Logger } from \"../logger\";\n\nexport type ScmCredentialsResult =\n | {\n ok: true;\n username: string;\n password: string;\n expiresAtEpochMs: number;\n }\n | { ok: false; status: number; error: string };\n\n/**\n * Service that mints short-lived SCM credentials for the sandbox-side git\n * credential helper.\n *\n * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\n * and adapts upstream errors into a discriminated union that the HTTP handler\n * can map directly to a response. Never logs the returned password.\n */\nexport class ScmCredentialsService {\n constructor(\n private readonly provider: SourceControlProvider,\n private readonly log: Logger\n ) {}\n\n async getCredentials(\n repos: Array<{ owner: string; name: string }>\n ): Promise<ScmCredentialsResult> {\n try {\n const auth = await this.provider.generateCredentialHelperAuth(repos);\n if (\n !auth.username.trim() ||\n !auth.password.trim() ||\n !Number.isFinite(auth.expiresAtEpochMs) ||\n auth.expiresAtEpochMs <= Date.now()\n ) {\n this.log.error(\"Provider returned invalid SCM credential helper auth\", {\n scm_provider: this.provider.name,\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n\n return {\n ok: true,\n username: auth.username,\n password: auth.password,\n expiresAtEpochMs: auth.expiresAtEpochMs,\n };\n } catch (e) {\n if (e instanceof SourceControlProviderError) {\n // Permanent → 500 (config error, retrying won't help).\n // Transient → 502 (upstream/network blip, the helper exits 1 and\n // the next git op will retry).\n const status = e.errorType === \"permanent\" ? 500 : 502;\n this.log.warn(\"SCM credential helper auth failed\", {\n scm_provider: this.provider.name,\n error_type: e.errorType,\n error: e.message,\n });\n return { ok: false, status, error: e.message };\n }\n\n this.log.error(\"Unexpected error generating SCM credentials\", {\n scm_provider: this.provider.name,\n error: e instanceof Error ? e.message : String(e),\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n }\n}\n246: getSessionRepositories(): SessionRepositoryEntry[] {\n[project]\nname = \"open-inspect-sandbox-runtime\"\nversion = \"0.1.0\"\ndescription = \"Provider-agnostic sandbox runtime for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"cryptography>=44.0.0\",\n \"httpx>=0.27.0\",\n \"websockets>=13.0\",\n \"pydantic>=2.0\",\n \"PyJWT[crypto]>=2.9.0\",\n # Exact pin: the wheel bundles the `claude` binary and its message shapes\n # are what harness/claude.py translates.\n \"claude-agent-sdk==0.2.152\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=8.0\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/sandbox_runtime\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n[project]\nname = \"open-inspect-modal\"\nversion = \"0.1.0\"\ndescription = \"Modal sandbox infrastructure for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"open-inspect-sandbox-runtime\", # sibling package, resolved via [tool.uv.sources]\n \"modal>=1.4.3\", # Function.with_options() (per-call timeout override) requires >=1.4.3\n \"httpx>=0.27.0\",\n \"pydantic>=2.0\",\n \"fastapi>=0.110.0\",\n \"PyJWT[crypto]>=2.9.0\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=9.0.3\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[tool.uv.sources]\nopen-inspect-sandbox-runtime = { path = \"../sandbox-runtime\", editable = true }\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\npythonpath = [\"../sandbox-images/src\"]\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"src\", \"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n"}}
{"chunk_id":"d34bfd","wall_time_seconds":5.002878208,"session_id":3079,"original_token_count":834,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 42ms\n × adopts sockets under their tags and enumerates them by tag 27ms\n × returns no tags for a socket it never accepted 6ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 0ms\n × forwards text frames as strings and binary frames as ArrayBuffers 0ms\n × answers the exact keepalive request without delivering it 0ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 0ms\n × reports a lost connection as an unclean 1006 close 1ms\n × delivers one socket's events in order, one at a time, close last 2ms\n × logs a failed delivery and keeps delivering 0ms\n × forwards socket errors to the runtime 0ms\n × reports an incomplete closing handshake as unclean even with a normal code 1ms\n × pauses a flooding peer while a delivery is in flight and loses nothing 1ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 0ms\n × satisfies the core's open check without the ambient WebSocket global 1ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 38ms\n × answers /healthz itself, 200 while serving and 503 while draining 24ms\n × hands every other request to the app as a fetch Request 10ms\n × routes an upgrade to the upgrade handler 1ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 1ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/host.test.ts (10 tests | 10 failed) 2293ms\n × boots over the migrated global store and answers the health check and the route table 414ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 255ms\n × closes the cache database on a normal shutdown, not only on a failed boot 170ms\n × reports draining once a shutdown begins and stops listening when it ends 197ms\n × waits for a request in flight before closing the stores, and answers it 192ms\n × gives up a request that outlives the budget and reports it 208ms\n × marks a stop that abandoned nothing as clean 171ms\n × arms a deadline a previous process left only in the session file 188ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 265ms\n × releases what it acquired when a later boot step fails 233ms\n ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 26ms\n × answers 400 on any path but a session's 15ms\n × answers 400 to an upgrade whose Host makes no URL 5ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 0ms\n × answers 404 for a session the index does not know, without opening a runtime 0ms\n × answers 404 when the index knows the session but nothing is behind it 0ms\n × writes the session's rejection as the handshake's status 0ms\n × hands the session the upgrade as a request with its URL and headers 0ms\n × completes an accepted upgrade and the runtime exchanges messages on the socket 0ms\n × holds a frame sent on the 101 until the runtime has attached 1ms\n × closes the socket with 1011 when attachment fails 1ms\n"}{"status":"fulfilled","value":{"chunk_id":"645a9a","wall_time_seconds":0.32365525,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926967) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926958) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"315e5a","wall_time_seconds":0.336015,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7926969) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7926954) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"f1f9d7","wall_time_seconds":0.227511416,"exit_code":0,"original_token_count":3513,"output":"commit a7983425691f1a97de508158b45ae08becd289c6\nAuthor: gagan114662 <[REDACTED]>\nDate: Sat Sep 12 15:56:45 2026 -0400\n\n feat: scope sandbox-reachable GitHub credentials to repo + git-only permissions\n \n The GitHub App installation token handed to a sandbox (via the git\n credential helper and the gh CLI wrapper) previously carried the App's\n full grant, including pull_requests:write and issues:write. Because\n this is the same credential type the review-submission path uses, a\n sandbox's own agent-readable token could call the reviews endpoint on\n any PR the installation covers, not just perform git operations.\n \n Mint a token narrowed to contents:write + metadata:read, scoped to\n every repository a session or image build actually needs (not just a\n \"primary\" one, preserving multi-repo/sibling-repo support), fetched\n fresh with no caching and no fallback to the full-grant token on\n failure. Applies to the control-plane's sandbox-facing credential\n endpoint and image-build clone auth (TypeScript), and the Modal\n restore-path static token injection (Python), both independently.\n \n The brokered push+PR-creation path and the github-bot Worker's own\n review-submission minting are untouched — neither is sandbox-reachable\n and both legitimately need the App's full grant.\n \n Co-Authored-By: Claude Sonnet 5 <[REDACTED]>\n\n packages/control-plane/src/auth/github-app.ts | 99 ++++++++++++++++\n packages/control-plane/src/image-builds/planner.ts | 13 ++-\n packages/control-plane/src/session/components.ts | 4 +-\n .../session/http/handlers/sandbox.handler.test.ts | 39 ++++++-\n .../src/session/http/handlers/sandbox.handler.ts | 14 ++-\n .../src/session/scm-credentials-service.test.ts | 24 +++-\n .../src/session/scm-credentials-service.ts | 6 +-\n .../src/source-control/provider-from-env.test.ts | 4 +-\n .../providers/github-provider.test.ts | 86 +++++++++++---\n .../source-control/providers/github-provider.ts | 25 +++-\n .../providers/gitlab-provider.test.ts | 2 +-\n .../source-control/providers/gitlab-provider.ts | 10 +-\n packages/control-plane/src/source-control/types.ts | 20 +++-\n packages/modal-infra/src/clone_token.py | 27 ++++-\n packages/modal-infra/src/web_api.py | 4 +-\n packages/modal-infra/tests/test_clone_token.py | 51 +++++++-\n .../tests/test_web_api_create_sandbox.py | 8 +-\n .../src/sandbox_runtime/auth/__init__.py | 3 +-\n .../src/sandbox_runtime/auth/github_app.py | 64 ++++++++++-\n .../sandbox-runtime/tests/test_github_app_auth.py | 128 +++++++++++++++++++++\n 20 files changed, 571 insertions(+), 60 deletions(-)\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/** Default permission set for sandbox-reachable credentials: git push only. */\nexport const SANDBOX_SCOPED_PERMISSIONS: Record<string, string> = {\n contents: \"write\",\n metadata: \"read\",\n};\n\n/**\n * Mint a fresh installation token scoped to a set of repositories and a\n * minimal permission set (default: `contents:write` + `metadata:read` —\n * enough for git clone/fetch/push, nothing else).\n *\n * Intentionally uncached and never falls back to the full-grant token on\n * failure: a rejected narrowing request must propagate as an error so the\n * caller denies the credential rather than silently widening its scope.\n * Every mint hits GitHub fresh, trading a small amount of latency for the\n * guarantee that a scoped-credential caller can never receive a broader\n * grant than requested.\n *\n * Fails closed on malformed input rather than silently minting a broader\n * grant: an empty `repoNames` array, or an empty `permissions` object,\n * would each cause GitHub's API to omit the corresponding narrowing field\n * and return the installation's full, unnarrowed permission set — so both\n * are rejected here before any request is made.\n */\nexport async function getScopedInstallationTokenWithExpiry(\n config: GitHubAppConfig,\n repoNames: string[],\n env?: InstallationTokenCacheBindings,\n permissions: Record<string, string> = SANDBOX_SCOPED_PERMISSIONS\n): Promise<{ token: string; expiresAtEpochMs: number }> {\n if (repoNames.length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no repositories\");\n }\n if (Object.keys(permissions).length === 0) {\n throw new Error(\"Cannot mint a scoped installation token with no permissions\");\n }\n const jwt = await generateAppJwt(config.appId, config.privateKey);\n return getScopedInstallationTokenWithMetadata(\n jwt,\n config.installationId,\n resolveUserAgent(env),\n repoNames,\n permissions\n );\n}\n\nfunction getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n}\n\nfunction isTokenUsable(cached: CachedInstallationToken, nowEpochMs = Date.now()): boolean {\n const cacheAgeMs = nowEpochMs - cached.cachedAtEpochMs;\n if (cacheAgeMs >= INSTALLATION_TOKEN_CACHE_MAX_AGE_MS) {\n return false;\n }\n return nowEpochMs < cached.expiresAtEpochMs - INSTALLATION_TOKEN_MIN_REMAINING_MS;\n}\n\nasync function readInstallationTokenFromCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<CachedInstallationToken | null> {\n if (!env?.cacheStore) {\n return null;\n }\n\n try {\n const result = cachedInstallationTokenSchema.safeParse(\n await env.cacheStore.get(cacheKey, \"json\")\n );\n return result.success ? result.data : null;\n } catch {\n return null;\n }\n}\n\nasync function writeInstallationTokenToCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string,\n cached: CachedInstallationToken\n): Promise<void> {\n if (!env?.cacheStore) {\n return;\n }\n\n const nowEpochMs = Date.now();\n const remainingLifetimeMs = cached.expiresAtEpochMs - nowEpochMs;\n if (remainingLifetimeMs <= 0) {\n return;\n }\n\n const cacheBoundLifetimeMs = Math.min(remainingLifetimeMs, INSTALLATION_TOKEN_CACHE_MAX_AGE_MS);\n const ttlSeconds = Math.max(\n 1,\n Math.min(INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS, Math.floor(cacheBoundLifetimeMs / 1000))\n );\n\n try {\n await env.cacheStore.put(cacheKey, JSON.stringify(cached), { expirationTtl: ttlSeconds });\n } catch {\n // Cache failures are non-fatal.\n }\n}\n\nasync function invalidateInstallationTokenCache(\n env: InstallationTokenCacheBindings | undefined,\n cacheKey: string\n): Promise<void> {\n installationTokenMemoryCache.delete(cacheKey);\n installationTokenRefreshInFlight.delete(cacheKey);\n\n if (!env?.cacheStore) {\n return;\n }\n\n try {\n await env.cacheStore.delete(cacheKey);\n } catch {\n // Cache invalidation failures are non-fatal.\n }\n}\n\nasync function refreshInstallationToken(\n });\n return {\n authType: \"app\",\n token,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate GitHub App token: ${error instanceof Error ? error.message : String(error)}`,\n error\n );\n }\n }\n\n async generateCredentialHelperAuth(\n repos: Array<{ owner: string; name: string }>\n ): Promise<CredentialHelperAuth> {\n if (!this.appConfig) {\n throw new SourceControlProviderError(\n \"GitHub App not configured - cannot generate credential helper auth\",\n \"permanent\"\n );\n }\n const repoNames = [\n ...new Set(repos.map((r) => r.name.trim()).filter((name): name is string => name.length > 0)),\n ];\n if (repoNames.length === 0) {\n throw new SourceControlProviderError(\n \"Cannot generate a repo-scoped credential without a repository\",\n \"permanent\"\n );\n }\n\n // Scoped to every repository the caller needs, with git-only\n // permissions — this credential is directly reachable by the\n // sandbox's own shell (git credential helper, gh CLI wrapper). No\n // fallback to the full-grant token on failure: a rejected narrowing\n // must deny the credential, not silently widen it.\n try {\n const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n }\n );\n return {\n username: \"x-access-token\",\n password: token,\n expiresAtEpochMs,\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n `Failed to generate scoped GitHub credential helper auth for ${repoNames.join(\", \")}: ${error instanceof Error ? error.message : String(error)}`,\n error,\n extractHttpStatus(error)\n );\n }\n }\n\n buildManualPullRequestUrl(config: BuildManualPullRequestUrlConfig): string {\nimport type { SourceControlProvider } from \"../source-control\";\nimport { SourceControlProviderError } from \"../source-control/errors\";\nimport type { Logger } from \"../logger\";\n\nexport type ScmCredentialsResult =\n | {\n ok: true;\n username: string;\n password: string;\n expiresAtEpochMs: number;\n }\n | { ok: false; status: number; error: string };\n\n/**\n * Service that mints short-lived SCM credentials for the sandbox-side git\n * credential helper.\n *\n * Sits in front of {@link SourceControlProvider.generateCredentialHelperAuth}\n * and adapts upstream errors into a discriminated union that the HTTP handler\n * can map directly to a response. Never logs the returned password.\n */\nexport class ScmCredentialsService {\n constructor(\n private readonly provider: SourceControlProvider,\n private readonly log: Logger\n ) {}\n\n async getCredentials(\n repos: Array<{ owner: string; name: string }>\n ): Promise<ScmCredentialsResult> {\n try {\n const auth = await this.provider.generateCredentialHelperAuth(repos);\n if (\n !auth.username.trim() ||\n !auth.password.trim() ||\n !Number.isFinite(auth.expiresAtEpochMs) ||\n auth.expiresAtEpochMs <= Date.now()\n ) {\n this.log.error(\"Provider returned invalid SCM credential helper auth\", {\n scm_provider: this.provider.name,\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n\n return {\n ok: true,\n username: auth.username,\n password: auth.password,\n expiresAtEpochMs: auth.expiresAtEpochMs,\n };\n } catch (e) {\n if (e instanceof SourceControlProviderError) {\n // Permanent → 500 (config error, retrying won't help).\n // Transient → 502 (upstream/network blip, the helper exits 1 and\n // the next git op will retry).\n const status = e.errorType === \"permanent\" ? 500 : 502;\n this.log.warn(\"SCM credential helper auth failed\", {\n scm_provider: this.provider.name,\n error_type: e.errorType,\n error: e.message,\n });\n return { ok: false, status, error: e.message };\n }\n\n this.log.error(\"Unexpected error generating SCM credentials\", {\n scm_provider: this.provider.name,\n error: e instanceof Error ? e.message : String(e),\n });\n return {\n ok: false,\n status: 500,\n error: \"Failed to generate SCM credentials\",\n };\n }\n }\n}\n246: getSessionRepositories(): SessionRepositoryEntry[] {\n[project]\nname = \"open-inspect-sandbox-runtime\"\nversion = \"0.1.0\"\ndescription = \"Provider-agnostic sandbox runtime for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"cryptography>=44.0.0\",\n \"httpx>=0.27.0\",\n \"websockets>=13.0\",\n \"pydantic>=2.0\",\n \"PyJWT[crypto]>=2.9.0\",\n # Exact pin: the wheel bundles the `claude` binary and its message shapes\n # are what harness/claude.py translates.\n \"claude-agent-sdk==0.2.152\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=8.0\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src/sandbox_runtime\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n[project]\nname = \"open-inspect-modal\"\nversion = \"0.1.0\"\ndescription = \"Modal sandbox infrastructure for Open-Inspect coding agent\"\nrequires-python = \">=3.12\"\ndependencies = [\n \"open-inspect-sandbox-runtime\", # sibling package, resolved via [tool.uv.sources]\n \"modal>=1.4.3\", # Function.with_options() (per-call timeout override) requires >=1.4.3\n \"httpx>=0.27.0\",\n \"pydantic>=2.0\",\n \"fastapi>=0.110.0\",\n \"PyJWT[crypto]>=2.9.0\",\n]\n\n[project.optional-dependencies]\ndev = [\n \"pytest>=9.0.3\",\n \"pytest-asyncio>=0.24.0\",\n \"ruff>=0.9.0\",\n \"mypy>=1.14.0\",\n]\n\n[tool.uv.sources]\nopen-inspect-sandbox-runtime = { path = \"../sandbox-runtime\", editable = true }\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"src\"]\n\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\npythonpath = [\"../sandbox-images/src\"]\n\n[tool.ruff]\nextend = \"../../ruff.toml\"\nsrc = [\"src\", \"tests\"]\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"src\", \"sandbox_runtime\"]\n\n[tool.mypy]\npython_version = \"3.12\"\nstrict = true\nwarn_return_any = true\nwarn_unused_ignores = true\ndisallow_untyped_defs = true\nignore_missing_imports = true\nexclude = [\"venv\", \".venv\", \"tests\"]\n"}}
{"chunk_id":"d34bfd","wall_time_seconds":5.002878208,"session_id":3079,"original_token_count":834,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 42ms\n × adopts sockets under their tags and enumerates them by tag 27ms\n × returns no tags for a socket it never accepted 6ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 0ms\n × forwards text frames as strings and binary frames as ArrayBuffers 0ms\n × answers the exact keepalive request without delivering it 0ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 0ms\n × reports a lost connection as an unclean 1006 close 1ms\n × delivers one socket's events in order, one at a time, close last 2ms\n × logs a failed delivery and keeps delivering 0ms\n × forwards socket errors to the runtime 0ms\n × reports an incomplete closing handshake as unclean even with a normal code 1ms\n × pauses a flooding peer while a delivery is in flight and loses nothing 1ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 0ms\n × satisfies the core's open check without the ambient WebSocket global 1ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 38ms\n × answers /healthz itself, 200 while serving and 503 while draining 24ms\n × hands every other request to the app as a fetch Request 10ms\n × routes an upgrade to the upgrade handler 1ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 1ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/host.test.ts (10 tests | 10 failed) 2293ms\n × boots over the migrated global store and answers the health check and the route table 414ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 255ms\n × closes the cache database on a normal shutdown, not only on a failed boot 170ms\n × reports draining once a shutdown begins and stops listening when it ends 197ms\n × waits for a request in flight before closing the stores, and answers it 192ms\n × gives up a request that outlives the budget and reports it 208ms\n × marks a stop that abandoned nothing as clean 171ms\n × arms a deadline a previous process left only in the session file 188ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 265ms\n × releases what it acquired when a later boot step fails 233ms\n ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 26ms\n × answers 400 on any path but a session's 15ms\n × answers 400 to an upgrade whose Host makes no URL 5ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 0ms\n × answers 404 for a session the index does not know, without opening a runtime 0ms\n × answers 404 when the index knows the session but nothing is behind it 0ms\n × writes the session's rejection as the handshake's status 0ms\n × hands the session the upgrade as a request with its URL and headers 0ms\n × completes an accepted upgrade and the runtime exchanges messages on the socket 0ms\n × holds a frame sent on the 101 until the runtime has attached 1ms\n × closes the socket with 1011 when attachment fails 1ms\n"}Both TypeScript paths now pass the full repository list, and the Python functions explicitly reject the two unsafe inputs. Runtime verification has hit environment limits: uv cannot use its default cache, and control-plane tests are failing in server/socket tests. I’m checking the failures and trying a writable cache so I can distinguish environment blockers from code defects.
>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv uv run --offline --no-sync pytest tests/test_clone_token.py -q{"status":"fulfilled","value":{"chunk_id":"7d76c8","wall_time_seconds":1.007419,"session_id":64472,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"3c5d21","wall_time_seconds":1.00165625,"session_id":39495,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"a46249","wall_time_seconds":0,"exit_code":1,"original_token_count":4191,"output":" * resets with the set because it describes work on the replaced members.\n */\n replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\n this.sql.exec(`DELETE FROM session_repositories`);\n for (const repo of repositories) {\n this.sql.exec(\n `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\n VALUES (?, ?, ?, ?, ?)`,\n repo.position,\n repo.repoOwner,\n repo.repoName,\n repo.repoId,\n repo.baseBranch\n );\n }\n }\n\n getSessionRepositoryRows(): SessionRepositoryRow[] {\n const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\n return this.rows<SessionRepositoryRow>(result);\n }\n\n /**\n * Returns the session's repositories, using the scalar mirror fallback for\n * older sessions. Empty only for sessions without repository context.\n */\n getSessionRepositories(): SessionRepositoryEntry[] {\n const session = this.getSession();\n if (!session?.repo_owner || !session.repo_name) return [];\n return buildSessionRepositories(\n {\n repoOwner: session.repo_owner,\n repoName: session.repo_name,\n baseBranch: session.base_branch,\n },\n this.getSessionRepositoryRows()\n );\n }\n\n updateSessionRepositoryBranch(repoOwner: string, repoName: string, branchName: string): void {\n this.sql.exec(\n `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\n branchName,\n repoOwner,\n repoName\n );\n }\n\n setSessionDiffBaselines(\n repositories: Array<{\n position: number;\n repoOwner: string;\n repoName: string;\n baseSha: string;\n isPrimary: boolean;\n }>\n ): void {\n this.transactionSync(() => {\n for (const repository of repositories) {\n this.sql.exec(\n `UPDATE session_repositories\n\"\"\"Tests for GitHub App installation-token narrowing validation.\n\nCovers the fail-closed contract get_installation_token/generate_installation_token\nmust uphold: an empty or inconsistent repository/permissions combination must\nnever silently mint a broader-than-intended (or fully unnarrowed) token.\n\"\"\"\n\nimport pytest\n\nfrom sandbox_runtime.auth.github_app import (\n SANDBOX_SCOPED_PERMISSIONS,\n generate_installation_token,\n get_installation_token,\n)\n\n\ndef test_get_installation_token_rejects_permissions_without_repository():\n with pytest.raises(ValueError, match=\"permissions requires repository\"):\n get_installation_token(\"jwt\", \"456\", permissions={\"contents\": \"write\"})\n\n\ndef test_get_installation_token_rejects_empty_permissions_dict():\n \"\"\"An empty {} must not silently drop narrowing and mint the full installation grant.\"\"\"\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"repo\", permissions={})\n\n\ndef test_get_installation_token_rejects_empty_repository_string():\n with pytest.raises(ValueError, match=\"repository must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\n\n\ndef test_get_installation_token_sends_narrowed_body(monkeypatch):\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-scoped\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"url\"] = url\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\n \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\n )\n\n assert token == \"ghs-scoped\"\n assert captured[\"json\"] == {\n \"repositories\": [\"repo\"],\n \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\n }\n\n\ndef test_get_installation_token_unnarrowed_sends_no_body(monkeypatch):\n \"\"\"Legacy unnarrowed callers (control-plane's own server-side mint) are unaffected.\"\"\"\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-full\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\"jwt\", \"456\")\n\n assert token == \"ghs-full\"\n assert captured[\"json\"] is None\n\n\ndef test_generate_installation_token_defaults_permissions_when_repository_given(monkeypatch):\n captured = {}\n\n def fake_get_installation_token(\n jwt_token, installation_id, *, repository=None, permissions=None\n ):\n captured[\"repository\"] = repository\n captured[\"permissions\"] = permissions\n return \"ghs-scoped\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.get_installation_token\", fake_get_installation_token\n )\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n token = generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\")\n\n assert token == \"ghs-scoped\"\n assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\n\n\ndef test_generate_installation_token_rejects_explicit_empty_permissions(monkeypatch):\n \"\"\"Explicitly passing {} must not be silently replaced by the default — it's an error.\"\"\"\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\", permissions={})\n\"\"\"Tests for VCS clone token resolution.\"\"\"\n\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom src.clone_token import resolve_clone_token\n\n\[REDACTED](autouse=True)\ndef clear_clone_token_env(monkeypatch: pytest.MonkeyPatch) -> None:\n for name in [\n \"SCM_PROVIDER\",\n \"GITLAB_ACCESS_TOKEN\",\n \"GITHUB_APP_ID\",\n \"GITHUB_APP_PRIVATE_KEY\",\n \"GITHUB_APP_INSTALLATION_ID\",\n ]:\n monkeypatch.delenv(name, raising=False)\n\n\ndef test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n monkeypatch.setenv(\"GITLAB_ACCESS_TOKEN\", \"glpat-token\")\n\n assert resolve_clone_token() == \"glpat-token\"\n\n\ndef test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n\n assert resolve_clone_token() is None\n\n\ndef test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\n \"\"\"No repo context must fail closed — never mint an unnarrowed, installation-wide token.\n\n Uses a plain recording mock rather than a raising stub: resolve_clone_token\n wraps the mint call in a broad `except Exception`, so a stub that raises\n AssertionError to signal \"should not be called\" is indistinguishable from\n a real narrowing failure — both paths return None either way, so the test\n would pass even if the guard that skips the call entirely were deleted.\n Asserting call counts on a mock that doesn't raise is what actually proves\n the guard fired.\n \"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token() is None\n assert resolve_clone_token(\"acme\", None) is None\n assert resolve_clone_token(\"acme\", \"\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\n \"\"\"The token handed to a sandbox must be repo-scoped and permission-narrowed.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n captured = {}\n\n def fake_generate_installation_token(**kwargs):\n captured.update(kwargs)\n return \"ghs-scoped-token\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.generate_installation_token\", fake_generate_installation_token\n )\n\n assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\n assert captured == {\n \"app_id\": \"123\",\n \"private_key\": \"private-key\",\n \"installation_id\": \"456\",\n \"repository\": \"repo\",\n \"permissions\": {\"contents\": \"write\", \"metadata\": \"read\"},\n }\n # No pull_requests/issues write scope reaches a sandbox-bound token.\n assert \"pull_requests\" not in captured[\"permissions\"]\n assert \"issues\" not in captured[\"permissions\"]\n\n\ndef test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n def raise_from_generate(**_kwargs):\n raise RuntimeError(\"token generation failed\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", raise_from_generate)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n\n\ndef test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\n \"\"\"A rejected narrowing request must not be retried with a broader grant.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n calls = []\n\n def reject_narrowing(**kwargs):\n calls.append(kwargs)\n raise RuntimeError(\"422 Validation Failed: repositories not accessible\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", reject_narrowing)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n # Exactly one attempt — a narrowed one — never a second, unnarrowed retry.\n assert len(calls) == 1\n assert calls[0][\"repository\"] == \"repo\"\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n // Attach the HTTP status so callers can classify transient (5xx/429)\n // vs permanent failures rather than substring-matching the message.\n throw Object.assign(\n new Error(`Failed to get installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/**\n * Exchange JWT for an installation access token narrowed to a set of\n * repositories and a minimal permission set.\n *\n * Used exclusively for credentials that reach a sandbox (git credential\n * helper, gh CLI wrapper, image-build clone) — never for the control\n * plane's own server-side GitHub API calls (PR creation, labeling, review\n * submission), which legitimately need the App's full grant and keep using\n * {@link getCachedInstallationToken}.\n */\nasync function getScopedInstallationTokenWithMetadata(\n jwt: string,\n installationId: string,\n userAgent: string,\n repositories: string[],\n permissions: Record<string, string>\n): Promise<InstallationTokenResponse> {\n const url = `https://api.github.com/app/installations/${installationId}/access_tokens`;\n\n const response = await fetchWithTimeout(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${jwt}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ repositories, permissions }),\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw Object.assign(\n new Error(`Failed to get scoped installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n // Never falls back to the unnarrowed, full-grant mint.\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"scopes the token to every distinct repository a multi-repo session needs\", async () => {\n const expiresAtEpochMs = Date.now() + 60 * 60 * 1000;\n mockGetScopedInstallationTokenWithExpiry.mockResolvedValueOnce({\n token: \"ghs_scoped\",\n expiresAtEpochMs,\n });\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n await provider.generateCredentialHelperAuth([\n { owner: \"acme\", name: \"web\" },\n { owner: \"acme\", name: \"shared-lib\" },\n { owner: \"acme\", name: \"web\" }, // duplicate — must be deduped, not sent twice\n ]);\n\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\", \"shared-lib\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n });\n\n it(\"wraps upstream errors as SourceControlProviderError without falling back to the full-grant token\", async () => {\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(new Error(\"GitHub 500\"));\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).message).toContain(\"GitHub 500\");\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"classifies an upstream 5xx (with .status) as transient\", async () => {\n const httpError = Object.assign(\n new Error(\"Failed to get scoped installation token: 500 down\"),\n { status: 500 }\n );\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(httpError);\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n // Transient → the service maps this to 502, not 500.\n expect((err as SourceControlProviderError).errorType).toBe(\"transient\");\n expect((err as SourceControlProviderError).httpStatus).toBe(500);\n });\n });\n\n describe(\"createPullRequest\", () => {\n const prResponseBody = {\n number: 7,\n html_url: \"https://github.com/acme/web/pull/7\",\n263: repositories: REPO_REPOSITORIES,\n282: repositoriesFingerprint: expect.any(String),\n298: repositories: REPO_REPOSITORIES,\n323: repositories: REPO_REPOSITORIES,\npackages/modal-infra/tests/conftest.py\npackages/sandbox-runtime/tests/conftest.py\nls: .venv: No such file or directory\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 09:33 packages/modal-infra/.venv\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 15:18 packages/sandbox-runtime/.venv\n"}}
{"chunk_id":"c037b6","wall_time_seconds":5.003522417,"session_id":3079,"original_token_count":111,"output":" ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10016ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 27079ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10015ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10006ms\n × shutdown closes a socket adopted under a lease that predates it 5012ms\n"}>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv uv run --offline --no-sync pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"7d76c8","wall_time_seconds":1.007419,"session_id":64472,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"3c5d21","wall_time_seconds":1.00165625,"session_id":39495,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"a46249","wall_time_seconds":0,"exit_code":1,"original_token_count":4191,"output":" * resets with the set because it describes work on the replaced members.\n */\n replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\n this.sql.exec(`DELETE FROM session_repositories`);\n for (const repo of repositories) {\n this.sql.exec(\n `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\n VALUES (?, ?, ?, ?, ?)`,\n repo.position,\n repo.repoOwner,\n repo.repoName,\n repo.repoId,\n repo.baseBranch\n );\n }\n }\n\n getSessionRepositoryRows(): SessionRepositoryRow[] {\n const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\n return this.rows<SessionRepositoryRow>(result);\n }\n\n /**\n * Returns the session's repositories, using the scalar mirror fallback for\n * older sessions. Empty only for sessions without repository context.\n */\n getSessionRepositories(): SessionRepositoryEntry[] {\n const session = this.getSession();\n if (!session?.repo_owner || !session.repo_name) return [];\n return buildSessionRepositories(\n {\n repoOwner: session.repo_owner,\n repoName: session.repo_name,\n baseBranch: session.base_branch,\n },\n this.getSessionRepositoryRows()\n );\n }\n\n updateSessionRepositoryBranch(repoOwner: string, repoName: string, branchName: string): void {\n this.sql.exec(\n `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\n branchName,\n repoOwner,\n repoName\n );\n }\n\n setSessionDiffBaselines(\n repositories: Array<{\n position: number;\n repoOwner: string;\n repoName: string;\n baseSha: string;\n isPrimary: boolean;\n }>\n ): void {\n this.transactionSync(() => {\n for (const repository of repositories) {\n this.sql.exec(\n `UPDATE session_repositories\n\"\"\"Tests for GitHub App installation-token narrowing validation.\n\nCovers the fail-closed contract get_installation_token/generate_installation_token\nmust uphold: an empty or inconsistent repository/permissions combination must\nnever silently mint a broader-than-intended (or fully unnarrowed) token.\n\"\"\"\n\nimport pytest\n\nfrom sandbox_runtime.auth.github_app import (\n SANDBOX_SCOPED_PERMISSIONS,\n generate_installation_token,\n get_installation_token,\n)\n\n\ndef test_get_installation_token_rejects_permissions_without_repository():\n with pytest.raises(ValueError, match=\"permissions requires repository\"):\n get_installation_token(\"jwt\", \"456\", permissions={\"contents\": \"write\"})\n\n\ndef test_get_installation_token_rejects_empty_permissions_dict():\n \"\"\"An empty {} must not silently drop narrowing and mint the full installation grant.\"\"\"\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"repo\", permissions={})\n\n\ndef test_get_installation_token_rejects_empty_repository_string():\n with pytest.raises(ValueError, match=\"repository must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\n\n\ndef test_get_installation_token_sends_narrowed_body(monkeypatch):\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-scoped\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"url\"] = url\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\n \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\n )\n\n assert token == \"ghs-scoped\"\n assert captured[\"json\"] == {\n \"repositories\": [\"repo\"],\n \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\n }\n\n\ndef test_get_installation_token_unnarrowed_sends_no_body(monkeypatch):\n \"\"\"Legacy unnarrowed callers (control-plane's own server-side mint) are unaffected.\"\"\"\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-full\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\"jwt\", \"456\")\n\n assert token == \"ghs-full\"\n assert captured[\"json\"] is None\n\n\ndef test_generate_installation_token_defaults_permissions_when_repository_given(monkeypatch):\n captured = {}\n\n def fake_get_installation_token(\n jwt_token, installation_id, *, repository=None, permissions=None\n ):\n captured[\"repository\"] = repository\n captured[\"permissions\"] = permissions\n return \"ghs-scoped\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.get_installation_token\", fake_get_installation_token\n )\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n token = generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\")\n\n assert token == \"ghs-scoped\"\n assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\n\n\ndef test_generate_installation_token_rejects_explicit_empty_permissions(monkeypatch):\n \"\"\"Explicitly passing {} must not be silently replaced by the default — it's an error.\"\"\"\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\", permissions={})\n\"\"\"Tests for VCS clone token resolution.\"\"\"\n\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom src.clone_token import resolve_clone_token\n\n\[REDACTED](autouse=True)\ndef clear_clone_token_env(monkeypatch: pytest.MonkeyPatch) -> None:\n for name in [\n \"SCM_PROVIDER\",\n \"GITLAB_ACCESS_TOKEN\",\n \"GITHUB_APP_ID\",\n \"GITHUB_APP_PRIVATE_KEY\",\n \"GITHUB_APP_INSTALLATION_ID\",\n ]:\n monkeypatch.delenv(name, raising=False)\n\n\ndef test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n monkeypatch.setenv(\"GITLAB_ACCESS_TOKEN\", \"glpat-token\")\n\n assert resolve_clone_token() == \"glpat-token\"\n\n\ndef test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n\n assert resolve_clone_token() is None\n\n\ndef test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\n \"\"\"No repo context must fail closed — never mint an unnarrowed, installation-wide token.\n\n Uses a plain recording mock rather than a raising stub: resolve_clone_token\n wraps the mint call in a broad `except Exception`, so a stub that raises\n AssertionError to signal \"should not be called\" is indistinguishable from\n a real narrowing failure — both paths return None either way, so the test\n would pass even if the guard that skips the call entirely were deleted.\n Asserting call counts on a mock that doesn't raise is what actually proves\n the guard fired.\n \"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token() is None\n assert resolve_clone_token(\"acme\", None) is None\n assert resolve_clone_token(\"acme\", \"\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\n \"\"\"The token handed to a sandbox must be repo-scoped and permission-narrowed.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n captured = {}\n\n def fake_generate_installation_token(**kwargs):\n captured.update(kwargs)\n return \"ghs-scoped-token\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.generate_installation_token\", fake_generate_installation_token\n )\n\n assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\n assert captured == {\n \"app_id\": \"123\",\n \"private_key\": \"private-key\",\n \"installation_id\": \"456\",\n \"repository\": \"repo\",\n \"permissions\": {\"contents\": \"write\", \"metadata\": \"read\"},\n }\n # No pull_requests/issues write scope reaches a sandbox-bound token.\n assert \"pull_requests\" not in captured[\"permissions\"]\n assert \"issues\" not in captured[\"permissions\"]\n\n\ndef test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n def raise_from_generate(**_kwargs):\n raise RuntimeError(\"token generation failed\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", raise_from_generate)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n\n\ndef test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\n \"\"\"A rejected narrowing request must not be retried with a broader grant.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n calls = []\n\n def reject_narrowing(**kwargs):\n calls.append(kwargs)\n raise RuntimeError(\"422 Validation Failed: repositories not accessible\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", reject_narrowing)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n # Exactly one attempt — a narrowed one — never a second, unnarrowed retry.\n assert len(calls) == 1\n assert calls[0][\"repository\"] == \"repo\"\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n // Attach the HTTP status so callers can classify transient (5xx/429)\n // vs permanent failures rather than substring-matching the message.\n throw Object.assign(\n new Error(`Failed to get installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/**\n * Exchange JWT for an installation access token narrowed to a set of\n * repositories and a minimal permission set.\n *\n * Used exclusively for credentials that reach a sandbox (git credential\n * helper, gh CLI wrapper, image-build clone) — never for the control\n * plane's own server-side GitHub API calls (PR creation, labeling, review\n * submission), which legitimately need the App's full grant and keep using\n * {@link getCachedInstallationToken}.\n */\nasync function getScopedInstallationTokenWithMetadata(\n jwt: string,\n installationId: string,\n userAgent: string,\n repositories: string[],\n permissions: Record<string, string>\n): Promise<InstallationTokenResponse> {\n const url = `https://api.github.com/app/installations/${installationId}/access_tokens`;\n\n const response = await fetchWithTimeout(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${jwt}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ repositories, permissions }),\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw Object.assign(\n new Error(`Failed to get scoped installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n // Never falls back to the unnarrowed, full-grant mint.\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"scopes the token to every distinct repository a multi-repo session needs\", async () => {\n const expiresAtEpochMs = Date.now() + 60 * 60 * 1000;\n mockGetScopedInstallationTokenWithExpiry.mockResolvedValueOnce({\n token: \"ghs_scoped\",\n expiresAtEpochMs,\n });\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n await provider.generateCredentialHelperAuth([\n { owner: \"acme\", name: \"web\" },\n { owner: \"acme\", name: \"shared-lib\" },\n { owner: \"acme\", name: \"web\" }, // duplicate — must be deduped, not sent twice\n ]);\n\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\", \"shared-lib\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n });\n\n it(\"wraps upstream errors as SourceControlProviderError without falling back to the full-grant token\", async () => {\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(new Error(\"GitHub 500\"));\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).message).toContain(\"GitHub 500\");\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"classifies an upstream 5xx (with .status) as transient\", async () => {\n const httpError = Object.assign(\n new Error(\"Failed to get scoped installation token: 500 down\"),\n { status: 500 }\n );\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(httpError);\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n // Transient → the service maps this to 502, not 500.\n expect((err as SourceControlProviderError).errorType).toBe(\"transient\");\n expect((err as SourceControlProviderError).httpStatus).toBe(500);\n });\n });\n\n describe(\"createPullRequest\", () => {\n const prResponseBody = {\n number: 7,\n html_url: \"https://github.com/acme/web/pull/7\",\n263: repositories: REPO_REPOSITORIES,\n282: repositoriesFingerprint: expect.any(String),\n298: repositories: REPO_REPOSITORIES,\n323: repositories: REPO_REPOSITORIES,\npackages/modal-infra/tests/conftest.py\npackages/sandbox-runtime/tests/conftest.py\nls: .venv: No such file or directory\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 09:33 packages/modal-infra/.venv\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 15:18 packages/sandbox-runtime/.venv\n"}}
{"chunk_id":"c037b6","wall_time_seconds":5.003522417,"session_id":3079,"original_token_count":111,"output":" ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10016ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 27079ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10015ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10006ms\n × shutdown closes a socket adopted under a lease that predates it 5012ms\n"}>sed -n '220,280p' packages/control-plane/src/session/session-core-repository.ts; cat packages/sandbox-runtime/tests/test_github_app_auth.py; cat packages/modal-infra/tests/test_clone_token.py; sed -n '260,338p' packages/control-plane/src/auth/github-app.ts; sed -n '510,575p' packages/control-plane/src/source-control/providers/github-provider.test.ts; rg -n 'repositories|cloneAuth' packages/control-plane/src/routes/image-builds.trigger.test.ts; rg --files -g '*planner*test*' -g '*conftest*' packages; ls -ld packages/modal-infra/.venv packages/sandbox-runtime/.venv .venv{"status":"fulfilled","value":{"chunk_id":"7d76c8","wall_time_seconds":1.007419,"session_id":64472,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"3c5d21","wall_time_seconds":1.00165625,"session_id":39495,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"a46249","wall_time_seconds":0,"exit_code":1,"original_token_count":4191,"output":" * resets with the set because it describes work on the replaced members.\n */\n replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\n this.sql.exec(`DELETE FROM session_repositories`);\n for (const repo of repositories) {\n this.sql.exec(\n `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\n VALUES (?, ?, ?, ?, ?)`,\n repo.position,\n repo.repoOwner,\n repo.repoName,\n repo.repoId,\n repo.baseBranch\n );\n }\n }\n\n getSessionRepositoryRows(): SessionRepositoryRow[] {\n const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\n return this.rows<SessionRepositoryRow>(result);\n }\n\n /**\n * Returns the session's repositories, using the scalar mirror fallback for\n * older sessions. Empty only for sessions without repository context.\n */\n getSessionRepositories(): SessionRepositoryEntry[] {\n const session = this.getSession();\n if (!session?.repo_owner || !session.repo_name) return [];\n return buildSessionRepositories(\n {\n repoOwner: session.repo_owner,\n repoName: session.repo_name,\n baseBranch: session.base_branch,\n },\n this.getSessionRepositoryRows()\n );\n }\n\n updateSessionRepositoryBranch(repoOwner: string, repoName: string, branchName: string): void {\n this.sql.exec(\n `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\n branchName,\n repoOwner,\n repoName\n );\n }\n\n setSessionDiffBaselines(\n repositories: Array<{\n position: number;\n repoOwner: string;\n repoName: string;\n baseSha: string;\n isPrimary: boolean;\n }>\n ): void {\n this.transactionSync(() => {\n for (const repository of repositories) {\n this.sql.exec(\n `UPDATE session_repositories\n\"\"\"Tests for GitHub App installation-token narrowing validation.\n\nCovers the fail-closed contract get_installation_token/generate_installation_token\nmust uphold: an empty or inconsistent repository/permissions combination must\nnever silently mint a broader-than-intended (or fully unnarrowed) token.\n\"\"\"\n\nimport pytest\n\nfrom sandbox_runtime.auth.github_app import (\n SANDBOX_SCOPED_PERMISSIONS,\n generate_installation_token,\n get_installation_token,\n)\n\n\ndef test_get_installation_token_rejects_permissions_without_repository():\n with pytest.raises(ValueError, match=\"permissions requires repository\"):\n get_installation_token(\"jwt\", \"456\", permissions={\"contents\": \"write\"})\n\n\ndef test_get_installation_token_rejects_empty_permissions_dict():\n \"\"\"An empty {} must not silently drop narrowing and mint the full installation grant.\"\"\"\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"repo\", permissions={})\n\n\ndef test_get_installation_token_rejects_empty_repository_string():\n with pytest.raises(ValueError, match=\"repository must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\n\n\ndef test_get_installation_token_sends_narrowed_body(monkeypatch):\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-scoped\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"url\"] = url\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\n \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\n )\n\n assert token == \"ghs-scoped\"\n assert captured[\"json\"] == {\n \"repositories\": [\"repo\"],\n \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\n }\n\n\ndef test_get_installation_token_unnarrowed_sends_no_body(monkeypatch):\n \"\"\"Legacy unnarrowed callers (control-plane's own server-side mint) are unaffected.\"\"\"\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-full\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\"jwt\", \"456\")\n\n assert token == \"ghs-full\"\n assert captured[\"json\"] is None\n\n\ndef test_generate_installation_token_defaults_permissions_when_repository_given(monkeypatch):\n captured = {}\n\n def fake_get_installation_token(\n jwt_token, installation_id, *, repository=None, permissions=None\n ):\n captured[\"repository\"] = repository\n captured[\"permissions\"] = permissions\n return \"ghs-scoped\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.get_installation_token\", fake_get_installation_token\n )\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n token = generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\")\n\n assert token == \"ghs-scoped\"\n assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\n\n\ndef test_generate_installation_token_rejects_explicit_empty_permissions(monkeypatch):\n \"\"\"Explicitly passing {} must not be silently replaced by the default — it's an error.\"\"\"\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\", permissions={})\n\"\"\"Tests for VCS clone token resolution.\"\"\"\n\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom src.clone_token import resolve_clone_token\n\n\[REDACTED](autouse=True)\ndef clear_clone_token_env(monkeypatch: pytest.MonkeyPatch) -> None:\n for name in [\n \"SCM_PROVIDER\",\n \"GITLAB_ACCESS_TOKEN\",\n \"GITHUB_APP_ID\",\n \"GITHUB_APP_PRIVATE_KEY\",\n \"GITHUB_APP_INSTALLATION_ID\",\n ]:\n monkeypatch.delenv(name, raising=False)\n\n\ndef test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n monkeypatch.setenv(\"GITLAB_ACCESS_TOKEN\", \"glpat-token\")\n\n assert resolve_clone_token() == \"glpat-token\"\n\n\ndef test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n\n assert resolve_clone_token() is None\n\n\ndef test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\n \"\"\"No repo context must fail closed — never mint an unnarrowed, installation-wide token.\n\n Uses a plain recording mock rather than a raising stub: resolve_clone_token\n wraps the mint call in a broad `except Exception`, so a stub that raises\n AssertionError to signal \"should not be called\" is indistinguishable from\n a real narrowing failure — both paths return None either way, so the test\n would pass even if the guard that skips the call entirely were deleted.\n Asserting call counts on a mock that doesn't raise is what actually proves\n the guard fired.\n \"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token() is None\n assert resolve_clone_token(\"acme\", None) is None\n assert resolve_clone_token(\"acme\", \"\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\n \"\"\"The token handed to a sandbox must be repo-scoped and permission-narrowed.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n captured = {}\n\n def fake_generate_installation_token(**kwargs):\n captured.update(kwargs)\n return \"ghs-scoped-token\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.generate_installation_token\", fake_generate_installation_token\n )\n\n assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\n assert captured == {\n \"app_id\": \"123\",\n \"private_key\": \"private-key\",\n \"installation_id\": \"456\",\n \"repository\": \"repo\",\n \"permissions\": {\"contents\": \"write\", \"metadata\": \"read\"},\n }\n # No pull_requests/issues write scope reaches a sandbox-bound token.\n assert \"pull_requests\" not in captured[\"permissions\"]\n assert \"issues\" not in captured[\"permissions\"]\n\n\ndef test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n def raise_from_generate(**_kwargs):\n raise RuntimeError(\"token generation failed\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", raise_from_generate)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n\n\ndef test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\n \"\"\"A rejected narrowing request must not be retried with a broader grant.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n calls = []\n\n def reject_narrowing(**kwargs):\n calls.append(kwargs)\n raise RuntimeError(\"422 Validation Failed: repositories not accessible\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", reject_narrowing)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n # Exactly one attempt — a narrowed one — never a second, unnarrowed retry.\n assert len(calls) == 1\n assert calls[0][\"repository\"] == \"repo\"\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n // Attach the HTTP status so callers can classify transient (5xx/429)\n // vs permanent failures rather than substring-matching the message.\n throw Object.assign(\n new Error(`Failed to get installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/**\n * Exchange JWT for an installation access token narrowed to a set of\n * repositories and a minimal permission set.\n *\n * Used exclusively for credentials that reach a sandbox (git credential\n * helper, gh CLI wrapper, image-build clone) — never for the control\n * plane's own server-side GitHub API calls (PR creation, labeling, review\n * submission), which legitimately need the App's full grant and keep using\n * {@link getCachedInstallationToken}.\n */\nasync function getScopedInstallationTokenWithMetadata(\n jwt: string,\n installationId: string,\n userAgent: string,\n repositories: string[],\n permissions: Record<string, string>\n): Promise<InstallationTokenResponse> {\n const url = `https://api.github.com/app/installations/${installationId}/access_tokens`;\n\n const response = await fetchWithTimeout(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${jwt}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ repositories, permissions }),\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw Object.assign(\n new Error(`Failed to get scoped installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n // Never falls back to the unnarrowed, full-grant mint.\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"scopes the token to every distinct repository a multi-repo session needs\", async () => {\n const expiresAtEpochMs = Date.now() + 60 * 60 * 1000;\n mockGetScopedInstallationTokenWithExpiry.mockResolvedValueOnce({\n token: \"ghs_scoped\",\n expiresAtEpochMs,\n });\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n await provider.generateCredentialHelperAuth([\n { owner: \"acme\", name: \"web\" },\n { owner: \"acme\", name: \"shared-lib\" },\n { owner: \"acme\", name: \"web\" }, // duplicate — must be deduped, not sent twice\n ]);\n\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\", \"shared-lib\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n });\n\n it(\"wraps upstream errors as SourceControlProviderError without falling back to the full-grant token\", async () => {\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(new Error(\"GitHub 500\"));\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).message).toContain(\"GitHub 500\");\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"classifies an upstream 5xx (with .status) as transient\", async () => {\n const httpError = Object.assign(\n new Error(\"Failed to get scoped installation token: 500 down\"),\n { status: 500 }\n );\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(httpError);\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n // Transient → the service maps this to 502, not 500.\n expect((err as SourceControlProviderError).errorType).toBe(\"transient\");\n expect((err as SourceControlProviderError).httpStatus).toBe(500);\n });\n });\n\n describe(\"createPullRequest\", () => {\n const prResponseBody = {\n number: 7,\n html_url: \"https://github.com/acme/web/pull/7\",\n263: repositories: REPO_REPOSITORIES,\n282: repositoriesFingerprint: expect.any(String),\n298: repositories: REPO_REPOSITORIES,\n323: repositories: REPO_REPOSITORIES,\npackages/modal-infra/tests/conftest.py\npackages/sandbox-runtime/tests/conftest.py\nls: .venv: No such file or directory\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 09:33 packages/modal-infra/.venv\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 15:18 packages/sandbox-runtime/.venv\n"}}
{"chunk_id":"c037b6","wall_time_seconds":5.003522417,"session_id":3079,"original_token_count":111,"output":" ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10016ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 27079ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10015ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10006ms\n × shutdown closes a socket adopted under a lease that predates it 5012ms\n"}{"status":"fulfilled","value":{"chunk_id":"7d76c8","wall_time_seconds":1.007419,"session_id":64472,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"3c5d21","wall_time_seconds":1.00165625,"session_id":39495,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"a46249","wall_time_seconds":0,"exit_code":1,"original_token_count":4191,"output":" * resets with the set because it describes work on the replaced members.\n */\n replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\n this.sql.exec(`DELETE FROM session_repositories`);\n for (const repo of repositories) {\n this.sql.exec(\n `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\n VALUES (?, ?, ?, ?, ?)`,\n repo.position,\n repo.repoOwner,\n repo.repoName,\n repo.repoId,\n repo.baseBranch\n );\n }\n }\n\n getSessionRepositoryRows(): SessionRepositoryRow[] {\n const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\n return this.rows<SessionRepositoryRow>(result);\n }\n\n /**\n * Returns the session's repositories, using the scalar mirror fallback for\n * older sessions. Empty only for sessions without repository context.\n */\n getSessionRepositories(): SessionRepositoryEntry[] {\n const session = this.getSession();\n if (!session?.repo_owner || !session.repo_name) return [];\n return buildSessionRepositories(\n {\n repoOwner: session.repo_owner,\n repoName: session.repo_name,\n baseBranch: session.base_branch,\n },\n this.getSessionRepositoryRows()\n );\n }\n\n updateSessionRepositoryBranch(repoOwner: string, repoName: string, branchName: string): void {\n this.sql.exec(\n `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\n branchName,\n repoOwner,\n repoName\n );\n }\n\n setSessionDiffBaselines(\n repositories: Array<{\n position: number;\n repoOwner: string;\n repoName: string;\n baseSha: string;\n isPrimary: boolean;\n }>\n ): void {\n this.transactionSync(() => {\n for (const repository of repositories) {\n this.sql.exec(\n `UPDATE session_repositories\n\"\"\"Tests for GitHub App installation-token narrowing validation.\n\nCovers the fail-closed contract get_installation_token/generate_installation_token\nmust uphold: an empty or inconsistent repository/permissions combination must\nnever silently mint a broader-than-intended (or fully unnarrowed) token.\n\"\"\"\n\nimport pytest\n\nfrom sandbox_runtime.auth.github_app import (\n SANDBOX_SCOPED_PERMISSIONS,\n generate_installation_token,\n get_installation_token,\n)\n\n\ndef test_get_installation_token_rejects_permissions_without_repository():\n with pytest.raises(ValueError, match=\"permissions requires repository\"):\n get_installation_token(\"jwt\", \"456\", permissions={\"contents\": \"write\"})\n\n\ndef test_get_installation_token_rejects_empty_permissions_dict():\n \"\"\"An empty {} must not silently drop narrowing and mint the full installation grant.\"\"\"\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"repo\", permissions={})\n\n\ndef test_get_installation_token_rejects_empty_repository_string():\n with pytest.raises(ValueError, match=\"repository must be non-empty\"):\n get_installation_token(\"jwt\", \"456\", repository=\"\", permissions=SANDBOX_SCOPED_PERMISSIONS)\n\n\ndef test_get_installation_token_sends_narrowed_body(monkeypatch):\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-scoped\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"url\"] = url\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\n \"jwt\", \"456\", repository=\"repo\", permissions=SANDBOX_SCOPED_PERMISSIONS\n )\n\n assert token == \"ghs-scoped\"\n assert captured[\"json\"] == {\n \"repositories\": [\"repo\"],\n \"permissions\": SANDBOX_SCOPED_PERMISSIONS,\n }\n\n\ndef test_get_installation_token_unnarrowed_sends_no_body(monkeypatch):\n \"\"\"Legacy unnarrowed callers (control-plane's own server-side mint) are unaffected.\"\"\"\n captured = {}\n\n class FakeResponse:\n def raise_for_status(self):\n pass\n\n def json(self):\n return {\"token\": \"ghs-full\"}\n\n class FakeClient:\n def __enter__(self):\n return self\n\n def __exit__(self, *exc):\n return False\n\n def post(self, url, headers, json):\n captured[\"json\"] = json\n return FakeResponse()\n\n monkeypatch.setattr(\"sandbox_runtime.auth.github_app.httpx.Client\", FakeClient)\n\n token = get_installation_token(\"jwt\", \"456\")\n\n assert token == \"ghs-full\"\n assert captured[\"json\"] is None\n\n\ndef test_generate_installation_token_defaults_permissions_when_repository_given(monkeypatch):\n captured = {}\n\n def fake_get_installation_token(\n jwt_token, installation_id, *, repository=None, permissions=None\n ):\n captured[\"repository\"] = repository\n captured[\"permissions\"] = permissions\n return \"ghs-scoped\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.get_installation_token\", fake_get_installation_token\n )\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n token = generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\")\n\n assert token == \"ghs-scoped\"\n assert captured == {\"repository\": \"repo\", \"permissions\": SANDBOX_SCOPED_PERMISSIONS}\n\n\ndef test_generate_installation_token_rejects_explicit_empty_permissions(monkeypatch):\n \"\"\"Explicitly passing {} must not be silently replaced by the default — it's an error.\"\"\"\n monkeypatch.setattr(\n \"sandbox_runtime.auth.github_app.generate_jwt\", lambda app_id, private_key: \"jwt\"\n )\n\n with pytest.raises(ValueError, match=\"permissions must be non-empty\"):\n generate_installation_token(\"123\", \"key\", \"456\", repository=\"repo\", permissions={})\n\"\"\"Tests for VCS clone token resolution.\"\"\"\n\nfrom unittest.mock import MagicMock\n\nimport pytest\n\nfrom src.clone_token import resolve_clone_token\n\n\[REDACTED](autouse=True)\ndef clear_clone_token_env(monkeypatch: pytest.MonkeyPatch) -> None:\n for name in [\n \"SCM_PROVIDER\",\n \"GITLAB_ACCESS_TOKEN\",\n \"GITHUB_APP_ID\",\n \"GITHUB_APP_PRIVATE_KEY\",\n \"GITHUB_APP_INSTALLATION_ID\",\n ]:\n monkeypatch.delenv(name, raising=False)\n\n\ndef test_resolve_clone_token_uses_gitlab_access_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n monkeypatch.setenv(\"GITLAB_ACCESS_TOKEN\", \"glpat-token\")\n\n assert resolve_clone_token() == \"glpat-token\"\n\n\ndef test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\n monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n\n assert resolve_clone_token() is None\n\n\ndef test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\n \"\"\"No repo context must fail closed — never mint an unnarrowed, installation-wide token.\n\n Uses a plain recording mock rather than a raising stub: resolve_clone_token\n wraps the mint call in a broad `except Exception`, so a stub that raises\n AssertionError to signal \"should not be called\" is indistinguishable from\n a real narrowing failure — both paths return None either way, so the test\n would pass even if the guard that skips the call entirely were deleted.\n Asserting call counts on a mock that doesn't raise is what actually proves\n the guard fired.\n \"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token() is None\n assert resolve_clone_token(\"acme\", None) is None\n assert resolve_clone_token(\"acme\", \"\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\n \"\"\"The token handed to a sandbox must be repo-scoped and permission-narrowed.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n captured = {}\n\n def fake_generate_installation_token(**kwargs):\n captured.update(kwargs)\n return \"ghs-scoped-token\"\n\n monkeypatch.setattr(\n \"sandbox_runtime.auth.generate_installation_token\", fake_generate_installation_token\n )\n\n assert resolve_clone_token(\"acme\", \"repo\") == \"ghs-scoped-token\"\n assert captured == {\n \"app_id\": \"123\",\n \"private_key\": \"private-key\",\n \"installation_id\": \"456\",\n \"repository\": \"repo\",\n \"permissions\": {\"contents\": \"write\", \"metadata\": \"read\"},\n }\n # No pull_requests/issues write scope reaches a sandbox-bound token.\n assert \"pull_requests\" not in captured[\"permissions\"]\n assert \"issues\" not in captured[\"permissions\"]\n\n\ndef test_resolve_clone_token_returns_none_when_github_credentials_incomplete(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n mint = MagicMock(return_value=\"ghs-should-not-be-returned\")\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", mint)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n mint.assert_not_called()\n\n\ndef test_resolve_clone_token_returns_none_when_github_generation_fails(monkeypatch):\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n\n def raise_from_generate(**_kwargs):\n raise RuntimeError(\"token generation failed\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", raise_from_generate)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n\n\ndef test_resolve_clone_token_fails_closed_does_not_retry_unnarrowed(monkeypatch):\n \"\"\"A rejected narrowing request must not be retried with a broader grant.\"\"\"\n monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n calls = []\n\n def reject_narrowing(**kwargs):\n calls.append(kwargs)\n raise RuntimeError(\"422 Validation Failed: repositories not accessible\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", reject_narrowing)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\n # Exactly one attempt — a narrowed one — never a second, unnarrowed retry.\n assert len(calls) == 1\n assert calls[0][\"repository\"] == \"repo\"\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n },\n });\n\n if (!response.ok) {\n const error = await response.text();\n // Attach the HTTP status so callers can classify transient (5xx/429)\n // vs permanent failures rather than substring-matching the message.\n throw Object.assign(\n new Error(`Failed to get installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get installation token: invalid response\");\n }\n return parsed.data;\n}\n\n/**\n * Exchange JWT for an installation access token narrowed to a set of\n * repositories and a minimal permission set.\n *\n * Used exclusively for credentials that reach a sandbox (git credential\n * helper, gh CLI wrapper, image-build clone) — never for the control\n * plane's own server-side GitHub API calls (PR creation, labeling, review\n * submission), which legitimately need the App's full grant and keep using\n * {@link getCachedInstallationToken}.\n */\nasync function getScopedInstallationTokenWithMetadata(\n jwt: string,\n installationId: string,\n userAgent: string,\n repositories: string[],\n permissions: Record<string, string>\n): Promise<InstallationTokenResponse> {\n const url = `https://api.github.com/app/installations/${installationId}/access_tokens`;\n\n const response = await fetchWithTimeout(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${jwt}`,\n Accept: \"application/vnd.github+json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n \"User-Agent\": userAgent,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({ repositories, permissions }),\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw Object.assign(\n new Error(`Failed to get scoped installation token: ${response.status} ${error}`),\n { status: response.status }\n );\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n }\n\n const parsed = installationTokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new Error(\"Failed to get scoped installation token: invalid response\");\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n // Never falls back to the unnarrowed, full-grant mint.\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"scopes the token to every distinct repository a multi-repo session needs\", async () => {\n const expiresAtEpochMs = Date.now() + 60 * 60 * 1000;\n mockGetScopedInstallationTokenWithExpiry.mockResolvedValueOnce({\n token: \"ghs_scoped\",\n expiresAtEpochMs,\n });\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n await provider.generateCredentialHelperAuth([\n { owner: \"acme\", name: \"web\" },\n { owner: \"acme\", name: \"shared-lib\" },\n { owner: \"acme\", name: \"web\" }, // duplicate — must be deduped, not sent twice\n ]);\n\n expect(mockGetScopedInstallationTokenWithExpiry).toHaveBeenCalledWith(\n fakeAppConfig,\n [\"web\", \"shared-lib\"],\n expect.objectContaining({ userAgent: expect.any(String) })\n );\n });\n\n it(\"wraps upstream errors as SourceControlProviderError without falling back to the full-grant token\", async () => {\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(new Error(\"GitHub 500\"));\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).message).toContain(\"GitHub 500\");\n expect(mockGetCachedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"classifies an upstream 5xx (with .status) as transient\", async () => {\n const httpError = Object.assign(\n new Error(\"Failed to get scoped installation token: 500 down\"),\n { status: 500 }\n );\n mockGetScopedInstallationTokenWithExpiry.mockRejectedValueOnce(httpError);\n\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n // Transient → the service maps this to 502, not 500.\n expect((err as SourceControlProviderError).errorType).toBe(\"transient\");\n expect((err as SourceControlProviderError).httpStatus).toBe(500);\n });\n });\n\n describe(\"createPullRequest\", () => {\n const prResponseBody = {\n number: 7,\n html_url: \"https://github.com/acme/web/pull/7\",\n263: repositories: REPO_REPOSITORIES,\n282: repositoriesFingerprint: expect.any(String),\n298: repositories: REPO_REPOSITORIES,\n323: repositories: REPO_REPOSITORIES,\npackages/modal-infra/tests/conftest.py\npackages/sandbox-runtime/tests/conftest.py\nls: .venv: No such file or directory\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 09:33 packages/modal-infra/.venv\ndrwxr-xr-x@ 8 gaganarora staff 256 Sep 12 15:18 packages/sandbox-runtime/.venv\n"}}
{"chunk_id":"c037b6","wall_time_seconds":5.003522417,"session_id":3079,"original_token_count":111,"output":" ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10016ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 27079ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10015ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10006ms\n × shutdown closes a socket adopted under a lease that predates it 5012ms\n"}{"status":"fulfilled","value":{"chunk_id":"213a67","wall_time_seconds":0.000005459,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.03s\n"}}
{"status":"fulfilled","value":{"chunk_id":"067311","wall_time_seconds":1.67e-7,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.26s\n"}}
{"status":"fulfilled","value":{"chunk_id":"8b5866","wall_time_seconds":0.000001709,"exit_code":0,"original_token_count":4016,"output":"import { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n test: {\n environment: \"node\",\n include: [\"src/**/*.test.ts\", \"test/conformance/**/*.test.ts\"],\n coverage: {\n provider: \"v8\",\n reporter: [\"text\", \"json\"],\n reportsDirectory: \"./coverage\",\n include: [\"src/**/*.ts\"],\n exclude: [\"src/**/*.test.ts\", \"src/**/*.d.ts\", \"src/index.ts\"],\n },\n },\n});\nimport { once } from \"node:events\";\nimport type { AddressInfo } from \"node:net\";\nimport { afterEach, describe, expect, it, vi } from \"vitest\";\nimport { WebSocket as NodeWebSocket, WebSocketServer } from \"ws\";\nimport { WS_CLOSE_TRY_AGAIN_LATER } from \"@open-inspect/shared/types/websocket\";\nimport type { Logger } from \"../logger\";\nimport { isSocketOpen, type SessionWebSocket } from \"../platform-ports\";\nimport {\n NodeWebSocketHost,\n type NodeSocketHostOptions,\n type SessionWebSocketEventSink,\n} from \"./socket-host\";\n\nfunction createLogger(): Logger {\n const log = {\n debug: vi.fn(),\n info: vi.fn(),\n warn: vi.fn(),\n error: vi.fn(),\n child: vi.fn(() => log),\n };\n return log as unknown as Logger;\n}\n\nfunction createEvents() {\n return {\n onMessage: vi.fn(async (_ws: SessionWebSocket, _message: string | ArrayBuffer) => {}),\n onClose: vi.fn(\n async (_ws: SessionWebSocket, _code: number, _reason: string, _wasClean: boolean) => {}\n ),\n onError: vi.fn((_ws: SessionWebSocket, _error: Error) => {}),\n } satisfies SessionWebSocketEventSink;\n}\n\n/** A real `ws` server on an ephemeral port whose connections the test accepts into `host`. */\nasync function createHarness(options: NodeSocketHostOptions = {}) {\n const server = new WebSocketServer({ port: 0, host: \"127.0.0.1\" });\n await once(server, \"listening\");\n const { port } = server.address() as AddressInfo;\n const log = createLogger();\n const events = createEvents();\n const host = new NodeWebSocketHost(log, options);\n host.bindEventSink(events);\n const clients: NodeWebSocket[] = [];\n\n /** Open one connection; resolves once the server side is accepted under `tags`. */\n async function connect(\n tags: string[]\n ): Promise<{ client: NodeWebSocket; socket: NodeWebSocket }> {\n const accepted = new Promise<NodeWebSocket>((resolve) => {\n server.once(\"connection\", (socket) => {\n host.adopt(socket, tags);\n resolve(socket);\n });\n });\n const client = new NodeWebSocket(`ws://127.0.0.1:${port}`);\n clients.push(client);\n await once(client, \"open\");\n return { client, socket: await accepted };\n }\n\n return {\n host,\n events,\n log,\n server,\n connect,\n /** A server-side socket the host never upgraded, for wiring-error tests. */\n port,\n async close() {\n for (const client of clients) client.terminate();\n await new Promise<void>((resolve) => server.close(() => resolve()));\n },\n };\n}\n\ntype Harness = Awaited<ReturnType<typeof createHarness>>;\n\ndescribe(\"NodeWebSocketHost\", () => {\n let harness: Harness | null = null;\n afterEach(async () => {\n await harness?.close();\n harness = null;\n });\n\n it(\"adopts sockets under their tags and enumerates them by tag\", async () => {\n harness = await createHarness();\n const client1 = await harness.connect([\"wsid:ws-1\"]);\n const sandbox = await harness.connect([\"sandbox\", \"sid:sb-1\", \"socket:sbws-1\"]);\n\n expect(harness.host.tags(client1.socket)).toEqual([\"wsid:ws-1\"]);\n expect(harness.host.tags(sandbox.socket)).toEqual([\"sandbox\", \"sid:sb-1\", \"socket:sbws-1\"]);\n expect(harness.host.sockets()).toEqual([client1.socket, sandbox.socket]);\n expect(harness.host.sockets(\"sandbox\")).toEqual([sandbox.socket]);\n expect(harness.host.sockets(\"wsid:ws-1\")).toEqual([client1.socket]);\n expect(harness.host.sockets(\"missing\")).toEqual([]);\n });\n\n it(\"returns no tags for a socket it never accepted\", async () => {\n harness = await createHarness();\n expect(harness.host.tags({ readyState: 1, send() {}, close() {} })).toEqual([]);\n });\n\n it(\"refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\", async () => {\n harness = await createHarness();\n const { socket } = await harness.connect([\"wsid:ws-1\"]);\n\n expect(() => harness!.host.adopt({ readyState: 1, send() {}, close() {} }, [])).toThrow(\n TypeError\n );\n expect(() => harness!.host.adopt(socket, [\"wsid:again\"])).toThrow(/already adopted/);\n expect(() => new NodeWebSocketHost(createLogger()).adopt(socket, [])).toThrow(\n /before bindEventSink/\n );\n expect(() => harness!.host.bindEventSink(harness!.events)).toThrow(/already bound/);\n/**\n * Scope resolution — the ONLY module in the image-build subsystem that\n * switches on scope kind. Everything downstream (planner, workflow, store,\n * routes, adapters) is scope-agnostic and treats the kind as data.\n *\n * Resolution is split into phases rather than one monolithic call because the\n * planner's register-before-secrets ordering depends on it: the repository\n * set is resolved BEFORE the build row is registered (secret-free), while\n * secrets and sandbox settings are loaded AFTER, so a concurrent secret\n * change always sees a row to supersede.\n */\n\nimport { EnvironmentSecretsStore } from \"../db/environment-secrets\";\nimport { EnvironmentStore } from \"../db/environments\";\nimport { GlobalSecretsStore } from \"../db/global-secrets\";\nimport { RepoMetadataStore } from \"../db/repo-metadata\";\nimport { RepoSecretsStore } from \"../db/repo-secrets\";\nimport {\n auditSecretsMerge,\n mergeSecretSources,\n parseSecretsCapMode,\n type SecretSource,\n} from \"../db/secrets-validation\";\nimport { createLogger } from \"../logger\";\nimport { resolveSandboxSettings } from \"../session/integration-settings-resolution\";\nimport {\n createSourceControlProviderFromEnv,\n SourceControlProviderError,\n type RepositoryAccessResult,\n} from \"../source-control\";\nimport type { Env } from \"../types\";\nimport { errorMessage, ImageBuildPlanningError, ImageBuildScopeNotFoundError } from \"./errors\";\nimport { computeRepositoriesFingerprint } from \"./fingerprint\";\nimport { parseRepoScopeId, repoImageBuildScope, type ImageBuildScope } from \"./model\";\nimport type { ImageBuildRepository } from \"./types\";\nimport type { SqlDatabase } from \"../db/sql-database\";\n\nconst logger = createLogger(\"image-builds:scope\");\n\ninterface ResolvedImageBuildTargetBase {\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n}\n\n/**\n * Repositories + fingerprint, resolved before a build row exists.\n * Discriminated on the scope kind that produced it, so per-kind extras (a\n * repo scope's repoId) exist exactly on the arm that has them.\n */\nexport type ResolvedImageBuildTarget =\n | (ResolvedImageBuildTargetBase & { kind: \"environment\" })\n | (ResolvedImageBuildTargetBase & {\n kind: \"repo\";\n /**\n * Source-control numeric id of the repo scope's repository — the\n * repo_secrets key, resolved together with the target so the secrets\n * fold (loadScopeBuildSecrets) needs no second source-control round\n * trip.\n */\n repoId: number;\n });\n\n/** An enabled scope resolved to its current repositories and fingerprint. */\nexport interface EnabledScopeUnit {\n scope: ImageBuildScope;\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n}\n\n/** The scope's buildable repository set, in position order ([0] = primary). */\nexport async function resolveScopeTarget(\n env: Env,\n db: SqlDatabase,\n scope: ImageBuildScope\n): Promise<ResolvedImageBuildTarget> {\n switch (scope.kind) {\n case \"environment\": {\n const store = new EnvironmentStore(db);\n const environment = await store.getById(scope.id);\n if (!environment) {\n throw new ImageBuildScopeNotFoundError(scope.kind, scope.id);\n }\n\n const repositoryRows = await store.getRepositoriesForEnvironment(scope.id);\n if (repositoryRows.length === 0) {\n // Unreachable through the schema (environments require >= 1 repository);\n // defensive against direct store writes.\n throw new ImageBuildPlanningError(`Environment has no repositories: ${scope.id}`);\n }\n\n const repositories: ImageBuildRepository[] = repositoryRows.map((row) => ({\n repoOwner: row.repo_owner,\n repoName: row.repo_name,\n baseBranch: row.base_branch,\n }));\n\n return {\n kind: \"environment\",\n repositories,\n repositoriesFingerprint: await computeRepositoriesFingerprint(repositories),\n };\n }\n case \"repo\": {\n const repo = parseRepoScopeId(scope.id);\n if (!repo) {\n throw new ImageBuildPlanningError(`Malformed repo scope id: ${scope.id}`);\n }\n\n const resolved = await resolveRepositoryAccess(env, scope, repo);\n if (!resolved) {\n throw new ImageBuildScopeNotFoundError(scope.kind, scope.id);\n }\n\n // A repo scope always builds the repository's default branch; a session\n // on any other branch computes a different fingerprint and falls back\n // to the base image, reproducing the old base_branch spawn filter.\n const repositories: ImageBuildRepository[] = [\n {\n repoOwner: repo.repoOwner,\n repoName: repo.repoName,\n baseBranch: resolved.defaultBranch,\n },\n ];\n\n return {\n kind: \"repo\",\n repositories,\n repositoriesFingerprint: await computeRepositoriesFingerprint(repositories),\n repoId: resolved.repoId,\n };\n }\n }\n}\n\nasync function resolveRepositoryAccess(\n env: Env,\n scope: ImageBuildScope,\n repo: { repoOwner: string; repoName: string }\n): Promise<RepositoryAccessResult | null> {\n try {\n POST body:\n {\n \"snapshot_image_id\": \"...\",\n \"session_config\": {\n \"session_id\": \"...\",\n \"repo_owner\": \"...\",\n \"repo_name\": \"...\",\n \"provider\": \"anthropic\",\n \"model\": \"claude-sonnet-4-6\"\n },\n \"sandbox_id\": \"...\",\n \"control_plane_url\": \"...\",\n \"sandbox_auth_token\": \"...\"\n }\n\n Returns:\n {\n \"success\": true,\n \"data\": {\n \"sandbox_id\": \"...\",\n \"status\": \"warming\"\n }\n }\n \"\"\"\n async with _execute_endpoint(\n endpoint_name=\"api_restore_sandbox\",\n authorization=authorization,\n trace_id=x_trace_id,\n request_id=x_request_id,\n session_id=x_session_id,\n sandbox_id=x_sandbox_id,\n ):\n parsed_request = _parse_request(RestoreSandboxRequest, request)\n require_valid_control_plane_url(parsed_request.control_plane_url)\n\n from .sandbox.manager import (\n DEFAULT_SANDBOX_TIMEOUT_SECONDS,\n DEFAULT_VNC_ENABLED,\n SandboxManager,\n )\n\n session_config = parsed_request.session_config.model_dump(exclude_unset=True)\n repo_owner = parsed_request.session_config.repo_owner\n repo_name = parsed_request.session_config.repo_name\n\n manager = SandboxManager()\n clone_token = (\n resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n )\n\n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n snapshot_image_id=parsed_request.snapshot_image_id,\n session_config=session_config,\n sandbox_id=parsed_request.sandbox_id,\n control_plane_url=parsed_request.control_plane_url,\n sandbox_auth_token=[REDACTED] clone_token=[REDACTED] user_env_vars=parsed_request.user_env_vars or None,\n timeout_seconds=(\n parsed_request.timeout_seconds\n if parsed_request.timeout_seconds is not None\n else DEFAULT_SANDBOX_TIMEOUT_SECONDS\n ),\n code_server_enabled=parsed_request.code_server_enabled,\n vnc_enabled=(\n parsed_request.vnc_enabled\n if parsed_request.vnc_enabled is not None\n else DEFAULT_VNC_ENABLED\n ),\n agent_slack_notify_enabled=parsed_request.agent_slack_notify_enabled,\n settings=parsed_request.sandbox_settings or None,\n )\n\n return {\n \"success\": True,\n \"data\": {\n \"sandbox_id\": handle.sandbox_id,\n \"modal_object_id\": handle.modal_object_id,\n \"status\": handle.status.value,\n \"code_server_url\": handle.code_server_url,\n \"code_server_password\": handle.code_server_password,\n \"vnc_url\": handle.vnc_url,\n \"vnc_password\": handle.vnc_password,\n \"ttyd_url\": handle.ttyd_url,\n \"tunnel_urls\": handle.tunnel_urls,\n },\n }\n\n\[REDACTED](\n image=function_image,\n secrets=[REDACTED] def api_create_build_sandbox(\n request: dict[str, object],\n authorization: str | None = Header(None),\n x_trace_id: str | None = Header(None),\n x_request_id: str | None = Header(None),\n) -> dict:\npackages/sandbox-runtime/src/sandbox_runtime/gh-wrapper.sh:5: export GH_TOKEN=[REDACTED] gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:276: installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:280: ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:286: if env.get(\"GH_TOKEN\"):\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:287: return False # user-owned; the manager never injects GH_TOKEN\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:288: if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:291: return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:297: The wrapper exports whatever we print as ``GH_TOKEN``. When the\npackages/modal-infra/src/sandbox/vcs_env.py:35: env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\npackages/modal-infra/src/sandbox/vcs_env.py:38: env_vars[\"GITHUB_TOKEN\"] = clone_token\npackages/modal-infra/src/sandbox/vcs_env.py:40: env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\npackages/modal-infra/src/sandbox/manager.py:658: # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\npackages/modal-infra/src/clone_token.py:25: from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/modal-infra/src/clone_token.py:45: return generate_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:3:from .github_app import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:13: \"generate_installation_token\",\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:39:# never pull_requests/issues write. See generate_installation_token.\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:43:def get_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:103:def generate_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:123: (see get_installation_token).\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:125: get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:141: return get_installation_token(\n"}}{"status":"fulfilled","value":{"chunk_id":"213a67","wall_time_seconds":0.000005459,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.03s\n"}}
{"status":"fulfilled","value":{"chunk_id":"067311","wall_time_seconds":1.67e-7,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.26s\n"}}
{"status":"fulfilled","value":{"chunk_id":"8b5866","wall_time_seconds":0.000001709,"exit_code":0,"original_token_count":4016,"output":"import { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n test: {\n environment: \"node\",\n include: [\"src/**/*.test.ts\", \"test/conformance/**/*.test.ts\"],\n coverage: {\n provider: \"v8\",\n reporter: [\"text\", \"json\"],\n reportsDirectory: \"./coverage\",\n include: [\"src/**/*.ts\"],\n exclude: [\"src/**/*.test.ts\", \"src/**/*.d.ts\", \"src/index.ts\"],\n },\n },\n});\nimport { once } from \"node:events\";\nimport type { AddressInfo } from \"node:net\";\nimport { afterEach, describe, expect, it, vi } from \"vitest\";\nimport { WebSocket as NodeWebSocket, WebSocketServer } from \"ws\";\nimport { WS_CLOSE_TRY_AGAIN_LATER } from \"@open-inspect/shared/types/websocket\";\nimport type { Logger } from \"../logger\";\nimport { isSocketOpen, type SessionWebSocket } from \"../platform-ports\";\nimport {\n NodeWebSocketHost,\n type NodeSocketHostOptions,\n type SessionWebSocketEventSink,\n} from \"./socket-host\";\n\nfunction createLogger(): Logger {\n const log = {\n debug: vi.fn(),\n info: vi.fn(),\n warn: vi.fn(),\n error: vi.fn(),\n child: vi.fn(() => log),\n };\n return log as unknown as Logger;\n}\n\nfunction createEvents() {\n return {\n onMessage: vi.fn(async (_ws: SessionWebSocket, _message: string | ArrayBuffer) => {}),\n onClose: vi.fn(\n async (_ws: SessionWebSocket, _code: number, _reason: string, _wasClean: boolean) => {}\n ),\n onError: vi.fn((_ws: SessionWebSocket, _error: Error) => {}),\n } satisfies SessionWebSocketEventSink;\n}\n\n/** A real `ws` server on an ephemeral port whose connections the test accepts into `host`. */\nasync function createHarness(options: NodeSocketHostOptions = {}) {\n const server = new WebSocketServer({ port: 0, host: \"127.0.0.1\" });\n await once(server, \"listening\");\n const { port } = server.address() as AddressInfo;\n const log = createLogger();\n const events = createEvents();\n const host = new NodeWebSocketHost(log, options);\n host.bindEventSink(events);\n const clients: NodeWebSocket[] = [];\n\n /** Open one connection; resolves once the server side is accepted under `tags`. */\n async function connect(\n tags: string[]\n ): Promise<{ client: NodeWebSocket; socket: NodeWebSocket }> {\n const accepted = new Promise<NodeWebSocket>((resolve) => {\n server.once(\"connection\", (socket) => {\n host.adopt(socket, tags);\n resolve(socket);\n });\n });\n const client = new NodeWebSocket(`ws://127.0.0.1:${port}`);\n clients.push(client);\n await once(client, \"open\");\n return { client, socket: await accepted };\n }\n\n return {\n host,\n events,\n log,\n server,\n connect,\n /** A server-side socket the host never upgraded, for wiring-error tests. */\n port,\n async close() {\n for (const client of clients) client.terminate();\n await new Promise<void>((resolve) => server.close(() => resolve()));\n },\n };\n}\n\ntype Harness = Awaited<ReturnType<typeof createHarness>>;\n\ndescribe(\"NodeWebSocketHost\", () => {\n let harness: Harness | null = null;\n afterEach(async () => {\n await harness?.close();\n harness = null;\n });\n\n it(\"adopts sockets under their tags and enumerates them by tag\", async () => {\n harness = await createHarness();\n const client1 = await harness.connect([\"wsid:ws-1\"]);\n const sandbox = await harness.connect([\"sandbox\", \"sid:sb-1\", \"socket:sbws-1\"]);\n\n expect(harness.host.tags(client1.socket)).toEqual([\"wsid:ws-1\"]);\n expect(harness.host.tags(sandbox.socket)).toEqual([\"sandbox\", \"sid:sb-1\", \"socket:sbws-1\"]);\n expect(harness.host.sockets()).toEqual([client1.socket, sandbox.socket]);\n expect(harness.host.sockets(\"sandbox\")).toEqual([sandbox.socket]);\n expect(harness.host.sockets(\"wsid:ws-1\")).toEqual([client1.socket]);\n expect(harness.host.sockets(\"missing\")).toEqual([]);\n });\n\n it(\"returns no tags for a socket it never accepted\", async () => {\n harness = await createHarness();\n expect(harness.host.tags({ readyState: 1, send() {}, close() {} })).toEqual([]);\n });\n\n it(\"refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\", async () => {\n harness = await createHarness();\n const { socket } = await harness.connect([\"wsid:ws-1\"]);\n\n expect(() => harness!.host.adopt({ readyState: 1, send() {}, close() {} }, [])).toThrow(\n TypeError\n );\n expect(() => harness!.host.adopt(socket, [\"wsid:again\"])).toThrow(/already adopted/);\n expect(() => new NodeWebSocketHost(createLogger()).adopt(socket, [])).toThrow(\n /before bindEventSink/\n );\n expect(() => harness!.host.bindEventSink(harness!.events)).toThrow(/already bound/);\n/**\n * Scope resolution — the ONLY module in the image-build subsystem that\n * switches on scope kind. Everything downstream (planner, workflow, store,\n * routes, adapters) is scope-agnostic and treats the kind as data.\n *\n * Resolution is split into phases rather than one monolithic call because the\n * planner's register-before-secrets ordering depends on it: the repository\n * set is resolved BEFORE the build row is registered (secret-free), while\n * secrets and sandbox settings are loaded AFTER, so a concurrent secret\n * change always sees a row to supersede.\n */\n\nimport { EnvironmentSecretsStore } from \"../db/environment-secrets\";\nimport { EnvironmentStore } from \"../db/environments\";\nimport { GlobalSecretsStore } from \"../db/global-secrets\";\nimport { RepoMetadataStore } from \"../db/repo-metadata\";\nimport { RepoSecretsStore } from \"../db/repo-secrets\";\nimport {\n auditSecretsMerge,\n mergeSecretSources,\n parseSecretsCapMode,\n type SecretSource,\n} from \"../db/secrets-validation\";\nimport { createLogger } from \"../logger\";\nimport { resolveSandboxSettings } from \"../session/integration-settings-resolution\";\nimport {\n createSourceControlProviderFromEnv,\n SourceControlProviderError,\n type RepositoryAccessResult,\n} from \"../source-control\";\nimport type { Env } from \"../types\";\nimport { errorMessage, ImageBuildPlanningError, ImageBuildScopeNotFoundError } from \"./errors\";\nimport { computeRepositoriesFingerprint } from \"./fingerprint\";\nimport { parseRepoScopeId, repoImageBuildScope, type ImageBuildScope } from \"./model\";\nimport type { ImageBuildRepository } from \"./types\";\nimport type { SqlDatabase } from \"../db/sql-database\";\n\nconst logger = createLogger(\"image-builds:scope\");\n\ninterface ResolvedImageBuildTargetBase {\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n}\n\n/**\n * Repositories + fingerprint, resolved before a build row exists.\n * Discriminated on the scope kind that produced it, so per-kind extras (a\n * repo scope's repoId) exist exactly on the arm that has them.\n */\nexport type ResolvedImageBuildTarget =\n | (ResolvedImageBuildTargetBase & { kind: \"environment\" })\n | (ResolvedImageBuildTargetBase & {\n kind: \"repo\";\n /**\n * Source-control numeric id of the repo scope's repository — the\n * repo_secrets key, resolved together with the target so the secrets\n * fold (loadScopeBuildSecrets) needs no second source-control round\n * trip.\n */\n repoId: number;\n });\n\n/** An enabled scope resolved to its current repositories and fingerprint. */\nexport interface EnabledScopeUnit {\n scope: ImageBuildScope;\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n}\n\n/** The scope's buildable repository set, in position order ([0] = primary). */\nexport async function resolveScopeTarget(\n env: Env,\n db: SqlDatabase,\n scope: ImageBuildScope\n): Promise<ResolvedImageBuildTarget> {\n switch (scope.kind) {\n case \"environment\": {\n const store = new EnvironmentStore(db);\n const environment = await store.getById(scope.id);\n if (!environment) {\n throw new ImageBuildScopeNotFoundError(scope.kind, scope.id);\n }\n\n const repositoryRows = await store.getRepositoriesForEnvironment(scope.id);\n if (repositoryRows.length === 0) {\n // Unreachable through the schema (environments require >= 1 repository);\n // defensive against direct store writes.\n throw new ImageBuildPlanningError(`Environment has no repositories: ${scope.id}`);\n }\n\n const repositories: ImageBuildRepository[] = repositoryRows.map((row) => ({\n repoOwner: row.repo_owner,\n repoName: row.repo_name,\n baseBranch: row.base_branch,\n }));\n\n return {\n kind: \"environment\",\n repositories,\n repositoriesFingerprint: await computeRepositoriesFingerprint(repositories),\n };\n }\n case \"repo\": {\n const repo = parseRepoScopeId(scope.id);\n if (!repo) {\n throw new ImageBuildPlanningError(`Malformed repo scope id: ${scope.id}`);\n }\n\n const resolved = await resolveRepositoryAccess(env, scope, repo);\n if (!resolved) {\n throw new ImageBuildScopeNotFoundError(scope.kind, scope.id);\n }\n\n // A repo scope always builds the repository's default branch; a session\n // on any other branch computes a different fingerprint and falls back\n // to the base image, reproducing the old base_branch spawn filter.\n const repositories: ImageBuildRepository[] = [\n {\n repoOwner: repo.repoOwner,\n repoName: repo.repoName,\n baseBranch: resolved.defaultBranch,\n },\n ];\n\n return {\n kind: \"repo\",\n repositories,\n repositoriesFingerprint: await computeRepositoriesFingerprint(repositories),\n repoId: resolved.repoId,\n };\n }\n }\n}\n\nasync function resolveRepositoryAccess(\n env: Env,\n scope: ImageBuildScope,\n repo: { repoOwner: string; repoName: string }\n): Promise<RepositoryAccessResult | null> {\n try {\n POST body:\n {\n \"snapshot_image_id\": \"...\",\n \"session_config\": {\n \"session_id\": \"...\",\n \"repo_owner\": \"...\",\n \"repo_name\": \"...\",\n \"provider\": \"anthropic\",\n \"model\": \"claude-sonnet-4-6\"\n },\n \"sandbox_id\": \"...\",\n \"control_plane_url\": \"...\",\n \"sandbox_auth_token\": \"...\"\n }\n\n Returns:\n {\n \"success\": true,\n \"data\": {\n \"sandbox_id\": \"...\",\n \"status\": \"warming\"\n }\n }\n \"\"\"\n async with _execute_endpoint(\n endpoint_name=\"api_restore_sandbox\",\n authorization=authorization,\n trace_id=x_trace_id,\n request_id=x_request_id,\n session_id=x_session_id,\n sandbox_id=x_sandbox_id,\n ):\n parsed_request = _parse_request(RestoreSandboxRequest, request)\n require_valid_control_plane_url(parsed_request.control_plane_url)\n\n from .sandbox.manager import (\n DEFAULT_SANDBOX_TIMEOUT_SECONDS,\n DEFAULT_VNC_ENABLED,\n SandboxManager,\n )\n\n session_config = parsed_request.session_config.model_dump(exclude_unset=True)\n repo_owner = parsed_request.session_config.repo_owner\n repo_name = parsed_request.session_config.repo_name\n\n manager = SandboxManager()\n clone_token = (\n resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n )\n\n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n snapshot_image_id=parsed_request.snapshot_image_id,\n session_config=session_config,\n sandbox_id=parsed_request.sandbox_id,\n control_plane_url=parsed_request.control_plane_url,\n sandbox_auth_token=[REDACTED] clone_token=[REDACTED] user_env_vars=parsed_request.user_env_vars or None,\n timeout_seconds=(\n parsed_request.timeout_seconds\n if parsed_request.timeout_seconds is not None\n else DEFAULT_SANDBOX_TIMEOUT_SECONDS\n ),\n code_server_enabled=parsed_request.code_server_enabled,\n vnc_enabled=(\n parsed_request.vnc_enabled\n if parsed_request.vnc_enabled is not None\n else DEFAULT_VNC_ENABLED\n ),\n agent_slack_notify_enabled=parsed_request.agent_slack_notify_enabled,\n settings=parsed_request.sandbox_settings or None,\n )\n\n return {\n \"success\": True,\n \"data\": {\n \"sandbox_id\": handle.sandbox_id,\n \"modal_object_id\": handle.modal_object_id,\n \"status\": handle.status.value,\n \"code_server_url\": handle.code_server_url,\n \"code_server_password\": handle.code_server_password,\n \"vnc_url\": handle.vnc_url,\n \"vnc_password\": handle.vnc_password,\n \"ttyd_url\": handle.ttyd_url,\n \"tunnel_urls\": handle.tunnel_urls,\n },\n }\n\n\[REDACTED](\n image=function_image,\n secrets=[REDACTED] def api_create_build_sandbox(\n request: dict[str, object],\n authorization: str | None = Header(None),\n x_trace_id: str | None = Header(None),\n x_request_id: str | None = Header(None),\n) -> dict:\npackages/sandbox-runtime/src/sandbox_runtime/gh-wrapper.sh:5: export GH_TOKEN=[REDACTED] gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:276: installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:280: ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:286: if env.get(\"GH_TOKEN\"):\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:287: return False # user-owned; the manager never injects GH_TOKEN\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:288: if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:291: return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:297: The wrapper exports whatever we print as ``GH_TOKEN``. When the\npackages/modal-infra/src/sandbox/vcs_env.py:35: env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\npackages/modal-infra/src/sandbox/vcs_env.py:38: env_vars[\"GITHUB_TOKEN\"] = clone_token\npackages/modal-infra/src/sandbox/vcs_env.py:40: env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\npackages/modal-infra/src/sandbox/manager.py:658: # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\npackages/modal-infra/src/clone_token.py:25: from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/modal-infra/src/clone_token.py:45: return generate_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:3:from .github_app import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:13: \"generate_installation_token\",\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:39:# never pull_requests/issues write. See generate_installation_token.\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:43:def get_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:103:def generate_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:123: (see get_installation_token).\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:125: get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:141: return get_installation_token(\n"}}>cat packages/control-plane/vitest.config.ts; sed -n '1,115p' packages/control-plane/src/node/socket-host.test.ts; sed -n '1,140p' packages/control-plane/src/image-builds/scope.ts; sed -n '590,690p' packages/modal-infra/src/web_api.py; rg -n 'generate_installation_token|get_installation_token|GITHUB_TOKEN|GH_TOKEN' packages/modal-infra/src packages/sandbox-runtime/src packages/sandbox-images/src -g '*.py' -g '*.sh'{"status":"fulfilled","value":{"chunk_id":"213a67","wall_time_seconds":0.000005459,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.03s\n"}}
{"status":"fulfilled","value":{"chunk_id":"067311","wall_time_seconds":1.67e-7,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.26s\n"}}
{"status":"fulfilled","value":{"chunk_id":"8b5866","wall_time_seconds":0.000001709,"exit_code":0,"original_token_count":4016,"output":"import { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n test: {\n environment: \"node\",\n include: [\"src/**/*.test.ts\", \"test/conformance/**/*.test.ts\"],\n coverage: {\n provider: \"v8\",\n reporter: [\"text\", \"json\"],\n reportsDirectory: \"./coverage\",\n include: [\"src/**/*.ts\"],\n exclude: [\"src/**/*.test.ts\", \"src/**/*.d.ts\", \"src/index.ts\"],\n },\n },\n});\nimport { once } from \"node:events\";\nimport type { AddressInfo } from \"node:net\";\nimport { afterEach, describe, expect, it, vi } from \"vitest\";\nimport { WebSocket as NodeWebSocket, WebSocketServer } from \"ws\";\nimport { WS_CLOSE_TRY_AGAIN_LATER } from \"@open-inspect/shared/types/websocket\";\nimport type { Logger } from \"../logger\";\nimport { isSocketOpen, type SessionWebSocket } from \"../platform-ports\";\nimport {\n NodeWebSocketHost,\n type NodeSocketHostOptions,\n type SessionWebSocketEventSink,\n} from \"./socket-host\";\n\nfunction createLogger(): Logger {\n const log = {\n debug: vi.fn(),\n info: vi.fn(),\n warn: vi.fn(),\n error: vi.fn(),\n child: vi.fn(() => log),\n };\n return log as unknown as Logger;\n}\n\nfunction createEvents() {\n return {\n onMessage: vi.fn(async (_ws: SessionWebSocket, _message: string | ArrayBuffer) => {}),\n onClose: vi.fn(\n async (_ws: SessionWebSocket, _code: number, _reason: string, _wasClean: boolean) => {}\n ),\n onError: vi.fn((_ws: SessionWebSocket, _error: Error) => {}),\n } satisfies SessionWebSocketEventSink;\n}\n\n/** A real `ws` server on an ephemeral port whose connections the test accepts into `host`. */\nasync function createHarness(options: NodeSocketHostOptions = {}) {\n const server = new WebSocketServer({ port: 0, host: \"127.0.0.1\" });\n await once(server, \"listening\");\n const { port } = server.address() as AddressInfo;\n const log = createLogger();\n const events = createEvents();\n const host = new NodeWebSocketHost(log, options);\n host.bindEventSink(events);\n const clients: NodeWebSocket[] = [];\n\n /** Open one connection; resolves once the server side is accepted under `tags`. */\n async function connect(\n tags: string[]\n ): Promise<{ client: NodeWebSocket; socket: NodeWebSocket }> {\n const accepted = new Promise<NodeWebSocket>((resolve) => {\n server.once(\"connection\", (socket) => {\n host.adopt(socket, tags);\n resolve(socket);\n });\n });\n const client = new NodeWebSocket(`ws://127.0.0.1:${port}`);\n clients.push(client);\n await once(client, \"open\");\n return { client, socket: await accepted };\n }\n\n return {\n host,\n events,\n log,\n server,\n connect,\n /** A server-side socket the host never upgraded, for wiring-error tests. */\n port,\n async close() {\n for (const client of clients) client.terminate();\n await new Promise<void>((resolve) => server.close(() => resolve()));\n },\n };\n}\n\ntype Harness = Awaited<ReturnType<typeof createHarness>>;\n\ndescribe(\"NodeWebSocketHost\", () => {\n let harness: Harness | null = null;\n afterEach(async () => {\n await harness?.close();\n harness = null;\n });\n\n it(\"adopts sockets under their tags and enumerates them by tag\", async () => {\n harness = await createHarness();\n const client1 = await harness.connect([\"wsid:ws-1\"]);\n const sandbox = await harness.connect([\"sandbox\", \"sid:sb-1\", \"socket:sbws-1\"]);\n\n expect(harness.host.tags(client1.socket)).toEqual([\"wsid:ws-1\"]);\n expect(harness.host.tags(sandbox.socket)).toEqual([\"sandbox\", \"sid:sb-1\", \"socket:sbws-1\"]);\n expect(harness.host.sockets()).toEqual([client1.socket, sandbox.socket]);\n expect(harness.host.sockets(\"sandbox\")).toEqual([sandbox.socket]);\n expect(harness.host.sockets(\"wsid:ws-1\")).toEqual([client1.socket]);\n expect(harness.host.sockets(\"missing\")).toEqual([]);\n });\n\n it(\"returns no tags for a socket it never accepted\", async () => {\n harness = await createHarness();\n expect(harness.host.tags({ readyState: 1, send() {}, close() {} })).toEqual([]);\n });\n\n it(\"refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\", async () => {\n harness = await createHarness();\n const { socket } = await harness.connect([\"wsid:ws-1\"]);\n\n expect(() => harness!.host.adopt({ readyState: 1, send() {}, close() {} }, [])).toThrow(\n TypeError\n );\n expect(() => harness!.host.adopt(socket, [\"wsid:again\"])).toThrow(/already adopted/);\n expect(() => new NodeWebSocketHost(createLogger()).adopt(socket, [])).toThrow(\n /before bindEventSink/\n );\n expect(() => harness!.host.bindEventSink(harness!.events)).toThrow(/already bound/);\n/**\n * Scope resolution — the ONLY module in the image-build subsystem that\n * switches on scope kind. Everything downstream (planner, workflow, store,\n * routes, adapters) is scope-agnostic and treats the kind as data.\n *\n * Resolution is split into phases rather than one monolithic call because the\n * planner's register-before-secrets ordering depends on it: the repository\n * set is resolved BEFORE the build row is registered (secret-free), while\n * secrets and sandbox settings are loaded AFTER, so a concurrent secret\n * change always sees a row to supersede.\n */\n\nimport { EnvironmentSecretsStore } from \"../db/environment-secrets\";\nimport { EnvironmentStore } from \"../db/environments\";\nimport { GlobalSecretsStore } from \"../db/global-secrets\";\nimport { RepoMetadataStore } from \"../db/repo-metadata\";\nimport { RepoSecretsStore } from \"../db/repo-secrets\";\nimport {\n auditSecretsMerge,\n mergeSecretSources,\n parseSecretsCapMode,\n type SecretSource,\n} from \"../db/secrets-validation\";\nimport { createLogger } from \"../logger\";\nimport { resolveSandboxSettings } from \"../session/integration-settings-resolution\";\nimport {\n createSourceControlProviderFromEnv,\n SourceControlProviderError,\n type RepositoryAccessResult,\n} from \"../source-control\";\nimport type { Env } from \"../types\";\nimport { errorMessage, ImageBuildPlanningError, ImageBuildScopeNotFoundError } from \"./errors\";\nimport { computeRepositoriesFingerprint } from \"./fingerprint\";\nimport { parseRepoScopeId, repoImageBuildScope, type ImageBuildScope } from \"./model\";\nimport type { ImageBuildRepository } from \"./types\";\nimport type { SqlDatabase } from \"../db/sql-database\";\n\nconst logger = createLogger(\"image-builds:scope\");\n\ninterface ResolvedImageBuildTargetBase {\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n}\n\n/**\n * Repositories + fingerprint, resolved before a build row exists.\n * Discriminated on the scope kind that produced it, so per-kind extras (a\n * repo scope's repoId) exist exactly on the arm that has them.\n */\nexport type ResolvedImageBuildTarget =\n | (ResolvedImageBuildTargetBase & { kind: \"environment\" })\n | (ResolvedImageBuildTargetBase & {\n kind: \"repo\";\n /**\n * Source-control numeric id of the repo scope's repository — the\n * repo_secrets key, resolved together with the target so the secrets\n * fold (loadScopeBuildSecrets) needs no second source-control round\n * trip.\n */\n repoId: number;\n });\n\n/** An enabled scope resolved to its current repositories and fingerprint. */\nexport interface EnabledScopeUnit {\n scope: ImageBuildScope;\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n}\n\n/** The scope's buildable repository set, in position order ([0] = primary). */\nexport async function resolveScopeTarget(\n env: Env,\n db: SqlDatabase,\n scope: ImageBuildScope\n): Promise<ResolvedImageBuildTarget> {\n switch (scope.kind) {\n case \"environment\": {\n const store = new EnvironmentStore(db);\n const environment = await store.getById(scope.id);\n if (!environment) {\n throw new ImageBuildScopeNotFoundError(scope.kind, scope.id);\n }\n\n const repositoryRows = await store.getRepositoriesForEnvironment(scope.id);\n if (repositoryRows.length === 0) {\n // Unreachable through the schema (environments require >= 1 repository);\n // defensive against direct store writes.\n throw new ImageBuildPlanningError(`Environment has no repositories: ${scope.id}`);\n }\n\n const repositories: ImageBuildRepository[] = repositoryRows.map((row) => ({\n repoOwner: row.repo_owner,\n repoName: row.repo_name,\n baseBranch: row.base_branch,\n }));\n\n return {\n kind: \"environment\",\n repositories,\n repositoriesFingerprint: await computeRepositoriesFingerprint(repositories),\n };\n }\n case \"repo\": {\n const repo = parseRepoScopeId(scope.id);\n if (!repo) {\n throw new ImageBuildPlanningError(`Malformed repo scope id: ${scope.id}`);\n }\n\n const resolved = await resolveRepositoryAccess(env, scope, repo);\n if (!resolved) {\n throw new ImageBuildScopeNotFoundError(scope.kind, scope.id);\n }\n\n // A repo scope always builds the repository's default branch; a session\n // on any other branch computes a different fingerprint and falls back\n // to the base image, reproducing the old base_branch spawn filter.\n const repositories: ImageBuildRepository[] = [\n {\n repoOwner: repo.repoOwner,\n repoName: repo.repoName,\n baseBranch: resolved.defaultBranch,\n },\n ];\n\n return {\n kind: \"repo\",\n repositories,\n repositoriesFingerprint: await computeRepositoriesFingerprint(repositories),\n repoId: resolved.repoId,\n };\n }\n }\n}\n\nasync function resolveRepositoryAccess(\n env: Env,\n scope: ImageBuildScope,\n repo: { repoOwner: string; repoName: string }\n): Promise<RepositoryAccessResult | null> {\n try {\n POST body:\n {\n \"snapshot_image_id\": \"...\",\n \"session_config\": {\n \"session_id\": \"...\",\n \"repo_owner\": \"...\",\n \"repo_name\": \"...\",\n \"provider\": \"anthropic\",\n \"model\": \"claude-sonnet-4-6\"\n },\n \"sandbox_id\": \"...\",\n \"control_plane_url\": \"...\",\n \"sandbox_auth_token\": \"...\"\n }\n\n Returns:\n {\n \"success\": true,\n \"data\": {\n \"sandbox_id\": \"...\",\n \"status\": \"warming\"\n }\n }\n \"\"\"\n async with _execute_endpoint(\n endpoint_name=\"api_restore_sandbox\",\n authorization=authorization,\n trace_id=x_trace_id,\n request_id=x_request_id,\n session_id=x_session_id,\n sandbox_id=x_sandbox_id,\n ):\n parsed_request = _parse_request(RestoreSandboxRequest, request)\n require_valid_control_plane_url(parsed_request.control_plane_url)\n\n from .sandbox.manager import (\n DEFAULT_SANDBOX_TIMEOUT_SECONDS,\n DEFAULT_VNC_ENABLED,\n SandboxManager,\n )\n\n session_config = parsed_request.session_config.model_dump(exclude_unset=True)\n repo_owner = parsed_request.session_config.repo_owner\n repo_name = parsed_request.session_config.repo_name\n\n manager = SandboxManager()\n clone_token = (\n resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n )\n\n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n snapshot_image_id=parsed_request.snapshot_image_id,\n session_config=session_config,\n sandbox_id=parsed_request.sandbox_id,\n control_plane_url=parsed_request.control_plane_url,\n sandbox_auth_token=[REDACTED] clone_token=[REDACTED] user_env_vars=parsed_request.user_env_vars or None,\n timeout_seconds=(\n parsed_request.timeout_seconds\n if parsed_request.timeout_seconds is not None\n else DEFAULT_SANDBOX_TIMEOUT_SECONDS\n ),\n code_server_enabled=parsed_request.code_server_enabled,\n vnc_enabled=(\n parsed_request.vnc_enabled\n if parsed_request.vnc_enabled is not None\n else DEFAULT_VNC_ENABLED\n ),\n agent_slack_notify_enabled=parsed_request.agent_slack_notify_enabled,\n settings=parsed_request.sandbox_settings or None,\n )\n\n return {\n \"success\": True,\n \"data\": {\n \"sandbox_id\": handle.sandbox_id,\n \"modal_object_id\": handle.modal_object_id,\n \"status\": handle.status.value,\n \"code_server_url\": handle.code_server_url,\n \"code_server_password\": handle.code_server_password,\n \"vnc_url\": handle.vnc_url,\n \"vnc_password\": handle.vnc_password,\n \"ttyd_url\": handle.ttyd_url,\n \"tunnel_urls\": handle.tunnel_urls,\n },\n }\n\n\[REDACTED](\n image=function_image,\n secrets=[REDACTED] def api_create_build_sandbox(\n request: dict[str, object],\n authorization: str | None = Header(None),\n x_trace_id: str | None = Header(None),\n x_request_id: str | None = Header(None),\n) -> dict:\npackages/sandbox-runtime/src/sandbox_runtime/gh-wrapper.sh:5: export GH_TOKEN=[REDACTED] gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:276: installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:280: ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:286: if env.get(\"GH_TOKEN\"):\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:287: return False # user-owned; the manager never injects GH_TOKEN\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:288: if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:291: return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\npackages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:297: The wrapper exports whatever we print as ``GH_TOKEN``. When the\npackages/modal-infra/src/sandbox/vcs_env.py:35: env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\npackages/modal-infra/src/sandbox/vcs_env.py:38: env_vars[\"GITHUB_TOKEN\"] = clone_token\npackages/modal-infra/src/sandbox/vcs_env.py:40: env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\npackages/modal-infra/src/sandbox/manager.py:658: # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\npackages/modal-infra/src/clone_token.py:25: from sandbox_runtime.auth import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/modal-infra/src/clone_token.py:45: return generate_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:3:from .github_app import SANDBOX_SCOPED_PERMISSIONS, generate_installation_token\npackages/sandbox-runtime/src/sandbox_runtime/auth/__init__.py:13: \"generate_installation_token\",\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:39:# never pull_requests/issues write. See generate_installation_token.\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:43:def get_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:103:def generate_installation_token(\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:123: (see get_installation_token).\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:125: get_installation_token). Defaults to SANDBOX_SCOPED_PERMISSIONS\npackages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py:141: return get_installation_token(\n"}}>cat > /private/tmp/open-inspect-permissions-probe.py <<'PY'
from unittest.mock import patch
from sandbox_runtime.auth.github_app import get_installation_token
with patch('sandbox_runtime.auth.github_app.httpx.Client') as client:
client.return_value.__enter__.return_value.post.return_value.json.return_value = {'token': 'UNSAFE_SUCCESS'}
try:
token = get_installation_token('throwaway-jwt', '456', repository='sibling-repo', permissions={})
except ValueError as exc:
assert str(exc) == 'permissions must be non-empty when provided', str(exc)
print(f'PASS: repository set + permissions={{}} raises ValueError: {exc}')
else:
raise AssertionError(f'FAIL: empty permissions returned {token!r}')
client.assert_not_called()
print('PASS: HTTP client was never constructed; no mint request occurred')
PY
UV_CACHE_DIR=/private/tmp/open-inspect-review-uv uv run --offline --no-sync python /private/tmp/open-inspect-permissions-probe.py{"chunk_id":"8649e0","wall_time_seconds":0.313834916,"exit_code":0,"original_token_count":42,"output":"PASS: repository set + permissions={} raises ValueError: permissions must be non-empty when provided\nPASS: HTTP client was never constructed; no mint request occurred\n"}
{"status":"fulfilled","value":{"chunk_id":"6d8b62","wall_time_seconds":5.41e-7,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930435) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930428) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"528dc1","wall_time_seconds":0.000007042,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930437) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930434) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"d45101","wall_time_seconds":0.040378041,"exit_code":0,"original_token_count":2763,"output":"\"\"\"SCM credential environment shared by interactive and build sandboxes.\"\"\"\n\nimport os\n\n\ndef inject_vcs_env_vars(\n env_vars: dict[str, str],\n clone_token: str | None,\n *,\n clone_host: str | None = None,\n clone_username: str | None = None,\n include_github_cli_aliases: bool = False,\n) -> None:\n \"\"\"Inject provider metadata and optional one-shot clone credentials.\"\"\"\n scm_provider = os.environ.get(\"SCM_PROVIDER\", \"github\")\n if clone_host and clone_username:\n env_vars[\"VCS_HOST\"] = clone_host\n env_vars[\"VCS_CLONE_USERNAME\"] = clone_username\n elif scm_provider == \"bitbucket\":\n env_vars[\"VCS_HOST\"] = \"bitbucket.org\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-token-auth\"\n elif scm_provider == \"gitlab\":\n env_vars[\"VCS_HOST\"] = \"gitlab.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"oauth2\"\n else:\n env_vars[\"VCS_HOST\"] = \"github.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-access-token\"\n\n if not clone_token:\n return\n\n env_vars[\"VCS_CLONE_TOKEN\"] = clone_token\n if include_github_cli_aliases and scm_provider == \"github\":\n has_user_github_cli_token = any(\n env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\n )\n if not has_user_github_cli_token:\n env_vars[\"GITHUB_TOKEN\"] = clone_token\n env_vars[\"GITHUB_APP_TOKEN\"] = clone_token\n env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\n\n fresh = _fetch_from_control_plane(endpoint)\n _atomic_write_cache(fresh)\n return fresh\n finally:\n fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)\n\n\ndef _emit_response(input_lines: dict[str, str], credentials: dict[str, object]) -> None:\n \"\"\"Write the protocol response (context lines + fresh username/password).\"\"\"\n for key, value in input_lines.items():\n if key in {\"username\", \"password\"}:\n continue\n sys.stdout.write(f\"{key}={value}\\n\")\n sys.stdout.write(f\"username={credentials['username']}\\n\")\n sys.stdout.write(f\"password=[REDACTED] sys.stdout.write(\"\\n\")\n sys.stdout.flush()\n\n\ndef _gh_wrapper_should_mint(env: Mapping[str, str]) -> bool:\n \"\"\"Decide whether the gh CLI needs a freshly-minted token.\n\n gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\n we mint only when the environment has nothing usable: no user-provided\n token, and either nothing at all or just the system's short-lived\n installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\n expires in ~1h and must be refreshed). A user-provided token always wins.\n\n The marker is authoritative on its own: a value comparison between\n ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\n override, because the manager only sets the marker when it injected both\n values itself.\n \"\"\"\n if env.get(\"VCS_HOST\", \"github.com\").strip().lower() != \"github.com\":\n return False # non-github deployment: never touch gh's own auth\n if env.get(\"GH_TOKEN\"):\n return False # user-owned; the manager never injects GH_TOKEN\n if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\n return True # only the expiring system fallback is present → refresh\n # Otherwise mint only when there's no genuine user token to leave alone.\n return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\n\n\ndef _print_gh_token() -> int:\n \"\"\"Print a freshly-minted token for the gh CLI wrapper, or nothing.\n\n The wrapper exports whatever we print as ``GH_TOKEN``. When the\n environment already has a usable token we print nothing so gh uses its\n own env. A failed mint also prints nothing rather than failing: the\n wrapper then falls through to the existing env instead of aborting gh.\n Both cases exit 0 — the wrapper only needs the stdout, not the status.\n \"\"\"\n if not _gh_wrapper_should_mint(os.environ):\n return 0\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain gh token: {e}\")\n return 0\n sys.stdout.write(str(credentials[\"password\"]))\n sys.stdout.flush()\n return 0\n\n\ndef main(argv: list[str] | None = None) -> int:\n args = list(argv if argv is not None else sys.argv[1:])\n action = args[0] if args else \"get\"\n\n # `gh-token` is for the gh CLI wrapper: print a fresh token when the env\n # has none usable, otherwise nothing (see _print_gh_token).\n if action == \"gh-token\":\n return _print_gh_token()\n\n # We only mint credentials on `get`. `store` and `erase` are no-ops:\n # the control plane owns the truth and we don't persist anything git tells us.\n if action != \"get\":\n # Drain stdin so git doesn't see a SIGPIPE on the next helper.\n with contextlib.suppress(OSError):\n sys.stdin.read()\n return 0\n\n input_lines = _read_protocol_input(sys.stdin)\n\n # Scope the request to https on the configured host. git treats an empty\n # response as \"I have nothing\", so returning 0 with no output lets it fall\n # through to any other helper or fail the auth cleanly — without us ever\n # emitting the token to the wrong host.\n authorized, reason = _is_authorized_request(input_lines)\n if not authorized:\n _log(f\"refusing to serve credentials: {reason}\")\n return 0\n\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain credentials: {e}\")\n return 1\n\n _emit_response(input_lines, credentials)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n The OpenCode session resumes with full workspace state intact.\n Git clone is skipped since the workspace already has all changes.\n\n Args:\n snapshot_image_id: Modal Image ID from snapshot_filesystem()\n session_config: Session configuration\n sandbox_id: Optional sandbox ID (generated if not provided)\n control_plane_url: URL for the control plane\n sandbox_auth_token: Auth token for the sandbox\n clone_token: VCS clone token for git operations\n\n Returns:\n SandboxHandle for the restored sandbox\n \"\"\"\n start_time = time.time()\n\n if isinstance(session_config, dict):\n repo_owner = session_config.get(\"repo_owner\")\n repo_name = session_config.get(\"repo_name\")\n else:\n repo_owner = session_config.repo_owner\n repo_name = session_config.repo_name\n _has_repository(repo_owner, repo_name)\n\n # Snapshot restore still passes the clone token through for\n # repo-backed sandboxes. Snapshots taken before the credential-helper\n # migration ship an entrypoint that reads VCS_CLONE_TOKEN from env\n # and embeds it in the origin URL; without it, those legacy snapshots\n # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\n # so the gh CLI keeps working on snapshots predating the gh wrapper.\n # Host scoping remains common with fresh creates. These compatibility\n # credentials are explicitly requested only by the restore path.\n handle = await self._launch_sandbox(\n _SandboxLaunchSpec(\n config=SandboxConfig(\n repo_owner=repo_owner,\n repo_name=repo_name,\n sandbox_id=sandbox_id,\n session_config=session_config,\n control_plane_url=control_plane_url,\n sandbox_auth_token=[REDACTED] timeout_seconds=timeout_seconds,\n user_env_vars=user_env_vars,\n code_server_enabled=code_server_enabled,\n vnc_enabled=vnc_enabled,\n agent_slack_notify_enabled=agent_slack_notify_enabled,\n settings=settings,\n ),\n source=_SnapshotImageSource(\n image_id=snapshot_image_id,\n clone_token=[REDACTED] ),\n )\n )\n\n duration_ms = int((time.time() - start_time) * 1000)\n log.info(\n \"sandbox.restore\",\n sandbox_id=handle.sandbox_id,\n modal_object_id=handle.modal_object_id,\n snapshot_image_id=snapshot_image_id,\n repo_owner=repo_owner,\n repo_name=repo_name,\n duration_ms=duration_ms,\n outcome=\"success\",\n )\ndiff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts\nindex 7fab22e9..4a481bbe 100644\n--- a/packages/control-plane/src/image-builds/planner.ts\n+++ b/packages/control-plane/src/image-builds/planner.ts\n@@ -17,7 +17,7 @@ import {\n resolveScopeTarget,\n type ResolvedImageBuildTarget,\n } from \"./scope\";\n-import type { ImageBuildCloneAuth, ImageBuildPlan } from \"./types\";\n+import type { ImageBuildCloneAuth, ImageBuildPlan, ImageBuildRepository } from \"./types\";\n \n const logger = createLogger(\"image-builds:planner\");\n const MS_PER_SECOND = 1000;\n@@ -88,7 +88,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n resolveScopeSandboxSettings(this.db, params.scope, primary),\n loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n- this.resolveCloneAuth(params.scope),\n+ this.resolveCloneAuth(params.scope, repositories),\n ]);\n \n const basePlan = {\n@@ -118,10 +118,15 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n };\n }\n \n- private async resolveCloneAuth(scope: ImageBuildScope): Promise<ImageBuildCloneAuth> {\n+ private async resolveCloneAuth(\n+ scope: ImageBuildScope,\n+ repositories: ImageBuildRepository[]\n+ ): Promise<ImageBuildCloneAuth> {\n try {\n const provider = createSourceControlProviderFromEnv(this.env);\n- const auth = await provider.generateCredentialHelperAuth();\n+ const auth = await provider.generateCredentialHelperAuth(\n+ repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n+ );\n return {\n type: \"credential_helper\",\n host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\ndiff --git a/packages/modal-infra/src/web_api.py b/packages/modal-infra/src/web_api.py\nindex 9362d47c..ed63e18b 100644\n--- a/packages/modal-infra/src/web_api.py\n+++ b/packages/modal-infra/src/web_api.py\n@@ -633,7 +633,9 @@ async def api_restore_sandbox(\n repo_name = parsed_request.session_config.repo_name\n \n manager = SandboxManager()\n- clone_token = resolve_clone_token() if repo_owner and repo_name else None\n+ clone_token = (\n+ resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n+ )\n \n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n"}}
{"status":"fulfilled","value":{"chunk_id":"205628","wall_time_seconds":0.000001166,"exit_code":1,"original_token_count":3453,"output":"\n⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/s3-object-storage.test.ts > createS3ObjectStorage\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/s3-object-storage.test.ts:142:3\n 140| let storage: ObjectStorage;\n 141|\n 142| beforeAll(async () => {\n | ^\n 143| await s3.start();\n 144| storage = createS3ObjectStorage({\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/44]⎯\n\n\n⎯⎯⎯⎯⎯⎯ Failed Tests 43 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/host.test.ts > startNodeHost > boots over the migrated global store and answers the health check and the route table\n FAIL src/node/host.test.ts > startNodeHost > refuses a WebSocket upgrade for an unknown session and any other upgrade path\n FAIL src/node/host.test.ts > startNodeHost > closes the cache database on a normal shutdown, not only on a failed boot\n FAIL src/node/host.test.ts > startNodeHost > reports draining once a shutdown begins and stops listening when it ends\n FAIL src/node/host.test.ts > startNodeHost > waits for a request in flight before closing the stores, and answers it\n FAIL src/node/host.test.ts > startNodeHost > gives up a request that outlives the budget and reports it\n FAIL src/node/host.test.ts > startNodeHost > marks a stop that abandoned nothing as clean\n FAIL src/node/host.test.ts > startNodeHost > arms a deadline a previous process left only in the session file\n FAIL src/node/host.test.ts > startNodeHost > fails to boot on a malformed encryption key with the Worker's message, leaving nothing open\n FAIL src/node/host.test.ts > startNodeHost > releases what it acquired when a later boot step fails\n FAIL src/node/http-server.test.ts > createNodeHttpServer > answers /healthz itself, 200 while serving and 503 while draining\n FAIL src/node/http-server.test.ts > createNodeHttpServer > hands every other request to the app as a fetch Request\n FAIL src/node/http-server.test.ts > createNodeHttpServer > routes an upgrade to the upgrade handler\n FAIL src/node/http-server.test.ts > createNodeHttpServer > logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection\n FAIL src/node/http-server.test.ts > createNodeHttpServer > tracks requests in flight and drains them within a budget\n FAIL src/node/http-server.test.ts > createNodeHttpServer > stops tracking a request whose handler rejected\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > adopts sockets under their tags and enumerates them by tag\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > returns no tags for a socket it never accepted\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards text frames as strings and binary frames as ArrayBuffers\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > answers the exact keepalive request without delivering it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers a runtime-initiated close to the peer and to the runtime, then drops the socket\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports a lost connection as an unclean 1006 close\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers one socket's events in order, one at a time, close last\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > logs a failed delivery and keeps delivering\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards socket errors to the runtime\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports an incomplete closing handshake as unclean even with a normal code\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > pauses a flooding peer while a delivery is in flight and loses nothing\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > closes a peer whose parsed backlog exceeds the bound instead of retaining it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > satisfies the core's open check without the ambient WebSocket global\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 on any path but a session's\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 to an upgrade whose Host makes no URL\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 500 and logs when the path itself fails, so nothing rejects past it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 for a session the index does not know, without opening a runtime\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 when the index knows the session but nothing is behind it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > writes the session's rejection as the handshake's status\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > hands the session the upgrade as a request with its URL and headers\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > completes an accepted upgrade and the runtime exchanges messages on the socket\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > holds a frame sent on the 101 until the runtime has attached\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > closes the socket with 1011 when attachment fails\nError: listen EPERM: operation not permitted 127.0.0.1\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > routes socket events through the runtime, and keeps it resident until the socket closes\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > shutdown closes adopted sockets with 1012 and delivers their close against an open store\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:520:5\n 518| const clients: NodeWebSocket[] = [];\n 519|\n 520| beforeEach(async () => {\n | ^\n 521| wss = new WebSocketServer({ port: 0 });\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > shutdown closes a socket adopted under a lease that predates it\nError: Test timed out in 5000ms.\nIf this is a long-running test, pass a timeout value as the last argument or configure it globally with \"testTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:620:3\n 618| });\n 619|\n 620| it(\"shutdown closes a socket adopted under a lease that predates it\"…\n | ^\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/44]⎯\n\n⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯\n\nVitest caught 4 unhandled errors during the test run.\nThis might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"routes socket events through the runtime, and keeps it resident until the socket closes\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes adopted sockets with 1012 and delivers their close against an open store\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 127.0.0.1\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ node:net:2274:7\n ❯ processTicksAndRejections node:internal/process/task_queues:90:21\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '127.0.0.1' }\nThis error originated in \"src/node/s3-object-storage.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:623:17\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n 623| const wss = new WebSocketServer({ port: 0 });\n | ^\n 624| await new Promise<void>((done) => wss.once(\"listening\", done));\n 625| const client = new NodeWebSocket(`ws://127.0.0.1:${(wss.address() …\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n ❯ runWithCancel ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2323:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes a socket adopted under a lease that predates it\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\n\n\n Test Files 6 failed | 280 passed (286)\n Tests 43 failed | 4255 passed | 11 skipped (4309)\n Errors 4 errors\n Start at 16:09:54\n Duration 43.47s (transform 27.19s, setup 0ms, import 125.77s, tests 82.37s, environment 65ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}}>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv UV_OFFLINE=1 uv run pytest tests/test_clone_token.py -q{"chunk_id":"8649e0","wall_time_seconds":0.313834916,"exit_code":0,"original_token_count":42,"output":"PASS: repository set + permissions={} raises ValueError: permissions must be non-empty when provided\nPASS: HTTP client was never constructed; no mint request occurred\n"}
{"status":"fulfilled","value":{"chunk_id":"6d8b62","wall_time_seconds":5.41e-7,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930435) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930428) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"528dc1","wall_time_seconds":0.000007042,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930437) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930434) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"d45101","wall_time_seconds":0.040378041,"exit_code":0,"original_token_count":2763,"output":"\"\"\"SCM credential environment shared by interactive and build sandboxes.\"\"\"\n\nimport os\n\n\ndef inject_vcs_env_vars(\n env_vars: dict[str, str],\n clone_token: str | None,\n *,\n clone_host: str | None = None,\n clone_username: str | None = None,\n include_github_cli_aliases: bool = False,\n) -> None:\n \"\"\"Inject provider metadata and optional one-shot clone credentials.\"\"\"\n scm_provider = os.environ.get(\"SCM_PROVIDER\", \"github\")\n if clone_host and clone_username:\n env_vars[\"VCS_HOST\"] = clone_host\n env_vars[\"VCS_CLONE_USERNAME\"] = clone_username\n elif scm_provider == \"bitbucket\":\n env_vars[\"VCS_HOST\"] = \"bitbucket.org\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-token-auth\"\n elif scm_provider == \"gitlab\":\n env_vars[\"VCS_HOST\"] = \"gitlab.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"oauth2\"\n else:\n env_vars[\"VCS_HOST\"] = \"github.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-access-token\"\n\n if not clone_token:\n return\n\n env_vars[\"VCS_CLONE_TOKEN\"] = clone_token\n if include_github_cli_aliases and scm_provider == \"github\":\n has_user_github_cli_token = any(\n env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\n )\n if not has_user_github_cli_token:\n env_vars[\"GITHUB_TOKEN\"] = clone_token\n env_vars[\"GITHUB_APP_TOKEN\"] = clone_token\n env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\n\n fresh = _fetch_from_control_plane(endpoint)\n _atomic_write_cache(fresh)\n return fresh\n finally:\n fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)\n\n\ndef _emit_response(input_lines: dict[str, str], credentials: dict[str, object]) -> None:\n \"\"\"Write the protocol response (context lines + fresh username/password).\"\"\"\n for key, value in input_lines.items():\n if key in {\"username\", \"password\"}:\n continue\n sys.stdout.write(f\"{key}={value}\\n\")\n sys.stdout.write(f\"username={credentials['username']}\\n\")\n sys.stdout.write(f\"password=[REDACTED] sys.stdout.write(\"\\n\")\n sys.stdout.flush()\n\n\ndef _gh_wrapper_should_mint(env: Mapping[str, str]) -> bool:\n \"\"\"Decide whether the gh CLI needs a freshly-minted token.\n\n gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\n we mint only when the environment has nothing usable: no user-provided\n token, and either nothing at all or just the system's short-lived\n installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\n expires in ~1h and must be refreshed). A user-provided token always wins.\n\n The marker is authoritative on its own: a value comparison between\n ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\n override, because the manager only sets the marker when it injected both\n values itself.\n \"\"\"\n if env.get(\"VCS_HOST\", \"github.com\").strip().lower() != \"github.com\":\n return False # non-github deployment: never touch gh's own auth\n if env.get(\"GH_TOKEN\"):\n return False # user-owned; the manager never injects GH_TOKEN\n if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\n return True # only the expiring system fallback is present → refresh\n # Otherwise mint only when there's no genuine user token to leave alone.\n return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\n\n\ndef _print_gh_token() -> int:\n \"\"\"Print a freshly-minted token for the gh CLI wrapper, or nothing.\n\n The wrapper exports whatever we print as ``GH_TOKEN``. When the\n environment already has a usable token we print nothing so gh uses its\n own env. A failed mint also prints nothing rather than failing: the\n wrapper then falls through to the existing env instead of aborting gh.\n Both cases exit 0 — the wrapper only needs the stdout, not the status.\n \"\"\"\n if not _gh_wrapper_should_mint(os.environ):\n return 0\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain gh token: {e}\")\n return 0\n sys.stdout.write(str(credentials[\"password\"]))\n sys.stdout.flush()\n return 0\n\n\ndef main(argv: list[str] | None = None) -> int:\n args = list(argv if argv is not None else sys.argv[1:])\n action = args[0] if args else \"get\"\n\n # `gh-token` is for the gh CLI wrapper: print a fresh token when the env\n # has none usable, otherwise nothing (see _print_gh_token).\n if action == \"gh-token\":\n return _print_gh_token()\n\n # We only mint credentials on `get`. `store` and `erase` are no-ops:\n # the control plane owns the truth and we don't persist anything git tells us.\n if action != \"get\":\n # Drain stdin so git doesn't see a SIGPIPE on the next helper.\n with contextlib.suppress(OSError):\n sys.stdin.read()\n return 0\n\n input_lines = _read_protocol_input(sys.stdin)\n\n # Scope the request to https on the configured host. git treats an empty\n # response as \"I have nothing\", so returning 0 with no output lets it fall\n # through to any other helper or fail the auth cleanly — without us ever\n # emitting the token to the wrong host.\n authorized, reason = _is_authorized_request(input_lines)\n if not authorized:\n _log(f\"refusing to serve credentials: {reason}\")\n return 0\n\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain credentials: {e}\")\n return 1\n\n _emit_response(input_lines, credentials)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n The OpenCode session resumes with full workspace state intact.\n Git clone is skipped since the workspace already has all changes.\n\n Args:\n snapshot_image_id: Modal Image ID from snapshot_filesystem()\n session_config: Session configuration\n sandbox_id: Optional sandbox ID (generated if not provided)\n control_plane_url: URL for the control plane\n sandbox_auth_token: Auth token for the sandbox\n clone_token: VCS clone token for git operations\n\n Returns:\n SandboxHandle for the restored sandbox\n \"\"\"\n start_time = time.time()\n\n if isinstance(session_config, dict):\n repo_owner = session_config.get(\"repo_owner\")\n repo_name = session_config.get(\"repo_name\")\n else:\n repo_owner = session_config.repo_owner\n repo_name = session_config.repo_name\n _has_repository(repo_owner, repo_name)\n\n # Snapshot restore still passes the clone token through for\n # repo-backed sandboxes. Snapshots taken before the credential-helper\n # migration ship an entrypoint that reads VCS_CLONE_TOKEN from env\n # and embeds it in the origin URL; without it, those legacy snapshots\n # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\n # so the gh CLI keeps working on snapshots predating the gh wrapper.\n # Host scoping remains common with fresh creates. These compatibility\n # credentials are explicitly requested only by the restore path.\n handle = await self._launch_sandbox(\n _SandboxLaunchSpec(\n config=SandboxConfig(\n repo_owner=repo_owner,\n repo_name=repo_name,\n sandbox_id=sandbox_id,\n session_config=session_config,\n control_plane_url=control_plane_url,\n sandbox_auth_token=[REDACTED] timeout_seconds=timeout_seconds,\n user_env_vars=user_env_vars,\n code_server_enabled=code_server_enabled,\n vnc_enabled=vnc_enabled,\n agent_slack_notify_enabled=agent_slack_notify_enabled,\n settings=settings,\n ),\n source=_SnapshotImageSource(\n image_id=snapshot_image_id,\n clone_token=[REDACTED] ),\n )\n )\n\n duration_ms = int((time.time() - start_time) * 1000)\n log.info(\n \"sandbox.restore\",\n sandbox_id=handle.sandbox_id,\n modal_object_id=handle.modal_object_id,\n snapshot_image_id=snapshot_image_id,\n repo_owner=repo_owner,\n repo_name=repo_name,\n duration_ms=duration_ms,\n outcome=\"success\",\n )\ndiff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts\nindex 7fab22e9..4a481bbe 100644\n--- a/packages/control-plane/src/image-builds/planner.ts\n+++ b/packages/control-plane/src/image-builds/planner.ts\n@@ -17,7 +17,7 @@ import {\n resolveScopeTarget,\n type ResolvedImageBuildTarget,\n } from \"./scope\";\n-import type { ImageBuildCloneAuth, ImageBuildPlan } from \"./types\";\n+import type { ImageBuildCloneAuth, ImageBuildPlan, ImageBuildRepository } from \"./types\";\n \n const logger = createLogger(\"image-builds:planner\");\n const MS_PER_SECOND = 1000;\n@@ -88,7 +88,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n resolveScopeSandboxSettings(this.db, params.scope, primary),\n loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n- this.resolveCloneAuth(params.scope),\n+ this.resolveCloneAuth(params.scope, repositories),\n ]);\n \n const basePlan = {\n@@ -118,10 +118,15 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n };\n }\n \n- private async resolveCloneAuth(scope: ImageBuildScope): Promise<ImageBuildCloneAuth> {\n+ private async resolveCloneAuth(\n+ scope: ImageBuildScope,\n+ repositories: ImageBuildRepository[]\n+ ): Promise<ImageBuildCloneAuth> {\n try {\n const provider = createSourceControlProviderFromEnv(this.env);\n- const auth = await provider.generateCredentialHelperAuth();\n+ const auth = await provider.generateCredentialHelperAuth(\n+ repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n+ );\n return {\n type: \"credential_helper\",\n host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\ndiff --git a/packages/modal-infra/src/web_api.py b/packages/modal-infra/src/web_api.py\nindex 9362d47c..ed63e18b 100644\n--- a/packages/modal-infra/src/web_api.py\n+++ b/packages/modal-infra/src/web_api.py\n@@ -633,7 +633,9 @@ async def api_restore_sandbox(\n repo_name = parsed_request.session_config.repo_name\n \n manager = SandboxManager()\n- clone_token = resolve_clone_token() if repo_owner and repo_name else None\n+ clone_token = (\n+ resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n+ )\n \n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n"}}
{"status":"fulfilled","value":{"chunk_id":"205628","wall_time_seconds":0.000001166,"exit_code":1,"original_token_count":3453,"output":"\n⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/s3-object-storage.test.ts > createS3ObjectStorage\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/s3-object-storage.test.ts:142:3\n 140| let storage: ObjectStorage;\n 141|\n 142| beforeAll(async () => {\n | ^\n 143| await s3.start();\n 144| storage = createS3ObjectStorage({\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/44]⎯\n\n\n⎯⎯⎯⎯⎯⎯ Failed Tests 43 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/host.test.ts > startNodeHost > boots over the migrated global store and answers the health check and the route table\n FAIL src/node/host.test.ts > startNodeHost > refuses a WebSocket upgrade for an unknown session and any other upgrade path\n FAIL src/node/host.test.ts > startNodeHost > closes the cache database on a normal shutdown, not only on a failed boot\n FAIL src/node/host.test.ts > startNodeHost > reports draining once a shutdown begins and stops listening when it ends\n FAIL src/node/host.test.ts > startNodeHost > waits for a request in flight before closing the stores, and answers it\n FAIL src/node/host.test.ts > startNodeHost > gives up a request that outlives the budget and reports it\n FAIL src/node/host.test.ts > startNodeHost > marks a stop that abandoned nothing as clean\n FAIL src/node/host.test.ts > startNodeHost > arms a deadline a previous process left only in the session file\n FAIL src/node/host.test.ts > startNodeHost > fails to boot on a malformed encryption key with the Worker's message, leaving nothing open\n FAIL src/node/host.test.ts > startNodeHost > releases what it acquired when a later boot step fails\n FAIL src/node/http-server.test.ts > createNodeHttpServer > answers /healthz itself, 200 while serving and 503 while draining\n FAIL src/node/http-server.test.ts > createNodeHttpServer > hands every other request to the app as a fetch Request\n FAIL src/node/http-server.test.ts > createNodeHttpServer > routes an upgrade to the upgrade handler\n FAIL src/node/http-server.test.ts > createNodeHttpServer > logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection\n FAIL src/node/http-server.test.ts > createNodeHttpServer > tracks requests in flight and drains them within a budget\n FAIL src/node/http-server.test.ts > createNodeHttpServer > stops tracking a request whose handler rejected\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > adopts sockets under their tags and enumerates them by tag\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > returns no tags for a socket it never accepted\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards text frames as strings and binary frames as ArrayBuffers\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > answers the exact keepalive request without delivering it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers a runtime-initiated close to the peer and to the runtime, then drops the socket\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports a lost connection as an unclean 1006 close\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers one socket's events in order, one at a time, close last\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > logs a failed delivery and keeps delivering\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards socket errors to the runtime\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports an incomplete closing handshake as unclean even with a normal code\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > pauses a flooding peer while a delivery is in flight and loses nothing\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > closes a peer whose parsed backlog exceeds the bound instead of retaining it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > satisfies the core's open check without the ambient WebSocket global\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 on any path but a session's\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 to an upgrade whose Host makes no URL\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 500 and logs when the path itself fails, so nothing rejects past it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 for a session the index does not know, without opening a runtime\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 when the index knows the session but nothing is behind it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > writes the session's rejection as the handshake's status\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > hands the session the upgrade as a request with its URL and headers\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > completes an accepted upgrade and the runtime exchanges messages on the socket\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > holds a frame sent on the 101 until the runtime has attached\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > closes the socket with 1011 when attachment fails\nError: listen EPERM: operation not permitted 127.0.0.1\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > routes socket events through the runtime, and keeps it resident until the socket closes\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > shutdown closes adopted sockets with 1012 and delivers their close against an open store\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:520:5\n 518| const clients: NodeWebSocket[] = [];\n 519|\n 520| beforeEach(async () => {\n | ^\n 521| wss = new WebSocketServer({ port: 0 });\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > shutdown closes a socket adopted under a lease that predates it\nError: Test timed out in 5000ms.\nIf this is a long-running test, pass a timeout value as the last argument or configure it globally with \"testTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:620:3\n 618| });\n 619|\n 620| it(\"shutdown closes a socket adopted under a lease that predates it\"…\n | ^\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/44]⎯\n\n⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯\n\nVitest caught 4 unhandled errors during the test run.\nThis might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"routes socket events through the runtime, and keeps it resident until the socket closes\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes adopted sockets with 1012 and delivers their close against an open store\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 127.0.0.1\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ node:net:2274:7\n ❯ processTicksAndRejections node:internal/process/task_queues:90:21\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '127.0.0.1' }\nThis error originated in \"src/node/s3-object-storage.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:623:17\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n 623| const wss = new WebSocketServer({ port: 0 });\n | ^\n 624| await new Promise<void>((done) => wss.once(\"listening\", done));\n 625| const client = new NodeWebSocket(`ws://127.0.0.1:${(wss.address() …\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n ❯ runWithCancel ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2323:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes a socket adopted under a lease that predates it\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\n\n\n Test Files 6 failed | 280 passed (286)\n Tests 43 failed | 4255 passed | 11 skipped (4309)\n Errors 4 errors\n Start at 16:09:54\n Duration 43.47s (transform 27.19s, setup 0ms, import 125.77s, tests 82.37s, environment 65ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}}>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv UV_OFFLINE=1 uv run pytest tests/test_github_app_auth.py -q{"chunk_id":"8649e0","wall_time_seconds":0.313834916,"exit_code":0,"original_token_count":42,"output":"PASS: repository set + permissions={} raises ValueError: permissions must be non-empty when provided\nPASS: HTTP client was never constructed; no mint request occurred\n"}
{"status":"fulfilled","value":{"chunk_id":"6d8b62","wall_time_seconds":5.41e-7,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930435) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930428) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"528dc1","wall_time_seconds":0.000007042,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930437) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930434) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"d45101","wall_time_seconds":0.040378041,"exit_code":0,"original_token_count":2763,"output":"\"\"\"SCM credential environment shared by interactive and build sandboxes.\"\"\"\n\nimport os\n\n\ndef inject_vcs_env_vars(\n env_vars: dict[str, str],\n clone_token: str | None,\n *,\n clone_host: str | None = None,\n clone_username: str | None = None,\n include_github_cli_aliases: bool = False,\n) -> None:\n \"\"\"Inject provider metadata and optional one-shot clone credentials.\"\"\"\n scm_provider = os.environ.get(\"SCM_PROVIDER\", \"github\")\n if clone_host and clone_username:\n env_vars[\"VCS_HOST\"] = clone_host\n env_vars[\"VCS_CLONE_USERNAME\"] = clone_username\n elif scm_provider == \"bitbucket\":\n env_vars[\"VCS_HOST\"] = \"bitbucket.org\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-token-auth\"\n elif scm_provider == \"gitlab\":\n env_vars[\"VCS_HOST\"] = \"gitlab.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"oauth2\"\n else:\n env_vars[\"VCS_HOST\"] = \"github.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-access-token\"\n\n if not clone_token:\n return\n\n env_vars[\"VCS_CLONE_TOKEN\"] = clone_token\n if include_github_cli_aliases and scm_provider == \"github\":\n has_user_github_cli_token = any(\n env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\n )\n if not has_user_github_cli_token:\n env_vars[\"GITHUB_TOKEN\"] = clone_token\n env_vars[\"GITHUB_APP_TOKEN\"] = clone_token\n env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\n\n fresh = _fetch_from_control_plane(endpoint)\n _atomic_write_cache(fresh)\n return fresh\n finally:\n fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)\n\n\ndef _emit_response(input_lines: dict[str, str], credentials: dict[str, object]) -> None:\n \"\"\"Write the protocol response (context lines + fresh username/password).\"\"\"\n for key, value in input_lines.items():\n if key in {\"username\", \"password\"}:\n continue\n sys.stdout.write(f\"{key}={value}\\n\")\n sys.stdout.write(f\"username={credentials['username']}\\n\")\n sys.stdout.write(f\"password=[REDACTED] sys.stdout.write(\"\\n\")\n sys.stdout.flush()\n\n\ndef _gh_wrapper_should_mint(env: Mapping[str, str]) -> bool:\n \"\"\"Decide whether the gh CLI needs a freshly-minted token.\n\n gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\n we mint only when the environment has nothing usable: no user-provided\n token, and either nothing at all or just the system's short-lived\n installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\n expires in ~1h and must be refreshed). A user-provided token always wins.\n\n The marker is authoritative on its own: a value comparison between\n ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\n override, because the manager only sets the marker when it injected both\n values itself.\n \"\"\"\n if env.get(\"VCS_HOST\", \"github.com\").strip().lower() != \"github.com\":\n return False # non-github deployment: never touch gh's own auth\n if env.get(\"GH_TOKEN\"):\n return False # user-owned; the manager never injects GH_TOKEN\n if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\n return True # only the expiring system fallback is present → refresh\n # Otherwise mint only when there's no genuine user token to leave alone.\n return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\n\n\ndef _print_gh_token() -> int:\n \"\"\"Print a freshly-minted token for the gh CLI wrapper, or nothing.\n\n The wrapper exports whatever we print as ``GH_TOKEN``. When the\n environment already has a usable token we print nothing so gh uses its\n own env. A failed mint also prints nothing rather than failing: the\n wrapper then falls through to the existing env instead of aborting gh.\n Both cases exit 0 — the wrapper only needs the stdout, not the status.\n \"\"\"\n if not _gh_wrapper_should_mint(os.environ):\n return 0\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain gh token: {e}\")\n return 0\n sys.stdout.write(str(credentials[\"password\"]))\n sys.stdout.flush()\n return 0\n\n\ndef main(argv: list[str] | None = None) -> int:\n args = list(argv if argv is not None else sys.argv[1:])\n action = args[0] if args else \"get\"\n\n # `gh-token` is for the gh CLI wrapper: print a fresh token when the env\n # has none usable, otherwise nothing (see _print_gh_token).\n if action == \"gh-token\":\n return _print_gh_token()\n\n # We only mint credentials on `get`. `store` and `erase` are no-ops:\n # the control plane owns the truth and we don't persist anything git tells us.\n if action != \"get\":\n # Drain stdin so git doesn't see a SIGPIPE on the next helper.\n with contextlib.suppress(OSError):\n sys.stdin.read()\n return 0\n\n input_lines = _read_protocol_input(sys.stdin)\n\n # Scope the request to https on the configured host. git treats an empty\n # response as \"I have nothing\", so returning 0 with no output lets it fall\n # through to any other helper or fail the auth cleanly — without us ever\n # emitting the token to the wrong host.\n authorized, reason = _is_authorized_request(input_lines)\n if not authorized:\n _log(f\"refusing to serve credentials: {reason}\")\n return 0\n\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain credentials: {e}\")\n return 1\n\n _emit_response(input_lines, credentials)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n The OpenCode session resumes with full workspace state intact.\n Git clone is skipped since the workspace already has all changes.\n\n Args:\n snapshot_image_id: Modal Image ID from snapshot_filesystem()\n session_config: Session configuration\n sandbox_id: Optional sandbox ID (generated if not provided)\n control_plane_url: URL for the control plane\n sandbox_auth_token: Auth token for the sandbox\n clone_token: VCS clone token for git operations\n\n Returns:\n SandboxHandle for the restored sandbox\n \"\"\"\n start_time = time.time()\n\n if isinstance(session_config, dict):\n repo_owner = session_config.get(\"repo_owner\")\n repo_name = session_config.get(\"repo_name\")\n else:\n repo_owner = session_config.repo_owner\n repo_name = session_config.repo_name\n _has_repository(repo_owner, repo_name)\n\n # Snapshot restore still passes the clone token through for\n # repo-backed sandboxes. Snapshots taken before the credential-helper\n # migration ship an entrypoint that reads VCS_CLONE_TOKEN from env\n # and embeds it in the origin URL; without it, those legacy snapshots\n # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\n # so the gh CLI keeps working on snapshots predating the gh wrapper.\n # Host scoping remains common with fresh creates. These compatibility\n # credentials are explicitly requested only by the restore path.\n handle = await self._launch_sandbox(\n _SandboxLaunchSpec(\n config=SandboxConfig(\n repo_owner=repo_owner,\n repo_name=repo_name,\n sandbox_id=sandbox_id,\n session_config=session_config,\n control_plane_url=control_plane_url,\n sandbox_auth_token=[REDACTED] timeout_seconds=timeout_seconds,\n user_env_vars=user_env_vars,\n code_server_enabled=code_server_enabled,\n vnc_enabled=vnc_enabled,\n agent_slack_notify_enabled=agent_slack_notify_enabled,\n settings=settings,\n ),\n source=_SnapshotImageSource(\n image_id=snapshot_image_id,\n clone_token=[REDACTED] ),\n )\n )\n\n duration_ms = int((time.time() - start_time) * 1000)\n log.info(\n \"sandbox.restore\",\n sandbox_id=handle.sandbox_id,\n modal_object_id=handle.modal_object_id,\n snapshot_image_id=snapshot_image_id,\n repo_owner=repo_owner,\n repo_name=repo_name,\n duration_ms=duration_ms,\n outcome=\"success\",\n )\ndiff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts\nindex 7fab22e9..4a481bbe 100644\n--- a/packages/control-plane/src/image-builds/planner.ts\n+++ b/packages/control-plane/src/image-builds/planner.ts\n@@ -17,7 +17,7 @@ import {\n resolveScopeTarget,\n type ResolvedImageBuildTarget,\n } from \"./scope\";\n-import type { ImageBuildCloneAuth, ImageBuildPlan } from \"./types\";\n+import type { ImageBuildCloneAuth, ImageBuildPlan, ImageBuildRepository } from \"./types\";\n \n const logger = createLogger(\"image-builds:planner\");\n const MS_PER_SECOND = 1000;\n@@ -88,7 +88,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n resolveScopeSandboxSettings(this.db, params.scope, primary),\n loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n- this.resolveCloneAuth(params.scope),\n+ this.resolveCloneAuth(params.scope, repositories),\n ]);\n \n const basePlan = {\n@@ -118,10 +118,15 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n };\n }\n \n- private async resolveCloneAuth(scope: ImageBuildScope): Promise<ImageBuildCloneAuth> {\n+ private async resolveCloneAuth(\n+ scope: ImageBuildScope,\n+ repositories: ImageBuildRepository[]\n+ ): Promise<ImageBuildCloneAuth> {\n try {\n const provider = createSourceControlProviderFromEnv(this.env);\n- const auth = await provider.generateCredentialHelperAuth();\n+ const auth = await provider.generateCredentialHelperAuth(\n+ repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n+ );\n return {\n type: \"credential_helper\",\n host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\ndiff --git a/packages/modal-infra/src/web_api.py b/packages/modal-infra/src/web_api.py\nindex 9362d47c..ed63e18b 100644\n--- a/packages/modal-infra/src/web_api.py\n+++ b/packages/modal-infra/src/web_api.py\n@@ -633,7 +633,9 @@ async def api_restore_sandbox(\n repo_name = parsed_request.session_config.repo_name\n \n manager = SandboxManager()\n- clone_token = resolve_clone_token() if repo_owner and repo_name else None\n+ clone_token = (\n+ resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n+ )\n \n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n"}}
{"status":"fulfilled","value":{"chunk_id":"205628","wall_time_seconds":0.000001166,"exit_code":1,"original_token_count":3453,"output":"\n⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/s3-object-storage.test.ts > createS3ObjectStorage\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/s3-object-storage.test.ts:142:3\n 140| let storage: ObjectStorage;\n 141|\n 142| beforeAll(async () => {\n | ^\n 143| await s3.start();\n 144| storage = createS3ObjectStorage({\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/44]⎯\n\n\n⎯⎯⎯⎯⎯⎯ Failed Tests 43 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/host.test.ts > startNodeHost > boots over the migrated global store and answers the health check and the route table\n FAIL src/node/host.test.ts > startNodeHost > refuses a WebSocket upgrade for an unknown session and any other upgrade path\n FAIL src/node/host.test.ts > startNodeHost > closes the cache database on a normal shutdown, not only on a failed boot\n FAIL src/node/host.test.ts > startNodeHost > reports draining once a shutdown begins and stops listening when it ends\n FAIL src/node/host.test.ts > startNodeHost > waits for a request in flight before closing the stores, and answers it\n FAIL src/node/host.test.ts > startNodeHost > gives up a request that outlives the budget and reports it\n FAIL src/node/host.test.ts > startNodeHost > marks a stop that abandoned nothing as clean\n FAIL src/node/host.test.ts > startNodeHost > arms a deadline a previous process left only in the session file\n FAIL src/node/host.test.ts > startNodeHost > fails to boot on a malformed encryption key with the Worker's message, leaving nothing open\n FAIL src/node/host.test.ts > startNodeHost > releases what it acquired when a later boot step fails\n FAIL src/node/http-server.test.ts > createNodeHttpServer > answers /healthz itself, 200 while serving and 503 while draining\n FAIL src/node/http-server.test.ts > createNodeHttpServer > hands every other request to the app as a fetch Request\n FAIL src/node/http-server.test.ts > createNodeHttpServer > routes an upgrade to the upgrade handler\n FAIL src/node/http-server.test.ts > createNodeHttpServer > logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection\n FAIL src/node/http-server.test.ts > createNodeHttpServer > tracks requests in flight and drains them within a budget\n FAIL src/node/http-server.test.ts > createNodeHttpServer > stops tracking a request whose handler rejected\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > adopts sockets under their tags and enumerates them by tag\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > returns no tags for a socket it never accepted\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards text frames as strings and binary frames as ArrayBuffers\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > answers the exact keepalive request without delivering it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers a runtime-initiated close to the peer and to the runtime, then drops the socket\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports a lost connection as an unclean 1006 close\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers one socket's events in order, one at a time, close last\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > logs a failed delivery and keeps delivering\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards socket errors to the runtime\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports an incomplete closing handshake as unclean even with a normal code\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > pauses a flooding peer while a delivery is in flight and loses nothing\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > closes a peer whose parsed backlog exceeds the bound instead of retaining it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > satisfies the core's open check without the ambient WebSocket global\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 on any path but a session's\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 to an upgrade whose Host makes no URL\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 500 and logs when the path itself fails, so nothing rejects past it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 for a session the index does not know, without opening a runtime\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 when the index knows the session but nothing is behind it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > writes the session's rejection as the handshake's status\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > hands the session the upgrade as a request with its URL and headers\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > completes an accepted upgrade and the runtime exchanges messages on the socket\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > holds a frame sent on the 101 until the runtime has attached\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > closes the socket with 1011 when attachment fails\nError: listen EPERM: operation not permitted 127.0.0.1\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > routes socket events through the runtime, and keeps it resident until the socket closes\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > shutdown closes adopted sockets with 1012 and delivers their close against an open store\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:520:5\n 518| const clients: NodeWebSocket[] = [];\n 519|\n 520| beforeEach(async () => {\n | ^\n 521| wss = new WebSocketServer({ port: 0 });\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > shutdown closes a socket adopted under a lease that predates it\nError: Test timed out in 5000ms.\nIf this is a long-running test, pass a timeout value as the last argument or configure it globally with \"testTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:620:3\n 618| });\n 619|\n 620| it(\"shutdown closes a socket adopted under a lease that predates it\"…\n | ^\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/44]⎯\n\n⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯\n\nVitest caught 4 unhandled errors during the test run.\nThis might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"routes socket events through the runtime, and keeps it resident until the socket closes\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes adopted sockets with 1012 and delivers their close against an open store\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 127.0.0.1\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ node:net:2274:7\n ❯ processTicksAndRejections node:internal/process/task_queues:90:21\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '127.0.0.1' }\nThis error originated in \"src/node/s3-object-storage.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:623:17\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n 623| const wss = new WebSocketServer({ port: 0 });\n | ^\n 624| await new Promise<void>((done) => wss.once(\"listening\", done));\n 625| const client = new NodeWebSocket(`ws://127.0.0.1:${(wss.address() …\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n ❯ runWithCancel ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2323:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes a socket adopted under a lease that predates it\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\n\n\n Test Files 6 failed | 280 passed (286)\n Tests 43 failed | 4255 passed | 11 skipped (4309)\n Errors 4 errors\n Start at 16:09:54\n Duration 43.47s (transform 27.19s, setup 0ms, import 125.77s, tests 82.37s, environment 65ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}}>rg -n 'scoped|Scoped' packages/control-plane/src/auth/github-app.test.ts; cat packages/modal-infra/src/sandbox/vcs_env.py; sed -n '250,370p' packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py; sed -n '630,695p' packages/modal-infra/src/sandbox/manager.py; git diff 7cef37e6..HEAD -- packages/control-plane/src/image-builds/planner.ts packages/modal-infra/src/web_api.py{"chunk_id":"8649e0","wall_time_seconds":0.313834916,"exit_code":0,"original_token_count":42,"output":"PASS: repository set + permissions={} raises ValueError: permissions must be non-empty when provided\nPASS: HTTP client was never constructed; no mint request occurred\n"}
{"status":"fulfilled","value":{"chunk_id":"6d8b62","wall_time_seconds":5.41e-7,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930435) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930428) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"528dc1","wall_time_seconds":0.000007042,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930437) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930434) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"d45101","wall_time_seconds":0.040378041,"exit_code":0,"original_token_count":2763,"output":"\"\"\"SCM credential environment shared by interactive and build sandboxes.\"\"\"\n\nimport os\n\n\ndef inject_vcs_env_vars(\n env_vars: dict[str, str],\n clone_token: str | None,\n *,\n clone_host: str | None = None,\n clone_username: str | None = None,\n include_github_cli_aliases: bool = False,\n) -> None:\n \"\"\"Inject provider metadata and optional one-shot clone credentials.\"\"\"\n scm_provider = os.environ.get(\"SCM_PROVIDER\", \"github\")\n if clone_host and clone_username:\n env_vars[\"VCS_HOST\"] = clone_host\n env_vars[\"VCS_CLONE_USERNAME\"] = clone_username\n elif scm_provider == \"bitbucket\":\n env_vars[\"VCS_HOST\"] = \"bitbucket.org\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-token-auth\"\n elif scm_provider == \"gitlab\":\n env_vars[\"VCS_HOST\"] = \"gitlab.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"oauth2\"\n else:\n env_vars[\"VCS_HOST\"] = \"github.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-access-token\"\n\n if not clone_token:\n return\n\n env_vars[\"VCS_CLONE_TOKEN\"] = clone_token\n if include_github_cli_aliases and scm_provider == \"github\":\n has_user_github_cli_token = any(\n env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\n )\n if not has_user_github_cli_token:\n env_vars[\"GITHUB_TOKEN\"] = clone_token\n env_vars[\"GITHUB_APP_TOKEN\"] = clone_token\n env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\n\n fresh = _fetch_from_control_plane(endpoint)\n _atomic_write_cache(fresh)\n return fresh\n finally:\n fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)\n\n\ndef _emit_response(input_lines: dict[str, str], credentials: dict[str, object]) -> None:\n \"\"\"Write the protocol response (context lines + fresh username/password).\"\"\"\n for key, value in input_lines.items():\n if key in {\"username\", \"password\"}:\n continue\n sys.stdout.write(f\"{key}={value}\\n\")\n sys.stdout.write(f\"username={credentials['username']}\\n\")\n sys.stdout.write(f\"password=[REDACTED] sys.stdout.write(\"\\n\")\n sys.stdout.flush()\n\n\ndef _gh_wrapper_should_mint(env: Mapping[str, str]) -> bool:\n \"\"\"Decide whether the gh CLI needs a freshly-minted token.\n\n gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\n we mint only when the environment has nothing usable: no user-provided\n token, and either nothing at all or just the system's short-lived\n installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\n expires in ~1h and must be refreshed). A user-provided token always wins.\n\n The marker is authoritative on its own: a value comparison between\n ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\n override, because the manager only sets the marker when it injected both\n values itself.\n \"\"\"\n if env.get(\"VCS_HOST\", \"github.com\").strip().lower() != \"github.com\":\n return False # non-github deployment: never touch gh's own auth\n if env.get(\"GH_TOKEN\"):\n return False # user-owned; the manager never injects GH_TOKEN\n if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\n return True # only the expiring system fallback is present → refresh\n # Otherwise mint only when there's no genuine user token to leave alone.\n return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\n\n\ndef _print_gh_token() -> int:\n \"\"\"Print a freshly-minted token for the gh CLI wrapper, or nothing.\n\n The wrapper exports whatever we print as ``GH_TOKEN``. When the\n environment already has a usable token we print nothing so gh uses its\n own env. A failed mint also prints nothing rather than failing: the\n wrapper then falls through to the existing env instead of aborting gh.\n Both cases exit 0 — the wrapper only needs the stdout, not the status.\n \"\"\"\n if not _gh_wrapper_should_mint(os.environ):\n return 0\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain gh token: {e}\")\n return 0\n sys.stdout.write(str(credentials[\"password\"]))\n sys.stdout.flush()\n return 0\n\n\ndef main(argv: list[str] | None = None) -> int:\n args = list(argv if argv is not None else sys.argv[1:])\n action = args[0] if args else \"get\"\n\n # `gh-token` is for the gh CLI wrapper: print a fresh token when the env\n # has none usable, otherwise nothing (see _print_gh_token).\n if action == \"gh-token\":\n return _print_gh_token()\n\n # We only mint credentials on `get`. `store` and `erase` are no-ops:\n # the control plane owns the truth and we don't persist anything git tells us.\n if action != \"get\":\n # Drain stdin so git doesn't see a SIGPIPE on the next helper.\n with contextlib.suppress(OSError):\n sys.stdin.read()\n return 0\n\n input_lines = _read_protocol_input(sys.stdin)\n\n # Scope the request to https on the configured host. git treats an empty\n # response as \"I have nothing\", so returning 0 with no output lets it fall\n # through to any other helper or fail the auth cleanly — without us ever\n # emitting the token to the wrong host.\n authorized, reason = _is_authorized_request(input_lines)\n if not authorized:\n _log(f\"refusing to serve credentials: {reason}\")\n return 0\n\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain credentials: {e}\")\n return 1\n\n _emit_response(input_lines, credentials)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n The OpenCode session resumes with full workspace state intact.\n Git clone is skipped since the workspace already has all changes.\n\n Args:\n snapshot_image_id: Modal Image ID from snapshot_filesystem()\n session_config: Session configuration\n sandbox_id: Optional sandbox ID (generated if not provided)\n control_plane_url: URL for the control plane\n sandbox_auth_token: Auth token for the sandbox\n clone_token: VCS clone token for git operations\n\n Returns:\n SandboxHandle for the restored sandbox\n \"\"\"\n start_time = time.time()\n\n if isinstance(session_config, dict):\n repo_owner = session_config.get(\"repo_owner\")\n repo_name = session_config.get(\"repo_name\")\n else:\n repo_owner = session_config.repo_owner\n repo_name = session_config.repo_name\n _has_repository(repo_owner, repo_name)\n\n # Snapshot restore still passes the clone token through for\n # repo-backed sandboxes. Snapshots taken before the credential-helper\n # migration ship an entrypoint that reads VCS_CLONE_TOKEN from env\n # and embeds it in the origin URL; without it, those legacy snapshots\n # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\n # so the gh CLI keeps working on snapshots predating the gh wrapper.\n # Host scoping remains common with fresh creates. These compatibility\n # credentials are explicitly requested only by the restore path.\n handle = await self._launch_sandbox(\n _SandboxLaunchSpec(\n config=SandboxConfig(\n repo_owner=repo_owner,\n repo_name=repo_name,\n sandbox_id=sandbox_id,\n session_config=session_config,\n control_plane_url=control_plane_url,\n sandbox_auth_token=[REDACTED] timeout_seconds=timeout_seconds,\n user_env_vars=user_env_vars,\n code_server_enabled=code_server_enabled,\n vnc_enabled=vnc_enabled,\n agent_slack_notify_enabled=agent_slack_notify_enabled,\n settings=settings,\n ),\n source=_SnapshotImageSource(\n image_id=snapshot_image_id,\n clone_token=[REDACTED] ),\n )\n )\n\n duration_ms = int((time.time() - start_time) * 1000)\n log.info(\n \"sandbox.restore\",\n sandbox_id=handle.sandbox_id,\n modal_object_id=handle.modal_object_id,\n snapshot_image_id=snapshot_image_id,\n repo_owner=repo_owner,\n repo_name=repo_name,\n duration_ms=duration_ms,\n outcome=\"success\",\n )\ndiff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts\nindex 7fab22e9..4a481bbe 100644\n--- a/packages/control-plane/src/image-builds/planner.ts\n+++ b/packages/control-plane/src/image-builds/planner.ts\n@@ -17,7 +17,7 @@ import {\n resolveScopeTarget,\n type ResolvedImageBuildTarget,\n } from \"./scope\";\n-import type { ImageBuildCloneAuth, ImageBuildPlan } from \"./types\";\n+import type { ImageBuildCloneAuth, ImageBuildPlan, ImageBuildRepository } from \"./types\";\n \n const logger = createLogger(\"image-builds:planner\");\n const MS_PER_SECOND = 1000;\n@@ -88,7 +88,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n resolveScopeSandboxSettings(this.db, params.scope, primary),\n loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n- this.resolveCloneAuth(params.scope),\n+ this.resolveCloneAuth(params.scope, repositories),\n ]);\n \n const basePlan = {\n@@ -118,10 +118,15 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n };\n }\n \n- private async resolveCloneAuth(scope: ImageBuildScope): Promise<ImageBuildCloneAuth> {\n+ private async resolveCloneAuth(\n+ scope: ImageBuildScope,\n+ repositories: ImageBuildRepository[]\n+ ): Promise<ImageBuildCloneAuth> {\n try {\n const provider = createSourceControlProviderFromEnv(this.env);\n- const auth = await provider.generateCredentialHelperAuth();\n+ const auth = await provider.generateCredentialHelperAuth(\n+ repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n+ );\n return {\n type: \"credential_helper\",\n host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\ndiff --git a/packages/modal-infra/src/web_api.py b/packages/modal-infra/src/web_api.py\nindex 9362d47c..ed63e18b 100644\n--- a/packages/modal-infra/src/web_api.py\n+++ b/packages/modal-infra/src/web_api.py\n@@ -633,7 +633,9 @@ async def api_restore_sandbox(\n repo_name = parsed_request.session_config.repo_name\n \n manager = SandboxManager()\n- clone_token = resolve_clone_token() if repo_owner and repo_name else None\n+ clone_token = (\n+ resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n+ )\n \n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n"}}
{"status":"fulfilled","value":{"chunk_id":"205628","wall_time_seconds":0.000001166,"exit_code":1,"original_token_count":3453,"output":"\n⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/s3-object-storage.test.ts > createS3ObjectStorage\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/s3-object-storage.test.ts:142:3\n 140| let storage: ObjectStorage;\n 141|\n 142| beforeAll(async () => {\n | ^\n 143| await s3.start();\n 144| storage = createS3ObjectStorage({\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/44]⎯\n\n\n⎯⎯⎯⎯⎯⎯ Failed Tests 43 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/host.test.ts > startNodeHost > boots over the migrated global store and answers the health check and the route table\n FAIL src/node/host.test.ts > startNodeHost > refuses a WebSocket upgrade for an unknown session and any other upgrade path\n FAIL src/node/host.test.ts > startNodeHost > closes the cache database on a normal shutdown, not only on a failed boot\n FAIL src/node/host.test.ts > startNodeHost > reports draining once a shutdown begins and stops listening when it ends\n FAIL src/node/host.test.ts > startNodeHost > waits for a request in flight before closing the stores, and answers it\n FAIL src/node/host.test.ts > startNodeHost > gives up a request that outlives the budget and reports it\n FAIL src/node/host.test.ts > startNodeHost > marks a stop that abandoned nothing as clean\n FAIL src/node/host.test.ts > startNodeHost > arms a deadline a previous process left only in the session file\n FAIL src/node/host.test.ts > startNodeHost > fails to boot on a malformed encryption key with the Worker's message, leaving nothing open\n FAIL src/node/host.test.ts > startNodeHost > releases what it acquired when a later boot step fails\n FAIL src/node/http-server.test.ts > createNodeHttpServer > answers /healthz itself, 200 while serving and 503 while draining\n FAIL src/node/http-server.test.ts > createNodeHttpServer > hands every other request to the app as a fetch Request\n FAIL src/node/http-server.test.ts > createNodeHttpServer > routes an upgrade to the upgrade handler\n FAIL src/node/http-server.test.ts > createNodeHttpServer > logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection\n FAIL src/node/http-server.test.ts > createNodeHttpServer > tracks requests in flight and drains them within a budget\n FAIL src/node/http-server.test.ts > createNodeHttpServer > stops tracking a request whose handler rejected\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > adopts sockets under their tags and enumerates them by tag\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > returns no tags for a socket it never accepted\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards text frames as strings and binary frames as ArrayBuffers\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > answers the exact keepalive request without delivering it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers a runtime-initiated close to the peer and to the runtime, then drops the socket\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports a lost connection as an unclean 1006 close\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers one socket's events in order, one at a time, close last\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > logs a failed delivery and keeps delivering\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards socket errors to the runtime\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports an incomplete closing handshake as unclean even with a normal code\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > pauses a flooding peer while a delivery is in flight and loses nothing\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > closes a peer whose parsed backlog exceeds the bound instead of retaining it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > satisfies the core's open check without the ambient WebSocket global\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 on any path but a session's\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 to an upgrade whose Host makes no URL\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 500 and logs when the path itself fails, so nothing rejects past it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 for a session the index does not know, without opening a runtime\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 when the index knows the session but nothing is behind it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > writes the session's rejection as the handshake's status\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > hands the session the upgrade as a request with its URL and headers\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > completes an accepted upgrade and the runtime exchanges messages on the socket\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > holds a frame sent on the 101 until the runtime has attached\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > closes the socket with 1011 when attachment fails\nError: listen EPERM: operation not permitted 127.0.0.1\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > routes socket events through the runtime, and keeps it resident until the socket closes\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > shutdown closes adopted sockets with 1012 and delivers their close against an open store\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:520:5\n 518| const clients: NodeWebSocket[] = [];\n 519|\n 520| beforeEach(async () => {\n | ^\n 521| wss = new WebSocketServer({ port: 0 });\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > shutdown closes a socket adopted under a lease that predates it\nError: Test timed out in 5000ms.\nIf this is a long-running test, pass a timeout value as the last argument or configure it globally with \"testTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:620:3\n 618| });\n 619|\n 620| it(\"shutdown closes a socket adopted under a lease that predates it\"…\n | ^\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/44]⎯\n\n⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯\n\nVitest caught 4 unhandled errors during the test run.\nThis might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"routes socket events through the runtime, and keeps it resident until the socket closes\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes adopted sockets with 1012 and delivers their close against an open store\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 127.0.0.1\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ node:net:2274:7\n ❯ processTicksAndRejections node:internal/process/task_queues:90:21\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '127.0.0.1' }\nThis error originated in \"src/node/s3-object-storage.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:623:17\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n 623| const wss = new WebSocketServer({ port: 0 });\n | ^\n 624| await new Promise<void>((done) => wss.once(\"listening\", done));\n 625| const client = new NodeWebSocket(`ws://127.0.0.1:${(wss.address() …\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n ❯ runWithCancel ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2323:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes a socket adopted under a lease that predates it\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\n\n\n Test Files 6 failed | 280 passed (286)\n Tests 43 failed | 4255 passed | 11 skipped (4309)\n Errors 4 errors\n Start at 16:09:54\n Duration 43.47s (transform 27.19s, setup 0ms, import 125.77s, tests 82.37s, environment 65ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}}{"chunk_id":"8649e0","wall_time_seconds":0.313834916,"exit_code":0,"original_token_count":42,"output":"PASS: repository set + permissions={} raises ValueError: permissions must be non-empty when provided\nPASS: HTTP client was never constructed; no mint request occurred\n"}
{"status":"fulfilled","value":{"chunk_id":"6d8b62","wall_time_seconds":5.41e-7,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930435) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930428) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"528dc1","wall_time_seconds":0.000007042,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7930437) panicked at /Users/brew/Library/Caches/Homebrew/cargo_cache/registry/src/index.crates.io-1949cf8c6b5b557f/system-configuration-0.6.1/src/dynamic_store.rs:154:1:\nAttempted to create a NULL object.\nnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace\n\nthread 'main' (7930434) panicked at /private/tmp/uv-20260126-7843-e57oag/uv-0.9.27/crates/uv/src/lib.rs:2646:10:\nTokio executor failed, was there a panic?: Any { .. }\n"}}
{"status":"fulfilled","value":{"chunk_id":"d45101","wall_time_seconds":0.040378041,"exit_code":0,"original_token_count":2763,"output":"\"\"\"SCM credential environment shared by interactive and build sandboxes.\"\"\"\n\nimport os\n\n\ndef inject_vcs_env_vars(\n env_vars: dict[str, str],\n clone_token: str | None,\n *,\n clone_host: str | None = None,\n clone_username: str | None = None,\n include_github_cli_aliases: bool = False,\n) -> None:\n \"\"\"Inject provider metadata and optional one-shot clone credentials.\"\"\"\n scm_provider = os.environ.get(\"SCM_PROVIDER\", \"github\")\n if clone_host and clone_username:\n env_vars[\"VCS_HOST\"] = clone_host\n env_vars[\"VCS_CLONE_USERNAME\"] = clone_username\n elif scm_provider == \"bitbucket\":\n env_vars[\"VCS_HOST\"] = \"bitbucket.org\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-token-auth\"\n elif scm_provider == \"gitlab\":\n env_vars[\"VCS_HOST\"] = \"gitlab.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"oauth2\"\n else:\n env_vars[\"VCS_HOST\"] = \"github.com\"\n env_vars[\"VCS_CLONE_USERNAME\"] = \"x-access-token\"\n\n if not clone_token:\n return\n\n env_vars[\"VCS_CLONE_TOKEN\"] = clone_token\n if include_github_cli_aliases and scm_provider == \"github\":\n has_user_github_cli_token = any(\n env_vars.get(key) for key in (\"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_APP_TOKEN\")\n )\n if not has_user_github_cli_token:\n env_vars[\"GITHUB_TOKEN\"] = clone_token\n env_vars[\"GITHUB_APP_TOKEN\"] = clone_token\n env_vars[\"OI_GITHUB_TOKEN_IS_FALLBACK\"] = \"1\"\n\n fresh = _fetch_from_control_plane(endpoint)\n _atomic_write_cache(fresh)\n return fresh\n finally:\n fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)\n\n\ndef _emit_response(input_lines: dict[str, str], credentials: dict[str, object]) -> None:\n \"\"\"Write the protocol response (context lines + fresh username/password).\"\"\"\n for key, value in input_lines.items():\n if key in {\"username\", \"password\"}:\n continue\n sys.stdout.write(f\"{key}={value}\\n\")\n sys.stdout.write(f\"username={credentials['username']}\\n\")\n sys.stdout.write(f\"password=[REDACTED] sys.stdout.write(\"\\n\")\n sys.stdout.flush()\n\n\ndef _gh_wrapper_should_mint(env: Mapping[str, str]) -> bool:\n \"\"\"Decide whether the gh CLI needs a freshly-minted token.\n\n gh reads ``GH_TOKEN`` then ``GITHUB_TOKEN`` from its own environment, so\n we mint only when the environment has nothing usable: no user-provided\n token, and either nothing at all or just the system's short-lived\n installation fallback (marked ``OI_GITHUB_TOKEN_IS_FALLBACK=[REDACTED] which\n expires in ~1h and must be refreshed). A user-provided token always wins.\n\n The marker is authoritative on its own: a value comparison between\n ``GITHUB_TOKEN`` and ``GITHUB_APP_TOKEN`` is not needed to detect a user\n override, because the manager only sets the marker when it injected both\n values itself.\n \"\"\"\n if env.get(\"VCS_HOST\", \"github.com\").strip().lower() != \"github.com\":\n return False # non-github deployment: never touch gh's own auth\n if env.get(\"GH_TOKEN\"):\n return False # user-owned; the manager never injects GH_TOKEN\n if env.get(\"OI_GITHUB_TOKEN_IS_FALLBACK\") == \"1\":\n return True # only the expiring system fallback is present → refresh\n # Otherwise mint only when there's no genuine user token to leave alone.\n return not (env.get(\"GITHUB_TOKEN\") or env.get(\"GITHUB_APP_TOKEN\"))\n\n\ndef _print_gh_token() -> int:\n \"\"\"Print a freshly-minted token for the gh CLI wrapper, or nothing.\n\n The wrapper exports whatever we print as ``GH_TOKEN``. When the\n environment already has a usable token we print nothing so gh uses its\n own env. A failed mint also prints nothing rather than failing: the\n wrapper then falls through to the existing env instead of aborting gh.\n Both cases exit 0 — the wrapper only needs the stdout, not the status.\n \"\"\"\n if not _gh_wrapper_should_mint(os.environ):\n return 0\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain gh token: {e}\")\n return 0\n sys.stdout.write(str(credentials[\"password\"]))\n sys.stdout.flush()\n return 0\n\n\ndef main(argv: list[str] | None = None) -> int:\n args = list(argv if argv is not None else sys.argv[1:])\n action = args[0] if args else \"get\"\n\n # `gh-token` is for the gh CLI wrapper: print a fresh token when the env\n # has none usable, otherwise nothing (see _print_gh_token).\n if action == \"gh-token\":\n return _print_gh_token()\n\n # We only mint credentials on `get`. `store` and `erase` are no-ops:\n # the control plane owns the truth and we don't persist anything git tells us.\n if action != \"get\":\n # Drain stdin so git doesn't see a SIGPIPE on the next helper.\n with contextlib.suppress(OSError):\n sys.stdin.read()\n return 0\n\n input_lines = _read_protocol_input(sys.stdin)\n\n # Scope the request to https on the configured host. git treats an empty\n # response as \"I have nothing\", so returning 0 with no output lets it fall\n # through to any other helper or fail the auth cleanly — without us ever\n # emitting the token to the wrong host.\n authorized, reason = _is_authorized_request(input_lines)\n if not authorized:\n _log(f\"refusing to serve credentials: {reason}\")\n return 0\n\n try:\n credentials = _get_credentials()\n except Exception as e:\n _log(f\"failed to obtain credentials: {e}\")\n return 1\n\n _emit_response(input_lines, credentials)\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n The OpenCode session resumes with full workspace state intact.\n Git clone is skipped since the workspace already has all changes.\n\n Args:\n snapshot_image_id: Modal Image ID from snapshot_filesystem()\n session_config: Session configuration\n sandbox_id: Optional sandbox ID (generated if not provided)\n control_plane_url: URL for the control plane\n sandbox_auth_token: Auth token for the sandbox\n clone_token: VCS clone token for git operations\n\n Returns:\n SandboxHandle for the restored sandbox\n \"\"\"\n start_time = time.time()\n\n if isinstance(session_config, dict):\n repo_owner = session_config.get(\"repo_owner\")\n repo_name = session_config.get(\"repo_name\")\n else:\n repo_owner = session_config.repo_owner\n repo_name = session_config.repo_name\n _has_repository(repo_owner, repo_name)\n\n # Snapshot restore still passes the clone token through for\n # repo-backed sandboxes. Snapshots taken before the credential-helper\n # migration ship an entrypoint that reads VCS_CLONE_TOKEN from env\n # and embeds it in the origin URL; without it, those legacy snapshots\n # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\n # so the gh CLI keeps working on snapshots predating the gh wrapper.\n # Host scoping remains common with fresh creates. These compatibility\n # credentials are explicitly requested only by the restore path.\n handle = await self._launch_sandbox(\n _SandboxLaunchSpec(\n config=SandboxConfig(\n repo_owner=repo_owner,\n repo_name=repo_name,\n sandbox_id=sandbox_id,\n session_config=session_config,\n control_plane_url=control_plane_url,\n sandbox_auth_token=[REDACTED] timeout_seconds=timeout_seconds,\n user_env_vars=user_env_vars,\n code_server_enabled=code_server_enabled,\n vnc_enabled=vnc_enabled,\n agent_slack_notify_enabled=agent_slack_notify_enabled,\n settings=settings,\n ),\n source=_SnapshotImageSource(\n image_id=snapshot_image_id,\n clone_token=[REDACTED] ),\n )\n )\n\n duration_ms = int((time.time() - start_time) * 1000)\n log.info(\n \"sandbox.restore\",\n sandbox_id=handle.sandbox_id,\n modal_object_id=handle.modal_object_id,\n snapshot_image_id=snapshot_image_id,\n repo_owner=repo_owner,\n repo_name=repo_name,\n duration_ms=duration_ms,\n outcome=\"success\",\n )\ndiff --git a/packages/control-plane/src/image-builds/planner.ts b/packages/control-plane/src/image-builds/planner.ts\nindex 7fab22e9..4a481bbe 100644\n--- a/packages/control-plane/src/image-builds/planner.ts\n+++ b/packages/control-plane/src/image-builds/planner.ts\n@@ -17,7 +17,7 @@ import {\n resolveScopeTarget,\n type ResolvedImageBuildTarget,\n } from \"./scope\";\n-import type { ImageBuildCloneAuth, ImageBuildPlan } from \"./types\";\n+import type { ImageBuildCloneAuth, ImageBuildPlan, ImageBuildRepository } from \"./types\";\n \n const logger = createLogger(\"image-builds:planner\");\n const MS_PER_SECOND = 1000;\n@@ -88,7 +88,7 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n resolveScopeSandboxSettings(this.db, params.scope, primary),\n loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n- this.resolveCloneAuth(params.scope),\n+ this.resolveCloneAuth(params.scope, repositories),\n ]);\n \n const basePlan = {\n@@ -118,10 +118,15 @@ export class ImageBuildPlanner implements ImageBuildPlannerPort {\n };\n }\n \n- private async resolveCloneAuth(scope: ImageBuildScope): Promise<ImageBuildCloneAuth> {\n+ private async resolveCloneAuth(\n+ scope: ImageBuildScope,\n+ repositories: ImageBuildRepository[]\n+ ): Promise<ImageBuildCloneAuth> {\n try {\n const provider = createSourceControlProviderFromEnv(this.env);\n- const auth = await provider.generateCredentialHelperAuth();\n+ const auth = await provider.generateCredentialHelperAuth(\n+ repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n+ );\n return {\n type: \"credential_helper\",\n host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\ndiff --git a/packages/modal-infra/src/web_api.py b/packages/modal-infra/src/web_api.py\nindex 9362d47c..ed63e18b 100644\n--- a/packages/modal-infra/src/web_api.py\n+++ b/packages/modal-infra/src/web_api.py\n@@ -633,7 +633,9 @@ async def api_restore_sandbox(\n repo_name = parsed_request.session_config.repo_name\n \n manager = SandboxManager()\n- clone_token = resolve_clone_token() if repo_owner and repo_name else None\n+ clone_token = (\n+ resolve_clone_token(repo_owner, repo_name) if repo_owner and repo_name else None\n+ )\n \n # Restore sandbox from snapshot\n handle = await manager.restore_from_snapshot(\n"}}
{"status":"fulfilled","value":{"chunk_id":"205628","wall_time_seconds":0.000001166,"exit_code":1,"original_token_count":3453,"output":"\n⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/s3-object-storage.test.ts > createS3ObjectStorage\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/s3-object-storage.test.ts:142:3\n 140| let storage: ObjectStorage;\n 141|\n 142| beforeAll(async () => {\n | ^\n 143| await s3.start();\n 144| storage = createS3ObjectStorage({\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/44]⎯\n\n\n⎯⎯⎯⎯⎯⎯ Failed Tests 43 ⎯⎯⎯⎯⎯⎯⎯\n\n FAIL src/node/host.test.ts > startNodeHost > boots over the migrated global store and answers the health check and the route table\n FAIL src/node/host.test.ts > startNodeHost > refuses a WebSocket upgrade for an unknown session and any other upgrade path\n FAIL src/node/host.test.ts > startNodeHost > closes the cache database on a normal shutdown, not only on a failed boot\n FAIL src/node/host.test.ts > startNodeHost > reports draining once a shutdown begins and stops listening when it ends\n FAIL src/node/host.test.ts > startNodeHost > waits for a request in flight before closing the stores, and answers it\n FAIL src/node/host.test.ts > startNodeHost > gives up a request that outlives the budget and reports it\n FAIL src/node/host.test.ts > startNodeHost > marks a stop that abandoned nothing as clean\n FAIL src/node/host.test.ts > startNodeHost > arms a deadline a previous process left only in the session file\n FAIL src/node/host.test.ts > startNodeHost > fails to boot on a malformed encryption key with the Worker's message, leaving nothing open\n FAIL src/node/host.test.ts > startNodeHost > releases what it acquired when a later boot step fails\n FAIL src/node/http-server.test.ts > createNodeHttpServer > answers /healthz itself, 200 while serving and 503 while draining\n FAIL src/node/http-server.test.ts > createNodeHttpServer > hands every other request to the app as a fetch Request\n FAIL src/node/http-server.test.ts > createNodeHttpServer > routes an upgrade to the upgrade handler\n FAIL src/node/http-server.test.ts > createNodeHttpServer > logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection\n FAIL src/node/http-server.test.ts > createNodeHttpServer > tracks requests in flight and drains them within a budget\n FAIL src/node/http-server.test.ts > createNodeHttpServer > stops tracking a request whose handler rejected\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > adopts sockets under their tags and enumerates them by tag\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > returns no tags for a socket it never accepted\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards text frames as strings and binary frames as ArrayBuffers\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > answers the exact keepalive request without delivering it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers a runtime-initiated close to the peer and to the runtime, then drops the socket\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports a lost connection as an unclean 1006 close\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > delivers one socket's events in order, one at a time, close last\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > logs a failed delivery and keeps delivering\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > forwards socket errors to the runtime\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > reports an incomplete closing handshake as unclean even with a normal code\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > pauses a flooding peer while a delivery is in flight and loses nothing\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > closes a peer whose parsed backlog exceeds the bound instead of retaining it\n FAIL src/node/socket-host.test.ts > NodeWebSocketHost > satisfies the core's open check without the ambient WebSocket global\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 on any path but a session's\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 400 to an upgrade whose Host makes no URL\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 500 and logs when the path itself fails, so nothing rejects past it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 for a session the index does not know, without opening a runtime\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > answers 404 when the index knows the session but nothing is behind it\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > writes the session's rejection as the handshake's status\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > hands the session the upgrade as a request with its URL and headers\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > completes an accepted upgrade and the runtime exchanges messages on the socket\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > holds a frame sent on the 101 until the runtime has attached\n FAIL src/node/websocket-upgrade.test.ts > createSessionUpgradeHandler > closes the socket with 1011 when attachment fails\nError: listen EPERM: operation not permitted 127.0.0.1\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > routes socket events through the runtime, and keeps it resident until the socket closes\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > with sockets > shutdown closes adopted sockets with 1012 and delivers their close against an open store\nError: Hook timed out in 10000ms.\nIf this is a long-running hook, pass a timeout value as the last argument or configure it globally with \"hookTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:520:5\n 518| const clients: NodeWebSocket[] = [];\n 519|\n 520| beforeEach(async () => {\n | ^\n 521| wss = new WebSocketServer({ port: 0 });\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/44]⎯\n\n FAIL src/node/session-runtime-registry.test.ts > SessionRuntimeRegistry > shutdown closes a socket adopted under a lease that predates it\nError: Test timed out in 5000ms.\nIf this is a long-running test, pass a timeout value as the last argument or configure it globally with \"testTimeout\".\n ❯ src/node/session-runtime-registry.test.ts:620:3\n 618| });\n 619|\n 620| it(\"shutdown closes a socket adopted under a lease that predates it\"…\n | ^\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/44]⎯\n\n⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯\n\nVitest caught 4 unhandled errors during the test run.\nThis might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"routes socket events through the runtime, and keeps it resident until the socket closes\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:521:13\n 519|\n 520| beforeEach(async () => {\n 521| wss = new WebSocketServer({ port: 0 });\n | ^\n 522| await new Promise<void>((done) => wss.once(\"listening\", done));\n 523| });\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ wrapper ../../node_modules/@vitest/runner/dist/chunk-artifact.js:722:10\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2305:20\n ❯ runWithTimeout ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2272:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes adopted sockets with 1012 and delivers their close against an open store\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 127.0.0.1\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ node:net:2274:7\n ❯ processTicksAndRejections node:internal/process/task_queues:90:21\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '127.0.0.1' }\nThis error originated in \"src/node/s3-object-storage.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\n\n⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯\nError: listen EPERM: operation not permitted 0.0.0.0\n ❯ Server.setupListenHandle [as _listen2] node:net:1986:21\n ❯ listenInCluster node:net:2065:12\n ❯ Server.listen node:net:2170:7\n ❯ new WebSocketServer node_modules/ws/lib/websocket-server.js:112:20\n ❯ src/node/session-runtime-registry.test.ts:623:17\n 621| // The upgrade path authorizes under a lease and adopts afterwards…\n 622| // shutdown that began during the authorization still closes the s…\n 623| const wss = new WebSocketServer({ port: 0 });\n | ^\n 624| await new Promise<void>((done) => wss.once(\"listening\", done));\n 625| const client = new NodeWebSocket(`ws://127.0.0.1:${(wss.address() …\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:302:11\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:1903:26\n ❯ ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2326:20\n ❯ runWithCancel ../../node_modules/@vitest/runner/dist/chunk-artifact.js:2323:10\n\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\nSerialized Error: { code: 'EPERM', errno: -1, syscall: 'listen', address: '0.0.0.0' }\nThis error originated in \"src/node/session-runtime-registry.test.ts\" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.\nThe latest test that might've caused the error is \"shutdown closes a socket adopted under a lease that predates it\". It might mean one of the following:\n- The error was thrown, while Vitest was running this test.\n- If the error occurred after the test had been completed, this was the last documented test before it was thrown.\n⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯\n\n\n Test Files 6 failed | 280 passed (286)\n Tests 43 failed | 4255 passed | 11 skipped (4309)\n Errors 4 errors\n Start at 16:09:54\n Duration 43.47s (transform 27.19s, setup 0ms, import 125.77s, tests 82.37s, environment 65ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}}Both Python suites passed: 7 tests each, using the installed environments with uv --offline --no-sync. The independent probe also passed: permissions={} raised ValueError before constructing an HTTP client.
The full control-plane run failed: 4,255 passed, 43 failed, and 11 skipped. The failures involve blocked socket binds (listen EPERM) and related timeouts. I’m adding a throwaway check for multi-repo image-build credentials, since the existing planner tests don’t explicitly assert sibling-repository scope.
>sed -n '1,175p' packages/control-plane/src/source-control/provider-from-env.ts; sed -n '1,130p' packages/control-plane/src/auth/github-app.ts; rg -n 'resolveScopeSandboxSettings' packages/control-plane/src/image-builds/scope.ts; sed -n '1,160p' packages/control-plane/src/source-control/providers/github-provider.ts; rg -n 'GITHUB_APP_INSTALLATION_ID|GITHUB_APP_ID' packages/control-plane/src/source-control/provider-from-env.ts; sed -n '1,90p' packages/control-plane/src/auth/github-app.test.ts; cat packages/sandbox-runtime/tests/conftest.py; cat packages/modal-infra/tests/conftest.py{"chunk_id":"97bd3f","wall_time_seconds":0.000014292,"exit_code":0,"original_token_count":5233,"output":"import { resolveAppName } from \"@open-inspect/shared/app-name\";\nimport { getGitHubAppConfig } from \"../auth/github-app\";\nimport type { Env } from \"../types\";\nimport { resolveScmProviderFromEnv } from \"./config\";\nimport { createSourceControlProvider } from \"./providers\";\nimport type { SourceControlProvider } from \"./types\";\n\nexport function createSourceControlProviderFromEnv(env: Env): SourceControlProvider {\n const appConfig = getGitHubAppConfig(env);\n const provider = resolveScmProviderFromEnv(env.SCM_PROVIDER);\n const userAgent = resolveAppName(env);\n\n return createSourceControlProvider({\n provider,\n github: {\n appConfig: appConfig ?? undefined,\n cacheStore: env.REPOS_CACHE,\n userAgent,\n },\n ...(env.GITLAB_ACCESS_TOKEN\n ? {\n gitlab: {\n accessToken: env.GITLAB_ACCESS_TOKEN,\n namespace: env.GITLAB_NAMESPACE,\n userAgent,\n },\n }\n : {}),\n });\n}\n/**\n * GitHub App authentication for generating installation tokens.\n *\n * Uses Web Crypto API for RSA-SHA256 signing (available in Cloudflare Workers).\n *\n * Token flow:\n * 1. Generate JWT signed with App's private key\n * 2. Exchange JWT for installation access token via GitHub API\n * 3. Token valid for 1 hour\n */\n\nimport type { InstallationRepository } from \"@open-inspect/shared/types/repository-catalog\";\nimport { DEFAULT_APP_NAME } from \"@open-inspect/shared/app-name\";\nimport type { CacheStore } from \"@open-inspect/shared/cache-store\";\nimport { z } from \"zod\";\n\nimport { base64UrlEncode } from \"./encoding\";\n\n/** Timeout for individual GitHub API requests (ms). */\nconst GITHUB_FETCH_TIMEOUT_MS = 60_000;\n\n/** Cache installation tokens for this duration at most (ms). */\nexport const INSTALLATION_TOKEN_CACHE_MAX_AGE_MS = 50 * 60 * 1000;\n\n/** Require at least this much remaining lifetime before using a cached token (ms). */\nexport const INSTALLATION_TOKEN_MIN_REMAINING_MS = 5 * 60 * 1000;\n\n/** Upper bound for KV cache TTL (seconds). */\nconst INSTALLATION_TOKEN_CACHE_MAX_TTL_SECONDS = 3600;\n\nconst INSTALLATION_TOKEN_CACHE_KEY_PREFIX = \"github:installation-token:v1\";\n\ninterface InstallationTokenCacheBindings {\n cacheStore?: CacheStore;\n /** User-Agent header sent on outbound GitHub API requests. */\n userAgent?: string;\n}\n\nfunction resolveUserAgent(env: InstallationTokenCacheBindings | undefined): string {\n const value = env?.userAgent?.trim();\n return value && value.length > 0 ? value : DEFAULT_APP_NAME;\n}\n\nconst cachedInstallationTokenSchema = z.object({\n token: z.string(),\n expiresAtEpochMs: z.number(),\n cachedAtEpochMs: z.number(),\n});\n\ntype CachedInstallationToken = z.infer<typeof cachedInstallationTokenSchema>;\n\ninterface GitHubHttpError extends Error {\n status?: number;\n}\n\nfunction createHttpError(message: string, status: number): GitHubHttpError {\n const error = new Error(message) as GitHubHttpError;\n error.status = status;\n return error;\n}\n\nconst installationTokenMemoryCache = new Map<string, CachedInstallationToken>();\nconst installationTokenRefreshInFlight = new Map<string, Promise<CachedInstallationToken>>();\nconst importedPrivateKeyCache = new Map<string, Promise<CryptoKey>>();\n\n/** Fetch with an AbortController timeout. */\nexport function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs = GITHUB_FETCH_TIMEOUT_MS\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer));\n}\n\n/** Per-page timing record returned from listInstallationRepositories. */\ninterface GitHubPageTiming {\n page: number;\n fetchMs: number;\n repoCount: number;\n}\n\n/** Timing breakdown returned alongside repos from listInstallationRepositories. */\nexport interface ListReposTiming {\n tokenGenerationMs: number;\n pages: GitHubPageTiming[];\n totalPages: number;\n totalRepos: number;\n}\n\n/**\n * Configuration for GitHub App authentication.\n */\nexport interface GitHubAppConfig {\n appId: string;\n privateKey: string; // PEM format\n installationId: string;\n}\n\nconst installationTokenResponseSchema = z\n .object({\n token: z.string(),\n expires_at: z.string().refine((value) => Number.isFinite(Date.parse(value))),\n })\n .transform(({ token, expires_at }) => ({\n token,\n expiresAtEpochMs: Date.parse(expires_at),\n }));\n\n/** GitHub installation token response. */\ntype InstallationTokenResponse = z.infer<typeof installationTokenResponseSchema>;\n\nconst installationRepositorySchema = z.object({\n id: z.number(),\n name: z.string(),\n full_name: z.string(),\n description: z.string().nullable(),\n private: z.boolean(),\n archived: z.boolean(),\n default_branch: z.string(),\n language: z.string().nullable().optional(),\n topics: z.array(z.string()).optional(),\n owner: z.object({ login: z.string() }),\n});\n\nconst listInstallationReposResponseSchema = z.object({\n total_count: z.number(),\n repositories: z.array(installationRepositorySchema),\n});\n256:export async function resolveScopeSandboxSettings(\n/**\n * GitHub source control provider implementation.\n *\n * Implements the SourceControlProvider interface for GitHub,\n * wrapping existing GitHub API functions.\n */\n\nimport { z } from \"zod\";\nimport type { InstallationRepository } from \"@open-inspect/shared/types/repository-catalog\";\nimport type { PullRequestStatus } from \"@open-inspect/shared/types/artifacts\";\nimport type {\n SourceControlProvider,\n SourceControlAuthContext,\n GetRepositoryConfig,\n RepositoryAccessResult,\n RepositoryInfo,\n CreatePullRequestConfig,\n CreatePullRequestResult,\n GetPullRequestConfig,\n PullRequestSnapshot,\n BuildManualPullRequestUrlConfig,\n BuildGitPushSpecConfig,\n GitPushSpec,\n GitPushAuthContext,\n CredentialHelperAuth,\n ResolvedCommit,\n RepositoryTree,\n} from \"../types\";\nimport {\n readResponseBytesWithinLimit,\n SourceControlProviderError,\n parseProviderResponse,\n} from \"../errors\";\nimport { classifyGitTreeEntry } from \"./git-tree\";\nimport {\n getCachedInstallationToken,\n getScopedInstallationTokenWithExpiry,\n getInstallationRepository,\n listInstallationRepositories,\n listRepositoryBranches,\n fetchWithTimeout,\n} from \"../../auth/github-app\";\nimport type { GitHubProviderConfig } from \"./types\";\nimport { USER_AGENT, GITHUB_API_BASE } from \"./constants\";\n\n/** Extract HTTP status from upstream errors (GitHubHttpError has a .status property). */\nfunction extractHttpStatus(error: unknown): number | undefined {\n if (error && typeof error === \"object\" && \"status\" in error && typeof error.status === \"number\") {\n return error.status;\n }\n return undefined;\n}\n\n/** GitHub pull-request state fields as the REST API reports them. */\ninterface GitHubPullRequestStateFields {\n /** GitHub's wire state is strictly open/closed; merged is a separate flag. */\n state: \"open\" | \"closed\";\n draft?: boolean | null;\n merged?: boolean | null;\n}\n\n/**\n * Pure mapping from GitHub's PR state fields to the stored status. GitHub\n * models merged as state \"closed\" + merged true; terminal states win over a\n * stale draft flag (isDraft is only meaningful while open). Shared by\n * createPullRequest (user-authed) and getPullRequest (app-authed).\n */\nexport function deriveGitHubPullRequestStatus(\n data: GitHubPullRequestStateFields\n): PullRequestStatus {\n if (data.merged) return { lifecycleState: \"merged\", isDraft: false };\n if (data.state === \"closed\") return { lifecycleState: \"closed\", isDraft: false };\n return { lifecycleState: \"open\", isDraft: data.draft === true };\n}\n\n/**\n * Wire schema of a GitHub REST pull request, limited to the fields we read.\n * `state` is a strict enum — an unexpected value is schema drift and fails\n * the parse rather than being coerced into an apparently-valid status.\n */\nconst githubPullResponseSchema = z.object({\n number: z.number(),\n html_url: z.string(),\n url: z.string(),\n state: z.enum([\"open\", \"closed\"]),\n draft: z.boolean().nullable().optional(),\n merged: z.boolean().nullable().optional(),\n created_at: z.string().optional(),\n updated_at: z.string().optional(),\n merged_at: z.string().nullable().optional(),\n closed_at: z.string().nullable().optional(),\n head: z.object({ ref: z.string(), sha: z.string().optional() }),\n base: z.object({\n ref: z.string(),\n repo: z\n .object({\n id: z.number().optional(),\n name: z.string().optional(),\n owner: z.object({ login: z.string().optional() }).optional(),\n })\n .nullable()\n .optional(),\n }),\n});\n\n/** Wire shape of GET /repositories/{id}, limited to the location fields. */\nconst githubRepositoryLocationSchema = z.object({\n name: z.string(),\n owner: z.object({ login: z.string() }),\n});\n\n/** Wire shape of GET /repos/{owner}/{repo}, limited to fields used for repo metadata. */\nconst githubRepositoryInfoSchema = z.object({\n id: z.number().int(),\n name: z.string(),\n full_name: z.string(),\n default_branch: z.string(),\n private: z.boolean(),\n owner: z.object({ login: z.string() }),\n});\n\n/** Wire shape of a GitHub git-ref response, limited to the branch head SHA. */\nconst githubBranchRefSchema = z.object({\n object: z.object({ sha: z.string().min(1) }),\n});\n\nconst githubFeedbackAuthorSchema = z.object({\n id: z.number(),\n login: z.string(),\n type: z.string(),\n});\n\nconst githubPullRequestCommentSchema = z.object({\n id: z.number(),\n body: z.string(),\n html_url: z.url(),\n issue_url: z.url(),\n user: githubFeedbackAuthorSchema,\n});\n\nconst githubPullRequestReviewSchema = z.object({\n id: z.number(),\n body: z.string().nullable(),\n html_url: z.url(),\n pull_request_url: z.url(),\n state: z.enum([\"PENDING\", \"COMMENTED\", \"APPROVED\", \"CHANGES_REQUESTED\", \"DISMISSED\"]),\n user: githubFeedbackAuthorSchema,\n});\n\nconst githubReviewCommentSchema = z.object({\n id: z.number(),\n in_reply_to_id: z.number().nullable().optional(),\n body: z.string(),\n html_url: z.url(),\n path: z.string(),\n line: z.number().nullable().optional(),\n start_line: z.number().nullable().optional(),\n side: z.string().nullable().optional(),\n start_side: z.string().nullable().optional(),\n diff_hunk: z.string(),\nimport { afterEach, describe, it, expect, vi } from \"vitest\";\nimport {\n isGitHubAppConfigured,\n getGitHubAppConfig,\n getCachedInstallationToken,\n getCachedInstallationTokenWithExpiry,\n getInstallationRepository,\n INSTALLATION_TOKEN_CACHE_MAX_AGE_MS,\n INSTALLATION_TOKEN_MIN_REMAINING_MS,\n listInstallationRepositories,\n listRepositoryBranches,\n} from \"./github-app\";\nimport type { CacheStore } from \"@open-inspect/shared/cache-store\";\n\nclass FakeCacheStore implements CacheStore {\n private readonly store = new Map<string, string>();\n\n async get(key: string): Promise<string | null>;\n async get(key: string, type: \"json\"): Promise<unknown | null>;\n async get(key: string, type?: \"json\"): Promise<string | unknown | null> {\n const value = this.store.get(key);\n if (value == null) {\n return null;\n }\n if (type !== \"json\") {\n return value;\n }\n return JSON.parse(value);\n }\n\n async put(key: string, value: string): Promise<void> {\n this.store.set(key, value);\n }\n\n async delete(key: string): Promise<void> {\n this.store.delete(key);\n }\n}\n\n/** Generate a PKCS#8 PEM RSA key pair for testing. */\nasync function generateTestKeyPair(): Promise<{ privateKeyPem: string }> {\n const keyPair = (await crypto.subtle.generateKey(\n {\n name: \"RSASSA-PKCS1-v1_5\",\n modulusLength: 2048,\n publicExponent: new Uint8Array([1, 0, 1]),\n hash: \"SHA-256\",\n },\n true,\n [\"sign\", \"verify\"]\n )) as CryptoKeyPair;\n\n const exported = (await crypto.subtle.exportKey(\"pkcs8\", keyPair.privateKey)) as ArrayBuffer;\n const base64 = btoa(String.fromCharCode(...new Uint8Array(exported)));\n const lines = base64.match(/.{1,64}/g)!.join(\"\\n\");\n return { privateKeyPem: `[REDACTED]` };\n}\n\nconst cachedTokenConfig = (suffix: string) => ({\n appId: `app-${suffix}-${Date.now()}`,\n privateKey: \"[REDACTED]\",\n installationId: `installation-${suffix}`,\n});\n\nasync function cacheInstallationToken(\n cacheStore: FakeCacheStore,\n config: ReturnType<typeof cachedTokenConfig>\n): Promise<void> {\n await cacheStore.put(\n `github:installation-token:v1:${config.appId}:${config.installationId}`,\n JSON.stringify({\n token: \"cached-token\",\n expiresAtEpochMs:\n Date.now() + INSTALLATION_TOKEN_CACHE_MAX_AGE_MS + INSTALLATION_TOKEN_MIN_REMAINING_MS,\n cachedAtEpochMs: Date.now(),\n })\n );\n}\n\nconst githubRepoResponse = {\n id: 123,\n name: \"background-agents\",\n full_name: \"open-inspect/background-agents\",\n description: null,\n private: true,\n archived: false,\n default_branch: \"main\",\n language: null,\n topics: [\"agents\", \"automation\"],\n owner: { login: \"open-inspect\" },\n\"\"\"Shared test fixtures and utilities for sandbox-runtime tests.\"\"\"\n\nfrom typing import TYPE_CHECKING, Any\n\nimport httpx\nimport pytest\n\nfrom sandbox_runtime.harness import EventSink, HarnessPrompt, PromptLimits, TurnOutcome\nfrom sandbox_runtime.harness.opencode import OpencodeHarness\nfrom sandbox_runtime.harness.opencode_client import OpenCodeClient\n\nif TYPE_CHECKING:\n from collections.abc import AsyncIterator, Callable\n\n from sandbox_runtime.bridge import AgentBridge\n\n\[REDACTED](autouse=True)\ndef isolate_runtime_file_paths(tmp_path, monkeypatch):\n \"\"\"Redirect the runtime's fixed file paths to per-test locations.\n\n This suite routinely runs inside a live Open-Inspect sandbox (agents\n dogfooding on this repo), where /tmp/oi-repo-manifest.json is the running\n session's real manifest. A test that drives a real SandboxSupervisor\n (e.g. ``await sup.run()``) would otherwise overwrite it with fixture\n repos, which breaks push targeting and PR creation for the live session —\n and likewise delete the live boot-warnings file or read the live\n tunnel-env file. Tests that care about a specific path still patch it\n themselves; this fixture is the backstop that keeps every other test off\n the real files.\n \"\"\"\n manifest_path = str(tmp_path / \"oi-repo-manifest.json\")\n boot_warnings_path = str(tmp_path / \"oi-boot-warnings.jsonl\")\n tunnel_env_path = str(tmp_path / \".tunnels.env\")\n monkeypatch.setattr(\"sandbox_runtime.repository_boot.REPO_MANIFEST_FILE_PATH\", manifest_path)\n monkeypatch.setattr(\"sandbox_runtime.bridge.REPO_MANIFEST_FILE_PATH\", manifest_path)\n monkeypatch.setattr(\"sandbox_runtime.boot_warnings.BOOT_WARNINGS_FILE_PATH\", boot_warnings_path)\n monkeypatch.setattr(\"sandbox_runtime.supervisor.BOOT_WARNINGS_FILE_PATH\", boot_warnings_path)\n monkeypatch.setattr(\"sandbox_runtime.bridge.BOOT_WARNINGS_FILE_PATH\", boot_warnings_path)\n monkeypatch.setattr(\"sandbox_runtime.tunnel_environment.TUNNEL_ENV_FILE_PATH\", tunnel_env_path)\n\n\ndef wire_opencode_transport(bridge: \"AgentBridge\", http_client: Any) -> Any:\n \"\"\"Point a bridge's OpenCode harness at a fake HTTP transport (test seam).\n\n Rebuilds ``bridge.harness`` around the fake (carrying the vendor session\n id over), so the lazily built prompt stream rebinds to the new client,\n and stashes the fake on ``bridge.http_client`` so tests can read it back\n to script responses. Returns the fake for convenience.\n \"\"\"\n previous = bridge.harness\n assert isinstance(previous, OpencodeHarness)\n harness = OpencodeHarness(\n client=OpenCodeClient(\n base_url=f\"http://localhost:{bridge.opencode_port}\",\n log=bridge.log,\n http_client=http_client,\n ),\n attachment_processor=bridge.attachment_processor,\n log=bridge.log,\n limits=previous.limits,\n )\n harness.session_id = previous.session_id\n bridge.harness = harness\n bridge.http_client = http_client\n return http_client\n\n\ndef set_prompt_limits(bridge: \"AgentBridge\", **overrides: float) -> None:\n \"\"\"Adjust per-prompt budgets before the harness builds its prompt stream.\"\"\"\n from dataclasses import replace\n\n assert isinstance(bridge.harness, OpencodeHarness)\n bridge.prompt_limits = replace(bridge.prompt_limits, **overrides)\n bridge.harness.limits = bridge.prompt_limits\n\n\ndef stream_opencode_events(\n bridge: \"AgentBridge\",\n message_id: str,\n content: str,\n *,\n model: str | None = None,\n reasoning_effort: str | None = None,\n attachments: list[Any] | None = None,\n) -> \"AsyncIterator[dict[str, Any]]\":\n \"\"\"The raw translated event stream of one OpenCode prompt (what run_prompt drains).\"\"\"\n assert isinstance(bridge.harness, OpencodeHarness)\n return bridge.harness.stream_events(\n HarnessPrompt(\n message_id=message_id,\n text=content,\n model=model,\n reasoning_effort=reasoning_effort,\n attachments=tuple(attachments or ()),\n )\n )\n\n\nclass ScriptedHarness:\n \"\"\"An ``AgentHarness`` that replays a scripted event stream (bridge tests).\n\n ``stream`` is an async-generator function; it is called once per prompt\n and its events are emitted verbatim. The outcome is derived exactly as\n the OpenCode harness derives it: an ``error`` event fails the turn, the\n last ``step_finish.messageCostUsd`` is the turn cost.\n \"\"\"\n\n from sandbox_runtime.harness import HarnessId\n\n id = HarnessId.OPENCODE\n\n def __init__(\n self,\n stream: \"Callable[..., AsyncIterator[dict[str, Any]]] | None\" = None,\n *,\n session_id: str | None = \"oc-session-123\",\n ) -> None:\n self.stream = stream\n self.session_id = session_id\n self.prompts: list[HarnessPrompt] = []\n self.abort_calls = 0\n self.opened = False\n self.closed = False\n\n async def open(self) -> None:\n self.opened = True\n\n async def close(self) -> None:\n self.closed = True\n\n async def resume_session(self, persisted_id: str) -> bool:\n self.session_id = persisted_id\n return True\n\n async def create_session(self) -> None:\n self.session_id = self.session_id or \"oc-session-new\"\n\n async def run_prompt(self, prompt: HarnessPrompt, emit: EventSink) -> TurnOutcome:\n self.prompts.append(prompt)\n if self.stream is None:\n return TurnOutcome.ok()\n error: str | None = None\n cost: float | None = None\n async for event in self.stream(prompt.message_id, prompt.text):\n if event.get(\"type\") == \"error\":\n error = str(event.get(\"error\"))\n if event.get(\"type\") == \"step_finish\" and \"messageCostUsd\" in event:\n cost = event[\"messageCostUsd\"]\n await emit(event)\n if error is not None:\n return TurnOutcome.failed(error, message_cost_usd=cost)\n return TurnOutcome.ok(message_cost_usd=cost)\n\n async def abort(self) -> bool:\n self.abort_calls += 1\n return True\n\n\n__all__ = [\n \"MockResponse\",\n \"PromptLimits\",\n \"ScriptedHarness\",\n \"oc_message_id\",\n \"set_prompt_limits\",\n \"stream_opencode_events\",\n \"wire_opencode_transport\",\n]\n\n\nclass MockResponse:\n \"\"\"Mock HTTP response for testing.\"\"\"\n\n def __init__(self, status_code: int, json_data: Any = None, text: str = \"\"):\n self.status_code = status_code\n self._json_data = json_data\n self.text = text\n\n def json(self) -> Any:\n return self._json_data\n\n def raise_for_status(self) -> None:\n if self.status_code >= 400:\n raise httpx.HTTPStatusError(\n f\"HTTP {self.status_code}\",\n request=httpx.Request(\"GET\", \"http://test\"),\n response=httpx.Response(self.status_code),\n )\n\n\ndef oc_message_id(timestamp_ms: int, counter: int, suffix: str = \"a\") -> str:\n \"\"\"Build a valid OpenCode ascending message ID at a chosen creation point.\n\n Mirrors OpenCodeIdentifier's format: ``msg_`` + 12 hex chars encoding\n ``timestamp_ms * 0x1000 + counter`` + 14 base62 chars. Deterministic\n inputs let boundary tests place IDs immediately before, at, or after a\n prompt's user message instead of relying on ad-hoc strings that happen\n to compare in the desired order.\n \"\"\"\n encoded = (timestamp_ms * 0x1000 + counter) & 0xFFFFFFFFFFFF\n return \"msg_\" + encoded.to_bytes(6, byteorder=\"big\").hex() + (suffix * 14)[:14]\n\"\"\"Register test functions with an inert image reference, without provider access.\"\"\"\n\nimport importlib\nimport os\nfrom unittest.mock import patch\n\n# Production imports require a verified image. Tests mock all native operations;\n# keep their declaration-only reference out of the environment used by tests.\nwith (\n patch(\"modal.is_local\", return_value=False),\n patch.dict(os.environ, {\"OPENINSPECT_MODAL_BASE_IMAGE_ID\": \"im-test-functions\"}),\n):\n importlib.import_module(\"src\")\n"}>cat > packages/control-plane/src/image-builds/review-scope-probe.tmp.test.ts <<'TS'
import { generateKeyPairSync } from "node:crypto";
import { afterEach, expect, it, vi } from "vitest";
import { ImageBuildPlanner } from "./planner";
import { getScopedInstallationTokenWithExpiry } from "../auth/github-app";
import type { Env } from "../types";
import type { SqlDatabase } from "../db/sql-database";
vi.mock("./scope", () => ({
resolveScopeTarget: vi.fn(),
resolveScopeSandboxSettings: vi.fn().mockResolvedValue(undefined),
loadScopeBuildSecrets: vi.fn().mockResolvedValue(undefined),
}));
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
const config = { appId: "123", installationId: "456", privateKey };
const env = {
SCM_PROVIDER: "github",
GITHUB_APP_ID: config.appId,
GITHUB_APP_INSTALLATION_ID: config.installationId,
GITHUB_APP_PRIVATE_KEY: privateKey,
} as Env;
const repositories = [
{ repoOwner: "acme", repoName: "primary", baseBranch: "main" },
{ repoOwner: "acme", repoName: "sibling", baseBranch: "main" },
];
const request = {
buildId: "probe-build",
scope: { kind: "environment" as const, id: "probe-env" },
target: { kind: "environment" as const, repositories, repositoriesFingerprint: "probe" },
callbackUrl: "https://invalid.example/callback",
failureCallbackUrl: "https://invalid.example/failure",
correlation: {},
callbackAuth: { token: "fake-callback", tokenHash: "fake-hash", expiresAt: Date.now() + 60000 },
};
afterEach(() => vi.unstubAllGlobals());
it("planner sends every build repository and git-only permissions through the real provider and mint code", async () => {
const fetch = vi.fn().mockResolvedValue(Response.json({ token: "scoped-probe", expires_at: new Date(Date.now() + 3600000).toISOString() }));
vi.stubGlobal("fetch", fetch);
const plan = await new ImageBuildPlanner(env, {} as SqlDatabase).planBuild(request);
expect(plan.cloneAuth).toMatchObject({ type: "credential_helper", token: "scoped-probe" });
expect(fetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
repositories: ["primary", "sibling"], permissions: { contents: "write", metadata: "read" },
});
});
it("planner denies credentials after a narrowing rejection without retrying broadly", async () => {
const fetch = vi.fn().mockResolvedValue(new Response("rejected", { status: 422 }));
vi.stubGlobal("fetch", fetch);
const plan = await new ImageBuildPlanner(env, {} as SqlDatabase).planBuild(request);
expect(plan.cloneAuth).toEqual({ type: "unavailable" });
expect(fetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetch.mock.calls[0][1].body).repositories).toEqual(["primary", "sibling"]);
});
it("the TypeScript mint rejects empty repository and permission scopes before HTTP", async () => {
const fetch = vi.fn();
vi.stubGlobal("fetch", fetch);
await expect(getScopedInstallationTokenWithExpiry(config, [])).rejects.toThrow("no repositories");
await expect(getScopedInstallationTokenWithExpiry(config, ["primary"], undefined, {})).rejects.toThrow("no permissions");
expect(fetch).not.toHaveBeenCalled();
});
TS
npm run test -w @open-inspect/control-plane -- src/image-builds/review-scope-probe.tmp.test.ts src/session/http/handlers/sandbox.handler.test.ts src/source-control/providers/github-provider.test.ts src/session/scm-credentials-service.test.ts src/routes/image-builds.trigger.test.ts{"chunk_id":"4201df","wall_time_seconds":1.002183917,"session_id":71135,"original_token_count":95,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run src/image-builds/review-scope-probe.tmp.test.ts src/session/http/handlers/sandbox.handler.test.ts src/source-control/providers/github-provider.test.ts src/session/scm-credentials-service.test.ts src/routes/image-builds.trigger.test.ts\n\n\n RUN v4.1.9 /Users/gaganarora/workplace/open-inspect-72e95a/packages/control-plane\n\n"}>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv uv run --offline --no-sync python -c 'import sandbox_runtime.auth.github_app as module; print(module.__file__)'{"status":"fulfilled","value":{"chunk_id":"540b0c","wall_time_seconds":1.786461583,"exit_code":0,"original_token_count":28,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"277310","wall_time_seconds":1.866378,"exit_code":0,"original_token_count":50,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n/Users/gaganarora/workplace/open-inspect-72e95a/packages/modal-infra/src/__init__.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"8e5580","wall_time_seconds":0.0017035,"exit_code":0,"original_token_count":43,"output":"\n Test Files 5 passed (5)\n Tests 154 passed (154)\n Start at 16:12:07\n Duration 2.37s (transform 2.17s, setup 0ms, import 3.39s, tests 266ms, environment 1ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"e34a4a","wall_time_seconds":0.182027166,"exit_code":0,"original_token_count":1393,"output":" 336\t\n 337\t async scmCredentials(log: Logger): Promise<Response> {\n 338\t const session = this.sessionCoreRepository.getSession();\n 339\t if (!session) {\n 340\t return Response.json({ error: \"No session\" }, { status: 404 });\n 341\t }\n 342\t if (!session.repo_owner || !session.repo_name) {\n 343\t return Response.json(\n 344\t { error: \"SCM credentials require a repository context\" },\n 345\t { status: 400 }\n 346\t );\n 347\t }\n 348\t\n 349\t // The full member repository set, not just the primary — a session's\n 350\t // sandbox may need to clone/push/fetch sibling repositories, and a\n 351\t // credential scoped to the primary alone would deny those operations.\n 352\t const repos = this.sessionCoreRepository\n 353\t .getSessionRepositories()\n 354\t .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n 355\t\n 356\t const result = await this.getScmCredentials(repos, log);\n 357\t if (!result.ok) {\n 358\t return Response.json({ error: result.error }, { status: result.status });\n 359\t }\n 360\t\n 82\t }\n 83\t\n 84\t async planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan> {\n 85\t const { repositories, repositoriesFingerprint } = params.target;\n 86\t const primary = repositories[0];\n 87\t\n 88\t const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n 89\t resolveScopeSandboxSettings(this.db, params.scope, primary),\n 90\t loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n 91\t this.resolveCloneAuth(params.scope, repositories),\n 92\t ]);\n 93\t\n 94\t const basePlan = {\n 95\t buildId: params.buildId,\n 120\t\n 121\t private async resolveCloneAuth(\n 122\t scope: ImageBuildScope,\n 123\t repositories: ImageBuildRepository[]\n 124\t ): Promise<ImageBuildCloneAuth> {\n 125\t try {\n 126\t const provider = createSourceControlProviderFromEnv(this.env);\n 127\t const auth = await provider.generateCredentialHelperAuth(\n 128\t repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n 129\t );\n 130\t return {\n 131\t type: \"credential_helper\",\n 132\t host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\n 133\t username: auth.username,\n 134\t token: auth.password,\n 135\t };\n 136\t } catch (e) {\n 137\t logger.warn(\"image_build.clone_token_failed\", {\n 138\t error: e instanceof Error ? e.message : String(e),\n 139\t scope_kind: scope.kind,\n 140\t scope_id: scope.id,\n 29\t if scm_provider == \"gitlab\":\n 30\t token = os.environ.get(\"GITLAB_ACCESS_TOKEN\")\n 31\t if not token:\n 32\t log.warn(\"gitlab.token_missing\")\n 33\t return token\n 34\t\n 35\t if not repo_name:\n 36\t log.warn(\"github.repo_context_missing\", repo_owner=repo_owner, repo_name=repo_name)\n 37\t return None\n 38\t\n 39\t try:\n 40\t app_id = os.environ.get(\"GITHUB_APP_ID\")\n 41\t private_key = os.environ.get(\"GITHUB_APP_PRIVATE_KEY\")\n 42\t installation_id = os.environ.get(\"GITHUB_APP_INSTALLATION_ID\")\n 43\t\n 44\t if app_id and private_key and installation_id:\n 45\t return generate_installation_token(\n 46\t app_id=app_id,\n 47\t private_key=[REDACTED] 48\t installation_id=installation_id,\n 49\t repository=repo_name,\n 50\t permissions=SANDBOX_SCOPED_PERMISSIONS,\n 51\t )\n 52\t except Exception as e:\n 53\t log.warn(\"github.token_error\", exc=e, repo_owner=repo_owner, repo_name=repo_name)\n 54\t\n 55\t return None\n 70\t Raises:\n 71\t ValueError: if `permissions` is given without `repository`, or if\n 72\t either is an empty container — an empty `permissions={}` would\n 73\t make GitHub omit the field entirely and return the installation's\n 74\t full, unnarrowed permission set, which is exactly the silent\n 75\t widening this function must never do.\n 76\t httpx.HTTPStatusError: If the GitHub API request fails\n 77\t \"\"\"\n 78\t if permissions is not None and repository is None:\n 79\t raise ValueError(\"permissions requires repository — it narrows nothing on its own\")\n 80\t if repository is not None and not repository:\n 81\t raise ValueError(\"repository must be non-empty when provided\")\n 82\t if permissions is not None and not permissions:\n 83\t raise ValueError(\"permissions must be non-empty when provided\")\n 84\t\n 85\t url = f\"https://api.github.com/app/installations/{installation_id}/access_tokens\"\n 86\t headers = {\n 87\t \"Authorization\": f\"Bearer {jwt_token}\",\n 88\t \"Accept\": \"application/vnd.github+json\",\n 89\t \"X-GitHub-Api-Version\": \"2022-11-28\",\n 90\t }\n 91\t body: dict[str, object] = {}\n 92\t if repository is not None:\n 93\t body[\"repositories\"] = [repository]\n 94\t if permissions is not None:\n 95\t body[\"permissions\"] = permissions\n 96\t\n 97\t with httpx.Client() as client:\n 98\t response = client.post(url, headers=headers, json=body or None)\n 99\t response.raise_for_status()\n 100\t return response.json()[\"token\"]\n 101\t\n 102\t\n?? packages/control-plane/src/image-builds/review-scope-probe.tmp.test.ts\n"}}>UV_CACHE_DIR=/private/tmp/open-inspect-review-uv uv run --offline --no-sync python -c 'import sandbox_runtime.auth.github_app as module; print(module.__file__); import importlib.util; print(importlib.util.find_spec("src").origin)'{"status":"fulfilled","value":{"chunk_id":"540b0c","wall_time_seconds":1.786461583,"exit_code":0,"original_token_count":28,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"277310","wall_time_seconds":1.866378,"exit_code":0,"original_token_count":50,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n/Users/gaganarora/workplace/open-inspect-72e95a/packages/modal-infra/src/__init__.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"8e5580","wall_time_seconds":0.0017035,"exit_code":0,"original_token_count":43,"output":"\n Test Files 5 passed (5)\n Tests 154 passed (154)\n Start at 16:12:07\n Duration 2.37s (transform 2.17s, setup 0ms, import 3.39s, tests 266ms, environment 1ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"e34a4a","wall_time_seconds":0.182027166,"exit_code":0,"original_token_count":1393,"output":" 336\t\n 337\t async scmCredentials(log: Logger): Promise<Response> {\n 338\t const session = this.sessionCoreRepository.getSession();\n 339\t if (!session) {\n 340\t return Response.json({ error: \"No session\" }, { status: 404 });\n 341\t }\n 342\t if (!session.repo_owner || !session.repo_name) {\n 343\t return Response.json(\n 344\t { error: \"SCM credentials require a repository context\" },\n 345\t { status: 400 }\n 346\t );\n 347\t }\n 348\t\n 349\t // The full member repository set, not just the primary — a session's\n 350\t // sandbox may need to clone/push/fetch sibling repositories, and a\n 351\t // credential scoped to the primary alone would deny those operations.\n 352\t const repos = this.sessionCoreRepository\n 353\t .getSessionRepositories()\n 354\t .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n 355\t\n 356\t const result = await this.getScmCredentials(repos, log);\n 357\t if (!result.ok) {\n 358\t return Response.json({ error: result.error }, { status: result.status });\n 359\t }\n 360\t\n 82\t }\n 83\t\n 84\t async planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan> {\n 85\t const { repositories, repositoriesFingerprint } = params.target;\n 86\t const primary = repositories[0];\n 87\t\n 88\t const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n 89\t resolveScopeSandboxSettings(this.db, params.scope, primary),\n 90\t loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n 91\t this.resolveCloneAuth(params.scope, repositories),\n 92\t ]);\n 93\t\n 94\t const basePlan = {\n 95\t buildId: params.buildId,\n 120\t\n 121\t private async resolveCloneAuth(\n 122\t scope: ImageBuildScope,\n 123\t repositories: ImageBuildRepository[]\n 124\t ): Promise<ImageBuildCloneAuth> {\n 125\t try {\n 126\t const provider = createSourceControlProviderFromEnv(this.env);\n 127\t const auth = await provider.generateCredentialHelperAuth(\n 128\t repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n 129\t );\n 130\t return {\n 131\t type: \"credential_helper\",\n 132\t host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\n 133\t username: auth.username,\n 134\t token: auth.password,\n 135\t };\n 136\t } catch (e) {\n 137\t logger.warn(\"image_build.clone_token_failed\", {\n 138\t error: e instanceof Error ? e.message : String(e),\n 139\t scope_kind: scope.kind,\n 140\t scope_id: scope.id,\n 29\t if scm_provider == \"gitlab\":\n 30\t token = os.environ.get(\"GITLAB_ACCESS_TOKEN\")\n 31\t if not token:\n 32\t log.warn(\"gitlab.token_missing\")\n 33\t return token\n 34\t\n 35\t if not repo_name:\n 36\t log.warn(\"github.repo_context_missing\", repo_owner=repo_owner, repo_name=repo_name)\n 37\t return None\n 38\t\n 39\t try:\n 40\t app_id = os.environ.get(\"GITHUB_APP_ID\")\n 41\t private_key = os.environ.get(\"GITHUB_APP_PRIVATE_KEY\")\n 42\t installation_id = os.environ.get(\"GITHUB_APP_INSTALLATION_ID\")\n 43\t\n 44\t if app_id and private_key and installation_id:\n 45\t return generate_installation_token(\n 46\t app_id=app_id,\n 47\t private_key=[REDACTED] 48\t installation_id=installation_id,\n 49\t repository=repo_name,\n 50\t permissions=SANDBOX_SCOPED_PERMISSIONS,\n 51\t )\n 52\t except Exception as e:\n 53\t log.warn(\"github.token_error\", exc=e, repo_owner=repo_owner, repo_name=repo_name)\n 54\t\n 55\t return None\n 70\t Raises:\n 71\t ValueError: if `permissions` is given without `repository`, or if\n 72\t either is an empty container — an empty `permissions={}` would\n 73\t make GitHub omit the field entirely and return the installation's\n 74\t full, unnarrowed permission set, which is exactly the silent\n 75\t widening this function must never do.\n 76\t httpx.HTTPStatusError: If the GitHub API request fails\n 77\t \"\"\"\n 78\t if permissions is not None and repository is None:\n 79\t raise ValueError(\"permissions requires repository — it narrows nothing on its own\")\n 80\t if repository is not None and not repository:\n 81\t raise ValueError(\"repository must be non-empty when provided\")\n 82\t if permissions is not None and not permissions:\n 83\t raise ValueError(\"permissions must be non-empty when provided\")\n 84\t\n 85\t url = f\"https://api.github.com/app/installations/{installation_id}/access_tokens\"\n 86\t headers = {\n 87\t \"Authorization\": f\"Bearer {jwt_token}\",\n 88\t \"Accept\": \"application/vnd.github+json\",\n 89\t \"X-GitHub-Api-Version\": \"2022-11-28\",\n 90\t }\n 91\t body: dict[str, object] = {}\n 92\t if repository is not None:\n 93\t body[\"repositories\"] = [repository]\n 94\t if permissions is not None:\n 95\t body[\"permissions\"] = permissions\n 96\t\n 97\t with httpx.Client() as client:\n 98\t response = client.post(url, headers=headers, json=body or None)\n 99\t response.raise_for_status()\n 100\t return response.json()[\"token\"]\n 101\t\n 102\t\n?? packages/control-plane/src/image-builds/review-scope-probe.tmp.test.ts\n"}}{"status":"fulfilled","value":{"chunk_id":"540b0c","wall_time_seconds":1.786461583,"exit_code":0,"original_token_count":28,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"277310","wall_time_seconds":1.866378,"exit_code":0,"original_token_count":50,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n/Users/gaganarora/workplace/open-inspect-72e95a/packages/modal-infra/src/__init__.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"8e5580","wall_time_seconds":0.0017035,"exit_code":0,"original_token_count":43,"output":"\n Test Files 5 passed (5)\n Tests 154 passed (154)\n Start at 16:12:07\n Duration 2.37s (transform 2.17s, setup 0ms, import 3.39s, tests 266ms, environment 1ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"e34a4a","wall_time_seconds":0.182027166,"exit_code":0,"original_token_count":1393,"output":" 336\t\n 337\t async scmCredentials(log: Logger): Promise<Response> {\n 338\t const session = this.sessionCoreRepository.getSession();\n 339\t if (!session) {\n 340\t return Response.json({ error: \"No session\" }, { status: 404 });\n 341\t }\n 342\t if (!session.repo_owner || !session.repo_name) {\n 343\t return Response.json(\n 344\t { error: \"SCM credentials require a repository context\" },\n 345\t { status: 400 }\n 346\t );\n 347\t }\n 348\t\n 349\t // The full member repository set, not just the primary — a session's\n 350\t // sandbox may need to clone/push/fetch sibling repositories, and a\n 351\t // credential scoped to the primary alone would deny those operations.\n 352\t const repos = this.sessionCoreRepository\n 353\t .getSessionRepositories()\n 354\t .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n 355\t\n 356\t const result = await this.getScmCredentials(repos, log);\n 357\t if (!result.ok) {\n 358\t return Response.json({ error: result.error }, { status: result.status });\n 359\t }\n 360\t\n 82\t }\n 83\t\n 84\t async planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan> {\n 85\t const { repositories, repositoriesFingerprint } = params.target;\n 86\t const primary = repositories[0];\n 87\t\n 88\t const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n 89\t resolveScopeSandboxSettings(this.db, params.scope, primary),\n 90\t loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n 91\t this.resolveCloneAuth(params.scope, repositories),\n 92\t ]);\n 93\t\n 94\t const basePlan = {\n 95\t buildId: params.buildId,\n 120\t\n 121\t private async resolveCloneAuth(\n 122\t scope: ImageBuildScope,\n 123\t repositories: ImageBuildRepository[]\n 124\t ): Promise<ImageBuildCloneAuth> {\n 125\t try {\n 126\t const provider = createSourceControlProviderFromEnv(this.env);\n 127\t const auth = await provider.generateCredentialHelperAuth(\n 128\t repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n 129\t );\n 130\t return {\n 131\t type: \"credential_helper\",\n 132\t host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\n 133\t username: auth.username,\n 134\t token: auth.password,\n 135\t };\n 136\t } catch (e) {\n 137\t logger.warn(\"image_build.clone_token_failed\", {\n 138\t error: e instanceof Error ? e.message : String(e),\n 139\t scope_kind: scope.kind,\n 140\t scope_id: scope.id,\n 29\t if scm_provider == \"gitlab\":\n 30\t token = os.environ.get(\"GITLAB_ACCESS_TOKEN\")\n 31\t if not token:\n 32\t log.warn(\"gitlab.token_missing\")\n 33\t return token\n 34\t\n 35\t if not repo_name:\n 36\t log.warn(\"github.repo_context_missing\", repo_owner=repo_owner, repo_name=repo_name)\n 37\t return None\n 38\t\n 39\t try:\n 40\t app_id = os.environ.get(\"GITHUB_APP_ID\")\n 41\t private_key = os.environ.get(\"GITHUB_APP_PRIVATE_KEY\")\n 42\t installation_id = os.environ.get(\"GITHUB_APP_INSTALLATION_ID\")\n 43\t\n 44\t if app_id and private_key and installation_id:\n 45\t return generate_installation_token(\n 46\t app_id=app_id,\n 47\t private_key=[REDACTED] 48\t installation_id=installation_id,\n 49\t repository=repo_name,\n 50\t permissions=SANDBOX_SCOPED_PERMISSIONS,\n 51\t )\n 52\t except Exception as e:\n 53\t log.warn(\"github.token_error\", exc=e, repo_owner=repo_owner, repo_name=repo_name)\n 54\t\n 55\t return None\n 70\t Raises:\n 71\t ValueError: if `permissions` is given without `repository`, or if\n 72\t either is an empty container — an empty `permissions={}` would\n 73\t make GitHub omit the field entirely and return the installation's\n 74\t full, unnarrowed permission set, which is exactly the silent\n 75\t widening this function must never do.\n 76\t httpx.HTTPStatusError: If the GitHub API request fails\n 77\t \"\"\"\n 78\t if permissions is not None and repository is None:\n 79\t raise ValueError(\"permissions requires repository — it narrows nothing on its own\")\n 80\t if repository is not None and not repository:\n 81\t raise ValueError(\"repository must be non-empty when provided\")\n 82\t if permissions is not None and not permissions:\n 83\t raise ValueError(\"permissions must be non-empty when provided\")\n 84\t\n 85\t url = f\"https://api.github.com/app/installations/{installation_id}/access_tokens\"\n 86\t headers = {\n 87\t \"Authorization\": f\"Bearer {jwt_token}\",\n 88\t \"Accept\": \"application/vnd.github+json\",\n 89\t \"X-GitHub-Api-Version\": \"2022-11-28\",\n 90\t }\n 91\t body: dict[str, object] = {}\n 92\t if repository is not None:\n 93\t body[\"repositories\"] = [repository]\n 94\t if permissions is not None:\n 95\t body[\"permissions\"] = permissions\n 96\t\n 97\t with httpx.Client() as client:\n 98\t response = client.post(url, headers=headers, json=body or None)\n 99\t response.raise_for_status()\n 100\t return response.json()[\"token\"]\n 101\t\n 102\t\n?? packages/control-plane/src/image-builds/review-scope-probe.tmp.test.ts\n"}}>nl -ba packages/control-plane/src/session/http/handlers/sandbox.handler.ts | sed -n '336,360p'; nl -ba packages/control-plane/src/image-builds/planner.ts | sed -n '82,95p;120,140p'; nl -ba packages/modal-infra/src/clone_token.py | sed -n '29,58p'; nl -ba packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py | sed -n '70,102p'; git status --short{"status":"fulfilled","value":{"chunk_id":"540b0c","wall_time_seconds":1.786461583,"exit_code":0,"original_token_count":28,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"277310","wall_time_seconds":1.866378,"exit_code":0,"original_token_count":50,"output":"/Users/gaganarora/workplace/open-inspect-72e95a/packages/sandbox-runtime/src/sandbox_runtime/auth/github_app.py\n/Users/gaganarora/workplace/open-inspect-72e95a/packages/modal-infra/src/__init__.py\n"}}
{"status":"fulfilled","value":{"chunk_id":"8e5580","wall_time_seconds":0.0017035,"exit_code":0,"original_token_count":43,"output":"\n Test Files 5 passed (5)\n Tests 154 passed (154)\n Start at 16:12:07\n Duration 2.37s (transform 2.17s, setup 0ms, import 3.39s, tests 266ms, environment 1ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"e34a4a","wall_time_seconds":0.182027166,"exit_code":0,"original_token_count":1393,"output":" 336\t\n 337\t async scmCredentials(log: Logger): Promise<Response> {\n 338\t const session = this.sessionCoreRepository.getSession();\n 339\t if (!session) {\n 340\t return Response.json({ error: \"No session\" }, { status: 404 });\n 341\t }\n 342\t if (!session.repo_owner || !session.repo_name) {\n 343\t return Response.json(\n 344\t { error: \"SCM credentials require a repository context\" },\n 345\t { status: 400 }\n 346\t );\n 347\t }\n 348\t\n 349\t // The full member repository set, not just the primary — a session's\n 350\t // sandbox may need to clone/push/fetch sibling repositories, and a\n 351\t // credential scoped to the primary alone would deny those operations.\n 352\t const repos = this.sessionCoreRepository\n 353\t .getSessionRepositories()\n 354\t .map((r) => ({ owner: r.repoOwner, name: r.repoName }));\n 355\t\n 356\t const result = await this.getScmCredentials(repos, log);\n 357\t if (!result.ok) {\n 358\t return Response.json({ error: result.error }, { status: result.status });\n 359\t }\n 360\t\n 82\t }\n 83\t\n 84\t async planBuild(params: ImageBuildPlanRequest): Promise<ImageBuildPlan> {\n 85\t const { repositories, repositoriesFingerprint } = params.target;\n 86\t const primary = repositories[0];\n 87\t\n 88\t const [sandboxSettings, userEnvVars, cloneAuth] = await Promise.all([\n 89\t resolveScopeSandboxSettings(this.db, params.scope, primary),\n 90\t loadScopeBuildSecrets(this.env, this.db, params.scope, params.target),\n 91\t this.resolveCloneAuth(params.scope, repositories),\n 92\t ]);\n 93\t\n 94\t const basePlan = {\n 95\t buildId: params.buildId,\n 120\t\n 121\t private async resolveCloneAuth(\n 122\t scope: ImageBuildScope,\n 123\t repositories: ImageBuildRepository[]\n 124\t ): Promise<ImageBuildCloneAuth> {\n 125\t try {\n 126\t const provider = createSourceControlProviderFromEnv(this.env);\n 127\t const auth = await provider.generateCredentialHelperAuth(\n 128\t repositories.map((r) => ({ owner: r.repoOwner, name: r.repoName }))\n 129\t );\n 130\t return {\n 131\t type: \"credential_helper\",\n 132\t host: scmCloneIdentity(resolveScmProviderFromEnv(this.env.SCM_PROVIDER)).host,\n 133\t username: auth.username,\n 134\t token: auth.password,\n 135\t };\n 136\t } catch (e) {\n 137\t logger.warn(\"image_build.clone_token_failed\", {\n 138\t error: e instanceof Error ? e.message : String(e),\n 139\t scope_kind: scope.kind,\n 140\t scope_id: scope.id,\n 29\t if scm_provider == \"gitlab\":\n 30\t token = os.environ.get(\"GITLAB_ACCESS_TOKEN\")\n 31\t if not token:\n 32\t log.warn(\"gitlab.token_missing\")\n 33\t return token\n 34\t\n 35\t if not repo_name:\n 36\t log.warn(\"github.repo_context_missing\", repo_owner=repo_owner, repo_name=repo_name)\n 37\t return None\n 38\t\n 39\t try:\n 40\t app_id = os.environ.get(\"GITHUB_APP_ID\")\n 41\t private_key = os.environ.get(\"GITHUB_APP_PRIVATE_KEY\")\n 42\t installation_id = os.environ.get(\"GITHUB_APP_INSTALLATION_ID\")\n 43\t\n 44\t if app_id and private_key and installation_id:\n 45\t return generate_installation_token(\n 46\t app_id=app_id,\n 47\t private_key=[REDACTED] 48\t installation_id=installation_id,\n 49\t repository=repo_name,\n 50\t permissions=SANDBOX_SCOPED_PERMISSIONS,\n 51\t )\n 52\t except Exception as e:\n 53\t log.warn(\"github.token_error\", exc=e, repo_owner=repo_owner, repo_name=repo_name)\n 54\t\n 55\t return None\n 70\t Raises:\n 71\t ValueError: if `permissions` is given without `repository`, or if\n 72\t either is an empty container — an empty `permissions={}` would\n 73\t make GitHub omit the field entirely and return the installation's\n 74\t full, unnarrowed permission set, which is exactly the silent\n 75\t widening this function must never do.\n 76\t httpx.HTTPStatusError: If the GitHub API request fails\n 77\t \"\"\"\n 78\t if permissions is not None and repository is None:\n 79\t raise ValueError(\"permissions requires repository — it narrows nothing on its own\")\n 80\t if repository is not None and not repository:\n 81\t raise ValueError(\"repository must be non-empty when provided\")\n 82\t if permissions is not None and not permissions:\n 83\t raise ValueError(\"permissions must be non-empty when provided\")\n 84\t\n 85\t url = f\"https://api.github.com/app/installations/{installation_id}/access_tokens\"\n 86\t headers = {\n 87\t \"Authorization\": f\"Bearer {jwt_token}\",\n 88\t \"Accept\": \"application/vnd.github+json\",\n 89\t \"X-GitHub-Api-Version\": \"2022-11-28\",\n 90\t }\n 91\t body: dict[str, object] = {}\n 92\t if repository is not None:\n 93\t body[\"repositories\"] = [repository]\n 94\t if permissions is not None:\n 95\t body[\"permissions\"] = permissions\n 96\t\n 97\t with httpx.Client() as client:\n 98\t response = client.post(url, headers=headers, json=body or None)\n 99\t response.raise_for_status()\n 100\t return response.json()[\"token\"]\n 101\t\n 102\t\n?? packages/control-plane/src/image-builds/review-scope-probe.tmp.test.ts\n"}}The focused TypeScript run passed all 154 tests, including the three throwaway checks. The planner probe used the real provider and token-minting code with a mocked HTTP response: its request included both repositories and only contents:write plus metadata:read. A rejected request produced unavailable credentials without a broader retry.
All three original findings are confirmed fixed by these checks. I found no additional substantiated P1/P2 issue in the reviewed paths. I’m removing the temporary probes and checking the worktree is clean.
>rm packages/control-plane/src/image-builds/review-scope-probe.tmp.test.ts /private/tmp/open-inspect-permissions-probe.py
git status --short
git diff --check{"chunk_id":"fdfaa0","wall_time_seconds":0.124344791,"exit_code":0,"original_token_count":0,"output":""}All three original findings are CONFIRMED FIXED, based on executed tests and independent probes:
None for missing/empty repo_name. Passing tests explicitly assert the mint function is never called.ValueError. My throwaway Python script confirmed repository="sibling-repo", permissions={} raises before constructing an HTTP client.Actual test results:
| Run | Result |
|---|---|
Control-plane npm run test | Failed: 4,255 passed, 43 failed, 11 skipped |
| Focused TypeScript tests, including three additional probes | 154 passed |
tests/test_clone_token.py | 7 passed |
tests/test_github_app_auth.py | 7 passed |
I cannot confirm the full control-plane suite passes: socket binds failed with listen EPERM, causing failures and related timeouts. The exact Python commands initially failed on uv cache access; both suites passed using a writable cache and uv run --offline --no-sync. I verified they imported this worktree’s code.
No additional substantiated P1/P2 findings in the reviewed paths. HTTP minting was mocked; no live GitHub token was issued. Temporary probes were removed, and the worktree is clean.
No content