Prepared test environment and scoped sandbox credentials.
SetupRan targeted TypeScript and Python tests.
VerifyInvestigated test failures and regression test weakness.
UnderstandConfirmed primary credential fixes with targeted tests.
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 credential paths, run all three test commands, and probe the empty-permissions case with a throwaway script. I’ll stay within repository code and avoid the directories you listed.
>pwd && git status --short && rg --files -g AGENTS.md -g '!agents/**' -g '!.agents/**' -g '!.claude/**' -g '!node_modules/**' -g '!**/node_modules/**'{"chunk_id":"9dd1ff","wall_time_seconds":0.3170985,"exit_code":0,"original_token_count":7,"output":"/tmp/codex-verify\nAGENTS.md\n"}
{"chunk_id":"864ed6","wall_time_seconds":0.000003166,"exit_code":0,"original_token_count":3919,"output":"import 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 }\n if (error instanceof OpenAITokenStorageError) {\n return Response.json({ error: error.message }, { status: 500 });\n }\n if (error instanceof OpenAITokenUpstreamError) {\n return Response.json({ error: error.message }, { status: 502 });\n }\n throw error;\n }\n\n return Response.json(\n {\n access_token: token.accessToken,\n expires_in: token.expiresIn,\n account_id: token.accountId,\n },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n async xaiTokenRefresh(log: Logger): Promise<Response> {\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":"6015b5","wall_time_seconds":0.000001958,"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"}>sed -n '1,280p' packages/control-plane/src/session/http/handlers/sandbox.handler.ts && sed -n '1,300p' packages/control-plane/src/image-builds/planner.ts{"chunk_id":"9dd1ff","wall_time_seconds":0.3170985,"exit_code":0,"original_token_count":7,"output":"/tmp/codex-verify\nAGENTS.md\n"}
{"chunk_id":"864ed6","wall_time_seconds":0.000003166,"exit_code":0,"original_token_count":3919,"output":"import 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 }\n if (error instanceof OpenAITokenStorageError) {\n return Response.json({ error: error.message }, { status: 500 });\n }\n if (error instanceof OpenAITokenUpstreamError) {\n return Response.json({ error: error.message }, { status: 502 });\n }\n throw error;\n }\n\n return Response.json(\n {\n access_token: token.accessToken,\n expires_in: token.expiresIn,\n account_id: token.accountId,\n },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n async xaiTokenRefresh(log: Logger): Promise<Response> {\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":"6015b5","wall_time_seconds":0.000001958,"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":"9dd1ff","wall_time_seconds":0.3170985,"exit_code":0,"original_token_count":7,"output":"/tmp/codex-verify\nAGENTS.md\n"}
{"chunk_id":"864ed6","wall_time_seconds":0.000003166,"exit_code":0,"original_token_count":3919,"output":"import 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 }\n if (error instanceof OpenAITokenStorageError) {\n return Response.json({ error: error.message }, { status: 500 });\n }\n if (error instanceof OpenAITokenUpstreamError) {\n return Response.json({ error: error.message }, { status: 502 });\n }\n throw error;\n }\n\n return Response.json(\n {\n access_token: token.accessToken,\n expires_in: token.expiresIn,\n account_id: token.accountId,\n },\n { status: 200, headers: { \"Cache-Control\": \"no-store\" } }\n );\n }\n\n async xaiTokenRefresh(log: Logger): Promise<Response> {\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":"6015b5","wall_time_seconds":0.000001958,"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":"4a54bf","wall_time_seconds":1.00214775,"session_id":38631,"original_token_count":30,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c21fd4","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":"3504c6","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":"6e5a2c","wall_time_seconds":0,"exit_code":1,"original_token_count":3535,"output":"# AGENTS.md\n\nOpen-Inspect is a background coding agent system that spawns sandboxed dev environments to work on\nGitHub repositories. Single-tenant design. Stack: Cloudflare Workers (TypeScript), Modal (Python),\nNext.js (React), Terraform.\n\n## Architecture\n\nThree tiers connected by WebSockets:\n\n1. **Web Client** (Next.js on Vercel or Cloudflare Workers via OpenNext) — UI with GitHub OAuth,\n session dashboard, real-time streaming\n2. **Control Plane** (Cloudflare Workers + Durable Objects) — session lifecycle, WebSocket hub,\n GitHub/auth integration. Each session is a Durable Object with SQLite storage. Uses D1 for the\n session index, repo metadata, environments, and encrypted secrets.\n3. **Data Plane** (Modal, Python) — sandboxed environments running coding agents. Manages sandbox\n creation, snapshots, and repository/environment image builds.\n\n**Bot integrations** — all Cloudflare Workers using Hono:\n\n- `slack-bot` — Slack messages → coding sessions\n- `github-bot` — PR review assignments and @mention commands\n- `linear-bot` — Linear agent webhooks → coding sessions\n\n**Data flow**: User prompt → web client → control plane DO (WebSocket) → Modal sandbox → streaming\nevents back through the same WebSocket chain.\n\n### Package Dependency Graph\n\n```\n@open-inspect/shared ← control-plane, web, slack-bot, github-bot, linear-bot\n```\n\n**Build `@open-inspect/shared` first** whenever you change shared types. Other packages import from\nit at build time.\n\n## Package Overview\n\n| Package | Lang / Framework | Purpose |\n| --------------- | ---------------------------------- | ----------------------------------------------------------- |\n| `shared` | TypeScript | Shared types, auth utilities, model definitions |\n| `control-plane` | TypeScript / CF Workers + DO | Session management, WebSocket streaming, GitHub integration |\n| `web` | TypeScript / Next.js 16 + React 19 | User-facing dashboard, OAuth, real-time UI |\n| `slack-bot` | TypeScript / CF Workers + Hono | Slack event handler, session creation |\n| `github-bot` | TypeScript / CF Workers + Hono | PR review and @mention webhook handler |\n| `linear-bot` | TypeScript / CF Workers + Hono | Linear agent webhook handler |\n| `modal-infra` | Python 3.12 / Modal + FastAPI | Sandbox lifecycle, WebSocket bridge to control plane |\n\n## Common Commands\n\n```bash\n# Install & build\nnpm install\nnpm run build # all packages\nnpm run build -w @open-inspect/shared # shared only (build first!)\n\n# Lint & format\nnpm run lint:fix # ESLint + Prettier fix\nnpm run format # Prettier only\nnpm run typecheck # tsc across all TS packages\n\n# Tests — TypeScript (Vitest)\nnpm test -w @open-inspect/control-plane # unit tests (node env)\nnpm run test:integration -w @open-inspect/control-plane # integration (workerd/Miniflare + real D1)\nnpm test -w @open-inspect/web\nnpm test -w @open-inspect/github-bot\nnpm test -w @open-inspect/slack-bot\nnpm test -w @open-inspect/linear-bot\n\n# Tests — Python (pytest)\ncd packages/modal-infra && pytest tests/ -v\n\n# Python linting\ncd packages/modal-infra && ruff check --fix && ruff format\n```\n\n## Testing\n\nAll TypeScript packages use **Vitest**; Python uses **pytest** + pytest-asyncio.\n\n### Test file locations\n\n- **control-plane unit**: co-located as `src/**/*.test.ts` — run in Node environment\n- **control-plane integration**: separate `test/integration/*.test.ts` — run in workerd via\n `@cloudflare/vitest-pool-workers` with real D1 bindings\n- **web, slack-bot, linear-bot**: co-located `src/**/*.test.ts`\n- **github-bot**: separate `test/*.test.ts`\n- **modal-infra**: `tests/test_*.py`\n\n### Control-plane integration tests\n\nThese run inside a real `workerd` runtime with Miniflare, using the `cloudflareTest()` plugin from\n`@cloudflare/vitest-pool-workers`. Important:\n\n- Integration tests share one D1 instance — use `cleanD1Tables()` or equivalent cleanup in\n `beforeEach`/`afterEach` to avoid cross-test pollution\n- D1 migrations from `terraform/d1/migrations/` are applied automatically via\n `test/integration/apply-migrations.ts`\n- Helpers in `test/integration/helpers.ts`: `initSession()`, `queryDO()`, `seedEvents()`\n\n## Coding Conventions\n\n### Durations and timeouts\n\n- **Use seconds for Python, milliseconds for TypeScript.** These match each ecosystem's conventions\n (Modal `timeout=` takes seconds; control-plane uses `_MS` suffixes throughout).\n- **Encode the unit in the name.** Python: `timeout_seconds`. TypeScript: `timeoutMs`,\n `INACTIVITY_TIMEOUT_MS`. Never use a bare `timeout`.\n- **Define each default value exactly once.** Extract to a named constant and import everywhere.\n- **Don't restate literal values in comments.** Write `Defaults to DEFAULT_SANDBOX_TIMEOUT_SECONDS`,\n not `Default: 7200`.\n\n### Extending existing patterns\n\n- When threading an existing field through new code paths, evaluate whether the existing design\n (naming, types, units) is correct — don't blindly propagate it. Fix bad names or units in the same\n change rather than spreading the problem.\n\n### Commit messages\n\nUse conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`. Keep the subject\nunder 72 characters. Use the PR body for details, not the commit message.\n\n## Key Gotchas\n\n- **Build order**: always build `@open-inspect/shared` before packages that depend on it.\n- **PKCS#8 keys**: Cloudflare Workers require PKCS#8 format for GitHub App private keys — convert\n with `openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt`.\n- **Durable Object bindings**: new DO bindings require a two-phase Terraform deploy — first with\n `enable_durable_object_bindings = false`, then `true`.\n- **No `wrangler.toml`**: control-plane config is generated by Terraform, not checked in.\n- **Modal deployment**: from `packages/modal-infra`, run\n `uv run python deploy.py --build-sandbox-image` before `uv run modal deploy deploy.py` (or\n `uv run modal deploy -m src`). Never deploy `src/app.py` directly; it doesn't import function\n modules.\n- **Modal image rebuild**: update `CACHE_BUSTER` in `src/images/base.py` to force a rebuild.\n- **Web platform choice**: set `web_platform = \"cloudflare\"` in Terraform variables to deploy the\n web app to Cloudflare Workers via OpenNext instead of Vercel. When using Cloudflare, Vercel\n credentials are not required (dummy defaults are used). `NEXT_PUBLIC_WS_URL` must be available at\n build time since Next.js inlines `NEXT_PUBLIC_*` vars into the client bundle.\n- **Repo owners can be nested namespaces**: a `repo_owner` is not always a single segment. GitHub\n owners are (`octocat`), but GitLab subgroups nest (`group/subgroup`), so an owner may contain `/`.\n Only `repo_name` is a single path segment (it's the checkout directory under `/workspace`); the\n owner remains part of the repository identity in clone URLs, API routes, manifests, and storage\n keys. Don't validate or split owners as single segments. Use the shared repository identity\n helpers in TypeScript; where a full `owner/name` string is unavoidable, split on the **last** `/`\n and encode the owner as one API route segment. `repo_config.parse_repositories` accepts `/`-joined\n owners (see `is_safe_repo_owner`).\n\n## CI/CD\n\nPushing to `main` auto-deploys changed services:\n\n- **Terraform** → control plane + D1 migrations + web app if `web_platform = \"cloudflare\"`\n (triggers: `terraform/`, `packages/*/`)\n- **Vercel** → web app when `web_platform = \"vercel\"` (triggers: `packages/web/`,\n `packages/shared/`)\n- **Modal** → data plane (triggers: `packages/modal-infra/`, deployed via Terraform apply)\n\nCI runs lint, typecheck, and tests for all TypeScript and Python packages on every push and PR.\n\n## Further Reading\n\n- [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — deploy your own instance\n- [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md) — detailed architecture and session lifecycle\n- [CONTRIBUTING.md](CONTRIBUTING.md) — contribution guidelines\n- [packages/control-plane/README.md](packages/control-plane/README.md) — API reference, WebSocket\n protocol, D1 schema, security model\n- [packages/modal-infra/README.md](packages/modal-infra/README.md) — sandbox internals, Modal\n deployment, endpoint URLs\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}\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}\n"}}>uv run pytest tests/test_clone_token.py -q{"status":"fulfilled","value":{"chunk_id":"4a54bf","wall_time_seconds":1.00214775,"session_id":38631,"original_token_count":30,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c21fd4","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":"3504c6","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":"6e5a2c","wall_time_seconds":0,"exit_code":1,"original_token_count":3535,"output":"# AGENTS.md\n\nOpen-Inspect is a background coding agent system that spawns sandboxed dev environments to work on\nGitHub repositories. Single-tenant design. Stack: Cloudflare Workers (TypeScript), Modal (Python),\nNext.js (React), Terraform.\n\n## Architecture\n\nThree tiers connected by WebSockets:\n\n1. **Web Client** (Next.js on Vercel or Cloudflare Workers via OpenNext) — UI with GitHub OAuth,\n session dashboard, real-time streaming\n2. **Control Plane** (Cloudflare Workers + Durable Objects) — session lifecycle, WebSocket hub,\n GitHub/auth integration. Each session is a Durable Object with SQLite storage. Uses D1 for the\n session index, repo metadata, environments, and encrypted secrets.\n3. **Data Plane** (Modal, Python) — sandboxed environments running coding agents. Manages sandbox\n creation, snapshots, and repository/environment image builds.\n\n**Bot integrations** — all Cloudflare Workers using Hono:\n\n- `slack-bot` — Slack messages → coding sessions\n- `github-bot` — PR review assignments and @mention commands\n- `linear-bot` — Linear agent webhooks → coding sessions\n\n**Data flow**: User prompt → web client → control plane DO (WebSocket) → Modal sandbox → streaming\nevents back through the same WebSocket chain.\n\n### Package Dependency Graph\n\n```\n@open-inspect/shared ← control-plane, web, slack-bot, github-bot, linear-bot\n```\n\n**Build `@open-inspect/shared` first** whenever you change shared types. Other packages import from\nit at build time.\n\n## Package Overview\n\n| Package | Lang / Framework | Purpose |\n| --------------- | ---------------------------------- | ----------------------------------------------------------- |\n| `shared` | TypeScript | Shared types, auth utilities, model definitions |\n| `control-plane` | TypeScript / CF Workers + DO | Session management, WebSocket streaming, GitHub integration |\n| `web` | TypeScript / Next.js 16 + React 19 | User-facing dashboard, OAuth, real-time UI |\n| `slack-bot` | TypeScript / CF Workers + Hono | Slack event handler, session creation |\n| `github-bot` | TypeScript / CF Workers + Hono | PR review and @mention webhook handler |\n| `linear-bot` | TypeScript / CF Workers + Hono | Linear agent webhook handler |\n| `modal-infra` | Python 3.12 / Modal + FastAPI | Sandbox lifecycle, WebSocket bridge to control plane |\n\n## Common Commands\n\n```bash\n# Install & build\nnpm install\nnpm run build # all packages\nnpm run build -w @open-inspect/shared # shared only (build first!)\n\n# Lint & format\nnpm run lint:fix # ESLint + Prettier fix\nnpm run format # Prettier only\nnpm run typecheck # tsc across all TS packages\n\n# Tests — TypeScript (Vitest)\nnpm test -w @open-inspect/control-plane # unit tests (node env)\nnpm run test:integration -w @open-inspect/control-plane # integration (workerd/Miniflare + real D1)\nnpm test -w @open-inspect/web\nnpm test -w @open-inspect/github-bot\nnpm test -w @open-inspect/slack-bot\nnpm test -w @open-inspect/linear-bot\n\n# Tests — Python (pytest)\ncd packages/modal-infra && pytest tests/ -v\n\n# Python linting\ncd packages/modal-infra && ruff check --fix && ruff format\n```\n\n## Testing\n\nAll TypeScript packages use **Vitest**; Python uses **pytest** + pytest-asyncio.\n\n### Test file locations\n\n- **control-plane unit**: co-located as `src/**/*.test.ts` — run in Node environment\n- **control-plane integration**: separate `test/integration/*.test.ts` — run in workerd via\n `@cloudflare/vitest-pool-workers` with real D1 bindings\n- **web, slack-bot, linear-bot**: co-located `src/**/*.test.ts`\n- **github-bot**: separate `test/*.test.ts`\n- **modal-infra**: `tests/test_*.py`\n\n### Control-plane integration tests\n\nThese run inside a real `workerd` runtime with Miniflare, using the `cloudflareTest()` plugin from\n`@cloudflare/vitest-pool-workers`. Important:\n\n- Integration tests share one D1 instance — use `cleanD1Tables()` or equivalent cleanup in\n `beforeEach`/`afterEach` to avoid cross-test pollution\n- D1 migrations from `terraform/d1/migrations/` are applied automatically via\n `test/integration/apply-migrations.ts`\n- Helpers in `test/integration/helpers.ts`: `initSession()`, `queryDO()`, `seedEvents()`\n\n## Coding Conventions\n\n### Durations and timeouts\n\n- **Use seconds for Python, milliseconds for TypeScript.** These match each ecosystem's conventions\n (Modal `timeout=` takes seconds; control-plane uses `_MS` suffixes throughout).\n- **Encode the unit in the name.** Python: `timeout_seconds`. TypeScript: `timeoutMs`,\n `INACTIVITY_TIMEOUT_MS`. Never use a bare `timeout`.\n- **Define each default value exactly once.** Extract to a named constant and import everywhere.\n- **Don't restate literal values in comments.** Write `Defaults to DEFAULT_SANDBOX_TIMEOUT_SECONDS`,\n not `Default: 7200`.\n\n### Extending existing patterns\n\n- When threading an existing field through new code paths, evaluate whether the existing design\n (naming, types, units) is correct — don't blindly propagate it. Fix bad names or units in the same\n change rather than spreading the problem.\n\n### Commit messages\n\nUse conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`. Keep the subject\nunder 72 characters. Use the PR body for details, not the commit message.\n\n## Key Gotchas\n\n- **Build order**: always build `@open-inspect/shared` before packages that depend on it.\n- **PKCS#8 keys**: Cloudflare Workers require PKCS#8 format for GitHub App private keys — convert\n with `openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt`.\n- **Durable Object bindings**: new DO bindings require a two-phase Terraform deploy — first with\n `enable_durable_object_bindings = false`, then `true`.\n- **No `wrangler.toml`**: control-plane config is generated by Terraform, not checked in.\n- **Modal deployment**: from `packages/modal-infra`, run\n `uv run python deploy.py --build-sandbox-image` before `uv run modal deploy deploy.py` (or\n `uv run modal deploy -m src`). Never deploy `src/app.py` directly; it doesn't import function\n modules.\n- **Modal image rebuild**: update `CACHE_BUSTER` in `src/images/base.py` to force a rebuild.\n- **Web platform choice**: set `web_platform = \"cloudflare\"` in Terraform variables to deploy the\n web app to Cloudflare Workers via OpenNext instead of Vercel. When using Cloudflare, Vercel\n credentials are not required (dummy defaults are used). `NEXT_PUBLIC_WS_URL` must be available at\n build time since Next.js inlines `NEXT_PUBLIC_*` vars into the client bundle.\n- **Repo owners can be nested namespaces**: a `repo_owner` is not always a single segment. GitHub\n owners are (`octocat`), but GitLab subgroups nest (`group/subgroup`), so an owner may contain `/`.\n Only `repo_name` is a single path segment (it's the checkout directory under `/workspace`); the\n owner remains part of the repository identity in clone URLs, API routes, manifests, and storage\n keys. Don't validate or split owners as single segments. Use the shared repository identity\n helpers in TypeScript; where a full `owner/name` string is unavoidable, split on the **last** `/`\n and encode the owner as one API route segment. `repo_config.parse_repositories` accepts `/`-joined\n owners (see `is_safe_repo_owner`).\n\n## CI/CD\n\nPushing to `main` auto-deploys changed services:\n\n- **Terraform** → control plane + D1 migrations + web app if `web_platform = \"cloudflare\"`\n (triggers: `terraform/`, `packages/*/`)\n- **Vercel** → web app when `web_platform = \"vercel\"` (triggers: `packages/web/`,\n `packages/shared/`)\n- **Modal** → data plane (triggers: `packages/modal-infra/`, deployed via Terraform apply)\n\nCI runs lint, typecheck, and tests for all TypeScript and Python packages on every push and PR.\n\n## Further Reading\n\n- [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — deploy your own instance\n- [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md) — detailed architecture and session lifecycle\n- [CONTRIBUTING.md](CONTRIBUTING.md) — contribution guidelines\n- [packages/control-plane/README.md](packages/control-plane/README.md) — API reference, WebSocket\n protocol, D1 schema, security model\n- [packages/modal-infra/README.md](packages/modal-infra/README.md) — sandbox internals, Modal\n deployment, endpoint URLs\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}\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}\n"}}>uv run pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"4a54bf","wall_time_seconds":1.00214775,"session_id":38631,"original_token_count":30,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c21fd4","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":"3504c6","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":"6e5a2c","wall_time_seconds":0,"exit_code":1,"original_token_count":3535,"output":"# AGENTS.md\n\nOpen-Inspect is a background coding agent system that spawns sandboxed dev environments to work on\nGitHub repositories. Single-tenant design. Stack: Cloudflare Workers (TypeScript), Modal (Python),\nNext.js (React), Terraform.\n\n## Architecture\n\nThree tiers connected by WebSockets:\n\n1. **Web Client** (Next.js on Vercel or Cloudflare Workers via OpenNext) — UI with GitHub OAuth,\n session dashboard, real-time streaming\n2. **Control Plane** (Cloudflare Workers + Durable Objects) — session lifecycle, WebSocket hub,\n GitHub/auth integration. Each session is a Durable Object with SQLite storage. Uses D1 for the\n session index, repo metadata, environments, and encrypted secrets.\n3. **Data Plane** (Modal, Python) — sandboxed environments running coding agents. Manages sandbox\n creation, snapshots, and repository/environment image builds.\n\n**Bot integrations** — all Cloudflare Workers using Hono:\n\n- `slack-bot` — Slack messages → coding sessions\n- `github-bot` — PR review assignments and @mention commands\n- `linear-bot` — Linear agent webhooks → coding sessions\n\n**Data flow**: User prompt → web client → control plane DO (WebSocket) → Modal sandbox → streaming\nevents back through the same WebSocket chain.\n\n### Package Dependency Graph\n\n```\n@open-inspect/shared ← control-plane, web, slack-bot, github-bot, linear-bot\n```\n\n**Build `@open-inspect/shared` first** whenever you change shared types. Other packages import from\nit at build time.\n\n## Package Overview\n\n| Package | Lang / Framework | Purpose |\n| --------------- | ---------------------------------- | ----------------------------------------------------------- |\n| `shared` | TypeScript | Shared types, auth utilities, model definitions |\n| `control-plane` | TypeScript / CF Workers + DO | Session management, WebSocket streaming, GitHub integration |\n| `web` | TypeScript / Next.js 16 + React 19 | User-facing dashboard, OAuth, real-time UI |\n| `slack-bot` | TypeScript / CF Workers + Hono | Slack event handler, session creation |\n| `github-bot` | TypeScript / CF Workers + Hono | PR review and @mention webhook handler |\n| `linear-bot` | TypeScript / CF Workers + Hono | Linear agent webhook handler |\n| `modal-infra` | Python 3.12 / Modal + FastAPI | Sandbox lifecycle, WebSocket bridge to control plane |\n\n## Common Commands\n\n```bash\n# Install & build\nnpm install\nnpm run build # all packages\nnpm run build -w @open-inspect/shared # shared only (build first!)\n\n# Lint & format\nnpm run lint:fix # ESLint + Prettier fix\nnpm run format # Prettier only\nnpm run typecheck # tsc across all TS packages\n\n# Tests — TypeScript (Vitest)\nnpm test -w @open-inspect/control-plane # unit tests (node env)\nnpm run test:integration -w @open-inspect/control-plane # integration (workerd/Miniflare + real D1)\nnpm test -w @open-inspect/web\nnpm test -w @open-inspect/github-bot\nnpm test -w @open-inspect/slack-bot\nnpm test -w @open-inspect/linear-bot\n\n# Tests — Python (pytest)\ncd packages/modal-infra && pytest tests/ -v\n\n# Python linting\ncd packages/modal-infra && ruff check --fix && ruff format\n```\n\n## Testing\n\nAll TypeScript packages use **Vitest**; Python uses **pytest** + pytest-asyncio.\n\n### Test file locations\n\n- **control-plane unit**: co-located as `src/**/*.test.ts` — run in Node environment\n- **control-plane integration**: separate `test/integration/*.test.ts` — run in workerd via\n `@cloudflare/vitest-pool-workers` with real D1 bindings\n- **web, slack-bot, linear-bot**: co-located `src/**/*.test.ts`\n- **github-bot**: separate `test/*.test.ts`\n- **modal-infra**: `tests/test_*.py`\n\n### Control-plane integration tests\n\nThese run inside a real `workerd` runtime with Miniflare, using the `cloudflareTest()` plugin from\n`@cloudflare/vitest-pool-workers`. Important:\n\n- Integration tests share one D1 instance — use `cleanD1Tables()` or equivalent cleanup in\n `beforeEach`/`afterEach` to avoid cross-test pollution\n- D1 migrations from `terraform/d1/migrations/` are applied automatically via\n `test/integration/apply-migrations.ts`\n- Helpers in `test/integration/helpers.ts`: `initSession()`, `queryDO()`, `seedEvents()`\n\n## Coding Conventions\n\n### Durations and timeouts\n\n- **Use seconds for Python, milliseconds for TypeScript.** These match each ecosystem's conventions\n (Modal `timeout=` takes seconds; control-plane uses `_MS` suffixes throughout).\n- **Encode the unit in the name.** Python: `timeout_seconds`. TypeScript: `timeoutMs`,\n `INACTIVITY_TIMEOUT_MS`. Never use a bare `timeout`.\n- **Define each default value exactly once.** Extract to a named constant and import everywhere.\n- **Don't restate literal values in comments.** Write `Defaults to DEFAULT_SANDBOX_TIMEOUT_SECONDS`,\n not `Default: 7200`.\n\n### Extending existing patterns\n\n- When threading an existing field through new code paths, evaluate whether the existing design\n (naming, types, units) is correct — don't blindly propagate it. Fix bad names or units in the same\n change rather than spreading the problem.\n\n### Commit messages\n\nUse conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`. Keep the subject\nunder 72 characters. Use the PR body for details, not the commit message.\n\n## Key Gotchas\n\n- **Build order**: always build `@open-inspect/shared` before packages that depend on it.\n- **PKCS#8 keys**: Cloudflare Workers require PKCS#8 format for GitHub App private keys — convert\n with `openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt`.\n- **Durable Object bindings**: new DO bindings require a two-phase Terraform deploy — first with\n `enable_durable_object_bindings = false`, then `true`.\n- **No `wrangler.toml`**: control-plane config is generated by Terraform, not checked in.\n- **Modal deployment**: from `packages/modal-infra`, run\n `uv run python deploy.py --build-sandbox-image` before `uv run modal deploy deploy.py` (or\n `uv run modal deploy -m src`). Never deploy `src/app.py` directly; it doesn't import function\n modules.\n- **Modal image rebuild**: update `CACHE_BUSTER` in `src/images/base.py` to force a rebuild.\n- **Web platform choice**: set `web_platform = \"cloudflare\"` in Terraform variables to deploy the\n web app to Cloudflare Workers via OpenNext instead of Vercel. When using Cloudflare, Vercel\n credentials are not required (dummy defaults are used). `NEXT_PUBLIC_WS_URL` must be available at\n build time since Next.js inlines `NEXT_PUBLIC_*` vars into the client bundle.\n- **Repo owners can be nested namespaces**: a `repo_owner` is not always a single segment. GitHub\n owners are (`octocat`), but GitLab subgroups nest (`group/subgroup`), so an owner may contain `/`.\n Only `repo_name` is a single path segment (it's the checkout directory under `/workspace`); the\n owner remains part of the repository identity in clone URLs, API routes, manifests, and storage\n keys. Don't validate or split owners as single segments. Use the shared repository identity\n helpers in TypeScript; where a full `owner/name` string is unavoidable, split on the **last** `/`\n and encode the owner as one API route segment. `repo_config.parse_repositories` accepts `/`-joined\n owners (see `is_safe_repo_owner`).\n\n## CI/CD\n\nPushing to `main` auto-deploys changed services:\n\n- **Terraform** → control plane + D1 migrations + web app if `web_platform = \"cloudflare\"`\n (triggers: `terraform/`, `packages/*/`)\n- **Vercel** → web app when `web_platform = \"vercel\"` (triggers: `packages/web/`,\n `packages/shared/`)\n- **Modal** → data plane (triggers: `packages/modal-infra/`, deployed via Terraform apply)\n\nCI runs lint, typecheck, and tests for all TypeScript and Python packages on every push and PR.\n\n## Further Reading\n\n- [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — deploy your own instance\n- [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md) — detailed architecture and session lifecycle\n- [CONTRIBUTING.md](CONTRIBUTING.md) — contribution guidelines\n- [packages/control-plane/README.md](packages/control-plane/README.md) — API reference, WebSocket\n protocol, D1 schema, security model\n- [packages/modal-infra/README.md](packages/modal-infra/README.md) — sandbox internals, Modal\n deployment, endpoint URLs\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}\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}\n"}}>cat AGENTS.md && sed -n '295,410p' packages/control-plane/src/session/http/handlers/sandbox.handler.ts && cat packages/control-plane/src/session/scm-credentials-service.ts && rg -n 'generateCredentialHelperAuth|repositoryNames|repositories:' packages/control-plane/src/source-control/github* && cat packages/modal-infra/tests/test_clone_token.py && cat packages/sandbox-runtime/tests/test_github_app_auth.py{"status":"fulfilled","value":{"chunk_id":"4a54bf","wall_time_seconds":1.00214775,"session_id":38631,"original_token_count":30,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"c21fd4","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":"3504c6","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":"6e5a2c","wall_time_seconds":0,"exit_code":1,"original_token_count":3535,"output":"# AGENTS.md\n\nOpen-Inspect is a background coding agent system that spawns sandboxed dev environments to work on\nGitHub repositories. Single-tenant design. Stack: Cloudflare Workers (TypeScript), Modal (Python),\nNext.js (React), Terraform.\n\n## Architecture\n\nThree tiers connected by WebSockets:\n\n1. **Web Client** (Next.js on Vercel or Cloudflare Workers via OpenNext) — UI with GitHub OAuth,\n session dashboard, real-time streaming\n2. **Control Plane** (Cloudflare Workers + Durable Objects) — session lifecycle, WebSocket hub,\n GitHub/auth integration. Each session is a Durable Object with SQLite storage. Uses D1 for the\n session index, repo metadata, environments, and encrypted secrets.\n3. **Data Plane** (Modal, Python) — sandboxed environments running coding agents. Manages sandbox\n creation, snapshots, and repository/environment image builds.\n\n**Bot integrations** — all Cloudflare Workers using Hono:\n\n- `slack-bot` — Slack messages → coding sessions\n- `github-bot` — PR review assignments and @mention commands\n- `linear-bot` — Linear agent webhooks → coding sessions\n\n**Data flow**: User prompt → web client → control plane DO (WebSocket) → Modal sandbox → streaming\nevents back through the same WebSocket chain.\n\n### Package Dependency Graph\n\n```\n@open-inspect/shared ← control-plane, web, slack-bot, github-bot, linear-bot\n```\n\n**Build `@open-inspect/shared` first** whenever you change shared types. Other packages import from\nit at build time.\n\n## Package Overview\n\n| Package | Lang / Framework | Purpose |\n| --------------- | ---------------------------------- | ----------------------------------------------------------- |\n| `shared` | TypeScript | Shared types, auth utilities, model definitions |\n| `control-plane` | TypeScript / CF Workers + DO | Session management, WebSocket streaming, GitHub integration |\n| `web` | TypeScript / Next.js 16 + React 19 | User-facing dashboard, OAuth, real-time UI |\n| `slack-bot` | TypeScript / CF Workers + Hono | Slack event handler, session creation |\n| `github-bot` | TypeScript / CF Workers + Hono | PR review and @mention webhook handler |\n| `linear-bot` | TypeScript / CF Workers + Hono | Linear agent webhook handler |\n| `modal-infra` | Python 3.12 / Modal + FastAPI | Sandbox lifecycle, WebSocket bridge to control plane |\n\n## Common Commands\n\n```bash\n# Install & build\nnpm install\nnpm run build # all packages\nnpm run build -w @open-inspect/shared # shared only (build first!)\n\n# Lint & format\nnpm run lint:fix # ESLint + Prettier fix\nnpm run format # Prettier only\nnpm run typecheck # tsc across all TS packages\n\n# Tests — TypeScript (Vitest)\nnpm test -w @open-inspect/control-plane # unit tests (node env)\nnpm run test:integration -w @open-inspect/control-plane # integration (workerd/Miniflare + real D1)\nnpm test -w @open-inspect/web\nnpm test -w @open-inspect/github-bot\nnpm test -w @open-inspect/slack-bot\nnpm test -w @open-inspect/linear-bot\n\n# Tests — Python (pytest)\ncd packages/modal-infra && pytest tests/ -v\n\n# Python linting\ncd packages/modal-infra && ruff check --fix && ruff format\n```\n\n## Testing\n\nAll TypeScript packages use **Vitest**; Python uses **pytest** + pytest-asyncio.\n\n### Test file locations\n\n- **control-plane unit**: co-located as `src/**/*.test.ts` — run in Node environment\n- **control-plane integration**: separate `test/integration/*.test.ts` — run in workerd via\n `@cloudflare/vitest-pool-workers` with real D1 bindings\n- **web, slack-bot, linear-bot**: co-located `src/**/*.test.ts`\n- **github-bot**: separate `test/*.test.ts`\n- **modal-infra**: `tests/test_*.py`\n\n### Control-plane integration tests\n\nThese run inside a real `workerd` runtime with Miniflare, using the `cloudflareTest()` plugin from\n`@cloudflare/vitest-pool-workers`. Important:\n\n- Integration tests share one D1 instance — use `cleanD1Tables()` or equivalent cleanup in\n `beforeEach`/`afterEach` to avoid cross-test pollution\n- D1 migrations from `terraform/d1/migrations/` are applied automatically via\n `test/integration/apply-migrations.ts`\n- Helpers in `test/integration/helpers.ts`: `initSession()`, `queryDO()`, `seedEvents()`\n\n## Coding Conventions\n\n### Durations and timeouts\n\n- **Use seconds for Python, milliseconds for TypeScript.** These match each ecosystem's conventions\n (Modal `timeout=` takes seconds; control-plane uses `_MS` suffixes throughout).\n- **Encode the unit in the name.** Python: `timeout_seconds`. TypeScript: `timeoutMs`,\n `INACTIVITY_TIMEOUT_MS`. Never use a bare `timeout`.\n- **Define each default value exactly once.** Extract to a named constant and import everywhere.\n- **Don't restate literal values in comments.** Write `Defaults to DEFAULT_SANDBOX_TIMEOUT_SECONDS`,\n not `Default: 7200`.\n\n### Extending existing patterns\n\n- When threading an existing field through new code paths, evaluate whether the existing design\n (naming, types, units) is correct — don't blindly propagate it. Fix bad names or units in the same\n change rather than spreading the problem.\n\n### Commit messages\n\nUse conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`. Keep the subject\nunder 72 characters. Use the PR body for details, not the commit message.\n\n## Key Gotchas\n\n- **Build order**: always build `@open-inspect/shared` before packages that depend on it.\n- **PKCS#8 keys**: Cloudflare Workers require PKCS#8 format for GitHub App private keys — convert\n with `openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt`.\n- **Durable Object bindings**: new DO bindings require a two-phase Terraform deploy — first with\n `enable_durable_object_bindings = false`, then `true`.\n- **No `wrangler.toml`**: control-plane config is generated by Terraform, not checked in.\n- **Modal deployment**: from `packages/modal-infra`, run\n `uv run python deploy.py --build-sandbox-image` before `uv run modal deploy deploy.py` (or\n `uv run modal deploy -m src`). Never deploy `src/app.py` directly; it doesn't import function\n modules.\n- **Modal image rebuild**: update `CACHE_BUSTER` in `src/images/base.py` to force a rebuild.\n- **Web platform choice**: set `web_platform = \"cloudflare\"` in Terraform variables to deploy the\n web app to Cloudflare Workers via OpenNext instead of Vercel. When using Cloudflare, Vercel\n credentials are not required (dummy defaults are used). `NEXT_PUBLIC_WS_URL` must be available at\n build time since Next.js inlines `NEXT_PUBLIC_*` vars into the client bundle.\n- **Repo owners can be nested namespaces**: a `repo_owner` is not always a single segment. GitHub\n owners are (`octocat`), but GitLab subgroups nest (`group/subgroup`), so an owner may contain `/`.\n Only `repo_name` is a single path segment (it's the checkout directory under `/workspace`); the\n owner remains part of the repository identity in clone URLs, API routes, manifests, and storage\n keys. Don't validate or split owners as single segments. Use the shared repository identity\n helpers in TypeScript; where a full `owner/name` string is unavoidable, split on the **last** `/`\n and encode the owner as one API route segment. `repo_config.parse_repositories` accepts `/`-joined\n owners (see `is_safe_repo_owner`).\n\n## CI/CD\n\nPushing to `main` auto-deploys changed services:\n\n- **Terraform** → control plane + D1 migrations + web app if `web_platform = \"cloudflare\"`\n (triggers: `terraform/`, `packages/*/`)\n- **Vercel** → web app when `web_platform = \"vercel\"` (triggers: `packages/web/`,\n `packages/shared/`)\n- **Modal** → data plane (triggers: `packages/modal-infra/`, deployed via Terraform apply)\n\nCI runs lint, typecheck, and tests for all TypeScript and Python packages on every push and PR.\n\n## Further Reading\n\n- [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md) — deploy your own instance\n- [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md) — detailed architecture and session lifecycle\n- [CONTRIBUTING.md](CONTRIBUTING.md) — contribution guidelines\n- [packages/control-plane/README.md](packages/control-plane/README.md) — API reference, WebSocket\n protocol, D1 schema, security model\n- [packages/modal-infra/README.md](packages/modal-infra/README.md) — sandbox internals, Modal\n deployment, endpoint URLs\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}\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}\n"}}>UV_CACHE_DIR=/private/tmp/codex-verify-uv-cache uv run pytest tests/test_clone_token.py -q{"status":"fulfilled","value":{"chunk_id":"34ec4f","wall_time_seconds":0.338466083,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908547) 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' (7908541) 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":"db7d95","wall_time_seconds":0.37465625,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908548) 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' (7908542) 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":"ad7a80","wall_time_seconds":0,"exit_code":2,"original_token_count":2274,"output":"a7983425 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\n8d806040 docs: item #2 harness tamper-resistance audit complete, item #3 credential isolation queued\ncommit 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(-)\nrg: packages/control-plane/src/image-builds/planner.test.ts: No such file or directory (os error 2)\npackages/control-plane/src/session/session-core-repository.ts:47:/** Persistence for the session and its member repositories. */\npackages/control-plane/src/session/session-core-repository.ts:222: replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\npackages/control-plane/src/session/session-core-repository.ts:223: this.sql.exec(`DELETE FROM session_repositories`);\npackages/control-plane/src/session/session-core-repository.ts:224: for (const repo of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:226: `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\npackages/control-plane/src/session/session-core-repository.ts:238: const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\npackages/control-plane/src/session/session-core-repository.ts:243: * Returns the session's repositories, using the scalar mirror fallback for\npackages/control-plane/src/session/session-core-repository.ts:246: getSessionRepositories(): SessionRepositoryEntry[] {\npackages/control-plane/src/session/session-core-repository.ts:261: `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\npackages/control-plane/src/session/session-core-repository.ts:269: repositories: Array<{\npackages/control-plane/src/session/session-core-repository.ts:278: for (const repository of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:280: `UPDATE session_repositories\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:28: const getSessionRepositories = vi.fn(() => [] as Array<{ repoOwner: string; repoName: string }>);\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:50: { getSession, getSessionRepositories } as unknown as SessionCoreRepository,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:84: getSessionRepositories,\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:649: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:686: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:713: getSessionRepositories.mockReturnValue([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:239: it(\"returns null for archived repositories\", async () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:322: it(\"excludes archived repositories\", async () => {\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/github-provider.test.ts:976: \"https://api.github.com/repositories/9001\",\npackages/control-plane/src/source-control/providers/github-provider.ts:106:/** Wire shape of GET /repositories/{id}, limited to the location fields. */\npackages/control-plane/src/source-control/providers/github-provider.ts:626: * GET /repositories/{id} is GitHub's stable-but-undocumented by-id alias of\npackages/control-plane/src/source-control/providers/github-provider.ts:637: `${GITHUB_API_BASE}/repositories/${encodeURIComponent(repositoryExternalId)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:700: * List all repositories accessible to the GitHub App installation.\npackages/control-plane/src/source-control/providers/github-provider.ts:705: \"GitHub App not configured - cannot list repositories\",\npackages/control-plane/src/source-control/providers/github-provider.ts:718: `Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.ts:993: // permissions — this credential is directly reachable by the\n"}}
{"chunk_id":"bfa8b1","wall_time_seconds":5.004845541,"session_id":38631,"original_token_count":283,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 83ms\n × adopts sockets under their tags and enumerates them by tag 41ms\n × returns no tags for a socket it never accepted 5ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 1ms\n × forwards text frames as strings and binary frames as ArrayBuffers 6ms\n × answers the exact keepalive request without delivering it 6ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 1ms\n × reports a lost connection as an unclean 1006 close 0ms\n × delivers one socket's events in order, one at a time, close last 1ms\n × logs a failed delivery and keeps delivering 1ms\n × forwards socket errors to the runtime 5ms\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 4ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 1ms\n × satisfies the core's open check without the ambient WebSocket global 0ms\n"}>UV_CACHE_DIR=/private/tmp/codex-verify-uv-cache uv run pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"34ec4f","wall_time_seconds":0.338466083,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908547) 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' (7908541) 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":"db7d95","wall_time_seconds":0.37465625,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908548) 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' (7908542) 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":"ad7a80","wall_time_seconds":0,"exit_code":2,"original_token_count":2274,"output":"a7983425 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\n8d806040 docs: item #2 harness tamper-resistance audit complete, item #3 credential isolation queued\ncommit 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(-)\nrg: packages/control-plane/src/image-builds/planner.test.ts: No such file or directory (os error 2)\npackages/control-plane/src/session/session-core-repository.ts:47:/** Persistence for the session and its member repositories. */\npackages/control-plane/src/session/session-core-repository.ts:222: replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\npackages/control-plane/src/session/session-core-repository.ts:223: this.sql.exec(`DELETE FROM session_repositories`);\npackages/control-plane/src/session/session-core-repository.ts:224: for (const repo of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:226: `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\npackages/control-plane/src/session/session-core-repository.ts:238: const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\npackages/control-plane/src/session/session-core-repository.ts:243: * Returns the session's repositories, using the scalar mirror fallback for\npackages/control-plane/src/session/session-core-repository.ts:246: getSessionRepositories(): SessionRepositoryEntry[] {\npackages/control-plane/src/session/session-core-repository.ts:261: `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\npackages/control-plane/src/session/session-core-repository.ts:269: repositories: Array<{\npackages/control-plane/src/session/session-core-repository.ts:278: for (const repository of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:280: `UPDATE session_repositories\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:28: const getSessionRepositories = vi.fn(() => [] as Array<{ repoOwner: string; repoName: string }>);\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:50: { getSession, getSessionRepositories } as unknown as SessionCoreRepository,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:84: getSessionRepositories,\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:649: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:686: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:713: getSessionRepositories.mockReturnValue([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:239: it(\"returns null for archived repositories\", async () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:322: it(\"excludes archived repositories\", async () => {\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/github-provider.test.ts:976: \"https://api.github.com/repositories/9001\",\npackages/control-plane/src/source-control/providers/github-provider.ts:106:/** Wire shape of GET /repositories/{id}, limited to the location fields. */\npackages/control-plane/src/source-control/providers/github-provider.ts:626: * GET /repositories/{id} is GitHub's stable-but-undocumented by-id alias of\npackages/control-plane/src/source-control/providers/github-provider.ts:637: `${GITHUB_API_BASE}/repositories/${encodeURIComponent(repositoryExternalId)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:700: * List all repositories accessible to the GitHub App installation.\npackages/control-plane/src/source-control/providers/github-provider.ts:705: \"GitHub App not configured - cannot list repositories\",\npackages/control-plane/src/source-control/providers/github-provider.ts:718: `Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.ts:993: // permissions — this credential is directly reachable by the\n"}}
{"chunk_id":"bfa8b1","wall_time_seconds":5.004845541,"session_id":38631,"original_token_count":283,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 83ms\n × adopts sockets under their tags and enumerates them by tag 41ms\n × returns no tags for a socket it never accepted 5ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 1ms\n × forwards text frames as strings and binary frames as ArrayBuffers 6ms\n × answers the exact keepalive request without delivering it 6ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 1ms\n × reports a lost connection as an unclean 1006 close 0ms\n × delivers one socket's events in order, one at a time, close last 1ms\n × logs a failed delivery and keeps delivering 1ms\n × forwards socket errors to the runtime 5ms\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 4ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 1ms\n × satisfies the core's open check without the ambient WebSocket global 0ms\n"}>git log -5 --oneline && git show --stat HEAD && rg -n 'generateCredentialHelperAuth|getSessionRepositories|repositories|permissions' packages/control-plane/src/source-control/providers/github* packages/control-plane/src/session/session-core-repository.ts packages/control-plane/src/image-builds/planner.test.ts packages/control-plane/src/session/http/handlers/sandbox.handler.test.ts && cat packages/modal-infra/tests/test_clone_token.py && cat packages/sandbox-runtime/tests/test_github_app_auth.py{"status":"fulfilled","value":{"chunk_id":"34ec4f","wall_time_seconds":0.338466083,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908547) 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' (7908541) 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":"db7d95","wall_time_seconds":0.37465625,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908548) 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' (7908542) 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":"ad7a80","wall_time_seconds":0,"exit_code":2,"original_token_count":2274,"output":"a7983425 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\n8d806040 docs: item #2 harness tamper-resistance audit complete, item #3 credential isolation queued\ncommit 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(-)\nrg: packages/control-plane/src/image-builds/planner.test.ts: No such file or directory (os error 2)\npackages/control-plane/src/session/session-core-repository.ts:47:/** Persistence for the session and its member repositories. */\npackages/control-plane/src/session/session-core-repository.ts:222: replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\npackages/control-plane/src/session/session-core-repository.ts:223: this.sql.exec(`DELETE FROM session_repositories`);\npackages/control-plane/src/session/session-core-repository.ts:224: for (const repo of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:226: `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\npackages/control-plane/src/session/session-core-repository.ts:238: const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\npackages/control-plane/src/session/session-core-repository.ts:243: * Returns the session's repositories, using the scalar mirror fallback for\npackages/control-plane/src/session/session-core-repository.ts:246: getSessionRepositories(): SessionRepositoryEntry[] {\npackages/control-plane/src/session/session-core-repository.ts:261: `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\npackages/control-plane/src/session/session-core-repository.ts:269: repositories: Array<{\npackages/control-plane/src/session/session-core-repository.ts:278: for (const repository of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:280: `UPDATE session_repositories\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:28: const getSessionRepositories = vi.fn(() => [] as Array<{ repoOwner: string; repoName: string }>);\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:50: { getSession, getSessionRepositories } as unknown as SessionCoreRepository,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:84: getSessionRepositories,\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:649: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:686: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:713: getSessionRepositories.mockReturnValue([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:239: it(\"returns null for archived repositories\", async () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:322: it(\"excludes archived repositories\", async () => {\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/github-provider.test.ts:976: \"https://api.github.com/repositories/9001\",\npackages/control-plane/src/source-control/providers/github-provider.ts:106:/** Wire shape of GET /repositories/{id}, limited to the location fields. */\npackages/control-plane/src/source-control/providers/github-provider.ts:626: * GET /repositories/{id} is GitHub's stable-but-undocumented by-id alias of\npackages/control-plane/src/source-control/providers/github-provider.ts:637: `${GITHUB_API_BASE}/repositories/${encodeURIComponent(repositoryExternalId)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:700: * List all repositories accessible to the GitHub App installation.\npackages/control-plane/src/source-control/providers/github-provider.ts:705: \"GitHub App not configured - cannot list repositories\",\npackages/control-plane/src/source-control/providers/github-provider.ts:718: `Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.ts:993: // permissions — this credential is directly reachable by the\n"}}
{"chunk_id":"bfa8b1","wall_time_seconds":5.004845541,"session_id":38631,"original_token_count":283,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 83ms\n × adopts sockets under their tags and enumerates them by tag 41ms\n × returns no tags for a socket it never accepted 5ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 1ms\n × forwards text frames as strings and binary frames as ArrayBuffers 6ms\n × answers the exact keepalive request without delivering it 6ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 1ms\n × reports a lost connection as an unclean 1006 close 0ms\n × delivers one socket's events in order, one at a time, close last 1ms\n × logs a failed delivery and keeps delivering 1ms\n × forwards socket errors to the runtime 5ms\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 4ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 1ms\n × satisfies the core's open check without the ambient WebSocket global 0ms\n"}{"status":"fulfilled","value":{"chunk_id":"34ec4f","wall_time_seconds":0.338466083,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908547) 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' (7908541) 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":"db7d95","wall_time_seconds":0.37465625,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7908548) 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' (7908542) 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":"ad7a80","wall_time_seconds":0,"exit_code":2,"original_token_count":2274,"output":"a7983425 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\n8d806040 docs: item #2 harness tamper-resistance audit complete, item #3 credential isolation queued\ncommit 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(-)\nrg: packages/control-plane/src/image-builds/planner.test.ts: No such file or directory (os error 2)\npackages/control-plane/src/session/session-core-repository.ts:47:/** Persistence for the session and its member repositories. */\npackages/control-plane/src/session/session-core-repository.ts:222: replaceSessionRepositories(repositories: SessionRepositoryData[]): void {\npackages/control-plane/src/session/session-core-repository.ts:223: this.sql.exec(`DELETE FROM session_repositories`);\npackages/control-plane/src/session/session-core-repository.ts:224: for (const repo of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:226: `INSERT INTO session_repositories (position, repo_owner, repo_name, repo_id, base_branch)\npackages/control-plane/src/session/session-core-repository.ts:238: const result = this.sql.exec(`SELECT * FROM session_repositories ORDER BY position`);\npackages/control-plane/src/session/session-core-repository.ts:243: * Returns the session's repositories, using the scalar mirror fallback for\npackages/control-plane/src/session/session-core-repository.ts:246: getSessionRepositories(): SessionRepositoryEntry[] {\npackages/control-plane/src/session/session-core-repository.ts:261: `UPDATE session_repositories SET branch_name = ? WHERE repo_owner = ? AND repo_name = ?`,\npackages/control-plane/src/session/session-core-repository.ts:269: repositories: Array<{\npackages/control-plane/src/session/session-core-repository.ts:278: for (const repository of repositories) {\npackages/control-plane/src/session/session-core-repository.ts:280: `UPDATE session_repositories\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:28: const getSessionRepositories = vi.fn(() => [] as Array<{ repoOwner: string; repoName: string }>);\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:50: { getSession, getSessionRepositories } as unknown as SessionCoreRepository,\npackages/control-plane/src/session/http/handlers/sandbox.handler.test.ts:84: getSessionRepositories,\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:649: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:686: getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\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:713: getSessionRepositories.mockReturnValue([\npackages/control-plane/src/source-control/providers/github-provider.test.ts:239: it(\"returns null for archived repositories\", async () => {\npackages/control-plane/src/source-control/providers/github-provider.test.ts:322: it(\"excludes archived repositories\", async () => {\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/github-provider.test.ts:976: \"https://api.github.com/repositories/9001\",\npackages/control-plane/src/source-control/providers/github-provider.ts:106:/** Wire shape of GET /repositories/{id}, limited to the location fields. */\npackages/control-plane/src/source-control/providers/github-provider.ts:626: * GET /repositories/{id} is GitHub's stable-but-undocumented by-id alias of\npackages/control-plane/src/source-control/providers/github-provider.ts:637: `${GITHUB_API_BASE}/repositories/${encodeURIComponent(repositoryExternalId)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:700: * List all repositories accessible to the GitHub App installation.\npackages/control-plane/src/source-control/providers/github-provider.ts:705: \"GitHub App not configured - cannot list repositories\",\npackages/control-plane/src/source-control/providers/github-provider.ts:718: `Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`,\npackages/control-plane/src/source-control/providers/github-provider.ts:973: async generateCredentialHelperAuth(\npackages/control-plane/src/source-control/providers/github-provider.ts:993: // permissions — this credential is directly reachable by the\n"}}
{"chunk_id":"bfa8b1","wall_time_seconds":5.004845541,"session_id":38631,"original_token_count":283,"output":" ❯ src/node/socket-host.test.ts (14 tests | 14 failed) 83ms\n × adopts sockets under their tags and enumerates them by tag 41ms\n × returns no tags for a socket it never accepted 5ms\n × refuses sockets it did not upgrade, double adoptions, and adoptions before bindEventSink 1ms\n × forwards text frames as strings and binary frames as ArrayBuffers 6ms\n × answers the exact keepalive request without delivering it 6ms\n × delivers a runtime-initiated close to the peer and to the runtime, then drops the socket 1ms\n × reports a lost connection as an unclean 1006 close 0ms\n × delivers one socket's events in order, one at a time, close last 1ms\n × logs a failed delivery and keeps delivering 1ms\n × forwards socket errors to the runtime 5ms\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 4ms\n × closes a peer whose parsed backlog exceeds the bound instead of retaining it 1ms\n × satisfies the core's open check without the ambient WebSocket global 0ms\n"}Both TypeScript paths now pass the full repository list, and the Python guards explicitly reject the unsafe inputs. Execution has hit environment issues: uv cannot use its default cache and then panics with a temporary cache; the control-plane suite has also reported socket-test failures. I’m checking the causes and continuing validation.
>cat packages/modal-infra/tests/test_clone_token.py && cat packages/sandbox-runtime/tests/test_github_app_auth.py && cat packages/modal-infra/pyproject.toml && cat packages/sandbox-runtime/pyproject.toml && ls -ld packages/modal-infra/.venv packages/sandbox-runtime/.venv && command -v python3 pytest uv{"status":"fulfilled","value":{"chunk_id":"bfd6fe","wall_time_seconds":0,"exit_code":1,"original_token_count":2764,"output":"\"\"\"Tests for VCS clone token resolution.\"\"\"\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 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 fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\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\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 def fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\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\"\"\"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[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[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\"]\nls: packages/modal-infra/.venv: No such file or directory\nls: packages/sandbox-runtime/.venv: No such file or directory\n"}}
{"status":"fulfilled","value":{"chunk_id":"416e40","wall_time_seconds":0.531933708,"exit_code":0,"original_token_count":4043,"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\ndiff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts\nindex 3dd9648e..bc632b8e 100644\n--- a/packages/control-plane/src/auth/github-app.ts\n+++ b/packages/control-plane/src/auth/github-app.ts\n@@ -287,6 +287,105 @@ async function getInstallationTokenWithMetadata(\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+ */\n+async 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+ }\n+ return parsed.data;\n+}\n+\n+/** Default permission set for sandbox-reachable credentials: git push only. */\n+export 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+ */\n+export 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+\n function getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n }\ndiff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts\nindex 896461a2..1a56dd1d 100644\n--- a/packages/control-plane/src/source-control/providers/github-provider.ts\n+++ b/packages/control-plane/src/source-control/providers/github-provider.ts\n@@ -34,7 +34,7 @@ import {\n import { classifyGitTreeEntry } from \"./git-tree\";\n import {\n getCachedInstallationToken,\n- getCachedInstallationTokenWithExpiry,\n+ getScopedInstallationTokenWithExpiry,\n getInstallationRepository,\n listInstallationRepositories,\n listRepositoryBranches,\n@@ -970,17 +970,34 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n }\n }\n \n- async generateCredentialHelperAuth(): Promise<CredentialHelperAuth> {\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 getCachedInstallationTokenWithExpiry(\n+ const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n+ repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n@@ -993,7 +1010,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n- `Failed to generate GitHub credential helper auth: ${error instanceof Error ? error.message : String(error)}`,\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 );\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\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 }\npackages/control-plane/src/image-builds/vercel-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/opencomputer-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/workflow.test.ts:87: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:103: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:110: planBuild?: ReturnType<typeof vi.fn>;\npackages/control-plane/src/image-builds/workflow.test.ts:122: const planBuild = options.planBuild ?? vi.fn().mockResolvedValue(plannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:137: const planner = { planBuild, resolveTarget, createCallbackAuth } as unknown as NonNullable<\npackages/control-plane/src/image-builds/workflow.test.ts:147: return { workflow, store, adapter, factory, planBuild, resolveTarget, createCallbackAuth };\npackages/control-plane/src/image-builds/workflow.test.ts:189: const { workflow, resolveTarget, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:199: expect(planBuild).toHaveBeenCalledWith(expect.objectContaining({ target }));\npackages/control-plane/src/image-builds/workflow.test.ts:205: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:210: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:237: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:243: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:277: const { workflow, adapter, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:282: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:303: const { workflow, store, adapter, planBuild } = createWorkflow({ jobs: null });\npackages/control-plane/src/image-builds/workflow.test.ts:308: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:314: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:318: // planBuild is where secrets are decrypted; a concurrent secret change\npackages/control-plane/src/image-builds/workflow.test.ts:321: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:338: planBuild: vi.fn(),\npackages/control-plane/src/image-builds/workflow.test.ts:416: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:422: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:428: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:433: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:454: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:462: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:495: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:501: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:525: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:532: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:551: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:557: planBuild,\npackages/control-plane/src/image-builds/modal-adapter.test.ts:27: cloneAuth: {\npackages/control-plane/src/image-builds/e2b-adapter.test.ts:31: cloneAuth: {\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/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:273: 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: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/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:45: return generate_installation_token(\npackages/modal-infra/src/sandbox/manager.py:658: # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\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: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:103:def generate_installation_token(\n"}}
{"status":"fulfilled","value":{"chunk_id":"62ab95","wall_time_seconds":0.429978041,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7909962) 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' (7909959) 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"}}
{"chunk_id":"be38df","wall_time_seconds":5.050550333,"session_id":38631,"original_token_count":458,"output":" ❯ src/node/host.test.ts (10 tests | 10 failed) 3599ms\n × boots over the migrated global store and answers the health check and the route table 447ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 249ms\n × closes the cache database on a normal shutdown, not only on a failed boot 267ms\n × reports draining once a shutdown begins and stops listening when it ends 448ms\n × waits for a request in flight before closing the stores, and answers it 837ms\n × gives up a request that outlives the budget and reports it 316ms\n × marks a stop that abandoned nothing as clean 370ms\n × arms a deadline a previous process left only in the session file 265ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 189ms\n × releases what it acquired when a later boot step fails 210ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 29795ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10036ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10017ms\n × shutdown closes a socket adopted under a lease that predates it 5009ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 44ms\n × answers /healthz itself, 200 while serving and 503 while draining 35ms\n × hands every other request to the app as a fetch Request 3ms\n × routes an upgrade to the upgrade handler 3ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 0ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10530ms\n"}>git show HEAD -- packages/control-plane/src/auth/github-app.ts packages/control-plane/src/source-control/providers/github-provider.ts packages/modal-infra/src/web_api.py && sed -n '236,257p' packages/control-plane/src/session/session-core-repository.ts && rg -n 'resolveCloneAuth|cloneAuth|planBuild' packages/control-plane/src/image-builds --glob '*test*' && rg -n 'resolve_clone_token|generate_installation_token|GITHUB_TOKEN' packages/modal-infra/src packages/sandbox-runtime/src --glob '*.py'{"status":"fulfilled","value":{"chunk_id":"bfd6fe","wall_time_seconds":0,"exit_code":1,"original_token_count":2764,"output":"\"\"\"Tests for VCS clone token resolution.\"\"\"\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 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 fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\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\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 def fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\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\"\"\"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[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[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\"]\nls: packages/modal-infra/.venv: No such file or directory\nls: packages/sandbox-runtime/.venv: No such file or directory\n"}}
{"status":"fulfilled","value":{"chunk_id":"416e40","wall_time_seconds":0.531933708,"exit_code":0,"original_token_count":4043,"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\ndiff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts\nindex 3dd9648e..bc632b8e 100644\n--- a/packages/control-plane/src/auth/github-app.ts\n+++ b/packages/control-plane/src/auth/github-app.ts\n@@ -287,6 +287,105 @@ async function getInstallationTokenWithMetadata(\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+ */\n+async 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+ }\n+ return parsed.data;\n+}\n+\n+/** Default permission set for sandbox-reachable credentials: git push only. */\n+export 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+ */\n+export 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+\n function getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n }\ndiff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts\nindex 896461a2..1a56dd1d 100644\n--- a/packages/control-plane/src/source-control/providers/github-provider.ts\n+++ b/packages/control-plane/src/source-control/providers/github-provider.ts\n@@ -34,7 +34,7 @@ import {\n import { classifyGitTreeEntry } from \"./git-tree\";\n import {\n getCachedInstallationToken,\n- getCachedInstallationTokenWithExpiry,\n+ getScopedInstallationTokenWithExpiry,\n getInstallationRepository,\n listInstallationRepositories,\n listRepositoryBranches,\n@@ -970,17 +970,34 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n }\n }\n \n- async generateCredentialHelperAuth(): Promise<CredentialHelperAuth> {\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 getCachedInstallationTokenWithExpiry(\n+ const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n+ repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n@@ -993,7 +1010,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n- `Failed to generate GitHub credential helper auth: ${error instanceof Error ? error.message : String(error)}`,\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 );\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\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 }\npackages/control-plane/src/image-builds/vercel-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/opencomputer-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/workflow.test.ts:87: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:103: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:110: planBuild?: ReturnType<typeof vi.fn>;\npackages/control-plane/src/image-builds/workflow.test.ts:122: const planBuild = options.planBuild ?? vi.fn().mockResolvedValue(plannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:137: const planner = { planBuild, resolveTarget, createCallbackAuth } as unknown as NonNullable<\npackages/control-plane/src/image-builds/workflow.test.ts:147: return { workflow, store, adapter, factory, planBuild, resolveTarget, createCallbackAuth };\npackages/control-plane/src/image-builds/workflow.test.ts:189: const { workflow, resolveTarget, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:199: expect(planBuild).toHaveBeenCalledWith(expect.objectContaining({ target }));\npackages/control-plane/src/image-builds/workflow.test.ts:205: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:210: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:237: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:243: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:277: const { workflow, adapter, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:282: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:303: const { workflow, store, adapter, planBuild } = createWorkflow({ jobs: null });\npackages/control-plane/src/image-builds/workflow.test.ts:308: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:314: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:318: // planBuild is where secrets are decrypted; a concurrent secret change\npackages/control-plane/src/image-builds/workflow.test.ts:321: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:338: planBuild: vi.fn(),\npackages/control-plane/src/image-builds/workflow.test.ts:416: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:422: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:428: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:433: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:454: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:462: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:495: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:501: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:525: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:532: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:551: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:557: planBuild,\npackages/control-plane/src/image-builds/modal-adapter.test.ts:27: cloneAuth: {\npackages/control-plane/src/image-builds/e2b-adapter.test.ts:31: cloneAuth: {\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/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:273: 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: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/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:45: return generate_installation_token(\npackages/modal-infra/src/sandbox/manager.py:658: # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\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: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:103:def generate_installation_token(\n"}}
{"status":"fulfilled","value":{"chunk_id":"62ab95","wall_time_seconds":0.429978041,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7909962) 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' (7909959) 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"}}
{"chunk_id":"be38df","wall_time_seconds":5.050550333,"session_id":38631,"original_token_count":458,"output":" ❯ src/node/host.test.ts (10 tests | 10 failed) 3599ms\n × boots over the migrated global store and answers the health check and the route table 447ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 249ms\n × closes the cache database on a normal shutdown, not only on a failed boot 267ms\n × reports draining once a shutdown begins and stops listening when it ends 448ms\n × waits for a request in flight before closing the stores, and answers it 837ms\n × gives up a request that outlives the budget and reports it 316ms\n × marks a stop that abandoned nothing as clean 370ms\n × arms a deadline a previous process left only in the session file 265ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 189ms\n × releases what it acquired when a later boot step fails 210ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 29795ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10036ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10017ms\n × shutdown closes a socket adopted under a lease that predates it 5009ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 44ms\n × answers /healthz itself, 200 while serving and 503 while draining 35ms\n × hands every other request to the app as a fetch Request 3ms\n × routes an upgrade to the upgrade handler 3ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 0ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10530ms\n"}>UV_CACHE_DIR=/private/tmp/codex-verify-uv-cache UV_OFFLINE=1 uv run --no-sync pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"bfd6fe","wall_time_seconds":0,"exit_code":1,"original_token_count":2764,"output":"\"\"\"Tests for VCS clone token resolution.\"\"\"\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 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 fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\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\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 def fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\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\"\"\"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[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[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\"]\nls: packages/modal-infra/.venv: No such file or directory\nls: packages/sandbox-runtime/.venv: No such file or directory\n"}}
{"status":"fulfilled","value":{"chunk_id":"416e40","wall_time_seconds":0.531933708,"exit_code":0,"original_token_count":4043,"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\ndiff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts\nindex 3dd9648e..bc632b8e 100644\n--- a/packages/control-plane/src/auth/github-app.ts\n+++ b/packages/control-plane/src/auth/github-app.ts\n@@ -287,6 +287,105 @@ async function getInstallationTokenWithMetadata(\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+ */\n+async 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+ }\n+ return parsed.data;\n+}\n+\n+/** Default permission set for sandbox-reachable credentials: git push only. */\n+export 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+ */\n+export 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+\n function getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n }\ndiff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts\nindex 896461a2..1a56dd1d 100644\n--- a/packages/control-plane/src/source-control/providers/github-provider.ts\n+++ b/packages/control-plane/src/source-control/providers/github-provider.ts\n@@ -34,7 +34,7 @@ import {\n import { classifyGitTreeEntry } from \"./git-tree\";\n import {\n getCachedInstallationToken,\n- getCachedInstallationTokenWithExpiry,\n+ getScopedInstallationTokenWithExpiry,\n getInstallationRepository,\n listInstallationRepositories,\n listRepositoryBranches,\n@@ -970,17 +970,34 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n }\n }\n \n- async generateCredentialHelperAuth(): Promise<CredentialHelperAuth> {\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 getCachedInstallationTokenWithExpiry(\n+ const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n+ repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n@@ -993,7 +1010,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n- `Failed to generate GitHub credential helper auth: ${error instanceof Error ? error.message : String(error)}`,\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 );\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\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 }\npackages/control-plane/src/image-builds/vercel-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/opencomputer-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/workflow.test.ts:87: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:103: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:110: planBuild?: ReturnType<typeof vi.fn>;\npackages/control-plane/src/image-builds/workflow.test.ts:122: const planBuild = options.planBuild ?? vi.fn().mockResolvedValue(plannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:137: const planner = { planBuild, resolveTarget, createCallbackAuth } as unknown as NonNullable<\npackages/control-plane/src/image-builds/workflow.test.ts:147: return { workflow, store, adapter, factory, planBuild, resolveTarget, createCallbackAuth };\npackages/control-plane/src/image-builds/workflow.test.ts:189: const { workflow, resolveTarget, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:199: expect(planBuild).toHaveBeenCalledWith(expect.objectContaining({ target }));\npackages/control-plane/src/image-builds/workflow.test.ts:205: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:210: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:237: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:243: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:277: const { workflow, adapter, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:282: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:303: const { workflow, store, adapter, planBuild } = createWorkflow({ jobs: null });\npackages/control-plane/src/image-builds/workflow.test.ts:308: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:314: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:318: // planBuild is where secrets are decrypted; a concurrent secret change\npackages/control-plane/src/image-builds/workflow.test.ts:321: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:338: planBuild: vi.fn(),\npackages/control-plane/src/image-builds/workflow.test.ts:416: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:422: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:428: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:433: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:454: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:462: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:495: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:501: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:525: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:532: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:551: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:557: planBuild,\npackages/control-plane/src/image-builds/modal-adapter.test.ts:27: cloneAuth: {\npackages/control-plane/src/image-builds/e2b-adapter.test.ts:31: cloneAuth: {\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/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:273: 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: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/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:45: return generate_installation_token(\npackages/modal-infra/src/sandbox/manager.py:658: # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\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: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:103:def generate_installation_token(\n"}}
{"status":"fulfilled","value":{"chunk_id":"62ab95","wall_time_seconds":0.429978041,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7909962) 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' (7909959) 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"}}
{"chunk_id":"be38df","wall_time_seconds":5.050550333,"session_id":38631,"original_token_count":458,"output":" ❯ src/node/host.test.ts (10 tests | 10 failed) 3599ms\n × boots over the migrated global store and answers the health check and the route table 447ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 249ms\n × closes the cache database on a normal shutdown, not only on a failed boot 267ms\n × reports draining once a shutdown begins and stops listening when it ends 448ms\n × waits for a request in flight before closing the stores, and answers it 837ms\n × gives up a request that outlives the budget and reports it 316ms\n × marks a stop that abandoned nothing as clean 370ms\n × arms a deadline a previous process left only in the session file 265ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 189ms\n × releases what it acquired when a later boot step fails 210ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 29795ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10036ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10017ms\n × shutdown closes a socket adopted under a lease that predates it 5009ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 44ms\n × answers /healthz itself, 200 while serving and 503 while draining 35ms\n × hands every other request to the app as a fetch Request 3ms\n × routes an upgrade to the upgrade handler 3ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 0ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10530ms\n"}{"status":"fulfilled","value":{"chunk_id":"bfd6fe","wall_time_seconds":0,"exit_code":1,"original_token_count":2764,"output":"\"\"\"Tests for VCS clone token resolution.\"\"\"\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 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 fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\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\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 def fail_if_called(**_kwargs):\n raise AssertionError(\"generate_installation_token should not be called\")\n\n monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\n\n assert resolve_clone_token(\"acme\", \"repo\") is None\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\"\"\"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[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[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\"]\nls: packages/modal-infra/.venv: No such file or directory\nls: packages/sandbox-runtime/.venv: No such file or directory\n"}}
{"status":"fulfilled","value":{"chunk_id":"416e40","wall_time_seconds":0.531933708,"exit_code":0,"original_token_count":4043,"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\ndiff --git a/packages/control-plane/src/auth/github-app.ts b/packages/control-plane/src/auth/github-app.ts\nindex 3dd9648e..bc632b8e 100644\n--- a/packages/control-plane/src/auth/github-app.ts\n+++ b/packages/control-plane/src/auth/github-app.ts\n@@ -287,6 +287,105 @@ async function getInstallationTokenWithMetadata(\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+ */\n+async 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+ }\n+ return parsed.data;\n+}\n+\n+/** Default permission set for sandbox-reachable credentials: git push only. */\n+export 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+ */\n+export 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+\n function getInstallationTokenCacheKey(config: GitHubAppConfig): string {\n return `${INSTALLATION_TOKEN_CACHE_KEY_PREFIX}:${config.appId}:${config.installationId}`;\n }\ndiff --git a/packages/control-plane/src/source-control/providers/github-provider.ts b/packages/control-plane/src/source-control/providers/github-provider.ts\nindex 896461a2..1a56dd1d 100644\n--- a/packages/control-plane/src/source-control/providers/github-provider.ts\n+++ b/packages/control-plane/src/source-control/providers/github-provider.ts\n@@ -34,7 +34,7 @@ import {\n import { classifyGitTreeEntry } from \"./git-tree\";\n import {\n getCachedInstallationToken,\n- getCachedInstallationTokenWithExpiry,\n+ getScopedInstallationTokenWithExpiry,\n getInstallationRepository,\n listInstallationRepositories,\n listRepositoryBranches,\n@@ -970,17 +970,34 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n }\n }\n \n- async generateCredentialHelperAuth(): Promise<CredentialHelperAuth> {\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 getCachedInstallationTokenWithExpiry(\n+ const { token, expiresAtEpochMs } = await getScopedInstallationTokenWithExpiry(\n this.appConfig,\n+ repoNames,\n {\n cacheStore: this.cacheStore,\n userAgent: this.userAgent,\n@@ -993,7 +1010,7 @@ export class GitHubSourceControlProvider implements SourceControlProvider {\n };\n } catch (error) {\n throw SourceControlProviderError.fromFetchError(\n- `Failed to generate GitHub credential helper auth: ${error instanceof Error ? error.message : String(error)}`,\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 );\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\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 }\npackages/control-plane/src/image-builds/vercel-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/opencomputer-adapter.test.ts:24: cloneAuth: {\npackages/control-plane/src/image-builds/workflow.test.ts:87: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:103: cloneAuth: { type: \"unavailable\" },\npackages/control-plane/src/image-builds/workflow.test.ts:110: planBuild?: ReturnType<typeof vi.fn>;\npackages/control-plane/src/image-builds/workflow.test.ts:122: const planBuild = options.planBuild ?? vi.fn().mockResolvedValue(plannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:137: const planner = { planBuild, resolveTarget, createCallbackAuth } as unknown as NonNullable<\npackages/control-plane/src/image-builds/workflow.test.ts:147: return { workflow, store, adapter, factory, planBuild, resolveTarget, createCallbackAuth };\npackages/control-plane/src/image-builds/workflow.test.ts:189: const { workflow, resolveTarget, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:199: expect(planBuild).toHaveBeenCalledWith(expect.objectContaining({ target }));\npackages/control-plane/src/image-builds/workflow.test.ts:205: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:210: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:237: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:243: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:277: const { workflow, adapter, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:282: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:303: const { workflow, store, adapter, planBuild } = createWorkflow({ jobs: null });\npackages/control-plane/src/image-builds/workflow.test.ts:308: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:314: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:318: // planBuild is where secrets are decrypted; a concurrent secret change\npackages/control-plane/src/image-builds/workflow.test.ts:321: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:338: planBuild: vi.fn(),\npackages/control-plane/src/image-builds/workflow.test.ts:416: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:422: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:428: const { workflow, store, planBuild } = createWorkflow({});\npackages/control-plane/src/image-builds/workflow.test.ts:433: planBuild.mock.invocationCallOrder[0]\npackages/control-plane/src/image-builds/workflow.test.ts:454: const { workflow, planBuild } = createWorkflow({ store });\npackages/control-plane/src/image-builds/workflow.test.ts:462: expect(planBuild).not.toHaveBeenCalled();\npackages/control-plane/src/image-builds/workflow.test.ts:495: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:501: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:525: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:532: planBuild,\npackages/control-plane/src/image-builds/workflow.test.ts:551: const planBuild = vi.fn().mockResolvedValue(vercelPlannedBuild());\npackages/control-plane/src/image-builds/workflow.test.ts:557: planBuild,\npackages/control-plane/src/image-builds/modal-adapter.test.ts:27: cloneAuth: {\npackages/control-plane/src/image-builds/e2b-adapter.test.ts:31: cloneAuth: {\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/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py:273: 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: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/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:45: return generate_installation_token(\npackages/modal-infra/src/sandbox/manager.py:658: # can't fetch. GITHUB_TOKEN/GITHUB_APP_TOKEN aliases are restored too\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: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:103:def generate_installation_token(\n"}}
{"status":"fulfilled","value":{"chunk_id":"62ab95","wall_time_seconds":0.429978041,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7909962) 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' (7909959) 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"}}
{"chunk_id":"be38df","wall_time_seconds":5.050550333,"session_id":38631,"original_token_count":458,"output":" ❯ src/node/host.test.ts (10 tests | 10 failed) 3599ms\n × boots over the migrated global store and answers the health check and the route table 447ms\n × refuses a WebSocket upgrade for an unknown session and any other upgrade path 249ms\n × closes the cache database on a normal shutdown, not only on a failed boot 267ms\n × reports draining once a shutdown begins and stops listening when it ends 448ms\n × waits for a request in flight before closing the stores, and answers it 837ms\n × gives up a request that outlives the budget and reports it 316ms\n × marks a stop that abandoned nothing as clean 370ms\n × arms a deadline a previous process left only in the session file 265ms\n × fails to boot on a malformed encryption key with the Worker's message, leaving nothing open 189ms\n × releases what it acquired when a later boot step fails 210ms\n ❯ src/node/session-runtime-registry.test.ts (34 tests | 3 failed) 29795ms\n × routes socket events through the runtime, and keeps it resident until the socket closes 10036ms\n × shutdown closes adopted sockets with 1012 and delivers their close against an open store 10017ms\n × shutdown closes a socket adopted under a lease that predates it 5009ms\n ❯ src/node/http-server.test.ts (6 tests | 6 failed) 44ms\n × answers /healthz itself, 200 while serving and 503 while draining 35ms\n × hands every other request to the app as a fetch Request 3ms\n × routes an upgrade to the upgrade handler 3ms\n × logs a rejecting upgrade handler and destroys the socket instead of leaking the rejection 0ms\n × tracks requests in flight and drains them within a budget 0ms\n × stops tracking a request whose handler rejected 0ms\n ❯ src/node/s3-object-storage.test.ts (13 tests | 11 skipped) 10530ms\n"}>command -v python3 python pytest uv && python3 -c 'import sys; print(sys.version); import pytest, httpx, jwt; print("test imports available")' && ls -d .venv /private/tmp/*venv*{"status":"fulfilled","value":{"chunk_id":"93a3c1","wall_time_seconds":0.000320208,"exit_code":1,"original_token_count":18,"output":"/opt/homebrew/bin/python3\n/opt/homebrew/bin/pytest\n/opt/homebrew/bin/uv\n"}}
{"status":"fulfilled","value":{"chunk_id":"3a4055","wall_time_seconds":0.068028125,"exit_code":0,"original_token_count":4168,"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});\n refreshXaiToken.mockResolvedValue({ ok: true, accessToken: \"xai-access\", expiresIn: 3600 });\n\n const response = await handler.xaiTokenRefresh();\n\n expect(response.status).toBe(200);\n expect(response.headers.get(\"Cache-Control\")).toBe(\"no-store\");\n expect(await response.json()).toEqual({ access_token: \"xai-access\", expires_in: 3600 });\n expect(refreshXaiToken).toHaveBeenCalledWith(session, log);\n });\n\n it(\"returns 404 when xAI token refresh has no session\", async () => {\n const { handler, getSession } = createHandler();\n getSession.mockReturnValue(null);\n\n const response = await handler.xaiTokenRefresh();\n\n expect(response.status).toBe(404);\n expect(await response.json()).toEqual({ error: \"No session\" });\n });\n\n it(\"returns mapped service error from xAI token refresh\", async () => {\n const { handler, getSession, refreshXaiToken } = createHandler();\n getSession.mockReturnValue({ id: \"session-1\" } as SessionRow);\n refreshXaiToken.mockResolvedValue({ ok: false, status: 401, error: \"xAI unauthorized\" });\n\n const response = await handler.xaiTokenRefresh();\n\n expect(response.status).toBe(401);\n expect(await response.json()).toEqual({ error: \"xAI unauthorized\" });\n });\n\n it(\"returns 404 when scm credentials have no session\", async () => {\n const { handler, getSession, getScmCredentials } = createHandler();\n getSession.mockReturnValue(null);\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(404);\n expect(await response.json()).toEqual({ error: \"No session\" });\n expect(getScmCredentials).not.toHaveBeenCalled();\n });\n\n it(\"returns mapped service error from scm credentials\", async () => {\n const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: \"acme\",\n repo_name: \"web-app\",\n } as SessionRow);\n getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\n getScmCredentials.mockResolvedValue({\n ok: false,\n status: 503,\n error: \"GitHub App not configured\",\n });\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(503);\n expect(await response.json()).toEqual({ error: \"GitHub App not configured\" });\n });\n\n it(\"rejects scm credentials for no-repository sessions\", async () => {\n const { handler, getSession, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: null,\n repo_name: null,\n } as SessionRow);\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(400);\n expect(await response.json()).toEqual({\n error: \"SCM credentials require a repository context\",\n });\n expect(getScmCredentials).not.toHaveBeenCalled();\n });\n\n it(\"returns scm credentials payload on success\", async () => {\n const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: \"acme\",\n repo_name: \"web-app\",\n } as SessionRow);\n getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\n const expiresAt = Date.now() + 60 * 60 * 1000;\n getScmCredentials.mockResolvedValue({\n ok: true,\n username: \"x-access-token\",\n password: \"ghs_secret\",\n expiresAtEpochMs: expiresAt,\n });\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(200);\n expect(response.headers.get(\"Cache-Control\")).toBe(\"no-store\");\n expect(await response.json()).toEqual({\n username: \"x-access-token\",\n password: \"ghs_secret\",\n expires_at_epoch_ms: expiresAt,\n });\n });\n\n it(\"requests credentials for every member repository, not just the primary\", async () => {\n const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: \"acme\",\n repo_name: \"web-app\",\n } as SessionRow);\n getSessionRepositories.mockReturnValue([\n { repoOwner: \"acme\", repoName: \"web-app\" },\n { repoOwner: \"acme\", repoName: \"shared-lib\" },\n ]);\n getScmCredentials.mockResolvedValue({\n ok: true,\n username: \"x-access-token\",\n password: \"ghs_secret\",\n expiresAtEpochMs: Date.now() + 60 * 60 * 1000,\n });\n\n await handler.scmCredentials();\n\n expect(getScmCredentials).toHaveBeenCalledWith(\n [\n { owner: \"acme\", name: \"web-app\" },\n { owner: \"acme\", name: \"shared-lib\" },\n ],\n expect.anything()\n );\n });\n\n it(\"returns 404 when tunnel URLs have no sandbox\", async () => {\n const { handler, getSandbox } = createHandler();\n getSandbox.mockReturnValue(null);\n\n const response = await handler.tunnelUrls();\n\n });\n\n describe(\"generateCredentialHelperAuth\", () => {\n it(\"throws a permanent error when the App is not configured\", async () => {\n const provider = new GitHubSourceControlProvider();\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).errorType).toBe(\"permanent\");\n expect((err as SourceControlProviderError).message).toMatch(/not configured/i);\n });\n\n it(\"throws a permanent error when no repository is given\", async () => {\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider.generateCredentialHelperAuth([]).catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).errorType).toBe(\"permanent\");\n expect((err as SourceControlProviderError).message).toMatch(/without a repository/i);\n expect(mockGetScopedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"throws a permanent error when every given repository name is blank\", async () => {\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).message).toMatch(/without a repository/i);\n expect(mockGetScopedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"forwards a fresh, repo-scoped installation token with its expiry and x-access-token username\", 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 const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\n\n expect(auth).toEqual({\n username: \"x-access-token\",\n password: \"ghs_scoped\",\n expiresAtEpochMs,\n });\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\",\n url: \"https://api.github.com/repos/acme/web/pulls/7\",\n state: \"open\",\n draft: false,\n merged: false,\n head: { ref: \"feature\" },\n\"\"\"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 try:\n modal_sandbox = await modal.Sandbox.from_id.aio(sandbox_id)\n return SandboxHandle(\n sandbox_id=sandbox_id,\n modal_sandbox=modal_sandbox,\n status=SandboxStatus.READY, # Assume ready if we can retrieve it\n created_at=time.time(),\n )\n except Exception as e:\n log.warn(\"sandbox.lookup_error\", sandbox_id=sandbox_id, exc=e)\n return None\n\n async def restore_from_snapshot(\n self,\n snapshot_image_id: str,\n session_config: SessionConfig | dict[str, Any],\n sandbox_id: str | None = None,\n control_plane_url: str = \"\",\n sandbox_auth_token: str = \"\",\n clone_token: str | None = None,\n user_env_vars: dict[str, str] | None = None,\n timeout_seconds: int = DEFAULT_SANDBOX_TIMEOUT_SECONDS,\n code_server_enabled: bool = False,\n vnc_enabled: bool = DEFAULT_VNC_ENABLED,\n agent_slack_notify_enabled: bool = False,\n settings: dict[str, Any] | None = None,\n ) -> SandboxHandle:\n \"\"\"\n Create a new sandbox from a filesystem snapshot Image.\n\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 )\n\n return handle\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"}}
[{"name":"apply_patch","description":"The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.\n\nexec tool "},{"name":"clock__curr_time","description":"Tools for reading and waiting on time.\n\nReturn the current time in UTC.\n\nexec tool declaration:\n```ts\ndeclare const tool"},{"name":"create_goal","description":"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordin"},{"name":"exec_command","description":"Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\nexec tool declaration:\n```ts\ndeclare"},{"name":"get_goal","description":"Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budge"},{"name":"image_gen__imagegen","description":"Tools in the image_gen namespace.\n\nThe `image_gen.imagegen` tool enables image generation from descriptions and editing "},{"name":"list_mcp_resource_templates","description":"Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that take"},{"name":"list_mcp_resources","description":"Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models,"},{"name":"mcp__chrome_devtools__click","description":"Clicks on the provided element\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__chrome_devtools__click(args: {\n"},{"name":"mcp__chrome_devtools__close_page","description":"Closes the page by its index. The last open page cannot be closed.\n\nexec tool declaration:\n```ts\ndeclare const tools: { "},{"name":"mcp__chrome_devtools__drag","description":"Drag an element onto another element\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__chrome_devtools__drag(arg"},{"name":"mcp__chrome_devtools__emulate","description":"Emulates various features on the target page.\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__chrome_devtools_"},{"name":"mcp__chrome_devtools__evaluate_script","description":"Evaluate a JavaScript function inside the target page. Returns the response as JSON, so returned values have to be JSON-"}…9839 tokens truncated… about the user's meetings using natural language. Returns a tailored response wi"},{"name":"mcp__codex_apps__hotline_get_local_hotline","description":"Look up local suicide or self harm helpline information for the user based on country inferred from the conversation. Yo"},{"name":"mcp__codex_apps__namecheap_domain_bulk_check","description":"Enables checking real-time registration availability and pricing for one or more specified domain names, including bulk "},{"name":"mcp__codex_apps__plugin_management_get_app_permissions","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__plugin_management_get_plugin_dependencies","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__plugin_management_uninstall_app","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__plugin_management_update_app_permissions","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__safety_settings_get_family_info","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_get_parental_controls","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_get_trusted_contact","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_prepare_parental_control_update","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_update_parental_control","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__sites_add_custom_domain","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_change_site_slug","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_create_site","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_create_source_repository_write_credential","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_deploy_private_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_deploy_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_generate_siwc_bypass_token","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_deployment_status","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_environment_variables","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_site","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_site_worker_logs","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_list_custom_domains","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_list_site_versions","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_list_sites","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_read_database_overview","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_read_database_table_rows","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_refresh_custom_domain_status","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_remove_custom_domain","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_save_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_update_environment_variables","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_update_site_access","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_update_site_metadata","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__slack_slack_add_reaction","description":"Share work and pull context\n\nAdds a reaction (emoji) to a Slack message.\n\nRequires the channel_id and message_ts of the "},{"name":"mcp__codex_apps__slack_slack_complete_file_upload","description":"Share work and pull context\n\nFinalizes a file upload started with slack_get_file_upload_url, using the file_id from that"},{"name":"mcp__codex_apps__slack_slack_create_canvas","description":"Share work and pull context\n\nCreates a Slack Canvas document from Canvas-flavored Markdown content. Return the canvas li"},{"name":"mcp__codex_apps__slack_slack_create_conversation","description":"Share work and pull context\n\nCreate a channel, DM, or group DM. Returns a channel_id for use with slack_send_message, sl"},{"name":"mcp__codex_apps__slack_slack_create_list","description":"Share work and pull context\n\nCreates a new Slack List with optional columns and a description. Returns the list name, ID"},{"name":"mcp__codex_apps__slack_slack_create_reminder","description":"Share work and pull context\n\nCreates a new Slack reminder for the authed user; create-only, not for Slack Later/saved-fo"},{"name":"mcp__codex_apps__slack_slack_delete_message","description":"Share work and pull context\n\nDeletes an existing Slack message sent by the current user. This is destructive and cannot "},{"name":"mcp__codex_apps__slack_slack_edit_message","description":"Share work and pull context\n\nEdits an existing Slack message sent by the current user. Return the updated message ID to "},{"name":"mcp__codex_apps__slack_slack_get_file_upload_url","description":"Share work and pull context\n\nGenerates a signed upload URL for a file, plus a file ID. This is the first step of the two"},{"name":"mcp__codex_apps__slack_slack_get_reactions","description":"Share work and pull context\n\nRetrieves all reactions (emoji) on a specific Slack message. Read-only.\n\nRequires channel_i"},{"name":"mcp__codex_apps__slack_slack_invite_to_conversation","description":"Share work and pull context\n\nInvites users to an existing public or private Slack channel. Use this to add one or more u"},{"name":"mcp__codex_apps__slack_slack_join_conversation","description":"Share work and pull context\n\nJoins an existing public Slack channel by channel ID. Use this only for public channels (`C"},{"name":"mcp__codex_apps__slack_slack_leave_conversation","description":"Share work and pull context\n\nLeaves an existing public or private Slack channel. Use this for channel conversations (`C."},{"name":"mcp__codex_apps__slack_slack_list_channel_members","description":"Share work and pull context\n\nLists members of a Slack channel, group, or group DM (MPIM). Returns profile details or jus"},{"name":"mcp__codex_apps__slack_slack_list_starred_items","description":"Share work and pull context\n\nLists Slack items starred by the authed user. Use this for the user's starred messages, fil"},{"name":"mcp__codex_apps__slack_slack_list_user_channels","description":"Share work and pull context\n\nLists channels the user is a member of. Supports public channels, private channels, DMs (im"},{"name":"mcp__codex_apps__slack_slack_list_user_conversations","description":"Share work and pull context\n\nLists Slack conversations visible to a user. Use this to discover real conversation IDs for"},{"name":"mcp__codex_apps__slack_slack_list_user_groups","description":"Share work and pull context\n\nLists Slack user groups and returns mention syntax for each group. Use this when the user a"},{"name":"mcp__codex_apps__slack_slack_list_workspaces","description":"Share work and pull context\n\nLists Slack workspaces available to the authenticated installation. This tool is part of pl"},{"name":"mcp__codex_apps__slack_slack_read_canvas","description":"Share work and pull context\n\nRetrieves the markdown content and section ID mapping of a Slack Canvas document. Read-only"},{"name":"mcp__codex_apps__slack_slack_read_channel","description":"Share work and pull context\n\nReads messages from a Slack channel in reverse chronological order (newest first). To read "},{"name":"mcp__codex_apps__slack_slack_read_file","description":"Share work and pull context\n\nReads a Slack file's content by file ID. Returns text content directly or a file reference "},{"name":"mcp__codex_apps__slack_slack_read_list","description":"Share work and pull context\n\nRead the contents of a Slack list including its column schema and records. Returns data as "},{"name":"mcp__codex_apps__slack_slack_read_thread","description":"Share work and pull context\n\nReads messages from a specific Slack thread (parent message + all replies). Read-only.\n\nReq"},{"name":"mcp__codex_apps__slack_slack_read_user_profile","description":"Share work and pull context\n\nRetrieves detailed profile information for a Slack user: contact info, status, timezone, or"},{"name":"mcp__codex_apps__slack_slack_schedule_message","description":"Share work and pull context\n\nSchedules a message for future delivery to a Slack channel. Does NOT send immediately — use"},{"name":"mcp__codex_apps__slack_slack_search_channels","description":"Share work and pull context\n\nSearch for Slack channels by name or description. Returns channel names, IDs, topics, purpo"},{"name":"mcp__codex_apps__slack_slack_search_emojis","description":"Share work and pull context\n\nSearch custom emojis available in this workspace by name. Useful for discovering workspace-"},{"name":"mcp__codex_apps__slack_slack_search_public","description":"Share work and pull context\n\nSearches for messages, files in public Slack channels ONLY.\n\n`slack_search_public` does NOT"},{"name":"mcp__codex_apps__slack_slack_search_public_and_private","description":"Share work and pull context\n\nSearches for messages, files in ALL Slack channels, including public channels, private chan"},{"name":"mcp__codex_apps__slack_slack_search_users","description":"Share work and pull context\n\nSearch for Slack users by name, email, or profile attributes (department, role, title).\n\nQu"},{"name":"mcp__codex_apps__slack_slack_send_message","description":"Share work and pull context\n\nSends a message to a Slack channel or user. To DM a user, use their user_id as channel_id. "},{"name":"mcp__codex_apps__slack_slack_send_message_draft","description":"Share work and pull context\n\nCreates a draft message in a Slack channel. The draft is saved to the user's \"Drafts & Sent"},{"name":"mcp__codex_apps__slack_slack_update_canvas","description":"Share work and pull context\n\nUpdates an existing Slack Canvas with markdown. Operations apply atomically against one doc"},{"name":"mcp__codex_apps__slack_slack_update_list","description":"Share work and pull context\n\nUpdate an existing Slack list's metadata (name, description, icon) and/or column schema (ad"},{"name":"mcp__codex_apps__slack_slack_update_user_profile","description":"Share work and pull context\n\nUpdates Slack profile fields, including custom status. Use this for profile mutations such "},{"name":"mcp__codex_apps__stripe_get_stripe_account_info","description":"Accept payments. Grow revenue.\n\nThis will get the account info for the logged in Stripe account. This tool is part of pl"},{"name":"mcp__codex_apps__stripe_list_available_accounts_or_orgs","description":"Accept payments. Grow revenue.\n\nLists all Stripe accounts in this session with their stripe_context and livemode values."},{"name":"mcp__codex_apps__stripe_manage_stripe_accounts","description":"Accept payments. Grow revenue.\n\nReturns a URL to the Stripe Dashboard where users can add accounts, remove accounts, or "},{"name":"mcp__codex_apps__stripe_search_stripe_documentation","description":"Accept payments. Grow revenue.\n\nSearch the Stripe documentation for the given question and language.\n\nIt takes two argum"},{"name":"mcp__codex_apps__stripe_send_stripe_mcp_feedback","description":"Accept payments. Grow revenue.\n\nSubmit feedback from user or agent about Stripe's MCP server tools.\n\nValid: \"the search "},{"name":"mcp__codex_apps__stripe_stripe_api_details","description":"Accept payments. Grow revenue.\n\nGet detailed parameter information for a specific Stripe API operation.\nProvide the stri"},{"name":"mcp__codex_apps__stripe_stripe_api_read","description":"Accept payments. Grow revenue.\n\nRead data from any Stripe API GET operation:\n1. Use stripe_api_search to find the operat"},{"name":"mcp__codex_apps__stripe_stripe_api_search","description":"Accept payments. Grow revenue.\n\nSearch for Stripe API operations by providing an intent and a resource to operate on.\n\nF"},{"name":"mcp__codex_apps__stripe_stripe_api_write","description":"Accept payments. Grow revenue.\n\nWrite data via any Stripe API POST/PATCH/PUT/DELETE operation:\n1. Use stripe_api_search "},{"name":"mcp__codex_apps__stripe_stripe_implementation_planner","description":"Accept payments. Grow revenue.\n\nStripe payment integration planner. Use this tool to help users accept payments, sell pr"},{"name":"mcp__node_repl__js","description":"Use `js` for `node_repl` execution with persistent, redeclarable top-level bindings, `js_reset` to clear bindings, and `"},{"name":"mcp__node_repl__js_add_node_module_dir","description":"Use `js` for `node_repl` execution with persistent, redeclarable top-level bindings, `js_reset` to clear bindings, and `"},{"name":"mcp__node_repl__js_reset","description":"Use `js` for `node_repl` execution with persistent, redeclarable top-level bindings, `js_reset` to clear bindings, and `"},{"name":"mcp__whatsapp__download_media","description":"Download media from a WhatsApp message and get the local file path.\n\n Args:\n message_id: The ID of the message"},{"name":"mcp__whatsapp__get_chat","description":"Get WhatsApp chat metadata by JID.\n\n Args:\n chat_jid: The JID of the chat to retrieve\n include_last_mes"},{"name":"mcp__whatsapp__get_contact","description":"Look up a WhatsApp contact by phone number, LID, or full JID.\n\n Automatically detects the identifier type and queries"},{"name":"mcp__whatsapp__get_contact_chats","description":"Get all WhatsApp chats involving the contact.\n\n Args:\n jid: The contact's JID to search for\n limit: Max"},{"name":"mcp__whatsapp__get_direct_chat_by_contact","description":"Get WhatsApp chat metadata by sender phone number.\n\n Args:\n sender_phone_number: The phone number to search fo"},{"name":"mcp__whatsapp__get_last_interaction","description":"Get most recent WhatsApp message involving the contact.\n\n Args:\n jid: The JID of the contact to search for\n\n "},{"name":"mcp__whatsapp__get_message_context","description":"Get context around a specific WhatsApp message.\n\n Args:\n message_id: The ID of the message to get context for\n"},{"name":"mcp__whatsapp__list_chats","description":"Get WhatsApp chats matching specified criteria.\n\n Args:\n query: Search term to filter chats by name or JID\n "},{"name":"mcp__whatsapp__list_messages","description":"Get WhatsApp messages matching specified criteria with optional context.\n\n Each message includes sender_display showi"},{"name":"mcp__whatsapp__mark_messages_read","description":"Mark selected WhatsApp messages as read and send read receipts.\n\n This is an explicit external side effect. All messa"},{"name":"mcp__whatsapp__search_contacts","description":"Search WhatsApp contacts by name or phone number.\n\n Args:\n query: Search term to match against contact names o"},{"name":"mcp__whatsapp__send_audio_message","description":"Send any audio file as a WhatsApp audio message to the specified recipient. For group messages use the JID. If it errors"},{"name":"mcp__whatsapp__send_file","description":"Send a file (image, video, document) via WhatsApp, optionally with a caption.\n\n When `caption` is provided, the file "},{"name":"mcp__whatsapp__send_message","description":"Send a WhatsApp message to a person or group. For group chats use the JID.\n\n Args:\n recipient: The recipient -"},{"name":"mcp__whatsapp__send_reaction","description":"Send (or remove) a reaction to a WhatsApp message.\n\n Args:\n recipient: The chat JID the message belongs to (e."},{"name":"read_mcp_resource","description":"Read a specific resource from an MCP server given the server name and resource URI.\n\nexec tool declaration:\n```ts\ndeclar"},{"name":"request_plugin_install","description":"# Suggest a recommended plugin installation\n\nUse this tool only when all of the following are true:\n- The user explicitl"},{"name":"update_goal","description":"Update the existing goal.\nUse this tool only to mark the goal achieved or genuinely blocked.\nSet status to `complete` on"},{"name":"view_image","description":"View a local image file from the filesystem when visual inspection is needed. Use this for images already available on d"},{"name":"web__run","description":"Tools in the web namespace.\n\nTool for accessing the internet.\n\n\n---\n\n## Examples of different commands available in this"},{"name":"write_stdin","description":"Writes characters to an existing unified exec session and returns recent output.\n\nexec tool declaration:\n```ts\ndeclare c"}]>cat packages/control-plane/vitest.config.ts && sed -n '600,740p' packages/control-plane/src/session/http/handlers/sandbox.handler.test.ts && sed -n '460,580p' packages/control-plane/src/source-control/providers/github-provider.test.ts && cat packages/modal-infra/src/sandbox/vcs_env.py && sed -n '600,710p' packages/modal-infra/src/sandbox/manager.py && cat packages/modal-infra/tests/conftest.py{"status":"fulfilled","value":{"chunk_id":"93a3c1","wall_time_seconds":0.000320208,"exit_code":1,"original_token_count":18,"output":"/opt/homebrew/bin/python3\n/opt/homebrew/bin/pytest\n/opt/homebrew/bin/uv\n"}}
{"status":"fulfilled","value":{"chunk_id":"3a4055","wall_time_seconds":0.068028125,"exit_code":0,"original_token_count":4168,"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});\n refreshXaiToken.mockResolvedValue({ ok: true, accessToken: \"xai-access\", expiresIn: 3600 });\n\n const response = await handler.xaiTokenRefresh();\n\n expect(response.status).toBe(200);\n expect(response.headers.get(\"Cache-Control\")).toBe(\"no-store\");\n expect(await response.json()).toEqual({ access_token: \"xai-access\", expires_in: 3600 });\n expect(refreshXaiToken).toHaveBeenCalledWith(session, log);\n });\n\n it(\"returns 404 when xAI token refresh has no session\", async () => {\n const { handler, getSession } = createHandler();\n getSession.mockReturnValue(null);\n\n const response = await handler.xaiTokenRefresh();\n\n expect(response.status).toBe(404);\n expect(await response.json()).toEqual({ error: \"No session\" });\n });\n\n it(\"returns mapped service error from xAI token refresh\", async () => {\n const { handler, getSession, refreshXaiToken } = createHandler();\n getSession.mockReturnValue({ id: \"session-1\" } as SessionRow);\n refreshXaiToken.mockResolvedValue({ ok: false, status: 401, error: \"xAI unauthorized\" });\n\n const response = await handler.xaiTokenRefresh();\n\n expect(response.status).toBe(401);\n expect(await response.json()).toEqual({ error: \"xAI unauthorized\" });\n });\n\n it(\"returns 404 when scm credentials have no session\", async () => {\n const { handler, getSession, getScmCredentials } = createHandler();\n getSession.mockReturnValue(null);\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(404);\n expect(await response.json()).toEqual({ error: \"No session\" });\n expect(getScmCredentials).not.toHaveBeenCalled();\n });\n\n it(\"returns mapped service error from scm credentials\", async () => {\n const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: \"acme\",\n repo_name: \"web-app\",\n } as SessionRow);\n getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\n getScmCredentials.mockResolvedValue({\n ok: false,\n status: 503,\n error: \"GitHub App not configured\",\n });\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(503);\n expect(await response.json()).toEqual({ error: \"GitHub App not configured\" });\n });\n\n it(\"rejects scm credentials for no-repository sessions\", async () => {\n const { handler, getSession, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: null,\n repo_name: null,\n } as SessionRow);\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(400);\n expect(await response.json()).toEqual({\n error: \"SCM credentials require a repository context\",\n });\n expect(getScmCredentials).not.toHaveBeenCalled();\n });\n\n it(\"returns scm credentials payload on success\", async () => {\n const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: \"acme\",\n repo_name: \"web-app\",\n } as SessionRow);\n getSessionRepositories.mockReturnValue([{ repoOwner: \"acme\", repoName: \"web-app\" }]);\n const expiresAt = Date.now() + 60 * 60 * 1000;\n getScmCredentials.mockResolvedValue({\n ok: true,\n username: \"x-access-token\",\n password: \"ghs_secret\",\n expiresAtEpochMs: expiresAt,\n });\n\n const response = await handler.scmCredentials();\n\n expect(response.status).toBe(200);\n expect(response.headers.get(\"Cache-Control\")).toBe(\"no-store\");\n expect(await response.json()).toEqual({\n username: \"x-access-token\",\n password: \"ghs_secret\",\n expires_at_epoch_ms: expiresAt,\n });\n });\n\n it(\"requests credentials for every member repository, not just the primary\", async () => {\n const { handler, getSession, getSessionRepositories, getScmCredentials } = createHandler();\n getSession.mockReturnValue({\n id: \"session-1\",\n repo_owner: \"acme\",\n repo_name: \"web-app\",\n } as SessionRow);\n getSessionRepositories.mockReturnValue([\n { repoOwner: \"acme\", repoName: \"web-app\" },\n { repoOwner: \"acme\", repoName: \"shared-lib\" },\n ]);\n getScmCredentials.mockResolvedValue({\n ok: true,\n username: \"x-access-token\",\n password: \"ghs_secret\",\n expiresAtEpochMs: Date.now() + 60 * 60 * 1000,\n });\n\n await handler.scmCredentials();\n\n expect(getScmCredentials).toHaveBeenCalledWith(\n [\n { owner: \"acme\", name: \"web-app\" },\n { owner: \"acme\", name: \"shared-lib\" },\n ],\n expect.anything()\n );\n });\n\n it(\"returns 404 when tunnel URLs have no sandbox\", async () => {\n const { handler, getSandbox } = createHandler();\n getSandbox.mockReturnValue(null);\n\n const response = await handler.tunnelUrls();\n\n });\n\n describe(\"generateCredentialHelperAuth\", () => {\n it(\"throws a permanent error when the App is not configured\", async () => {\n const provider = new GitHubSourceControlProvider();\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).errorType).toBe(\"permanent\");\n expect((err as SourceControlProviderError).message).toMatch(/not configured/i);\n });\n\n it(\"throws a permanent error when no repository is given\", async () => {\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider.generateCredentialHelperAuth([]).catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).errorType).toBe(\"permanent\");\n expect((err as SourceControlProviderError).message).toMatch(/without a repository/i);\n expect(mockGetScopedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"throws a permanent error when every given repository name is blank\", async () => {\n const provider = new GitHubSourceControlProvider({ appConfig: fakeAppConfig });\n const err = await provider\n .generateCredentialHelperAuth([{ owner: \"acme\", name: \"\" }])\n .catch((e: unknown) => e);\n\n expect(err).toBeInstanceOf(SourceControlProviderError);\n expect((err as SourceControlProviderError).message).toMatch(/without a repository/i);\n expect(mockGetScopedInstallationTokenWithExpiry).not.toHaveBeenCalled();\n });\n\n it(\"forwards a fresh, repo-scoped installation token with its expiry and x-access-token username\", 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 const auth = await provider.generateCredentialHelperAuth([{ owner: \"acme\", name: \"web\" }]);\n\n expect(auth).toEqual({\n username: \"x-access-token\",\n password: \"ghs_scoped\",\n expiresAtEpochMs,\n });\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\",\n url: \"https://api.github.com/repos/acme/web/pulls/7\",\n state: \"open\",\n draft: false,\n merged: false,\n head: { ref: \"feature\" },\n\"\"\"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 try:\n modal_sandbox = await modal.Sandbox.from_id.aio(sandbox_id)\n return SandboxHandle(\n sandbox_id=sandbox_id,\n modal_sandbox=modal_sandbox,\n status=SandboxStatus.READY, # Assume ready if we can retrieve it\n created_at=time.time(),\n )\n except Exception as e:\n log.warn(\"sandbox.lookup_error\", sandbox_id=sandbox_id, exc=e)\n return None\n\n async def restore_from_snapshot(\n self,\n snapshot_image_id: str,\n session_config: SessionConfig | dict[str, Any],\n sandbox_id: str | None = None,\n control_plane_url: str = \"\",\n sandbox_auth_token: str = \"\",\n clone_token: str | None = None,\n user_env_vars: dict[str, str] | None = None,\n timeout_seconds: int = DEFAULT_SANDBOX_TIMEOUT_SECONDS,\n code_server_enabled: bool = False,\n vnc_enabled: bool = DEFAULT_VNC_ENABLED,\n agent_slack_notify_enabled: bool = False,\n settings: dict[str, Any] | None = None,\n ) -> SandboxHandle:\n \"\"\"\n Create a new sandbox from a filesystem snapshot Image.\n\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 )\n\n return handle\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"}}
[{"name":"apply_patch","description":"The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.\n\nexec tool "},{"name":"clock__curr_time","description":"Tools for reading and waiting on time.\n\nReturn the current time in UTC.\n\nexec tool declaration:\n```ts\ndeclare const tool"},{"name":"create_goal","description":"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordin"},{"name":"exec_command","description":"Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\nexec tool declaration:\n```ts\ndeclare"},{"name":"get_goal","description":"Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budge"},{"name":"image_gen__imagegen","description":"Tools in the image_gen namespace.\n\nThe `image_gen.imagegen` tool enables image generation from descriptions and editing "},{"name":"list_mcp_resource_templates","description":"Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that take"},{"name":"list_mcp_resources","description":"Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models,"},{"name":"mcp__chrome_devtools__click","description":"Clicks on the provided element\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__chrome_devtools__click(args: {\n"},{"name":"mcp__chrome_devtools__close_page","description":"Closes the page by its index. The last open page cannot be closed.\n\nexec tool declaration:\n```ts\ndeclare const tools: { "},{"name":"mcp__chrome_devtools__drag","description":"Drag an element onto another element\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__chrome_devtools__drag(arg"},{"name":"mcp__chrome_devtools__emulate","description":"Emulates various features on the target page.\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__chrome_devtools_"},{"name":"mcp__chrome_devtools__evaluate_script","description":"Evaluate a JavaScript function inside the target page. Returns the response as JSON, so returned values have to be JSON-"}…9839 tokens truncated… about the user's meetings using natural language. Returns a tailored response wi"},{"name":"mcp__codex_apps__hotline_get_local_hotline","description":"Look up local suicide or self harm helpline information for the user based on country inferred from the conversation. Yo"},{"name":"mcp__codex_apps__namecheap_domain_bulk_check","description":"Enables checking real-time registration availability and pricing for one or more specified domain names, including bulk "},{"name":"mcp__codex_apps__plugin_management_get_app_permissions","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__plugin_management_get_plugin_dependencies","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__plugin_management_uninstall_app","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__plugin_management_update_app_permissions","description":"Manage plugins, settings, permissions, and connections. Prefer available built-in tools or connected plugins when they f"},{"name":"mcp__codex_apps__safety_settings_get_family_info","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_get_parental_controls","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_get_trusted_contact","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_prepare_parental_control_update","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__safety_settings_update_parental_control","description":"For ChatGPT Parental Controls (your child or teen's settings, features, Study Mode, quiet hours, family setup) and Trust"},{"name":"mcp__codex_apps__sites_add_custom_domain","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_change_site_slug","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_create_site","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_create_source_repository_write_credential","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_deploy_private_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_deploy_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_generate_siwc_bypass_token","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_deployment_status","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_environment_variables","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_site","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_get_site_worker_logs","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_list_custom_domains","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_list_site_versions","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_list_sites","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_read_database_overview","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_read_database_table_rows","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_refresh_custom_domain_status","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_remove_custom_domain","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_save_site_version","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_update_environment_variables","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_update_site_access","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__sites_update_site_metadata","description":"Use Sites to build or modify websites, including landing pages, portfolios, dashboards, portals, trackers, hubs, and int"},{"name":"mcp__codex_apps__slack_slack_add_reaction","description":"Share work and pull context\n\nAdds a reaction (emoji) to a Slack message.\n\nRequires the channel_id and message_ts of the "},{"name":"mcp__codex_apps__slack_slack_complete_file_upload","description":"Share work and pull context\n\nFinalizes a file upload started with slack_get_file_upload_url, using the file_id from that"},{"name":"mcp__codex_apps__slack_slack_create_canvas","description":"Share work and pull context\n\nCreates a Slack Canvas document from Canvas-flavored Markdown content. Return the canvas li"},{"name":"mcp__codex_apps__slack_slack_create_conversation","description":"Share work and pull context\n\nCreate a channel, DM, or group DM. Returns a channel_id for use with slack_send_message, sl"},{"name":"mcp__codex_apps__slack_slack_create_list","description":"Share work and pull context\n\nCreates a new Slack List with optional columns and a description. Returns the list name, ID"},{"name":"mcp__codex_apps__slack_slack_create_reminder","description":"Share work and pull context\n\nCreates a new Slack reminder for the authed user; create-only, not for Slack Later/saved-fo"},{"name":"mcp__codex_apps__slack_slack_delete_message","description":"Share work and pull context\n\nDeletes an existing Slack message sent by the current user. This is destructive and cannot "},{"name":"mcp__codex_apps__slack_slack_edit_message","description":"Share work and pull context\n\nEdits an existing Slack message sent by the current user. Return the updated message ID to "},{"name":"mcp__codex_apps__slack_slack_get_file_upload_url","description":"Share work and pull context\n\nGenerates a signed upload URL for a file, plus a file ID. This is the first step of the two"},{"name":"mcp__codex_apps__slack_slack_get_reactions","description":"Share work and pull context\n\nRetrieves all reactions (emoji) on a specific Slack message. Read-only.\n\nRequires channel_i"},{"name":"mcp__codex_apps__slack_slack_invite_to_conversation","description":"Share work and pull context\n\nInvites users to an existing public or private Slack channel. Use this to add one or more u"},{"name":"mcp__codex_apps__slack_slack_join_conversation","description":"Share work and pull context\n\nJoins an existing public Slack channel by channel ID. Use this only for public channels (`C"},{"name":"mcp__codex_apps__slack_slack_leave_conversation","description":"Share work and pull context\n\nLeaves an existing public or private Slack channel. Use this for channel conversations (`C."},{"name":"mcp__codex_apps__slack_slack_list_channel_members","description":"Share work and pull context\n\nLists members of a Slack channel, group, or group DM (MPIM). Returns profile details or jus"},{"name":"mcp__codex_apps__slack_slack_list_starred_items","description":"Share work and pull context\n\nLists Slack items starred by the authed user. Use this for the user's starred messages, fil"},{"name":"mcp__codex_apps__slack_slack_list_user_channels","description":"Share work and pull context\n\nLists channels the user is a member of. Supports public channels, private channels, DMs (im"},{"name":"mcp__codex_apps__slack_slack_list_user_conversations","description":"Share work and pull context\n\nLists Slack conversations visible to a user. Use this to discover real conversation IDs for"},{"name":"mcp__codex_apps__slack_slack_list_user_groups","description":"Share work and pull context\n\nLists Slack user groups and returns mention syntax for each group. Use this when the user a"},{"name":"mcp__codex_apps__slack_slack_list_workspaces","description":"Share work and pull context\n\nLists Slack workspaces available to the authenticated installation. This tool is part of pl"},{"name":"mcp__codex_apps__slack_slack_read_canvas","description":"Share work and pull context\n\nRetrieves the markdown content and section ID mapping of a Slack Canvas document. Read-only"},{"name":"mcp__codex_apps__slack_slack_read_channel","description":"Share work and pull context\n\nReads messages from a Slack channel in reverse chronological order (newest first). To read "},{"name":"mcp__codex_apps__slack_slack_read_file","description":"Share work and pull context\n\nReads a Slack file's content by file ID. Returns text content directly or a file reference "},{"name":"mcp__codex_apps__slack_slack_read_list","description":"Share work and pull context\n\nRead the contents of a Slack list including its column schema and records. Returns data as "},{"name":"mcp__codex_apps__slack_slack_read_thread","description":"Share work and pull context\n\nReads messages from a specific Slack thread (parent message + all replies). Read-only.\n\nReq"},{"name":"mcp__codex_apps__slack_slack_read_user_profile","description":"Share work and pull context\n\nRetrieves detailed profile information for a Slack user: contact info, status, timezone, or"},{"name":"mcp__codex_apps__slack_slack_schedule_message","description":"Share work and pull context\n\nSchedules a message for future delivery to a Slack channel. Does NOT send immediately — use"},{"name":"mcp__codex_apps__slack_slack_search_channels","description":"Share work and pull context\n\nSearch for Slack channels by name or description. Returns channel names, IDs, topics, purpo"},{"name":"mcp__codex_apps__slack_slack_search_emojis","description":"Share work and pull context\n\nSearch custom emojis available in this workspace by name. Useful for discovering workspace-"},{"name":"mcp__codex_apps__slack_slack_search_public","description":"Share work and pull context\n\nSearches for messages, files in public Slack channels ONLY.\n\n`slack_search_public` does NOT"},{"name":"mcp__codex_apps__slack_slack_search_public_and_private","description":"Share work and pull context\n\nSearches for messages, files in ALL Slack channels, including public channels, private chan"},{"name":"mcp__codex_apps__slack_slack_search_users","description":"Share work and pull context\n\nSearch for Slack users by name, email, or profile attributes (department, role, title).\n\nQu"},{"name":"mcp__codex_apps__slack_slack_send_message","description":"Share work and pull context\n\nSends a message to a Slack channel or user. To DM a user, use their user_id as channel_id. "},{"name":"mcp__codex_apps__slack_slack_send_message_draft","description":"Share work and pull context\n\nCreates a draft message in a Slack channel. The draft is saved to the user's \"Drafts & Sent"},{"name":"mcp__codex_apps__slack_slack_update_canvas","description":"Share work and pull context\n\nUpdates an existing Slack Canvas with markdown. Operations apply atomically against one doc"},{"name":"mcp__codex_apps__slack_slack_update_list","description":"Share work and pull context\n\nUpdate an existing Slack list's metadata (name, description, icon) and/or column schema (ad"},{"name":"mcp__codex_apps__slack_slack_update_user_profile","description":"Share work and pull context\n\nUpdates Slack profile fields, including custom status. Use this for profile mutations such "},{"name":"mcp__codex_apps__stripe_get_stripe_account_info","description":"Accept payments. Grow revenue.\n\nThis will get the account info for the logged in Stripe account. This tool is part of pl"},{"name":"mcp__codex_apps__stripe_list_available_accounts_or_orgs","description":"Accept payments. Grow revenue.\n\nLists all Stripe accounts in this session with their stripe_context and livemode values."},{"name":"mcp__codex_apps__stripe_manage_stripe_accounts","description":"Accept payments. Grow revenue.\n\nReturns a URL to the Stripe Dashboard where users can add accounts, remove accounts, or "},{"name":"mcp__codex_apps__stripe_search_stripe_documentation","description":"Accept payments. Grow revenue.\n\nSearch the Stripe documentation for the given question and language.\n\nIt takes two argum"},{"name":"mcp__codex_apps__stripe_send_stripe_mcp_feedback","description":"Accept payments. Grow revenue.\n\nSubmit feedback from user or agent about Stripe's MCP server tools.\n\nValid: \"the search "},{"name":"mcp__codex_apps__stripe_stripe_api_details","description":"Accept payments. Grow revenue.\n\nGet detailed parameter information for a specific Stripe API operation.\nProvide the stri"},{"name":"mcp__codex_apps__stripe_stripe_api_read","description":"Accept payments. Grow revenue.\n\nRead data from any Stripe API GET operation:\n1. Use stripe_api_search to find the operat"},{"name":"mcp__codex_apps__stripe_stripe_api_search","description":"Accept payments. Grow revenue.\n\nSearch for Stripe API operations by providing an intent and a resource to operate on.\n\nF"},{"name":"mcp__codex_apps__stripe_stripe_api_write","description":"Accept payments. Grow revenue.\n\nWrite data via any Stripe API POST/PATCH/PUT/DELETE operation:\n1. Use stripe_api_search "},{"name":"mcp__codex_apps__stripe_stripe_implementation_planner","description":"Accept payments. Grow revenue.\n\nStripe payment integration planner. Use this tool to help users accept payments, sell pr"},{"name":"mcp__node_repl__js","description":"Use `js` for `node_repl` execution with persistent, redeclarable top-level bindings, `js_reset` to clear bindings, and `"},{"name":"mcp__node_repl__js_add_node_module_dir","description":"Use `js` for `node_repl` execution with persistent, redeclarable top-level bindings, `js_reset` to clear bindings, and `"},{"name":"mcp__node_repl__js_reset","description":"Use `js` for `node_repl` execution with persistent, redeclarable top-level bindings, `js_reset` to clear bindings, and `"},{"name":"mcp__whatsapp__download_media","description":"Download media from a WhatsApp message and get the local file path.\n\n Args:\n message_id: The ID of the message"},{"name":"mcp__whatsapp__get_chat","description":"Get WhatsApp chat metadata by JID.\n\n Args:\n chat_jid: The JID of the chat to retrieve\n include_last_mes"},{"name":"mcp__whatsapp__get_contact","description":"Look up a WhatsApp contact by phone number, LID, or full JID.\n\n Automatically detects the identifier type and queries"},{"name":"mcp__whatsapp__get_contact_chats","description":"Get all WhatsApp chats involving the contact.\n\n Args:\n jid: The contact's JID to search for\n limit: Max"},{"name":"mcp__whatsapp__get_direct_chat_by_contact","description":"Get WhatsApp chat metadata by sender phone number.\n\n Args:\n sender_phone_number: The phone number to search fo"},{"name":"mcp__whatsapp__get_last_interaction","description":"Get most recent WhatsApp message involving the contact.\n\n Args:\n jid: The JID of the contact to search for\n\n "},{"name":"mcp__whatsapp__get_message_context","description":"Get context around a specific WhatsApp message.\n\n Args:\n message_id: The ID of the message to get context for\n"},{"name":"mcp__whatsapp__list_chats","description":"Get WhatsApp chats matching specified criteria.\n\n Args:\n query: Search term to filter chats by name or JID\n "},{"name":"mcp__whatsapp__list_messages","description":"Get WhatsApp messages matching specified criteria with optional context.\n\n Each message includes sender_display showi"},{"name":"mcp__whatsapp__mark_messages_read","description":"Mark selected WhatsApp messages as read and send read receipts.\n\n This is an explicit external side effect. All messa"},{"name":"mcp__whatsapp__search_contacts","description":"Search WhatsApp contacts by name or phone number.\n\n Args:\n query: Search term to match against contact names o"},{"name":"mcp__whatsapp__send_audio_message","description":"Send any audio file as a WhatsApp audio message to the specified recipient. For group messages use the JID. If it errors"},{"name":"mcp__whatsapp__send_file","description":"Send a file (image, video, document) via WhatsApp, optionally with a caption.\n\n When `caption` is provided, the file "},{"name":"mcp__whatsapp__send_message","description":"Send a WhatsApp message to a person or group. For group chats use the JID.\n\n Args:\n recipient: The recipient -"},{"name":"mcp__whatsapp__send_reaction","description":"Send (or remove) a reaction to a WhatsApp message.\n\n Args:\n recipient: The chat JID the message belongs to (e."},{"name":"read_mcp_resource","description":"Read a specific resource from an MCP server given the server name and resource URI.\n\nexec tool declaration:\n```ts\ndeclar"},{"name":"request_plugin_install","description":"# Suggest a recommended plugin installation\n\nUse this tool only when all of the following are true:\n- The user explicitl"},{"name":"update_goal","description":"Update the existing goal.\nUse this tool only to mark the goal achieved or genuinely blocked.\nSet status to `complete` on"},{"name":"view_image","description":"View a local image file from the filesystem when visual inspection is needed. Use this for images already available on d"},{"name":"web__run","description":"Tools in the web namespace.\n\nTool for accessing the internet.\n\n\n---\n\n## Examples of different commands available in this"},{"name":"write_stdin","description":"Writes characters to an existing unified exec session and returns recent output.\n\nexec tool declaration:\n```ts\ndeclare c"}]>python3 -c 'import sys; print(sys.version); import pytest, httpx, jwt; print("test imports available")'{"status":"fulfilled","value":{"chunk_id":"56754a","wall_time_seconds":1.244400917,"exit_code":0,"original_token_count":24,"output":"3.14.3 (main, Feb 3 2026, 15:32:20) [Clang 17.0.0 (clang-1700.6.3.2)]\ntest imports available\n"}}
{"status":"fulfilled","value":{"chunk_id":"e38fcb","wall_time_seconds":0.109826709,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7911556) 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' (7911553) 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":"7227e2","wall_time_seconds":0,"exit_code":1,"original_token_count":214,"output":"packages/control-plane/src/auth/github-app.ts:101:const installationTokenResponseSchema = z\npackages/control-plane/src/auth/github-app.ts:112:type InstallationTokenResponse = z.infer<typeof installationTokenResponseSchema>;\npackages/control-plane/src/auth/github-app.ts:283: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:300:async function getScopedInstallationTokenWithMetadata(\npackages/control-plane/src/auth/github-app.ts:336: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:367:export async function getScopedInstallationTokenWithExpiry(\npackages/control-plane/src/auth/github-app.ts:380: return getScopedInstallationTokenWithMetadata(\nzsh:1: no matches found: packages/sandbox-runtime/src/sandbox_runtime/credentials/gh*\n"}}
{"chunk_id":"7df6fd","wall_time_seconds":5.003528459,"session_id":38631,"original_token_count":205,"output":" ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 133ms\n × answers 400 on any path but a session's 47ms\n × answers 400 to an upgrade whose Host makes no URL 70ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 1ms\n × answers 404 for a session the index does not know, without opening a runtime 7ms\n × answers 404 when the index knows the session but nothing is behind it 1ms\n × writes the session's rejection as the handshake's status 3ms\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 0ms\n × closes the socket with 1011 when attachment fails 0ms\n"}>UV_CACHE_DIR=/private/tmp/codex-verify-uv-cache UV_OFFLINE=1 UV_PYTHON_DOWNLOADS=never uv run --python /opt/homebrew/bin/python3 --no-sync pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"56754a","wall_time_seconds":1.244400917,"exit_code":0,"original_token_count":24,"output":"3.14.3 (main, Feb 3 2026, 15:32:20) [Clang 17.0.0 (clang-1700.6.3.2)]\ntest imports available\n"}}
{"status":"fulfilled","value":{"chunk_id":"e38fcb","wall_time_seconds":0.109826709,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7911556) 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' (7911553) 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":"7227e2","wall_time_seconds":0,"exit_code":1,"original_token_count":214,"output":"packages/control-plane/src/auth/github-app.ts:101:const installationTokenResponseSchema = z\npackages/control-plane/src/auth/github-app.ts:112:type InstallationTokenResponse = z.infer<typeof installationTokenResponseSchema>;\npackages/control-plane/src/auth/github-app.ts:283: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:300:async function getScopedInstallationTokenWithMetadata(\npackages/control-plane/src/auth/github-app.ts:336: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:367:export async function getScopedInstallationTokenWithExpiry(\npackages/control-plane/src/auth/github-app.ts:380: return getScopedInstallationTokenWithMetadata(\nzsh:1: no matches found: packages/sandbox-runtime/src/sandbox_runtime/credentials/gh*\n"}}
{"chunk_id":"7df6fd","wall_time_seconds":5.003528459,"session_id":38631,"original_token_count":205,"output":" ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 133ms\n × answers 400 on any path but a session's 47ms\n × answers 400 to an upgrade whose Host makes no URL 70ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 1ms\n × answers 404 for a session the index does not know, without opening a runtime 7ms\n × answers 404 when the index knows the session but nothing is behind it 1ms\n × writes the session's rejection as the handshake's status 3ms\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 0ms\n × closes the socket with 1011 when attachment fails 0ms\n"}>rg -n 'getScopedInstallationToken|installationTokenResponseSchema' packages/control-plane/src/auth/github-app* && rg -n 'permission|gh |review|workflow' packages/sandbox-runtime/src/sandbox_runtime/credentials/gh* packages/sandbox-runtime/src/sandbox_runtime/credentials/git_credential_helper.py && ps -o pid,etime,command -ax | rg 'vitest|npm run test'{"status":"fulfilled","value":{"chunk_id":"56754a","wall_time_seconds":1.244400917,"exit_code":0,"original_token_count":24,"output":"3.14.3 (main, Feb 3 2026, 15:32:20) [Clang 17.0.0 (clang-1700.6.3.2)]\ntest imports available\n"}}
{"status":"fulfilled","value":{"chunk_id":"e38fcb","wall_time_seconds":0.109826709,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7911556) 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' (7911553) 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":"7227e2","wall_time_seconds":0,"exit_code":1,"original_token_count":214,"output":"packages/control-plane/src/auth/github-app.ts:101:const installationTokenResponseSchema = z\npackages/control-plane/src/auth/github-app.ts:112:type InstallationTokenResponse = z.infer<typeof installationTokenResponseSchema>;\npackages/control-plane/src/auth/github-app.ts:283: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:300:async function getScopedInstallationTokenWithMetadata(\npackages/control-plane/src/auth/github-app.ts:336: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:367:export async function getScopedInstallationTokenWithExpiry(\npackages/control-plane/src/auth/github-app.ts:380: return getScopedInstallationTokenWithMetadata(\nzsh:1: no matches found: packages/sandbox-runtime/src/sandbox_runtime/credentials/gh*\n"}}
{"chunk_id":"7df6fd","wall_time_seconds":5.003528459,"session_id":38631,"original_token_count":205,"output":" ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 133ms\n × answers 400 on any path but a session's 47ms\n × answers 400 to an upgrade whose Host makes no URL 70ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 1ms\n × answers 404 for a session the index does not know, without opening a runtime 7ms\n × answers 404 when the index knows the session but nothing is behind it 1ms\n × writes the session's rejection as the handshake's status 3ms\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 0ms\n × closes the socket with 1011 when attachment fails 0ms\n"}{"status":"fulfilled","value":{"chunk_id":"56754a","wall_time_seconds":1.244400917,"exit_code":0,"original_token_count":24,"output":"3.14.3 (main, Feb 3 2026, 15:32:20) [Clang 17.0.0 (clang-1700.6.3.2)]\ntest imports available\n"}}
{"status":"fulfilled","value":{"chunk_id":"e38fcb","wall_time_seconds":0.109826709,"exit_code":101,"original_token_count":117,"output":"\nthread 'main2' (7911556) 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' (7911553) 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":"7227e2","wall_time_seconds":0,"exit_code":1,"original_token_count":214,"output":"packages/control-plane/src/auth/github-app.ts:101:const installationTokenResponseSchema = z\npackages/control-plane/src/auth/github-app.ts:112:type InstallationTokenResponse = z.infer<typeof installationTokenResponseSchema>;\npackages/control-plane/src/auth/github-app.ts:283: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:300:async function getScopedInstallationTokenWithMetadata(\npackages/control-plane/src/auth/github-app.ts:336: const parsed = installationTokenResponseSchema.safeParse(raw);\npackages/control-plane/src/auth/github-app.ts:367:export async function getScopedInstallationTokenWithExpiry(\npackages/control-plane/src/auth/github-app.ts:380: return getScopedInstallationTokenWithMetadata(\nzsh:1: no matches found: packages/sandbox-runtime/src/sandbox_runtime/credentials/gh*\n"}}
{"chunk_id":"7df6fd","wall_time_seconds":5.003528459,"session_id":38631,"original_token_count":205,"output":" ❯ src/node/websocket-upgrade.test.ts (10 tests | 10 failed) 133ms\n × answers 400 on any path but a session's 47ms\n × answers 400 to an upgrade whose Host makes no URL 70ms\n × answers 500 and logs when the path itself fails, so nothing rejects past it 1ms\n × answers 404 for a session the index does not know, without opening a runtime 7ms\n × answers 404 when the index knows the session but nothing is behind it 1ms\n × writes the session's rejection as the handshake's status 3ms\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 0ms\n × closes the socket with 1011 when attachment fails 0ms\n"}>PYTHONPATH=src python3 -m pytest tests/test_github_app_auth.py -q{"status":"fulfilled","value":{"chunk_id":"f22fce","wall_time_seconds":1.049098167,"session_id":12866,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"18f456","wall_time_seconds":1.003000833,"session_id":54728,"original_token_count":175,"output":"ImportError while loading conftest '/private/tmp/codex-verify/packages/modal-infra/tests/conftest.py'.\ntests/conftest.py:10: in <module>\n patch(\"modal.is_local\", return_value=False),\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/unittest/mock.py:1487: in __enter__\n self.target = self.getter()\n ^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/pkgutil.py:458: in resolve_name\n mod = importlib.import_module(modname)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE ModuleNotFoundError: No module named 'modal'\n"}}
{"status":"fulfilled","value":{"chunk_id":"95e021","wall_time_seconds":0,"exit_code":1,"original_token_count":3157,"output":"import { 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};\n\ndescribe(\"github-app utilities\", () => {\n describe(\"isGitHubAppConfigured\", () => {\n it(\"returns true when all credentials are present\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_PRIVATE_KEY: \"[REDACTED]\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(true);\n });\n\n it(\"returns false when GITHUB_APP_ID is missing\", () => {\n const env = {\n GITHUB_APP_PRIVATE_KEY: \"key\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\n });\n\n it(\"returns false when GITHUB_APP_PRIVATE_KEY is missing\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\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 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(),\nimport type { RepositoryShaEntry } from \"@open-inspect/shared/types/image-builds\";\nimport type { CorrelationContext } from \"../logger\";\nimport type { ImageBuildProviderImageRef, ImageBuildScope } from \"./model\";\n\nexport type ImageBuildWorkflowContext = CorrelationContext;\n\n/** One repository of a build scope, in position order ([0] = primary). */\nexport interface ImageBuildRepository {\n repoOwner: string;\n repoName: string;\n baseBranch: string;\n}\n\n/**\n * Triggering is idempotent under the per-scope concurrency rule: a second\n * trigger while a build is in flight reports the existing build instead of\n * stacking another. `up_to_date` is returned only by the save-hook variant,\n * when a ready image already matches the current repository set.\n */\nexport type TriggerImageBuildResult =\n | { type: \"triggered\"; buildId: string }\n | { type: \"already_building\"; buildId: string }\n | { type: \"up_to_date\" };\n\n/** Clone auth handed to provider-session build sandboxes (provider-policy.ts). */\nexport type ImageBuildCloneAuth =\n | { type: \"credential_helper\"; host: string; username: string; token: string }\n | { type: \"unavailable\" };\n\n/**\n * Provider-neutral build request resolved before adapter-specific execution.\n * Every supported provider uses the same create-bind-launch session contract.\n */\nexport interface ImageBuildPlan {\n buildId: string;\n scope: ImageBuildScope;\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n callbackUrl: string;\n /**\n * Failure callback URL, sent explicitly alongside callbackUrl so the build\n * worker never derives it from the success route's path (routes on either\n * plane can be renamed without silently pointing failures at a 404).\n */\n failureCallbackUrl: string;\n /** User-configured build-execution budget; provider sessions add finalization headroom. */\n buildTimeoutMs: number;\n userEnvVars?: Record<string, string>;\n correlation: CorrelationContext;\n callbackToken: string;\n cloneAuth: ImageBuildCloneAuth;\n}\n\n/** Lets provider-session adapters bind the provider sandbox id before the runtime launches. */\nexport interface ImageBuildStartCallbacks {\n bindProviderSession(providerSessionId: string): Promise<void>;\n}\n\n/**\n * Wire form of the build-complete callback after route-level parsing.\n * repository_shas and runtime_version are reported by the build itself —\n * the route fails closed (400) when either is missing or unparseable, because\n * an unversioned image must never pass the floor check.\n */\nexport interface CompleteImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n repositoryShas: RepositoryShaEntry[];\n runtimeVersion: string;\n /** Wire seconds passed through unconverted — the D1 column is also seconds. */\n buildDurationSeconds: number;\n}\n\nexport interface FailImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n}\n\nexport interface DeleteImageInput {\n image: ImageBuildProviderImageRef;\n correlation?: CorrelationContext;\n signal?: AbortSignal;\n}\n\n/** Finalization input for provider-session builds (the deferred snapshot/checkpoint). */\nexport interface FinalizeImageBuildInput {\n buildId: string;\n providerSessionId: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n\nexport interface FailedImageBuildInput {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n"}}
{"chunk_id":"ff9542","wall_time_seconds":0.38031775,"exit_code":0,"original_token_count":25,"output":"PASS: raised ValueError(permissions must be non-empty when provided); HTTP client never constructed\n"}>PYTHONPATH=.:../sandbox-runtime/src python3 -m pytest tests/test_clone_token.py -q{"status":"fulfilled","value":{"chunk_id":"f22fce","wall_time_seconds":1.049098167,"session_id":12866,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"18f456","wall_time_seconds":1.003000833,"session_id":54728,"original_token_count":175,"output":"ImportError while loading conftest '/private/tmp/codex-verify/packages/modal-infra/tests/conftest.py'.\ntests/conftest.py:10: in <module>\n patch(\"modal.is_local\", return_value=False),\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/unittest/mock.py:1487: in __enter__\n self.target = self.getter()\n ^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/pkgutil.py:458: in resolve_name\n mod = importlib.import_module(modname)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE ModuleNotFoundError: No module named 'modal'\n"}}
{"status":"fulfilled","value":{"chunk_id":"95e021","wall_time_seconds":0,"exit_code":1,"original_token_count":3157,"output":"import { 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};\n\ndescribe(\"github-app utilities\", () => {\n describe(\"isGitHubAppConfigured\", () => {\n it(\"returns true when all credentials are present\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_PRIVATE_KEY: \"[REDACTED]\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(true);\n });\n\n it(\"returns false when GITHUB_APP_ID is missing\", () => {\n const env = {\n GITHUB_APP_PRIVATE_KEY: \"key\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\n });\n\n it(\"returns false when GITHUB_APP_PRIVATE_KEY is missing\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\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 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(),\nimport type { RepositoryShaEntry } from \"@open-inspect/shared/types/image-builds\";\nimport type { CorrelationContext } from \"../logger\";\nimport type { ImageBuildProviderImageRef, ImageBuildScope } from \"./model\";\n\nexport type ImageBuildWorkflowContext = CorrelationContext;\n\n/** One repository of a build scope, in position order ([0] = primary). */\nexport interface ImageBuildRepository {\n repoOwner: string;\n repoName: string;\n baseBranch: string;\n}\n\n/**\n * Triggering is idempotent under the per-scope concurrency rule: a second\n * trigger while a build is in flight reports the existing build instead of\n * stacking another. `up_to_date` is returned only by the save-hook variant,\n * when a ready image already matches the current repository set.\n */\nexport type TriggerImageBuildResult =\n | { type: \"triggered\"; buildId: string }\n | { type: \"already_building\"; buildId: string }\n | { type: \"up_to_date\" };\n\n/** Clone auth handed to provider-session build sandboxes (provider-policy.ts). */\nexport type ImageBuildCloneAuth =\n | { type: \"credential_helper\"; host: string; username: string; token: string }\n | { type: \"unavailable\" };\n\n/**\n * Provider-neutral build request resolved before adapter-specific execution.\n * Every supported provider uses the same create-bind-launch session contract.\n */\nexport interface ImageBuildPlan {\n buildId: string;\n scope: ImageBuildScope;\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n callbackUrl: string;\n /**\n * Failure callback URL, sent explicitly alongside callbackUrl so the build\n * worker never derives it from the success route's path (routes on either\n * plane can be renamed without silently pointing failures at a 404).\n */\n failureCallbackUrl: string;\n /** User-configured build-execution budget; provider sessions add finalization headroom. */\n buildTimeoutMs: number;\n userEnvVars?: Record<string, string>;\n correlation: CorrelationContext;\n callbackToken: string;\n cloneAuth: ImageBuildCloneAuth;\n}\n\n/** Lets provider-session adapters bind the provider sandbox id before the runtime launches. */\nexport interface ImageBuildStartCallbacks {\n bindProviderSession(providerSessionId: string): Promise<void>;\n}\n\n/**\n * Wire form of the build-complete callback after route-level parsing.\n * repository_shas and runtime_version are reported by the build itself —\n * the route fails closed (400) when either is missing or unparseable, because\n * an unversioned image must never pass the floor check.\n */\nexport interface CompleteImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n repositoryShas: RepositoryShaEntry[];\n runtimeVersion: string;\n /** Wire seconds passed through unconverted — the D1 column is also seconds. */\n buildDurationSeconds: number;\n}\n\nexport interface FailImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n}\n\nexport interface DeleteImageInput {\n image: ImageBuildProviderImageRef;\n correlation?: CorrelationContext;\n signal?: AbortSignal;\n}\n\n/** Finalization input for provider-session builds (the deferred snapshot/checkpoint). */\nexport interface FinalizeImageBuildInput {\n buildId: string;\n providerSessionId: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n\nexport interface FailedImageBuildInput {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n"}}
{"chunk_id":"ff9542","wall_time_seconds":0.38031775,"exit_code":0,"original_token_count":25,"output":"PASS: raised ValueError(permissions must be non-empty when provided); HTTP client never constructed\n"}>sed -n '1,120p' packages/control-plane/src/auth/github-app.test.ts && sed -n '1,120p' packages/control-plane/src/image-builds/scope.ts && sed -n '80,115p' packages/control-plane/src/auth/github-app.ts && cat packages/control-plane/src/image-builds/types.ts | head -100 && rg -n 'gh pr|gh api|gh issue|gh run|gh workflow|gh release' packages/sandbox-runtime/src/sandbox_runtime/credentials packages/sandbox-runtime/tests/test_git_credential_helper.py{"status":"fulfilled","value":{"chunk_id":"f22fce","wall_time_seconds":1.049098167,"session_id":12866,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"18f456","wall_time_seconds":1.003000833,"session_id":54728,"original_token_count":175,"output":"ImportError while loading conftest '/private/tmp/codex-verify/packages/modal-infra/tests/conftest.py'.\ntests/conftest.py:10: in <module>\n patch(\"modal.is_local\", return_value=False),\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/unittest/mock.py:1487: in __enter__\n self.target = self.getter()\n ^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/pkgutil.py:458: in resolve_name\n mod = importlib.import_module(modname)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE ModuleNotFoundError: No module named 'modal'\n"}}
{"status":"fulfilled","value":{"chunk_id":"95e021","wall_time_seconds":0,"exit_code":1,"original_token_count":3157,"output":"import { 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};\n\ndescribe(\"github-app utilities\", () => {\n describe(\"isGitHubAppConfigured\", () => {\n it(\"returns true when all credentials are present\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_PRIVATE_KEY: \"[REDACTED]\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(true);\n });\n\n it(\"returns false when GITHUB_APP_ID is missing\", () => {\n const env = {\n GITHUB_APP_PRIVATE_KEY: \"key\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\n });\n\n it(\"returns false when GITHUB_APP_PRIVATE_KEY is missing\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\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 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(),\nimport type { RepositoryShaEntry } from \"@open-inspect/shared/types/image-builds\";\nimport type { CorrelationContext } from \"../logger\";\nimport type { ImageBuildProviderImageRef, ImageBuildScope } from \"./model\";\n\nexport type ImageBuildWorkflowContext = CorrelationContext;\n\n/** One repository of a build scope, in position order ([0] = primary). */\nexport interface ImageBuildRepository {\n repoOwner: string;\n repoName: string;\n baseBranch: string;\n}\n\n/**\n * Triggering is idempotent under the per-scope concurrency rule: a second\n * trigger while a build is in flight reports the existing build instead of\n * stacking another. `up_to_date` is returned only by the save-hook variant,\n * when a ready image already matches the current repository set.\n */\nexport type TriggerImageBuildResult =\n | { type: \"triggered\"; buildId: string }\n | { type: \"already_building\"; buildId: string }\n | { type: \"up_to_date\" };\n\n/** Clone auth handed to provider-session build sandboxes (provider-policy.ts). */\nexport type ImageBuildCloneAuth =\n | { type: \"credential_helper\"; host: string; username: string; token: string }\n | { type: \"unavailable\" };\n\n/**\n * Provider-neutral build request resolved before adapter-specific execution.\n * Every supported provider uses the same create-bind-launch session contract.\n */\nexport interface ImageBuildPlan {\n buildId: string;\n scope: ImageBuildScope;\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n callbackUrl: string;\n /**\n * Failure callback URL, sent explicitly alongside callbackUrl so the build\n * worker never derives it from the success route's path (routes on either\n * plane can be renamed without silently pointing failures at a 404).\n */\n failureCallbackUrl: string;\n /** User-configured build-execution budget; provider sessions add finalization headroom. */\n buildTimeoutMs: number;\n userEnvVars?: Record<string, string>;\n correlation: CorrelationContext;\n callbackToken: string;\n cloneAuth: ImageBuildCloneAuth;\n}\n\n/** Lets provider-session adapters bind the provider sandbox id before the runtime launches. */\nexport interface ImageBuildStartCallbacks {\n bindProviderSession(providerSessionId: string): Promise<void>;\n}\n\n/**\n * Wire form of the build-complete callback after route-level parsing.\n * repository_shas and runtime_version are reported by the build itself —\n * the route fails closed (400) when either is missing or unparseable, because\n * an unversioned image must never pass the floor check.\n */\nexport interface CompleteImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n repositoryShas: RepositoryShaEntry[];\n runtimeVersion: string;\n /** Wire seconds passed through unconverted — the D1 column is also seconds. */\n buildDurationSeconds: number;\n}\n\nexport interface FailImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n}\n\nexport interface DeleteImageInput {\n image: ImageBuildProviderImageRef;\n correlation?: CorrelationContext;\n signal?: AbortSignal;\n}\n\n/** Finalization input for provider-session builds (the deferred snapshot/checkpoint). */\nexport interface FinalizeImageBuildInput {\n buildId: string;\n providerSessionId: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n\nexport interface FailedImageBuildInput {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n"}}
{"chunk_id":"ff9542","wall_time_seconds":0.38031775,"exit_code":0,"original_token_count":25,"output":"PASS: raised ValueError(permissions must be non-empty when provided); HTTP client never constructed\n"}>cat > /private/tmp/probe-scoped-credentials.py <<'PY'
import sys
from unittest.mock import patch
sys.path.insert(0, '/private/tmp/codex-verify/packages/sandbox-runtime/src')
from sandbox_runtime.auth.github_app import get_installation_token
# If validation regresses, this transport would return a token successfully.
with patch('sandbox_runtime.auth.github_app.httpx.Client') as client:
response = client.return_value.__enter__.return_value.post.return_value
response.json.return_value = {'token': 'broad-permissions-token'}
try:
get_installation_token('fake-jwt', '456', repository='repo', permissions={})
except ValueError as exc:
assert str(exc) == 'permissions must be non-empty when provided', str(exc)
client.assert_not_called()
print(f'PASS: raised ValueError({exc}); HTTP client never constructed')
else:
raise AssertionError('FAIL: empty permissions silently succeeded')
PY
python3 /private/tmp/probe-scoped-credentials.py{"status":"fulfilled","value":{"chunk_id":"f22fce","wall_time_seconds":1.049098167,"session_id":12866,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"18f456","wall_time_seconds":1.003000833,"session_id":54728,"original_token_count":175,"output":"ImportError while loading conftest '/private/tmp/codex-verify/packages/modal-infra/tests/conftest.py'.\ntests/conftest.py:10: in <module>\n patch(\"modal.is_local\", return_value=False),\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/unittest/mock.py:1487: in __enter__\n self.target = self.getter()\n ^^^^^^^^^^^^^\n/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/pkgutil.py:458: in resolve_name\n mod = importlib.import_module(modname)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nE ModuleNotFoundError: No module named 'modal'\n"}}
{"status":"fulfilled","value":{"chunk_id":"95e021","wall_time_seconds":0,"exit_code":1,"original_token_count":3157,"output":"import { 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};\n\ndescribe(\"github-app utilities\", () => {\n describe(\"isGitHubAppConfigured\", () => {\n it(\"returns true when all credentials are present\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_PRIVATE_KEY: \"[REDACTED]\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(true);\n });\n\n it(\"returns false when GITHUB_APP_ID is missing\", () => {\n const env = {\n GITHUB_APP_PRIVATE_KEY: \"key\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\n });\n\n it(\"returns false when GITHUB_APP_PRIVATE_KEY is missing\", () => {\n const env = {\n GITHUB_APP_ID: \"12345\",\n GITHUB_APP_INSTALLATION_ID: \"67890\",\n };\n\n expect(isGitHubAppConfigured(env)).toBe(false);\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 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(),\nimport type { RepositoryShaEntry } from \"@open-inspect/shared/types/image-builds\";\nimport type { CorrelationContext } from \"../logger\";\nimport type { ImageBuildProviderImageRef, ImageBuildScope } from \"./model\";\n\nexport type ImageBuildWorkflowContext = CorrelationContext;\n\n/** One repository of a build scope, in position order ([0] = primary). */\nexport interface ImageBuildRepository {\n repoOwner: string;\n repoName: string;\n baseBranch: string;\n}\n\n/**\n * Triggering is idempotent under the per-scope concurrency rule: a second\n * trigger while a build is in flight reports the existing build instead of\n * stacking another. `up_to_date` is returned only by the save-hook variant,\n * when a ready image already matches the current repository set.\n */\nexport type TriggerImageBuildResult =\n | { type: \"triggered\"; buildId: string }\n | { type: \"already_building\"; buildId: string }\n | { type: \"up_to_date\" };\n\n/** Clone auth handed to provider-session build sandboxes (provider-policy.ts). */\nexport type ImageBuildCloneAuth =\n | { type: \"credential_helper\"; host: string; username: string; token: string }\n | { type: \"unavailable\" };\n\n/**\n * Provider-neutral build request resolved before adapter-specific execution.\n * Every supported provider uses the same create-bind-launch session contract.\n */\nexport interface ImageBuildPlan {\n buildId: string;\n scope: ImageBuildScope;\n repositories: ImageBuildRepository[];\n repositoriesFingerprint: string;\n callbackUrl: string;\n /**\n * Failure callback URL, sent explicitly alongside callbackUrl so the build\n * worker never derives it from the success route's path (routes on either\n * plane can be renamed without silently pointing failures at a 404).\n */\n failureCallbackUrl: string;\n /** User-configured build-execution budget; provider sessions add finalization headroom. */\n buildTimeoutMs: number;\n userEnvVars?: Record<string, string>;\n correlation: CorrelationContext;\n callbackToken: string;\n cloneAuth: ImageBuildCloneAuth;\n}\n\n/** Lets provider-session adapters bind the provider sandbox id before the runtime launches. */\nexport interface ImageBuildStartCallbacks {\n bindProviderSession(providerSessionId: string): Promise<void>;\n}\n\n/**\n * Wire form of the build-complete callback after route-level parsing.\n * repository_shas and runtime_version are reported by the build itself —\n * the route fails closed (400) when either is missing or unparseable, because\n * an unversioned image must never pass the floor check.\n */\nexport interface CompleteImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n repositoryShas: RepositoryShaEntry[];\n runtimeVersion: string;\n /** Wire seconds passed through unconverted — the D1 column is also seconds. */\n buildDurationSeconds: number;\n}\n\nexport interface FailImageBuildCallback {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n}\n\nexport interface DeleteImageInput {\n image: ImageBuildProviderImageRef;\n correlation?: CorrelationContext;\n signal?: AbortSignal;\n}\n\n/** Finalization input for provider-session builds (the deferred snapshot/checkpoint). */\nexport interface FinalizeImageBuildInput {\n buildId: string;\n providerSessionId: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n\nexport interface FailedImageBuildInput {\n buildId: string;\n providerSessionId: string;\n errorMessage: string;\n correlation: CorrelationContext;\n signal?: AbortSignal;\n}\n"}}
{"chunk_id":"ff9542","wall_time_seconds":0.38031775,"exit_code":0,"original_token_count":25,"output":"PASS: raised ValueError(permissions must be non-empty when provided); HTTP client never constructed\n"}The throwaway probe passed: permissions={} raises ValueError before constructing an HTTP client, even with a fake transport ready to return a token. I’m using the installed Python interpreter to work around uv, and adding a temporary planner test because the existing suite does not directly test its multi-repository credential request.
>python3 -m pip install --target /private/tmp/codex-verify-python-deps modal fastapi --disable-pip-version-check{"status":"fulfilled","value":{"chunk_id":"b87795","wall_time_seconds":1.002481041,"session_id":70629,"original_token_count":230,"output":"WARNING: The directory '/Users/gaganarora/Library/Caches/pip' or its parent directory is not owned or is not writable by the current user. The cache has been disabled. Check the permissions and owner of that directory. If executing pip with sudo, you should use sudo's -H flag.\nWARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x107874830>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nWARNING: Retrying (Retry(total=3, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f4d0>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\n"}}
{"status":"fulfilled","value":{"chunk_id":"9876b6","wall_time_seconds":0.000014917,"exit_code":0,"original_token_count":1723,"output":"\"\"\"Open-Inspect Modal sandbox infrastructure.\"\"\"\n\n# Import modules to register functions with the app\n# (all use lazy imports internally to avoid pydantic dependency at load time)\nfrom . import web_api\nfrom .app import app\n\n__all__ = [\"app\", \"web_api\"]\n\"\"\"Re-export structured logging for non-sandbox code (web_api, app).\"\"\"\n\nfrom sandbox_runtime.log_config import StructuredLogger, configure_logging, get_logger\n\n__all__ = [\"StructuredLogger\", \"configure_logging\", \"get_logger\"]\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\n it(\"forwards text frames as strings and binary frames as ArrayBuffers\", async () => {\n harness = await createHarness();\n const { client, socket } = await harness.connect([\"wsid:ws-1\"]);\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"}}
{"chunk_id":"757252","wall_time_seconds":0.000130334,"exit_code":4,"original_token_count":124,"output":"ImportError while loading conftest '/private/tmp/codex-verify/packages/sandbox-runtime/tests/conftest.py'.\ntests/conftest.py:8: in <module>\n from sandbox_runtime.harness import EventSink, HarnessPrompt, PromptLimits, TurnOutcome\nsrc/sandbox_runtime/harness/__init__.py:25: in <module>\n from .claude import ClaudeHarness, ClaudeHarnessConfig\nsrc/sandbox_runtime/harness/claude.py:21: in <module>\n from claude_agent_sdk import (\nE ModuleNotFoundError: No module named 'claude_agent_sdk'\n"}
{"chunk_id":"fe3cc2","wall_time_seconds":6.25e-7,"exit_code":4,"original_token_count":0,"output":""}
{"chunk_id":"ada07e","wall_time_seconds":5e-7,"exit_code":1,"original_token_count":3442,"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 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⎯⎯⎯⎯⎯ 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\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:01:11\n Duration 82.08s (transform 48.43s, setup 0ms, import 271.45s, tests 135.25s, environment 94ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /private/tmp/codex-verify/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /private/tmp/codex-verify/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}>cat packages/modal-infra/src/__init__.py && cat packages/modal-infra/src/log_config.py && sed -n '1,120p' packages/control-plane/src/node/socket-host.test.ts && sed -n '1,60p' packages/control-plane/src/auth/github-app.ts{"status":"fulfilled","value":{"chunk_id":"b87795","wall_time_seconds":1.002481041,"session_id":70629,"original_token_count":230,"output":"WARNING: The directory '/Users/gaganarora/Library/Caches/pip' or its parent directory is not owned or is not writable by the current user. The cache has been disabled. Check the permissions and owner of that directory. If executing pip with sudo, you should use sudo's -H flag.\nWARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x107874830>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nWARNING: Retrying (Retry(total=3, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f4d0>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\n"}}
{"status":"fulfilled","value":{"chunk_id":"9876b6","wall_time_seconds":0.000014917,"exit_code":0,"original_token_count":1723,"output":"\"\"\"Open-Inspect Modal sandbox infrastructure.\"\"\"\n\n# Import modules to register functions with the app\n# (all use lazy imports internally to avoid pydantic dependency at load time)\nfrom . import web_api\nfrom .app import app\n\n__all__ = [\"app\", \"web_api\"]\n\"\"\"Re-export structured logging for non-sandbox code (web_api, app).\"\"\"\n\nfrom sandbox_runtime.log_config import StructuredLogger, configure_logging, get_logger\n\n__all__ = [\"StructuredLogger\", \"configure_logging\", \"get_logger\"]\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\n it(\"forwards text frames as strings and binary frames as ArrayBuffers\", async () => {\n harness = await createHarness();\n const { client, socket } = await harness.connect([\"wsid:ws-1\"]);\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"}}
{"chunk_id":"757252","wall_time_seconds":0.000130334,"exit_code":4,"original_token_count":124,"output":"ImportError while loading conftest '/private/tmp/codex-verify/packages/sandbox-runtime/tests/conftest.py'.\ntests/conftest.py:8: in <module>\n from sandbox_runtime.harness import EventSink, HarnessPrompt, PromptLimits, TurnOutcome\nsrc/sandbox_runtime/harness/__init__.py:25: in <module>\n from .claude import ClaudeHarness, ClaudeHarnessConfig\nsrc/sandbox_runtime/harness/claude.py:21: in <module>\n from claude_agent_sdk import (\nE ModuleNotFoundError: No module named 'claude_agent_sdk'\n"}
{"chunk_id":"fe3cc2","wall_time_seconds":6.25e-7,"exit_code":4,"original_token_count":0,"output":""}
{"chunk_id":"ada07e","wall_time_seconds":5e-7,"exit_code":1,"original_token_count":3442,"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 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⎯⎯⎯⎯⎯ 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\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:01:11\n Duration 82.08s (transform 48.43s, setup 0ms, import 271.45s, tests 135.25s, environment 94ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /private/tmp/codex-verify/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /private/tmp/codex-verify/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}{"status":"fulfilled","value":{"chunk_id":"b87795","wall_time_seconds":1.002481041,"session_id":70629,"original_token_count":230,"output":"WARNING: The directory '/Users/gaganarora/Library/Caches/pip' or its parent directory is not owned or is not writable by the current user. The cache has been disabled. Check the permissions and owner of that directory. If executing pip with sudo, you should use sudo's -H flag.\nWARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x107874830>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nWARNING: Retrying (Retry(total=3, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f4d0>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\n"}}
{"status":"fulfilled","value":{"chunk_id":"9876b6","wall_time_seconds":0.000014917,"exit_code":0,"original_token_count":1723,"output":"\"\"\"Open-Inspect Modal sandbox infrastructure.\"\"\"\n\n# Import modules to register functions with the app\n# (all use lazy imports internally to avoid pydantic dependency at load time)\nfrom . import web_api\nfrom .app import app\n\n__all__ = [\"app\", \"web_api\"]\n\"\"\"Re-export structured logging for non-sandbox code (web_api, app).\"\"\"\n\nfrom sandbox_runtime.log_config import StructuredLogger, configure_logging, get_logger\n\n__all__ = [\"StructuredLogger\", \"configure_logging\", \"get_logger\"]\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\n it(\"forwards text frames as strings and binary frames as ArrayBuffers\", async () => {\n harness = await createHarness();\n const { client, socket } = await harness.connect([\"wsid:ws-1\"]);\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"}}
{"chunk_id":"757252","wall_time_seconds":0.000130334,"exit_code":4,"original_token_count":124,"output":"ImportError while loading conftest '/private/tmp/codex-verify/packages/sandbox-runtime/tests/conftest.py'.\ntests/conftest.py:8: in <module>\n from sandbox_runtime.harness import EventSink, HarnessPrompt, PromptLimits, TurnOutcome\nsrc/sandbox_runtime/harness/__init__.py:25: in <module>\n from .claude import ClaudeHarness, ClaudeHarnessConfig\nsrc/sandbox_runtime/harness/claude.py:21: in <module>\n from claude_agent_sdk import (\nE ModuleNotFoundError: No module named 'claude_agent_sdk'\n"}
{"chunk_id":"fe3cc2","wall_time_seconds":6.25e-7,"exit_code":4,"original_token_count":0,"output":""}
{"chunk_id":"ada07e","wall_time_seconds":5e-7,"exit_code":1,"original_token_count":3442,"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 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⎯⎯⎯⎯⎯ 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\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:01:11\n Duration 82.08s (transform 48.43s, setup 0ms, import 271.45s, tests 135.25s, environment 94ms)\n\nnpm error Lifecycle script `test` failed with error:\nnpm error code 1\nnpm error path /private/tmp/codex-verify/packages/control-plane\nnpm error workspace @open-inspect/control-plane@0.1.0\nnpm error location /private/tmp/codex-verify/packages/control-plane\nnpm error command failed\nnpm error command sh -c vitest run\n"}>cat > packages/control-plane/src/image-builds/credential-review-probe.test.ts <<'TS'
import { afterEach, expect, it, vi } from "vitest";
import { generateKeyPairSync } from "node:crypto";
import { ImageBuildPlanner } from "./planner";
import { getScopedInstallationTokenWithExpiry } from "../auth/github-app";
vi.mock("./scope", () => ({
resolveScopeSandboxSettings: vi.fn().mockResolvedValue({}),
loadScopeBuildSecrets: vi.fn().mockResolvedValue(undefined),
resolveScopeTarget: vi.fn(),
}));
afterEach(() => vi.unstubAllGlobals());
it("planner sends both build repos and minimal permissions through the real GitHub provider", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const env = {
SCM_PROVIDER: "github",
GITHUB_APP_ID: "123",
GITHUB_APP_INSTALLATION_ID: "456",
GITHUB_APP_PRIVATE_KEY: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
};
const fetchMock = vi.fn().mockResolvedValue(Response.json({
token: "fake-scoped-token", expires_at: new Date(Date.now() + 3600000).toISOString(),
}));
vi.stubGlobal("fetch", fetchMock);
const repositories = [
{ repoOwner: "acme", repoName: "web", baseBranch: "main" },
{ repoOwner: "acme", repoName: "sibling", baseBranch: "main" },
];
const plan = await new ImageBuildPlanner(env as any, {} as any).planBuild({
buildId: "probe", scope: { kind: "environment", id: "env" },
target: { kind: "environment", repositories, repositoriesFingerprint: "probe" },
callbackUrl: "https://example.invalid/ok", failureCallbackUrl: "https://example.invalid/fail",
correlation: {}, callbackAuth: { token: "callback", tokenHash: "hash", expiresAt: Date.now() + 60000 },
});
expect(plan.cloneAuth).toMatchObject({ type: "credential_helper", token: "fake-scoped-token" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
repositories: ["web", "sibling"], permissions: { contents: "write", metadata: "read" },
});
});
it("TypeScript empty repository and permission scopes fail before HTTP", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const config = { appId: "123", installationId: "456", privateKey: "unused" };
await expect(getScopedInstallationTokenWithExpiry(config, [])).rejects.toThrow("no repositories");
await expect(getScopedInstallationTokenWithExpiry(config, ["web"], undefined, {})).rejects.toThrow("no permissions");
expect(fetchMock).not.toHaveBeenCalled();
});
TS
npm run test -- src/image-builds/credential-review-probe.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{"chunk_id":"b80a6c","wall_time_seconds":1.001321,"session_id":86785,"original_token_count":106,"output":"zsh:1: no such file or directory: packages/control-plane/src/image-builds/credential-review-probe.test.ts\n\n> @open-inspect/control-plane@0.1.0 test\n> vitest run src/image-builds/credential-review-probe.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\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}>cat > src/image-builds/credential-review-probe.test.ts <<'TS'
import { afterEach, expect, it, vi } from "vitest";
import { generateKeyPairSync } from "node:crypto";
import { ImageBuildPlanner } from "./planner";
import { getScopedInstallationTokenWithExpiry } from "../auth/github-app";
vi.mock("./scope", () => ({
resolveScopeSandboxSettings: vi.fn().mockResolvedValue({}),
loadScopeBuildSecrets: vi.fn().mockResolvedValue(undefined),
resolveScopeTarget: vi.fn(),
}));
afterEach(() => vi.unstubAllGlobals());
it("planner sends both build repos and minimal permissions through the real GitHub provider", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const env = {
SCM_PROVIDER: "github", GITHUB_APP_ID: "123", GITHUB_APP_INSTALLATION_ID: "456",
GITHUB_APP_PRIVATE_KEY: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
};
const fetchMock = vi.fn().mockResolvedValue(Response.json({
token: "fake-scoped-token", expires_at: new Date(Date.now() + 3600000).toISOString(),
}));
vi.stubGlobal("fetch", fetchMock);
const repositories = [
{ repoOwner: "acme", repoName: "web", baseBranch: "main" },
{ repoOwner: "acme", repoName: "sibling", baseBranch: "main" },
];
const plan = await new ImageBuildPlanner(env as any, {} as any).planBuild({
buildId: "probe", scope: { kind: "environment", id: "env" },
target: { kind: "environment", repositories, repositoriesFingerprint: "probe" },
callbackUrl: "https://example.invalid/ok", failureCallbackUrl: "https://example.invalid/fail",
correlation: {}, callbackAuth: { token: "callback", tokenHash: "hash", expiresAt: Date.now() + 60000 },
});
expect(plan.cloneAuth).toMatchObject({ type: "credential_helper", token: "fake-scoped-token" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
repositories: ["web", "sibling"], permissions: { contents: "write", metadata: "read" },
});
});
it("TypeScript empty repository and permission scopes fail before HTTP", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const config = { appId: "123", installationId: "456", privateKey: "unused" };
await expect(getScopedInstallationTokenWithExpiry(config, [])).rejects.toThrow("no repositories");
await expect(getScopedInstallationTokenWithExpiry(config, ["web"], undefined, {})).rejects.toThrow("no permissions");
expect(fetchMock).not.toHaveBeenCalled();
});
TS
npm run test -- src/image-builds/credential-review-probe.test.ts{"chunk_id":"f0d95c","wall_time_seconds":1.003741125,"session_id":39190,"original_token_count":43,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run src/image-builds/credential-review-probe.test.ts\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}
{"chunk_id":"306de0","wall_time_seconds":1.001357542,"session_id":60994,"original_token_count":0,"output":""}
{"chunk_id":"edfcb9","wall_time_seconds":1.001541333,"session_id":47519,"original_token_count":65,"output":"github.repo_context_missing\ngithub.repo_context_missing\ngithub.repo_context_missing\nPASS: missing/None/empty repo context returns None; mint called zero times\n....... [100%]\n7 passed in 0.10s\n"}>PYTHONPATH=src python3 -m pytest --noconftest tests/test_github_app_auth.py -q{"chunk_id":"f0d95c","wall_time_seconds":1.003741125,"session_id":39190,"original_token_count":43,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run src/image-builds/credential-review-probe.test.ts\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}
{"chunk_id":"306de0","wall_time_seconds":1.001357542,"session_id":60994,"original_token_count":0,"output":""}
{"chunk_id":"edfcb9","wall_time_seconds":1.001541333,"session_id":47519,"original_token_count":65,"output":"github.repo_context_missing\ngithub.repo_context_missing\ngithub.repo_context_missing\nPASS: missing/None/empty repo context returns None; mint called zero times\n....... [100%]\n7 passed in 0.10s\n"}>cat > /private/tmp/probe-clone-token-tests.py <<'PY'
import sys
import types
from unittest.mock import patch
import pytest
root = '/private/tmp/codex-verify/packages'
sys.path.insert(0, root + '/sandbox-runtime/src')
# Load the real clone_token and log_config without initializing the unrelated Modal app.
package = types.ModuleType('src')
package.__path__ = [root + '/modal-infra/src']
sys.modules['src'] = package
from src.clone_token import resolve_clone_token
with patch.dict('os.environ', {'SCM_PROVIDER': 'github', 'GITHUB_APP_ID': '123', 'GITHUB_APP_PRIVATE_KEY': 'key', 'GITHUB_APP_INSTALLATION_ID': '456'}):
with patch('sandbox_runtime.auth.generate_installation_token', return_value='unexpected-token') as mint:
for args in [(), ('acme', None), ('acme', '')]:
assert resolve_clone_token(*args) is None
mint.assert_not_called()
print('PASS: missing/None/empty repo context returns None; mint called zero times')
sys.exit(pytest.main(['--noconftest', root + '/modal-infra/tests/test_clone_token.py', '-q']))
PY
python3 /private/tmp/probe-clone-token-tests.py{"chunk_id":"f0d95c","wall_time_seconds":1.003741125,"session_id":39190,"original_token_count":43,"output":"\n> @open-inspect/control-plane@0.1.0 test\n> vitest run src/image-builds/credential-review-probe.test.ts\n\n\n RUN v4.1.9 /private/tmp/codex-verify/packages/control-plane\n\n"}
{"chunk_id":"306de0","wall_time_seconds":1.001357542,"session_id":60994,"original_token_count":0,"output":""}
{"chunk_id":"edfcb9","wall_time_seconds":1.001541333,"session_id":47519,"original_token_count":65,"output":"github.repo_context_missing\ngithub.repo_context_missing\ngithub.repo_context_missing\nPASS: missing/None/empty repo context returns None; mint called zero times\n....... [100%]\n7 passed in 0.10s\n"}{"status":"fulfilled","value":{"chunk_id":"b11b8b","wall_time_seconds":0.000107208,"exit_code":0,"original_token_count":43,"output":"\n Test Files 3 passed (3)\n Tests 131 passed (131)\n Start at 16:03:41\n Duration 733ms (transform 580ms, setup 0ms, import 874ms, tests 182ms, environment 0ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"cc0232","wall_time_seconds":1.66e-7,"exit_code":0,"original_token_count":42,"output":"\n Test Files 1 passed (1)\n Tests 2 passed (2)\n Start at 16:04:22\n Duration 1.06s (transform 244ms, setup 0ms, import 485ms, tests 342ms, environment 0ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"180c14","wall_time_seconds":4.59e-7,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.40s\n"}}
{"status":"fulfilled","value":{"chunk_id":"271be9","wall_time_seconds":1.25e-7,"exit_code":0,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"4a4bdd","wall_time_seconds":1.67e-7,"exit_code":1,"original_token_count":275,"output":"WARNING: Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f610>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nWARNING: Retrying (Retry(total=1, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f750>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nWARNING: Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f890>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nERROR: Could not find a version that satisfies the requirement modal (from versions: none)\nERROR: No matching distribution found for modal\n"}}
{"chunk_id":"d36682","wall_time_seconds":0.498339166,"exit_code":0,"original_token_count":1742,"output":" 24\t assert resolve_clone_token() == \"glpat-token\"\n 25\t\n 26\t\n 27\tdef test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\n 28\t monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n 29\t\n 30\t assert resolve_clone_token() is None\n 31\t\n 32\t\n 33\tdef test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\n 34\t \"\"\"No repo context must fail closed — never mint an unnarrowed, installation-wide token.\"\"\"\n 35\t monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n 36\t monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n 37\t monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n 38\t\n 39\t def fail_if_called(**_kwargs):\n 40\t raise AssertionError(\"generate_installation_token should not be called\")\n 41\t\n 42\t monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\n 43\t\n 44\t assert resolve_clone_token() is None\n 45\t assert resolve_clone_token(\"acme\", None) is None\n 46\t assert resolve_clone_token(\"acme\", \"\") is None\n 47\t\n 48\t\n 49\tdef test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\n 50\t \"\"\"The token handed to a sandbox must be repo-scoped and permission-narrowed.\"\"\"\n 51\t monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n 52\t monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\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 361\t return Response.json(\n 362\t {\n 363\t username: result.username,\n 364\t password: result.password,\n 365\t expires_at_epoch_ms: result.expiresAtEpochMs,\n 366\t },\n 367\t {\n 368\t status: 200,\n 369\t headers: { \"Cache-Control\": \"no-store\" },\n 370\t }\n 371\t );\n 372\t }\n 80\t expiresAt: Date.now() + IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS,\n 81\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 96\t scope: params.scope,\n 97\t repositories,\n 98\t repositoriesFingerprint,\n 99\t callbackUrl: params.callbackUrl,\n 100\t failureCallbackUrl: params.failureCallbackUrl,\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 141\t });\n 142\t return { type: \"unavailable\" };\n 143\t }\n 144\t }\n 145\t}\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 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?? packages/control-plane/src/image-builds/credential-review-probe.test.ts\n"}>nl -ba packages/modal-infra/tests/test_clone_token.py | sed -n '24,52p' && nl -ba packages/control-plane/src/session/http/handlers/sandbox.handler.ts | sed -n '343,372p' && nl -ba packages/control-plane/src/image-builds/planner.ts | sed -n '80,100p;122,146p' && 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 '72,94p' && git status --short{"status":"fulfilled","value":{"chunk_id":"b11b8b","wall_time_seconds":0.000107208,"exit_code":0,"original_token_count":43,"output":"\n Test Files 3 passed (3)\n Tests 131 passed (131)\n Start at 16:03:41\n Duration 733ms (transform 580ms, setup 0ms, import 874ms, tests 182ms, environment 0ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"cc0232","wall_time_seconds":1.66e-7,"exit_code":0,"original_token_count":42,"output":"\n Test Files 1 passed (1)\n Tests 2 passed (2)\n Start at 16:04:22\n Duration 1.06s (transform 244ms, setup 0ms, import 485ms, tests 342ms, environment 0ms)\n\n"}}
{"status":"fulfilled","value":{"chunk_id":"180c14","wall_time_seconds":4.59e-7,"exit_code":0,"original_token_count":25,"output":"....... [100%]\n7 passed in 0.40s\n"}}
{"status":"fulfilled","value":{"chunk_id":"271be9","wall_time_seconds":1.25e-7,"exit_code":0,"original_token_count":0,"output":""}}
{"status":"fulfilled","value":{"chunk_id":"4a4bdd","wall_time_seconds":1.67e-7,"exit_code":1,"original_token_count":275,"output":"WARNING: Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f610>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nWARNING: Retrying (Retry(total=1, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f750>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nWARNING: Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x10754f890>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known')': /simple/modal/\nERROR: Could not find a version that satisfies the requirement modal (from versions: none)\nERROR: No matching distribution found for modal\n"}}
{"chunk_id":"d36682","wall_time_seconds":0.498339166,"exit_code":0,"original_token_count":1742,"output":" 24\t assert resolve_clone_token() == \"glpat-token\"\n 25\t\n 26\t\n 27\tdef test_resolve_clone_token_returns_none_for_missing_gitlab_token(monkeypatch):\n 28\t monkeypatch.setenv(\"SCM_PROVIDER\", \"gitlab\")\n 29\t\n 30\t assert resolve_clone_token() is None\n 31\t\n 32\t\n 33\tdef test_resolve_clone_token_returns_none_without_repo_context(monkeypatch):\n 34\t \"\"\"No repo context must fail closed — never mint an unnarrowed, installation-wide token.\"\"\"\n 35\t monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n 36\t monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\n 37\t monkeypatch.setenv(\"GITHUB_APP_INSTALLATION_ID\", \"456\")\n 38\t\n 39\t def fail_if_called(**_kwargs):\n 40\t raise AssertionError(\"generate_installation_token should not be called\")\n 41\t\n 42\t monkeypatch.setattr(\"sandbox_runtime.auth.generate_installation_token\", fail_if_called)\n 43\t\n 44\t assert resolve_clone_token() is None\n 45\t assert resolve_clone_token(\"acme\", None) is None\n 46\t assert resolve_clone_token(\"acme\", \"\") is None\n 47\t\n 48\t\n 49\tdef test_resolve_clone_token_narrows_to_repo_and_git_only_permissions(monkeypatch):\n 50\t \"\"\"The token handed to a sandbox must be repo-scoped and permission-narrowed.\"\"\"\n 51\t monkeypatch.setenv(\"GITHUB_APP_ID\", \"123\")\n 52\t monkeypatch.setenv(\"GITHUB_APP_PRIVATE_KEY\", \"private-key\")\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 361\t return Response.json(\n 362\t {\n 363\t username: result.username,\n 364\t password: result.password,\n 365\t expires_at_epoch_ms: result.expiresAtEpochMs,\n 366\t },\n 367\t {\n 368\t status: 200,\n 369\t headers: { \"Cache-Control\": \"no-store\" },\n 370\t }\n 371\t );\n 372\t }\n 80\t expiresAt: Date.now() + IMAGE_BUILD_CALLBACK_TOKEN_TTL_MS,\n 81\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 96\t scope: params.scope,\n 97\t repositories,\n 98\t repositoriesFingerprint,\n 99\t callbackUrl: params.callbackUrl,\n 100\t failureCallbackUrl: params.failureCallbackUrl,\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 141\t });\n 142\t return { type: \"unavailable\" };\n 143\t }\n 144\t }\n 145\t}\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 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?? packages/control-plane/src/image-builds/credential-review-probe.test.ts\n"}The targeted checks passed: 131 existing TypeScript tests, both temporary planner/validation tests, and seven tests in each Python file with unrelated test setup bypassed. The full control-plane run failed with 43 failures tied to denied socket binds and resulting timeouts.
I also found a weakness in the missing-repository regression test: its “must not mint” assertion can be swallowed by the resolver’s exception handler. I’m checking that with an isolated mutation before reporting it.
>cat > /private/tmp/probe-clone-token-mutation.py <<'PY'
import ast
import pathlib
import sys
import types
import pytest
root = pathlib.Path('/private/tmp/codex-verify/packages')
sys.path.insert(0, str(root / 'sandbox-runtime/src'))
package = types.ModuleType('src')
package.__path__ = [str(root / 'modal-infra/src')]
sys.modules['src'] = package
import src.clone_token as module
path = root / 'modal-infra/src/clone_token.py'
tree = ast.parse(path.read_text())
func = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == 'resolve_clone_token')
removed = [node for node in func.body if isinstance(node, ast.If) and ast.unparse(node.test) == 'not repo_name']
assert len(removed) == 1
func.body.remove(removed[0])
# Only mutate the in-memory function in this process; repository files stay intact.
exec(compile(tree, str(path), 'exec'), module.__dict__)
result = pytest.main(['--noconftest', str(root / 'modal-infra/tests/test_clone_token.py'), '-q', '-k', 'returns_none_without_repo_context'])
assert result == 0, result
print('CONFIRMED TEST GAP: missing-repo guard removed in memory; regression test still passes')
PY
python3 /private/tmp/probe-clone-token-mutation.py{"chunk_id":"231f22","wall_time_seconds":0.882598375,"exit_code":0,"original_token_count":50,"output":". [100%]\n1 passed, 6 deselected in 0.14s\nCONFIRMED TEST GAP: missing-repo guard removed in memory; regression test still passes\n"}
{"chunk_id":"496ecf","wall_time_seconds":0.33663075,"exit_code":0,"original_token_count":0,"output":""}>rm packages/control-plane/src/image-builds/credential-review-probe.test.ts && git status --short && git diff --exit-code{"chunk_id":"231f22","wall_time_seconds":0.882598375,"exit_code":0,"original_token_count":50,"output":". [100%]\n1 passed, 6 deselected in 0.14s\nCONFIRMED TEST GAP: missing-repo guard removed in memory; regression test still passes\n"}
{"chunk_id":"496ecf","wall_time_seconds":0.33663075,"exit_code":0,"original_token_count":0,"output":""}All three original findings are CONFIRMED FIXED, based on executed targeted tests and probes. However, I cannot confirm that the requested test commands all pass in this environment.
| Original finding | Verdict | Executed evidence |
|---|---|---|
| [P1] Primary-only credentials | CONFIRMED FIXED | Session/provider tests pass. A temporary test exercised the real planner → provider → token-request path and captured both repositories plus contents:write and metadata:read. |
| [P2] Missing repository mints an unrestricted token | CONFIRMED FIXED | Independent probe verified omitted, None, and empty repository names return None, with zero mint calls. |
| [P2] Empty permissions silently drops narrowing | CONFIRMED FIXED | Throwaway script called get_installation_token(..., repository="repo", permissions={}): it raised ValueError before constructing an HTTP client. |
One new finding:
AssertionError, which the resolver’s except Exception swallows. I removed the repository guard in memory and reran this test: it still passed. Use a recording mock and assert assert_not_called() after invoking the resolver. The production guard works; this test does not reliably protect it.Actual test results:
npm run test: FAILED — 4,255 passed, 43 failed, 11 skipped. Failures involved listen EPERM and socket-related timeouts.uv run pytest ... commands: blocked before testing — cache permission failure; a temporary cache then exposed a uv panic.uv commands.No live GitHub tokens were minted. Temporary repository tests were removed; the worktree is clean.
No content