@defrex/autobuild 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,2541 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { basename, join } from 'node:path'
5
+ import { Sandbox, Snapshot, type NetworkPolicy, type SandboxRegion } from '@vercel/sandbox'
6
+ import { displayName, tomlKey, type RuntimeReferenceGroup } from '../../config/roles'
7
+ import {
8
+ type VercelProvisioningStep,
9
+ type VercelSandboxConfig,
10
+ vercelSandboxConfigSchema,
11
+ } from '../../config/schema'
12
+ import { distributionRoot } from '../../distribution'
13
+ import { defaultDistributionArchive, readDistributionIdentity } from './distribution-archive'
14
+ import type {
15
+ WorkspaceHandle,
16
+ WorkspaceProvider,
17
+ WorkspaceProvisionResult,
18
+ WorkspaceReapOutcome,
19
+ } from '../types'
20
+ import {
21
+ SandboxOperationError,
22
+ type OperatorSandboxExecution,
23
+ type SandboxCommandRequest,
24
+ type SandboxCommandResult,
25
+ type SandboxEnvironmentIdentity,
26
+ type SandboxWaitResult,
27
+ } from './operator-sandbox'
28
+ import type {
29
+ BuildExecution,
30
+ BuildExecutionExit,
31
+ BuildExecutionHandle,
32
+ BuildExecutionIdentity,
33
+ BuildExecutionStart,
34
+ ExecutionObservation,
35
+ } from './build-execution'
36
+ import {
37
+ HARVEST_RUNNER_OPTIONS_ENV,
38
+ type HarvestExecution,
39
+ type HarvestExecutionStart,
40
+ type HarvestRunnerLaunch,
41
+ } from './harvest-execution'
42
+ import { BUILD_RUNNER_OPTIONS_ENV } from './local-build-execution'
43
+ import type { Exec } from './git-worktree'
44
+ import { spawnExec } from './git-worktree'
45
+
46
+ export const VERCEL_WORKSPACE_PATH = '/vercel/sandbox/workspace'
47
+ export const VERCEL_AUTOBUILD_PATH = '/opt/autobuild'
48
+ export const VERCEL_PROVISIONED_MARKER = `${VERCEL_AUTOBUILD_PATH}/.provisioned`
49
+ /** Records the installed distribution's version. A reused sandbox whose marker
50
+ * disagrees with the version the current system would deliver is refreshed by
51
+ * reinstalling the archive, so an upgraded dispatcher retrofits its persistent
52
+ * guests (including legacy guests predating this marker) instead of resuming a
53
+ * stale distribution that the hosted store would reject. */
54
+ export const VERCEL_DISTRIBUTION_VERSION_MARKER = `${VERCEL_AUTOBUILD_PATH}/.distribution-version`
55
+ export const VERCEL_BUN_VERSION = '1.4.0'
56
+ export const VERCEL_BUN_PREFIX = '/opt/autobuild-runtime'
57
+ export const VERCEL_BUN_BIN_PATH = `${VERCEL_BUN_PREFIX}/node_modules/.bin`
58
+ export const VERCEL_BUN_EXECUTABLE = `${VERCEL_BUN_BIN_PATH}/bun`
59
+
60
+ export interface VercelCommand {
61
+ readonly exitCode: number | null
62
+ /** Provider-native command identity, recorded durably at launch so a later
63
+ * process can re-observe the execution. */
64
+ readonly cmdId: string
65
+ /** The optional signal lets a detaching supervisor release a pending
66
+ * long-poll without killing the remote command. */
67
+ wait(params?: { signal?: AbortSignal }): Promise<{ exitCode: number }>
68
+ kill(signal?: 'SIGTERM' | 'SIGKILL', opts?: { abortSignal?: AbortSignal }): Promise<void>
69
+ /** Completed-command output is read only for readiness/validation reporting
70
+ * and declared system-provisioning failure diagnostics, including failures
71
+ * during durable build provisioning. It is not a durable build-state channel;
72
+ * scoped Store facts/events remain authoritative. */
73
+ stdout?(): Promise<string>
74
+ stderr?(): Promise<string>
75
+ }
76
+
77
+ /**
78
+ * Bun's native fetch enforces its own request timeout on the SDK's `wait=true`
79
+ * long-poll and ignores the SDK's undici agent (`bodyTimeout: 0`). A guest
80
+ * execution routinely outlives that timeout, so an interrupted long-poll is
81
+ * re-issued until the command reports an exit code or the environment's own
82
+ * lifetime has passed. Any other failure still propagates: it may mean the
83
+ * environment is gone. The same classification reads the observation path's
84
+ * bounded wait: an interrupted or timed-out bounded wait leaves the execution
85
+ * provably unobserved, which reads as `running`.
86
+ */
87
+ export function isInterruptedLongPoll(error: unknown): boolean {
88
+ if (!(error instanceof Error)) return false
89
+ if (error.name === 'TimeoutError') return true
90
+ const message = error.message.toLowerCase()
91
+ return message.includes('timed out') || message.includes('socket connection was closed')
92
+ }
93
+
94
+ export async function waitForCommandExit(
95
+ command: Pick<VercelCommand, 'wait'>,
96
+ expired: () => boolean,
97
+ opts?: { signal?: AbortSignal },
98
+ ): Promise<{ exitCode: number }> {
99
+ for (;;) {
100
+ try {
101
+ return await command.wait(opts?.signal === undefined ? {} : { signal: opts.signal })
102
+ } catch (error) {
103
+ if (!isInterruptedLongPoll(error) || expired()) throw error
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Narrow re-observation surface of one recorded detached command. The
109
+ * provider's plain lookup reports `exitCode: null` for a command that finished
110
+ * while no client was waiting on it, and keeps reporting none until some
111
+ * client issues `wait` — a caller may not conclude `running` from
112
+ * `exitCode: null` without first issuing a wait, whose resolution carries the
113
+ * populated exit code. The SDK's `Command` satisfies this structurally. */
114
+ export interface VercelCommandLookup {
115
+ exitCode: number | null
116
+ wait(params?: { signal?: AbortSignal }): Promise<{ exitCode: number }>
117
+ /** Completed-command output, when the concrete handle exposes it (the SDK's
118
+ * `Command` does; narrow lookups may omit it). Read only after exit. */
119
+ stdout?(): Promise<string>
120
+ stderr?(): Promise<string>
121
+ }
122
+
123
+ export interface VercelSandboxHandle {
124
+ readonly name: string
125
+ /** The session's default working directory, where the server places a git
126
+ * source checkout. Managed images differ (`/vercel/sandbox` on the language
127
+ * images, `/vercel` on universal), so the checkout is located through it. */
128
+ readonly cwd?: string
129
+ currentSession?(): { sessionId: string }
130
+ /** Status of the session this handle observed, captured at `facade.get`
131
+ * time. Optional: facades without session metadata omit it. */
132
+ readonly sessionStatus?:
133
+ | 'failed'
134
+ | 'aborted'
135
+ | 'pending'
136
+ | 'running'
137
+ | 'stopping'
138
+ | 'stopped'
139
+ | 'snapshotting'
140
+ /** The provider-computed moment the running session times out (epoch ms;
141
+ * the SDK Sandbox's `expiresAt` getter, session start plus configured
142
+ * timeout). Optional: facades without session metadata omit it, and it is
143
+ * `undefined` while no session is running. Read live so a refreshed handle
144
+ * — including one carrying a resumed session's new expiry — reports fresh
145
+ * values on every observation. */
146
+ readonly sessionExpiresAt?: number
147
+ /** Narrowed re-observation of one recorded detached command. A missing
148
+ * command under a resumed session rejects; callers classify that as `lost`.
149
+ * The plain lookup reports no exit code for a command that finished while no
150
+ * client was waiting on it, and keeps reporting none until some client
151
+ * issues `wait` — a caller may not conclude `running` from `exitCode: null`
152
+ * without first issuing a wait. */
153
+ getCommand(cmdId: string, opts?: { signal?: AbortSignal }): Promise<VercelCommandLookup>
154
+ runCommand(params: {
155
+ cmd: string
156
+ args?: string[]
157
+ cwd?: string
158
+ env?: Record<string, string>
159
+ detached?: true
160
+ sudo?: boolean
161
+ signal?: AbortSignal
162
+ }): Promise<VercelCommand | { exitCode: number }>
163
+ writeFiles(
164
+ files: { path: string; content: Uint8Array }[],
165
+ opts?: { signal?: AbortSignal },
166
+ ): Promise<void>
167
+ stop(opts?: { signal?: AbortSignal }): Promise<unknown>
168
+ delete(opts?: { signal?: AbortSignal }): Promise<void>
169
+ update(
170
+ params: { networkPolicy: NetworkPolicy },
171
+ opts?: { signal?: AbortSignal },
172
+ ): Promise<unknown>
173
+ }
174
+
175
+ export type VercelSandboxCreateInput = {
176
+ name: string
177
+ source: {
178
+ type: 'git'
179
+ url: string
180
+ revision: string
181
+ depth?: number
182
+ username?: string
183
+ password?: string
184
+ }
185
+ image: string
186
+ resources: { vcpus: number }
187
+ timeout: number
188
+ persistent: true
189
+ region?: string
190
+ failoverRegions?: string[]
191
+ networkPolicy: NetworkPolicy
192
+ signal?: AbortSignal
193
+ /** Snapshot expiration in milliseconds for this environment's automatic
194
+ * snapshots (SDK `snapshotExpiration`); absent means the provider default. */
195
+ snapshotExpiration?: number
196
+ /** Retention bound for this environment's automatic snapshots. Snapshots
197
+ * are created whenever a session stops, and `Sandbox.delete()` never removes
198
+ * them; the bound keeps a live environment at exactly what resuming it
199
+ * needs. Release purges even this snapshot. */
200
+ keepLastSnapshots: { count: number; deleteEvicted: boolean }
201
+ }
202
+
203
+ /** Snapshots retained for a live environment: exactly the one resuming it
204
+ * needs. Each session stop supersedes the previous snapshot, so stops along
205
+ * the build never accumulate one snapshot per stop. */
206
+ export const VERCEL_KEEP_LAST_SNAPSHOTS = 1
207
+
208
+ /** The stop/snapshot margin added to the environment's configured lifetime by
209
+ * every VM-lifetime deadline: a command or observation may legitimately still
210
+ * resolve while the environment is being stopped or snapshotted, but past
211
+ * session expiry plus this margin it cannot still be running. Shared by the
212
+ * command path and the observation path so the two bounds cannot drift. */
213
+ export const VERCEL_LIFETIME_MARGIN_MS = 5 * 60 * 1000
214
+
215
+ /** The bound on the observation path's single wait on a recorded command
216
+ * whose plain lookup reports no exit code. A few seconds: long enough to
217
+ * observe an already-exited guest, far below the tick's operation timeout so
218
+ * one observation never approaches it. A genuinely running guest costs this
219
+ * much per observation; the next tick retries. */
220
+ export const VERCEL_OBSERVE_WAIT_MS = 5_000
221
+
222
+ /** The snapshot rows that hold billed storage. `deleted`/`failed` rows hold
223
+ * none and are never purge targets. */
224
+ const LIVE_SNAPSHOT_STATUS = 'created'
225
+
226
+ /** Bounded list→delete→re-list repetitions that absorb the provider's
227
+ * snapshot materialization lag between a session stop and its snapshot
228
+ * becoming listed. */
229
+ const SNAPSHOT_PURGE_PASSES = 3
230
+
231
+ export interface VercelSnapshotInfo {
232
+ id: string
233
+ sourceSessionId: string
234
+ status: 'created' | 'deleted' | 'failed'
235
+ sizeBytes?: number
236
+ }
237
+
238
+ /** Result of purging one environment's snapshots by exact name. `unknown`
239
+ * never silently claims confirmation. */
240
+ export interface VercelSnapshotPurge {
241
+ outcome: 'confirmed' | 'unknown'
242
+ deleted: number
243
+ error?: string
244
+ }
245
+
246
+ export interface VercelSandboxFacade {
247
+ get(name: string, signal?: AbortSignal): Promise<VercelSandboxHandle | null>
248
+ /** Durable build workspaces retain the named get-or-create lifecycle. */
249
+ create(input: VercelSandboxCreateInput): Promise<VercelSandboxHandle>
250
+ /** Readiness always acquires a new unnamed disposable environment. */
251
+ createFresh?(
252
+ input: Omit<VercelSandboxCreateInput, 'name' | 'persistent'>,
253
+ ): Promise<VercelSandboxHandle>
254
+ /** Snapshots listed under the exact sandbox name — the deterministic
255
+ * per-environment purge key. */
256
+ listSnapshots(name: string, signal?: AbortSignal): Promise<VercelSnapshotInfo[]>
257
+ deleteSnapshot(snapshotId: string, signal?: AbortSignal): Promise<void>
258
+ }
259
+
260
+ /** The team and project a Vercel OIDC token was issued for, read from its
261
+ * unverified payload exactly as the Sandbox SDK does (`owner_id`,
262
+ * `project_id`). Null when the value is not a decodable JWT carrying both. */
263
+ export function vercelOidcTokenScope(token: string): { teamId: string; projectId: string } | null {
264
+ const payload = token.split('.')[1]
265
+ if (payload === undefined || payload === '') return null
266
+ try {
267
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
268
+ owner_id?: unknown
269
+ project_id?: unknown
270
+ }
271
+ if (typeof claims.owner_id !== 'string' || typeof claims.project_id !== 'string') return null
272
+ return { teamId: claims.owner_id, projectId: claims.project_id }
273
+ } catch {
274
+ return null
275
+ }
276
+ }
277
+
278
+ /** Credentials handed to every Sandbox SDK call. An OIDC token in the
279
+ * dispatcher environment is passed explicitly with the team and project from
280
+ * its claims — the SDK's own `VERCEL_OIDC_TOKEN` lookup reads the process
281
+ * environment, which is not where a hosted invocation carries its token (a
282
+ * Vercel Function receives it as a request header). A token whose claims
283
+ * cannot be read is left to the SDK's own resolution. */
284
+ export function vercelSdkCredentials(
285
+ env: Record<string, string | undefined>,
286
+ ): Record<string, string> {
287
+ const oidcToken = env.VERCEL_OIDC_TOKEN
288
+ if (oidcToken) {
289
+ const scope = vercelOidcTokenScope(oidcToken)
290
+ return scope === null ? {} : { token: oidcToken, ...scope }
291
+ }
292
+ const token = env.VERCEL_TOKEN
293
+ const teamId = env.VERCEL_TEAM_ID
294
+ const projectId = env.VERCEL_PROJECT_ID
295
+ if (!token || !teamId || !projectId) {
296
+ throw new Error(
297
+ 'vercel-sandbox requires VERCEL_OIDC_TOKEN or VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID',
298
+ )
299
+ }
300
+ return { token, teamId, projectId }
301
+ }
302
+
303
+ /** The SDK's session metadata is a live getter on the returned object, but the
304
+ * adapter's handle interface names it `sessionStatus`. Bridge it in place so
305
+ * every facade-returned handle carries fresh session state for observation. */
306
+ function withSessionMetadata(sandbox: object): VercelSandboxHandle {
307
+ const status = () => (sandbox as { status?: VercelSandboxHandle['sessionStatus'] }).status
308
+ if (status() !== undefined && !Object.hasOwn(sandbox, 'sessionStatus')) {
309
+ Object.defineProperty(sandbox, 'sessionStatus', { get: status, configurable: true })
310
+ }
311
+ // The SDK computes `expiresAt` itself from the authoritative session fields
312
+ // (`(startedAt ?? createdAt) + timeout` while a session is running); bridge
313
+ // it as a live getter so observations never see a stale lifetime.
314
+ const expiresAt = () => (sandbox as { expiresAt?: Date | undefined }).expiresAt?.getTime()
315
+ if (expiresAt() !== undefined && !Object.hasOwn(sandbox, 'sessionExpiresAt')) {
316
+ Object.defineProperty(sandbox, 'sessionExpiresAt', { get: expiresAt, configurable: true })
317
+ }
318
+ return sandbox as VercelSandboxHandle
319
+ }
320
+
321
+ export function isMissingVercelSandbox(error: unknown): boolean {
322
+ if (error === null || typeof error !== 'object') return false
323
+ const candidate = error as {
324
+ response?: { status?: number }
325
+ json?: { error?: { code?: string } }
326
+ }
327
+ return (
328
+ candidate.response?.status === 404 ||
329
+ (candidate.response?.status === 410 && candidate.json?.error?.code === 'snapshot_not_found')
330
+ )
331
+ }
332
+
333
+ export function createVercelSdkFacade(
334
+ env: Record<string, string | undefined>,
335
+ ): VercelSandboxFacade {
336
+ const credentials = vercelSdkCredentials(env)
337
+ return {
338
+ async get(name, signal) {
339
+ try {
340
+ return withSessionMetadata(await Sandbox.get({ name, signal, ...credentials }))
341
+ } catch (error) {
342
+ if (isMissingVercelSandbox(error)) return null
343
+ throw error
344
+ }
345
+ },
346
+ async create(input) {
347
+ // get() returning null includes stale snapshots. getOrCreate performs the
348
+ // SDK's required stale-name deletion before recreating and also closes a
349
+ // concurrent provision race safely.
350
+ return withSessionMetadata(
351
+ await Sandbox.getOrCreate({
352
+ ...input,
353
+ region: input.region as SandboxRegion | undefined,
354
+ failoverRegions: input.failoverRegions as SandboxRegion[] | undefined,
355
+ ...credentials,
356
+ }),
357
+ )
358
+ },
359
+ async createFresh(input) {
360
+ return withSessionMetadata(
361
+ await Sandbox.create({
362
+ ...input,
363
+ region: input.region as SandboxRegion | undefined,
364
+ failoverRegions: input.failoverRegions as SandboxRegion[] | undefined,
365
+ ...credentials,
366
+ }),
367
+ )
368
+ },
369
+ async listSnapshots(name, signal) {
370
+ // The API rejects `limit` above 50; `toArray()` walks every page.
371
+ const page = await Snapshot.list({
372
+ name,
373
+ limit: 50,
374
+ ...(signal === undefined ? {} : { signal }),
375
+ ...credentials,
376
+ })
377
+ return (await page.toArray()).map((snapshot) => ({
378
+ id: snapshot.id,
379
+ sourceSessionId: snapshot.sourceSessionId,
380
+ status: snapshot.status,
381
+ ...(typeof snapshot.sizeBytes === 'number' && Number.isFinite(snapshot.sizeBytes)
382
+ ? { sizeBytes: snapshot.sizeBytes }
383
+ : {}),
384
+ }))
385
+ },
386
+ async deleteSnapshot(snapshotId, signal) {
387
+ const snapshot = await Snapshot.get({ snapshotId, ...credentials })
388
+ await snapshot.delete(signal === undefined ? {} : { signal })
389
+ },
390
+ }
391
+ }
392
+
393
+ export function requireVercelEnvironmentValue(
394
+ env: Record<string, string | undefined>,
395
+ name: string,
396
+ ): string {
397
+ const value = env[name]
398
+ if (value === undefined || value === '') {
399
+ throw new Error(`vercel-sandbox requires environment variable ${name}`)
400
+ }
401
+ return value
402
+ }
403
+
404
+ function describeError(error: unknown): string {
405
+ return error instanceof Error ? error.message : String(error)
406
+ }
407
+
408
+ /**
409
+ * Delete every `created` snapshot listed under the exact sandbox name, then
410
+ * re-list to prove absence, repeating a bounded number of times to absorb the
411
+ * provider's snapshot materialization lag. Any SDK failure — or a name that
412
+ * still lists live snapshots after the bounded passes — is reported as
413
+ * `unknown` so callers can retry rather than silently claim a purge.
414
+ */
415
+ export async function purgeEnvironmentSnapshots(
416
+ facade: VercelSandboxFacade,
417
+ name: string,
418
+ signal?: AbortSignal,
419
+ ): Promise<VercelSnapshotPurge> {
420
+ let deleted = 0
421
+ for (let pass = 0; ; pass += 1) {
422
+ let listed: VercelSnapshotInfo[]
423
+ try {
424
+ listed = await facade.listSnapshots(name, signal)
425
+ } catch (error) {
426
+ return { outcome: 'unknown', deleted, error: describeError(error) }
427
+ }
428
+ const live = listed.filter((snapshot) => snapshot.status === LIVE_SNAPSHOT_STATUS)
429
+ if (live.length === 0) return { outcome: 'confirmed', deleted }
430
+ if (pass >= SNAPSHOT_PURGE_PASSES) {
431
+ return {
432
+ outcome: 'unknown',
433
+ deleted,
434
+ error:
435
+ `${live.length} snapshot(s) still listed under ${JSON.stringify(name)} ` +
436
+ `after ${SNAPSHOT_PURGE_PASSES} purge passes`,
437
+ }
438
+ }
439
+ for (const snapshot of live) {
440
+ try {
441
+ await facade.deleteSnapshot(snapshot.id, signal)
442
+ deleted += 1
443
+ } catch (error) {
444
+ return { outcome: 'unknown', deleted, error: describeError(error) }
445
+ }
446
+ }
447
+ }
448
+ }
449
+
450
+ export function validateVercelGithubOrigin(raw: string): {
451
+ url: string
452
+ host: string
453
+ path: string
454
+ directory: string
455
+ } {
456
+ let url: URL
457
+ try {
458
+ url = new URL(raw)
459
+ } catch {
460
+ throw new Error('vercel-sandbox requires an HTTPS GitHub origin')
461
+ }
462
+ if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com') {
463
+ throw new Error('vercel-sandbox requires an HTTPS github.com origin')
464
+ }
465
+ url.username = ''
466
+ url.password = ''
467
+ url.search = ''
468
+ url.hash = ''
469
+ const path = url.pathname.replace(/\.git$/, '').replace(/^\//, '')
470
+ if (!/^[^/]+\/[^/]+$/.test(path)) throw new Error('GitHub origin must name owner/repository')
471
+ return {
472
+ url: `https://github.com/${path}.git`,
473
+ host: 'github.com',
474
+ path: `/${path}.git`,
475
+ directory: basename(path),
476
+ }
477
+ }
478
+
479
+ const cleanGithubOrigin = validateVercelGithubOrigin
480
+
481
+ function sandboxName(origin: string, branch: string, generation = 0): string {
482
+ const readable = branch
483
+ .replace(/^ab\//, '')
484
+ .toLowerCase()
485
+ .replace(/[^a-z0-9-]+/g, '-')
486
+ .slice(0, 40)
487
+ const digest = createHash('sha256')
488
+ .update(`${origin}\0${branch}\0${generation}`)
489
+ .digest('hex')
490
+ .slice(0, 10)
491
+ return `autobuild-${readable || 'build'}-g${generation}-${digest}`.slice(0, 63)
492
+ }
493
+
494
+ /** Deterministic harvest environment name: one per repository origin, with
495
+ * the same digest slicing and charset discipline as `sandboxName`. The name
496
+ * is the get-or-create key and the exact snapshot purge key. */
497
+ export function harvestSandboxName(origin: string): string {
498
+ const digest = createHash('sha256').update(origin).digest('hex').slice(0, 10)
499
+ return `autobuild-harvest-${digest}`.slice(0, 63)
500
+ }
501
+
502
+ /** Deterministic operator-sandbox environment name: one per (repository
503
+ * origin, operator) — the same digest over the two identities with the same
504
+ * slicing/charset/63-char rules as the build and harvest names. The name is
505
+ * the get-or-create key and the exact snapshot purge key. */
506
+ export function sandboxEnvironmentName(origin: string, operator: string): string {
507
+ const digest = createHash('sha256').update(`${origin}\0${operator}`).digest('hex').slice(0, 10)
508
+ return `autobuild-sandbox-${digest}`.slice(0, 63)
509
+ }
510
+
511
+ /** Hard ceiling on one operator-sandbox command wait (`exec`'s
512
+ * `timeoutSeconds` and `wait`'s `waitSeconds`), in seconds. The service
513
+ * validates against the same bound. */
514
+ export const SANDBOX_MAX_WAIT_SECONDS = 300
515
+
516
+ async function execOrThrow(
517
+ exec: Exec,
518
+ cmd: string[],
519
+ cwd: string,
520
+ signal?: AbortSignal,
521
+ ): Promise<string> {
522
+ const result = await exec(cmd, { cwd, ...(signal === undefined ? {} : { signal }) })
523
+ if (result.exitCode !== 0) {
524
+ throw new Error(
525
+ `${cmd.join(' ')} exited ${result.exitCode}: ${result.stderr.trim() || result.stdout.trim()}`,
526
+ )
527
+ }
528
+ return result.stdout.trim()
529
+ }
530
+
531
+ function oneSha(output: string, label: string): string | null {
532
+ const lines = output.trim() === '' ? [] : output.trim().split(/\r?\n/)
533
+ if (lines.length === 0) return null
534
+ if (lines.length !== 1) throw new Error(`${label} returned more than one ref`)
535
+ const sha = lines[0]!.split(/\s+/)[0]!
536
+ if (!/^[0-9a-f]{40,64}$/i.test(sha)) throw new Error(`${label} returned an invalid commit id`)
537
+ return sha
538
+ }
539
+
540
+ function uploadPackPolicy(
541
+ origin: ReturnType<typeof cleanGithubOrigin>,
542
+ auth?: string,
543
+ ): NetworkPolicy {
544
+ if (auth === undefined) return 'allow-all'
545
+ return {
546
+ allow: {
547
+ '*': [],
548
+ [origin.host]: [
549
+ {
550
+ match: {
551
+ method: ['GET'],
552
+ path: { exact: `${origin.path}/info/refs` },
553
+ queryString: [{ key: { exact: 'service' }, value: { exact: 'git-upload-pack' } }],
554
+ },
555
+ transform: [{ headers: { authorization: auth } }],
556
+ },
557
+ {
558
+ match: { method: ['POST'], path: { exact: `${origin.path}/git-upload-pack` } },
559
+ transform: [{ headers: { authorization: auth } }],
560
+ },
561
+ ],
562
+ },
563
+ }
564
+ }
565
+
566
+ /** Default checkout root when the handle does not report its session cwd. */
567
+ const VERCEL_DEFAULT_CWD = '/vercel/sandbox'
568
+
569
+ /** Where the server checked out the git source: the session cwd plus the
570
+ * repository directory name. Never assume the image's working directory. */
571
+ export function sourceCheckoutPath(sandbox: VercelSandboxHandle, directory: string): string {
572
+ const root = (sandbox.cwd ?? '').trim().replace(/\/+$/, '') || VERCEL_DEFAULT_CWD
573
+ return `${root}/${directory}`
574
+ }
575
+
576
+ /**
577
+ * Move the server's checkout to the adapter-owned workspace path. The
578
+ * workspace parent is created first: the universal image has no
579
+ * `/vercel/sandbox` directory of its own.
580
+ */
581
+ async function relocateCheckout(
582
+ sandbox: VercelSandboxHandle,
583
+ directory: string,
584
+ signal?: AbortSignal,
585
+ ): Promise<void> {
586
+ const sourcePath = sourceCheckoutPath(sandbox, directory)
587
+ try {
588
+ await commandOrThrow(sandbox, {
589
+ cmd: 'sh',
590
+ args: [
591
+ '-c',
592
+ 'mkdir -p "$(dirname "$2")" && mv "$1" "$2"',
593
+ 'relocate',
594
+ sourcePath,
595
+ VERCEL_WORKSPACE_PATH,
596
+ ],
597
+ ...(signal === undefined ? {} : { signal }),
598
+ })
599
+ } catch (error) {
600
+ throw new Error(
601
+ `vercel-sandbox could not move the checkout ${sourcePath} to ${VERCEL_WORKSPACE_PATH}: ${
602
+ error instanceof Error ? error.message : String(error)
603
+ }`,
604
+ { cause: error },
605
+ )
606
+ }
607
+ }
608
+
609
+ async function commandOrThrow(
610
+ sandbox: VercelSandboxHandle,
611
+ params: {
612
+ cmd: string
613
+ args?: string[]
614
+ cwd?: string
615
+ env?: Record<string, string>
616
+ sudo?: boolean
617
+ signal?: AbortSignal
618
+ },
619
+ ): Promise<void> {
620
+ const result = await sandbox.runCommand(params)
621
+ if (result.exitCode === null || result.exitCode === undefined) {
622
+ throw new Error(`sandbox command ${params.cmd} did not return an exit status`)
623
+ }
624
+ if (result.exitCode !== 0)
625
+ throw new Error(`sandbox command ${params.cmd} exited ${result.exitCode}`)
626
+ }
627
+
628
+ function bunProvisioningError(image: string, operation: string, error: unknown): Error {
629
+ return new Error(
630
+ `vercel-sandbox could not ${operation} Bun ${VERCEL_BUN_VERSION} on image ${JSON.stringify(image)}; Autobuild supports the universal managed image with working Node/npm, shell, filesystem, and package-registry access`,
631
+ { cause: error },
632
+ )
633
+ }
634
+
635
+ async function provisionBun(
636
+ sandbox: VercelSandboxHandle,
637
+ image: string,
638
+ signal?: AbortSignal,
639
+ ): Promise<void> {
640
+ try {
641
+ await commandOrThrow(sandbox, {
642
+ cmd: 'npm',
643
+ args: ['install', '--prefix', VERCEL_BUN_PREFIX, '--no-save', `bun@${VERCEL_BUN_VERSION}`],
644
+ ...(signal === undefined ? {} : { signal }),
645
+ })
646
+ } catch (error) {
647
+ throw bunProvisioningError(image, 'provision', error)
648
+ }
649
+ try {
650
+ await commandOrThrow(sandbox, {
651
+ cmd: VERCEL_BUN_EXECUTABLE,
652
+ args: ['--version'],
653
+ ...(signal === undefined ? {} : { signal }),
654
+ })
655
+ } catch (error) {
656
+ throw bunProvisioningError(image, 'verify provisioned', error)
657
+ }
658
+ }
659
+
660
+ function runtimeEnvironment(
661
+ config: VercelSandboxConfig,
662
+ env: Record<string, string | undefined>,
663
+ ): Record<string, string> {
664
+ return Object.fromEntries(
665
+ config.environmentVariables.map((name) => [name, requireVercelEnvironmentValue(env, name)]),
666
+ )
667
+ }
668
+
669
+ function redactRuntimeError(error: unknown, env: Record<string, string>): string {
670
+ let detail = error instanceof Error ? error.message : String(error)
671
+ for (const value of Object.values(env).sort((a, b) => b.length - a.length)) {
672
+ if (value !== '') detail = detail.split(value).join('[REDACTED]')
673
+ }
674
+ return detail
675
+ }
676
+
677
+ /** Bootstrap the workspace runtime references with the build path's
678
+ * install/preflight chain. `env` is the already-resolved guest environment:
679
+ * callers decide which names a guest may see — the build and harvest paths
680
+ * forward `[workspace.config].environmentVariables`, while the operator
681
+ * sandbox passes its credential-free forwarded set only (build-workspace
682
+ * variables never enter a sandbox). */
683
+ async function bootstrapRuntimes(
684
+ sandbox: VercelSandboxHandle,
685
+ config: VercelSandboxConfig,
686
+ env: Record<string, string>,
687
+ references: readonly RuntimeReferenceGroup[],
688
+ install: boolean,
689
+ signal?: AbortSignal,
690
+ ): Promise<void> {
691
+ for (const group of [...references].sort((left, right) =>
692
+ left.runtime < right.runtime ? -1 : left.runtime > right.runtime ? 1 : 0,
693
+ )) {
694
+ const provisioning = config.runtimeProvisioning?.[group.runtime]
695
+ if (provisioning === undefined) {
696
+ throw new Error(
697
+ `runtime ${displayName(group.runtime)} selected by ${group.references.join(', ')} has no provisioning; add [workspace.config.runtimeProvisioning.${tomlKey(group.runtime)}] with install and preflight commands`,
698
+ )
699
+ }
700
+ for (const stage of install ? (['install', 'preflight'] as const) : (['preflight'] as const)) {
701
+ try {
702
+ await readableCommand(sandbox, {
703
+ cmd: 'sh',
704
+ args: ['-c', provisioning[stage]],
705
+ cwd: VERCEL_WORKSPACE_PATH,
706
+ env,
707
+ ...(signal === undefined ? {} : { signal }),
708
+ })
709
+ } catch (error) {
710
+ throw new Error(
711
+ `runtime ${displayName(group.runtime)} ${stage} failed (selected by ${group.references.join(', ')}); fix workspace.config.runtimeProvisioning.${group.runtime}.${stage}: ${redactRuntimeError(error, env)}`,
712
+ )
713
+ }
714
+ }
715
+ }
716
+ }
717
+
718
+ async function runSystemProvisioning(
719
+ sandbox: VercelSandboxHandle,
720
+ steps: readonly VercelProvisioningStep[],
721
+ signal?: AbortSignal,
722
+ ): Promise<string[]> {
723
+ const completed: string[] = []
724
+ for (const step of steps) {
725
+ const result = (await sandbox.runCommand({
726
+ cmd: 'sh',
727
+ args: ['-c', step.command],
728
+ cwd: VERCEL_WORKSPACE_PATH,
729
+ sudo: true,
730
+ ...(signal === undefined ? {} : { signal }),
731
+ })) as VercelCommand
732
+ if (result.exitCode !== 0) {
733
+ const [stdout, stderr] = await Promise.all([
734
+ result.stdout?.() ?? Promise.resolve(''),
735
+ result.stderr?.() ?? Promise.resolve(''),
736
+ ])
737
+ throw new Error(
738
+ `system provisioning step ${JSON.stringify(step.name)} failed\n` +
739
+ `command: ${step.command}\n` +
740
+ `exit status: ${result.exitCode ?? '(missing)'}\n` +
741
+ `stdout:\n${stdout.trim() === '' ? '(empty)' : stdout}\n` +
742
+ `stderr:\n${stderr.trim() === '' ? '(empty)' : stderr}\n` +
743
+ 'remediation: fix [workspace.config].provisioning and rerun ab init --validate',
744
+ )
745
+ }
746
+ completed.push(step.name)
747
+ }
748
+ return completed
749
+ }
750
+
751
+ async function preflightBun(sandbox: VercelSandboxHandle, image: string): Promise<void> {
752
+ try {
753
+ await commandOrThrow(sandbox, { cmd: VERCEL_BUN_EXECUTABLE, args: ['--version'] })
754
+ } catch (error) {
755
+ throw new Error(
756
+ `vercel-sandbox Bun ${VERCEL_BUN_VERSION} preflight failed on image ${JSON.stringify(image)}; release and reprovision this sandbox before retrying the build`,
757
+ { cause: error },
758
+ )
759
+ }
760
+ }
761
+
762
+ export async function packageAutobuildDistribution(): Promise<Uint8Array> {
763
+ const destination = await mkdtemp(join(tmpdir(), 'autobuild-pack-'))
764
+ try {
765
+ await execOrThrow(
766
+ spawnExec,
767
+ ['bun', 'pm', 'pack', '--ignore-scripts', '--destination', destination],
768
+ distributionRoot(),
769
+ )
770
+ const archives = (await readdir(destination)).filter((name) => name.endsWith('.tgz'))
771
+ if (archives.length !== 1)
772
+ throw new Error('Autobuild packaging did not produce exactly one archive')
773
+ return new Uint8Array(await readFile(join(destination, archives[0]!)))
774
+ } finally {
775
+ await rm(destination, { recursive: true, force: true })
776
+ }
777
+ }
778
+
779
+ export interface VercelReadinessOptions {
780
+ config: VercelSandboxConfig
781
+ env: Record<string, string | undefined>
782
+ storeRef: string
783
+ storeToken: string
784
+ repo: string
785
+ baseBranch: string
786
+ facade?: VercelSandboxFacade
787
+ exec?: Exec
788
+ packageArchive?: () => Promise<Uint8Array>
789
+ runtimeReferences?: readonly RuntimeReferenceGroup[]
790
+ signal?: AbortSignal
791
+ /** Called as soon as the fresh sandbox has an identity, before bootstrap. */
792
+ onSandbox?: (name: string) => void
793
+ }
794
+
795
+ export interface VercelReadinessResult {
796
+ sandbox: string
797
+ revision: string
798
+ origin: string
799
+ provisioning: string[]
800
+ output: string
801
+ /** Automatic snapshots deleted while releasing the disposable environment;
802
+ * zero proves the readiness check left no snapshot storage behind. */
803
+ snapshotsDeleted: number
804
+ }
805
+
806
+ async function readableCommand(
807
+ sandbox: VercelSandboxHandle,
808
+ params: {
809
+ cmd: string
810
+ args?: string[]
811
+ cwd?: string
812
+ env?: Record<string, string>
813
+ sudo?: boolean
814
+ signal?: AbortSignal
815
+ },
816
+ ): Promise<string> {
817
+ const result = (await sandbox.runCommand(params)) as VercelCommand
818
+ const stderr = result.stderr === undefined ? '' : await result.stderr()
819
+ const stdout = result.stdout === undefined ? '' : await result.stdout()
820
+ if (result.exitCode === null || result.exitCode === undefined) {
821
+ throw new Error(`sandbox command ${params.cmd} did not return an exit status`)
822
+ }
823
+ if (result.exitCode !== 0) {
824
+ throw new Error(
825
+ `sandbox command ${params.cmd} exited ${result.exitCode}: ${stderr.trim() || stdout.trim() || '(no output)'}`,
826
+ )
827
+ }
828
+ return stdout
829
+ }
830
+
831
+ /** Create, inspect, and always delete one remote readiness environment. Unlike
832
+ * build provisioning this never uses a stable name, branch, or marker. */
833
+ export async function validateVercelSandbox(
834
+ options: VercelReadinessOptions,
835
+ ): Promise<VercelReadinessResult> {
836
+ const config = vercelSandboxConfigSchema.parse(options.config)
837
+ if (!/^https:\/\//i.test(options.storeRef) || options.storeToken === '') {
838
+ throw new Error(
839
+ 'remote validation requires an HTTPS AB_STORE and nonempty AB_TOKEN; configure a remotely reachable hosted Store',
840
+ )
841
+ }
842
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error('validation cancelled')
843
+ const exec = options.exec ?? spawnExec
844
+ const rawOrigin = await execOrThrow(
845
+ exec,
846
+ ['git', 'remote', 'get-url', 'origin'],
847
+ options.repo,
848
+ options.signal,
849
+ )
850
+ const origin = cleanGithubOrigin(rawOrigin)
851
+ const revision = oneSha(
852
+ await execOrThrow(
853
+ exec,
854
+ ['git', 'ls-remote', '--heads', 'origin', `refs/heads/${options.baseBranch}`],
855
+ options.repo,
856
+ options.signal,
857
+ ),
858
+ `remote base ${options.baseBranch}`,
859
+ )
860
+ if (revision === null) {
861
+ throw new Error(
862
+ `remote base ${options.baseBranch} does not exist; commit and push the setup before validating`,
863
+ )
864
+ }
865
+ const username =
866
+ config.gitUsernameEnv === undefined
867
+ ? undefined
868
+ : requireVercelEnvironmentValue(options.env, config.gitUsernameEnv)
869
+ const password =
870
+ config.gitPasswordEnv === undefined
871
+ ? undefined
872
+ : requireVercelEnvironmentValue(options.env, config.gitPasswordEnv)
873
+ const facade = options.facade ?? createVercelSdkFacade(options.env)
874
+ if (facade.createFresh === undefined) {
875
+ throw new Error('the configured Vercel SDK facade does not support fresh validation sandboxes')
876
+ }
877
+ const sandbox = await facade.createFresh({
878
+ source: {
879
+ type: 'git',
880
+ url: origin.url,
881
+ revision,
882
+ ...(username !== undefined && password !== undefined ? { username, password } : {}),
883
+ },
884
+ image: config.image,
885
+ resources: { vcpus: config.vcpus },
886
+ timeout: config.timeoutSeconds * 1000,
887
+ ...(config.region !== undefined ? { region: config.region } : {}),
888
+ ...(config.failoverRegions.length > 0 ? { failoverRegions: config.failoverRegions } : {}),
889
+ ...(config.snapshotExpirationSeconds === undefined
890
+ ? {}
891
+ : { snapshotExpiration: config.snapshotExpirationSeconds * 1000 }),
892
+ networkPolicy: 'allow-all',
893
+ keepLastSnapshots: { count: VERCEL_KEEP_LAST_SNAPSHOTS, deleteEvicted: true },
894
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
895
+ })
896
+ const name = sandbox.name
897
+ const withSignal = <T extends { cmd: string }>(params: T): T & { signal?: AbortSignal } => ({
898
+ ...params,
899
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
900
+ })
901
+ const checkCancellation = (): void => {
902
+ if (options.signal?.aborted)
903
+ throw options.signal.reason ?? new Error(`validation cancelled; releasing sandbox ${name}`)
904
+ }
905
+ let failure: unknown
906
+ let readiness: VercelReadinessResult | undefined
907
+ try {
908
+ options.onSandbox?.(name)
909
+ checkCancellation()
910
+ await relocateCheckout(sandbox, origin.directory, options.signal)
911
+ await commandOrThrow(
912
+ sandbox,
913
+ withSignal({
914
+ cmd: 'git',
915
+ args: ['remote', 'set-url', 'origin', origin.url],
916
+ cwd: VERCEL_WORKSPACE_PATH,
917
+ }),
918
+ )
919
+ for (const key of ['credential.helper', 'http.extraheader', `http.${origin.url}.extraheader`]) {
920
+ const result = await sandbox.runCommand(
921
+ withSignal({
922
+ cmd: 'git',
923
+ args: ['config', '--local', '--unset-all', key],
924
+ cwd: VERCEL_WORKSPACE_PATH,
925
+ }),
926
+ )
927
+ if (result.exitCode !== 0 && result.exitCode !== 5) {
928
+ throw new Error(`failed to scrub git config ${key}`)
929
+ }
930
+ }
931
+ await provisionBun(sandbox, config.image, options.signal)
932
+ const provisioning = await runSystemProvisioning(sandbox, config.provisioning, options.signal)
933
+ const archive = await (options.packageArchive ?? defaultDistributionArchive)()
934
+ checkCancellation()
935
+ await sandbox.writeFiles([{ path: '/tmp/autobuild.tgz', content: archive }], {
936
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
937
+ })
938
+ checkCancellation()
939
+ await commandOrThrow(sandbox, withSignal({ cmd: 'mkdir', args: ['-p', VERCEL_AUTOBUILD_PATH] }))
940
+ await commandOrThrow(
941
+ sandbox,
942
+ withSignal({
943
+ cmd: 'tar',
944
+ args: ['-xzf', '/tmp/autobuild.tgz', '--strip-components=1', '-C', VERCEL_AUTOBUILD_PATH],
945
+ }),
946
+ )
947
+ await commandOrThrow(
948
+ sandbox,
949
+ withSignal({
950
+ cmd: VERCEL_BUN_EXECUTABLE,
951
+ args: ['install', '--production', '--ignore-scripts'],
952
+ cwd: VERCEL_AUTOBUILD_PATH,
953
+ }),
954
+ )
955
+ await commandOrThrow(
956
+ sandbox,
957
+ withSignal({
958
+ cmd: 'sh',
959
+ args: [
960
+ '-c',
961
+ `if [ -f bun.lock ] || [ -f bun.lockb ]; then ${VERCEL_BUN_EXECUTABLE} install --frozen-lockfile; elif [ -f package-lock.json ]; then npm ci; elif [ -f pnpm-lock.yaml ]; then corepack pnpm install --frozen-lockfile; elif [ -f yarn.lock ]; then corepack yarn install --immutable; fi`,
962
+ ],
963
+ cwd: VERCEL_WORKSPACE_PATH,
964
+ }),
965
+ )
966
+ await bootstrapRuntimes(
967
+ sandbox,
968
+ config,
969
+ runtimeEnvironment(config, options.env),
970
+ options.runtimeReferences ?? [],
971
+ true,
972
+ options.signal,
973
+ )
974
+ const setup = await readableCommand(
975
+ sandbox,
976
+ withSignal({
977
+ cmd: 'sh',
978
+ args: [
979
+ '-c',
980
+ `PATH=${VERCEL_BUN_BIN_PATH}:$PATH exec ${VERCEL_BUN_EXECUTABLE} ${VERCEL_AUTOBUILD_PATH}/bin/ab-init-probe.ts`,
981
+ ],
982
+ cwd: VERCEL_WORKSPACE_PATH,
983
+ env: Object.fromEntries([
984
+ ['AB_STORE', options.storeRef],
985
+ ['AB_TOKEN', options.storeToken],
986
+ ...config.environmentVariables.map((name) => [
987
+ name,
988
+ requireVercelEnvironmentValue(options.env, name),
989
+ ]),
990
+ ]),
991
+ }),
992
+ )
993
+ readiness = {
994
+ sandbox: name,
995
+ revision,
996
+ origin: origin.url,
997
+ provisioning,
998
+ output: setup,
999
+ snapshotsDeleted: 0,
1000
+ }
1001
+ } catch (error) {
1002
+ failure = error
1003
+ }
1004
+ // Release with zero snapshot storage left behind. Stop first: it ends the
1005
+ // session deterministically, so no new snapshot can appear after the purge.
1006
+ // Deleting a running sandbox instead stops its session and thereby creates
1007
+ // exactly the orphan this sequence exists to prevent. A purge failure is a
1008
+ // release failure and surfaces the same manual-remediation guidance.
1009
+ const releaseErrors: unknown[] = []
1010
+ let snapshotsDeleted = 0
1011
+ try {
1012
+ await sandbox.stop()
1013
+ } catch (stopError) {
1014
+ releaseErrors.push(stopError)
1015
+ }
1016
+ for (const pass of ['pre-delete', 'post-delete'] as const) {
1017
+ const purged = await purgeEnvironmentSnapshots(facade, name)
1018
+ if (purged.outcome === 'unknown') {
1019
+ releaseErrors.push(
1020
+ new Error(
1021
+ `disposable sandbox ${name} ${pass} snapshot purge did not confirm: ${purged.error ?? 'unknown outcome'}`,
1022
+ ),
1023
+ )
1024
+ }
1025
+ snapshotsDeleted += purged.deleted
1026
+ if (pass === 'pre-delete') {
1027
+ try {
1028
+ await sandbox.delete()
1029
+ } catch (deleteError) {
1030
+ releaseErrors.push(deleteError)
1031
+ }
1032
+ }
1033
+ }
1034
+ if (releaseErrors.length > 0) {
1035
+ const guidance =
1036
+ `disposable sandbox ${name} could not be fully released; ` +
1037
+ `delete it manually in the Vercel dashboard or with: vercel sandbox rm ${name}, ` +
1038
+ 'and delete any snapshots remaining under its name (they keep incurring storage ' +
1039
+ 'until deleted or expired)'
1040
+ if (failure !== undefined) throw new AggregateError([failure, ...releaseErrors], guidance)
1041
+ if (releaseErrors.length === 1) {
1042
+ throw new Error(guidance, { cause: releaseErrors[0] })
1043
+ }
1044
+ throw new AggregateError(releaseErrors, guidance)
1045
+ }
1046
+ if (failure !== undefined) {
1047
+ throw new Error(
1048
+ `disposable sandbox ${name} was deleted after validation failed: ${failure instanceof Error ? failure.message : String(failure)}`,
1049
+ { cause: failure },
1050
+ )
1051
+ }
1052
+ return { ...readiness!, snapshotsDeleted }
1053
+ }
1054
+
1055
+ export type RuntimeReferencesSource =
1056
+ | readonly RuntimeReferenceGroup[]
1057
+ | (() => readonly RuntimeReferenceGroup[])
1058
+
1059
+ function currentRuntimeReferences(
1060
+ source: RuntimeReferencesSource | undefined,
1061
+ ): readonly RuntimeReferenceGroup[] {
1062
+ if (source === undefined) return []
1063
+ return typeof source === 'function' ? source() : source
1064
+ }
1065
+
1066
+ /** Install the distribution archive into `/opt/autobuild` and record its
1067
+ * version marker. Shared by fresh provisioning and reuse-path refreshes; the
1068
+ * readback comparison guards a silently failed write or extraction. */
1069
+ async function installDistribution(
1070
+ sandbox: VercelSandboxHandle,
1071
+ archive: Uint8Array,
1072
+ version: string,
1073
+ signal?: AbortSignal,
1074
+ ): Promise<void> {
1075
+ await sandbox.writeFiles([{ path: '/tmp/autobuild.tgz', content: archive }], {
1076
+ ...(signal === undefined ? {} : { signal }),
1077
+ })
1078
+ await commandOrThrow(sandbox, {
1079
+ cmd: 'mkdir',
1080
+ args: ['-p', VERCEL_AUTOBUILD_PATH],
1081
+ ...(signal === undefined ? {} : { signal }),
1082
+ })
1083
+ await commandOrThrow(sandbox, {
1084
+ cmd: 'tar',
1085
+ args: ['-xzf', '/tmp/autobuild.tgz', '--strip-components=1', '-C', VERCEL_AUTOBUILD_PATH],
1086
+ ...(signal === undefined ? {} : { signal }),
1087
+ })
1088
+ await commandOrThrow(sandbox, {
1089
+ cmd: VERCEL_BUN_EXECUTABLE,
1090
+ args: ['install', '--production', '--ignore-scripts'],
1091
+ cwd: VERCEL_AUTOBUILD_PATH,
1092
+ ...(signal === undefined ? {} : { signal }),
1093
+ })
1094
+ await writeDistributionVersionMarker(sandbox, version, signal)
1095
+ }
1096
+
1097
+ async function writeDistributionVersionMarker(
1098
+ sandbox: VercelSandboxHandle,
1099
+ version: string,
1100
+ signal?: AbortSignal,
1101
+ ): Promise<void> {
1102
+ await commandOrThrow(sandbox, {
1103
+ cmd: 'sh',
1104
+ args: [
1105
+ '-c',
1106
+ 'printf %s "$1" > "$2"',
1107
+ 'distribution-version',
1108
+ version,
1109
+ VERCEL_DISTRIBUTION_VERSION_MARKER,
1110
+ ],
1111
+ ...(signal === undefined ? {} : { signal }),
1112
+ })
1113
+ const readback = await readableCommand(sandbox, {
1114
+ cmd: 'cat',
1115
+ args: [VERCEL_DISTRIBUTION_VERSION_MARKER],
1116
+ ...(signal === undefined ? {} : { signal }),
1117
+ })
1118
+ if (readback.trim() !== version) {
1119
+ throw new Error(
1120
+ `distribution version marker readback mismatch in ${VERCEL_DISTRIBUTION_VERSION_MARKER}: ` +
1121
+ `wrote ${JSON.stringify(version)} but read ${JSON.stringify(readback.trim())}`,
1122
+ )
1123
+ }
1124
+ }
1125
+
1126
+ /** The installed distribution's version, or `undefined` when the marker is
1127
+ * unreadable — which includes every guest provisioned before the marker
1128
+ * existed and is treated as a version mismatch by the reuse path. */
1129
+ async function readDistributionVersionMarker(
1130
+ sandbox: VercelSandboxHandle,
1131
+ signal?: AbortSignal,
1132
+ ): Promise<string | undefined> {
1133
+ try {
1134
+ const content = await readableCommand(sandbox, {
1135
+ cmd: 'cat',
1136
+ args: [VERCEL_DISTRIBUTION_VERSION_MARKER],
1137
+ ...(signal === undefined ? {} : { signal }),
1138
+ })
1139
+ const trimmed = content.trim()
1140
+ return trimmed === '' ? undefined : trimmed
1141
+ } catch {
1142
+ return undefined
1143
+ }
1144
+ }
1145
+
1146
+ export interface VercelSandboxProviderOptions {
1147
+ config: VercelSandboxConfig
1148
+ env: Record<string, string | undefined>
1149
+ storeRef: string
1150
+ storeToken: string
1151
+ /** Dispatcher-local main checkout, used only for origin discovery and verification.
1152
+ * With the injected `origin`/`remoteBranchHead` seams (checkout-less origin
1153
+ * mode) it is never touched on the host. */
1154
+ repo: string
1155
+ facade?: VercelSandboxFacade
1156
+ exec?: Exec
1157
+ packageArchive?: () => Promise<Uint8Array>
1158
+ runtimeReferences?: RuntimeReferencesSource
1159
+ /** Checkout-less seam: the repository's HTTPS GitHub origin. Default reads
1160
+ * `git remote get-url origin` from `repo` on the host. */
1161
+ origin?: () => Promise<string>
1162
+ /** Checkout-less seam: the remote branch's current head, `undefined` when
1163
+ * the branch does not exist. Default runs `git ls-remote` from `repo` on
1164
+ * the host. */
1165
+ remoteBranchHead?: (branch: string) => Promise<string | undefined>
1166
+ /** Test seam: the distribution version the current archive source delivers.
1167
+ * Default reads the running distribution's package.json (origin mode keys
1168
+ * the guest archive to that same version; source mode packs the same tree
1169
+ * readDistributionIdentity reads), so the two agree by construction. */
1170
+ distributionVersion?: () => Promise<string>
1171
+ /** Test seam: the bound on the observation path's wait on a command whose
1172
+ * lookup shows no exit. Defaults to `VERCEL_OBSERVE_WAIT_MS`. */
1173
+ observeWaitMs?: number
1174
+ /** The repository's raw `[commands].setup` shell string, run once at the end
1175
+ * of a fresh operator-sandbox provision (never on resume). Set once at
1176
+ * construction; never per call. */
1177
+ setupCommand?: string
1178
+ /** Names of non-secret host environment variables forwarded into operator
1179
+ * sandbox commands (provisioning setup, exec, and start alike). The guest
1180
+ * environment is otherwise always built from an empty record — never the
1181
+ * process environment — so no credential can leak in. */
1182
+ sandboxEnvironmentVariables?: readonly string[]
1183
+ }
1184
+
1185
+ /** Vercel-backed working copy and executor. Completed SDK command output is
1186
+ * read only for readiness/validation reporting and declared system-provisioning
1187
+ * failure diagnostics, including failures during durable build provisioning. It
1188
+ * is not a durable build-state channel; scoped Store facts/events remain the
1189
+ * authoritative build-state channel. */
1190
+ export class VercelSandboxProvider implements WorkspaceProvider {
1191
+ readonly name = 'vercel-sandbox'
1192
+ readonly buildExecution: BuildExecution
1193
+ readonly harvestExecution: HarvestExecution
1194
+ readonly orchestratorSandbox: OperatorSandboxExecution
1195
+ readonly publication
1196
+ readonly recovery
1197
+ private readonly facade: VercelSandboxFacade
1198
+ private readonly exec: Exec
1199
+ private readonly active = new Set<string>()
1200
+ /** A failed stop leaves execution state unknown until a later stop/delete succeeds. */
1201
+ private readonly uncertain = new Set<string>()
1202
+ private readonly origins = new Map<string, ReturnType<typeof cleanGithubOrigin>>()
1203
+ private readonly sessions = new Map<string, VercelSandboxHandle>()
1204
+ private readonly observeWaitMs: number
1205
+
1206
+ constructor(private readonly options: VercelSandboxProviderOptions) {
1207
+ if (!/^https:\/\//i.test(options.storeRef) || options.storeToken === '') {
1208
+ throw new Error('vercel-sandbox requires an HTTPS BuildStore and scoped AB_TOKEN authority')
1209
+ }
1210
+ this.facade = options.facade ?? createVercelSdkFacade(options.env)
1211
+ this.exec = options.exec ?? spawnExec
1212
+ this.observeWaitMs = options.observeWaitMs ?? VERCEL_OBSERVE_WAIT_MS
1213
+ this.buildExecution = {
1214
+ start: (input) => this.start(input),
1215
+ observe: (identity) => this.observeExecution(identity),
1216
+ }
1217
+ this.harvestExecution = {
1218
+ start: (input) => this.startHarvestExecution(input),
1219
+ observe: (identity) => this.observeExecution(identity),
1220
+ }
1221
+ this.orchestratorSandbox = {
1222
+ describe: (input) => this.describeOperatorSandbox(input),
1223
+ ensure: (input) => this.ensureOperatorSandbox(input),
1224
+ exec: (handle, request) => this.sandboxExec(handle, request),
1225
+ start: (handle, request) => this.sandboxStart(handle, request),
1226
+ wait: (handle, input) => this.sandboxWait(handle, input),
1227
+ readFile: (handle, path) => this.sandboxReadFile(handle, path),
1228
+ writeFile: (handle, path, content) => this.sandboxWriteFile(handle, path, content),
1229
+ stop: (input) => this.sandboxStop(input),
1230
+ release: (input) => this.sandboxRelease(input),
1231
+ }
1232
+ this.recovery = {
1233
+ reap: (handle: WorkspaceHandle) => {
1234
+ if (handle.provider !== this.name) {
1235
+ throw new Error(
1236
+ `vercel-sandbox cannot reap a workspace owned by provider "${handle.provider}" (${handle.ref})`,
1237
+ )
1238
+ }
1239
+ return this.reap(handle.ref)
1240
+ },
1241
+ }
1242
+ this.publication = {
1243
+ isPublished: (input: { sha: string; branch: string }) => this.isPublished(input),
1244
+ publish: (input: { ref: string; sha: string; branch: string }) => this.publish(input),
1245
+ }
1246
+ }
1247
+
1248
+ async provision(opts: {
1249
+ repo: string
1250
+ baseBranch: string
1251
+ branch: string
1252
+ revision?: string
1253
+ generation?: number
1254
+ }): Promise<WorkspaceProvisionResult> {
1255
+ const rawOrigin = await this.origin()
1256
+ const origin = cleanGithubOrigin(rawOrigin)
1257
+ const name = sandboxName(origin.url, opts.branch, opts.generation)
1258
+ // Resolved once, before the create/reuse branch: the version the current
1259
+ // archive source would deliver, against which both a fresh install's
1260
+ // marker and a reused sandbox's marker are compared.
1261
+ const distributionVersion = await (
1262
+ this.options.distributionVersion ?? readDistributionIdentity
1263
+ )()
1264
+ let sandbox = await this.facade.get(name, this.operationSignal())
1265
+ const existing = await this.remoteBranchHead(opts.branch, `remote branch ${opts.branch}`)
1266
+ const base =
1267
+ existing ??
1268
+ opts.revision ??
1269
+ (await this.remoteBranchHead(opts.baseBranch, `remote base ${opts.baseBranch}`))
1270
+ if (base === null)
1271
+ throw new Error(
1272
+ `remote branch ${existing === null ? opts.baseBranch : opts.branch} does not exist`,
1273
+ )
1274
+
1275
+ if (sandbox !== null) {
1276
+ const marker = await sandbox.runCommand({
1277
+ cmd: 'test',
1278
+ args: ['-f', VERCEL_PROVISIONED_MARKER],
1279
+ })
1280
+ if (marker.exitCode === 0) {
1281
+ const installed = await readDistributionVersionMarker(sandbox, this.operationSignal())
1282
+ if (installed === distributionVersion) {
1283
+ await sandbox.stop({ signal: this.operationSignal() })
1284
+ } else {
1285
+ // A completed sandbox whose installed distribution disagrees with
1286
+ // what the current system would deliver — including legacy guests
1287
+ // without a marker — is refreshed in place. Any refresh failure
1288
+ // deletes the sandbox so the next pass rematerializes cleanly.
1289
+ try {
1290
+ const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1291
+ await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1292
+ } catch (error) {
1293
+ try {
1294
+ this.sessions.set(name, sandbox)
1295
+ await this.reap(name)
1296
+ } catch (deleteError) {
1297
+ throw new AggregateError(
1298
+ [error, deleteError],
1299
+ `sandbox ${name} distribution refresh failed and its incomplete environment could not be confirmed deleted`,
1300
+ )
1301
+ }
1302
+ throw error
1303
+ }
1304
+ await sandbox.stop({ signal: this.operationSignal() })
1305
+ }
1306
+ } else {
1307
+ // A named VM without the marker is a crashed/legacy provisioning
1308
+ // attempt. Never expose its potentially unscrubbed checkout to agents.
1309
+ await sandbox.delete({ signal: this.operationSignal() })
1310
+ sandbox = null
1311
+ }
1312
+ }
1313
+
1314
+ if (sandbox === null) {
1315
+ const username =
1316
+ this.options.config.gitUsernameEnv === undefined
1317
+ ? undefined
1318
+ : requireVercelEnvironmentValue(this.options.env, this.options.config.gitUsernameEnv)
1319
+ const password =
1320
+ this.options.config.gitPasswordEnv === undefined
1321
+ ? undefined
1322
+ : requireVercelEnvironmentValue(this.options.env, this.options.config.gitPasswordEnv)
1323
+ const readAuth =
1324
+ password === undefined
1325
+ ? undefined
1326
+ : `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
1327
+ sandbox = await this.facade.create({
1328
+ name,
1329
+ source: {
1330
+ type: 'git',
1331
+ url: origin.url,
1332
+ revision: base,
1333
+ ...(username !== undefined && password !== undefined ? { username, password } : {}),
1334
+ },
1335
+ image: this.options.config.image,
1336
+ resources: { vcpus: this.options.config.vcpus },
1337
+ timeout: this.options.config.timeoutSeconds * 1000,
1338
+ persistent: true,
1339
+ ...(this.options.config.region !== undefined ? { region: this.options.config.region } : {}),
1340
+ ...(this.options.config.failoverRegions.length > 0
1341
+ ? { failoverRegions: this.options.config.failoverRegions }
1342
+ : {}),
1343
+ ...(this.options.config.snapshotExpirationSeconds === undefined
1344
+ ? {}
1345
+ : { snapshotExpiration: this.options.config.snapshotExpirationSeconds * 1000 }),
1346
+ networkPolicy: uploadPackPolicy(origin, readAuth),
1347
+ keepLastSnapshots: { count: VERCEL_KEEP_LAST_SNAPSHOTS, deleteEvicted: true },
1348
+ signal: this.operationSignal(),
1349
+ })
1350
+ try {
1351
+ await relocateCheckout(sandbox, origin.directory)
1352
+ await commandOrThrow(sandbox, {
1353
+ cmd: 'git',
1354
+ args: ['checkout', '-B', opts.branch, base],
1355
+ cwd: VERCEL_WORKSPACE_PATH,
1356
+ })
1357
+ await commandOrThrow(sandbox, {
1358
+ cmd: 'git',
1359
+ args: ['remote', 'set-url', 'origin', origin.url],
1360
+ cwd: VERCEL_WORKSPACE_PATH,
1361
+ })
1362
+ for (const key of [
1363
+ 'credential.helper',
1364
+ 'http.extraheader',
1365
+ `http.${origin.url}.extraheader`,
1366
+ ]) {
1367
+ const result = await sandbox.runCommand({
1368
+ cmd: 'git',
1369
+ args: ['config', '--local', '--unset-all', key],
1370
+ cwd: VERCEL_WORKSPACE_PATH,
1371
+ })
1372
+ if (result.exitCode !== 0 && result.exitCode !== 5)
1373
+ throw new Error(`failed to scrub git config ${key}`)
1374
+ }
1375
+ await provisionBun(sandbox, this.options.config.image)
1376
+ await runSystemProvisioning(sandbox, this.options.config.provisioning ?? [])
1377
+ const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1378
+ await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1379
+ // Repository dependencies precede branch-owned package plugin loading.
1380
+ // The fixed bootstrap supports the consuming repository's lockfile; its
1381
+ // configured setup command still runs at every runner attachment.
1382
+ await commandOrThrow(sandbox, {
1383
+ cmd: 'sh',
1384
+ args: [
1385
+ '-c',
1386
+ `if [ -f bun.lock ] || [ -f bun.lockb ]; then ${VERCEL_BUN_EXECUTABLE} install --frozen-lockfile; elif [ -f package-lock.json ]; then npm ci; elif [ -f pnpm-lock.yaml ]; then corepack pnpm install --frozen-lockfile; elif [ -f yarn.lock ]; then corepack yarn install --immutable; fi`,
1387
+ ],
1388
+ cwd: VERCEL_WORKSPACE_PATH,
1389
+ })
1390
+ await bootstrapRuntimes(
1391
+ sandbox,
1392
+ this.options.config,
1393
+ runtimeEnvironment(this.options.config, this.options.env),
1394
+ currentRuntimeReferences(this.options.runtimeReferences),
1395
+ true,
1396
+ )
1397
+ await commandOrThrow(sandbox, {
1398
+ cmd: 'touch',
1399
+ args: [VERCEL_PROVISIONED_MARKER],
1400
+ })
1401
+ await sandbox.stop({ signal: this.operationSignal() })
1402
+ } catch (error) {
1403
+ try {
1404
+ this.sessions.set(name, sandbox)
1405
+ await this.reap(name)
1406
+ } catch (deleteError) {
1407
+ throw new AggregateError(
1408
+ [error, deleteError],
1409
+ `sandbox ${name} setup failed and its incomplete environment could not be confirmed deleted`,
1410
+ )
1411
+ }
1412
+ throw error
1413
+ }
1414
+ }
1415
+ this.origins.set(name, origin)
1416
+ this.sessions.set(name, sandbox)
1417
+ return {
1418
+ provider: this.name,
1419
+ ref: name,
1420
+ path: VERCEL_WORKSPACE_PATH,
1421
+ branch: opts.branch,
1422
+ base: { source: existing === null ? 'remote' : 'existing', sha: base },
1423
+ }
1424
+ }
1425
+
1426
+ async release(handle: WorkspaceHandle): Promise<void> {
1427
+ if (this.active.has(handle.ref)) throw new Error(`cannot release active sandbox ${handle.ref}`)
1428
+ await this.reap(handle.ref)
1429
+ }
1430
+
1431
+ private operationSignal(): AbortSignal {
1432
+ return AbortSignal.timeout(this.options.config.operationTimeoutMs ?? 30_000)
1433
+ }
1434
+
1435
+ /** Stop/delete and then prove absence by exact deterministic name — for the
1436
+ * sandbox and, because deleting a sandbox never removes the automatic
1437
+ * snapshots its sessions created, for its snapshot storage too. */
1438
+ private async reap(ref: string): Promise<WorkspaceReapOutcome> {
1439
+ let sandbox = this.sessions.get(ref) ?? (await this.facade.get(ref, this.operationSignal()))
1440
+ if (sandbox === null) {
1441
+ // An environment can vanish without its snapshots; the purge still
1442
+ // applies and only a confirmed empty list may forget the reference.
1443
+ try {
1444
+ const snapshots = await this.purgeOrRetryable(ref)
1445
+ this.forget(ref)
1446
+ return { outcome: 'absent', snapshots }
1447
+ } catch (error) {
1448
+ throw this.retryableCleanup(ref, error)
1449
+ }
1450
+ }
1451
+ try {
1452
+ try {
1453
+ await sandbox.stop({ signal: this.operationSignal() })
1454
+ } catch (error) {
1455
+ // A persistent sandbox that was resumed once can wedge so that stop
1456
+ // never returns while delete still succeeds within seconds. Absence
1457
+ // is proven below either way, so a timed-out stop is not fatal.
1458
+ if (!isInterruptedLongPoll(error)) throw error
1459
+ }
1460
+ // Purge between stop and delete: the sandbox still exists, so the exact
1461
+ // name filter is unambiguous, and after the explicit stop no new
1462
+ // snapshot can be created.
1463
+ const purged = await this.purgeOrRetryable(ref)
1464
+ await sandbox.delete({ signal: this.operationSignal() })
1465
+ sandbox = await this.facade.get(ref, this.operationSignal())
1466
+ if (sandbox !== null)
1467
+ throw new Error(`sandbox ${ref} still exists after delete acknowledgement`)
1468
+ // The post-delete pass catches anything the delete itself created;
1469
+ // after the stop above it is normally empty.
1470
+ const postDelete = await this.purgeOrRetryable(ref)
1471
+ this.forget(ref)
1472
+ return {
1473
+ outcome: 'confirmed',
1474
+ snapshots: {
1475
+ outcome: 'confirmed',
1476
+ deleted: purged.deleted + postDelete.deleted,
1477
+ },
1478
+ }
1479
+ } catch (error) {
1480
+ this.uncertain.add(ref)
1481
+ this.sessions.delete(ref)
1482
+ throw this.retryableCleanup(ref, error)
1483
+ }
1484
+ }
1485
+
1486
+ /** Purge one environment's snapshots, converting an unknown outcome into
1487
+ * the retryable cleanup failure the dispatcher already retries. */
1488
+ private async purgeOrRetryable(ref: string): Promise<VercelSnapshotPurge> {
1489
+ const purged = await purgeEnvironmentSnapshots(this.facade, ref, this.operationSignal())
1490
+ if (purged.outcome === 'unknown') {
1491
+ throw new Error(
1492
+ `snapshot purge for ${JSON.stringify(ref)} is unconfirmed: ${purged.error ?? 'unknown outcome'}`,
1493
+ )
1494
+ }
1495
+ return purged
1496
+ }
1497
+
1498
+ private retryableCleanup(ref: string, error: unknown): Error {
1499
+ return new Error(
1500
+ `sandbox ${ref} cleanup outcome is unknown and remains retryable: ${error instanceof Error ? error.message : String(error)}`,
1501
+ { cause: error },
1502
+ )
1503
+ }
1504
+
1505
+ private forget(ref: string): void {
1506
+ this.active.delete(ref)
1507
+ this.uncertain.delete(ref)
1508
+ this.sessions.delete(ref)
1509
+ this.origins.delete(ref)
1510
+ }
1511
+
1512
+ private async normalNetworkPolicy(ref: string): Promise<{
1513
+ origin: ReturnType<typeof cleanGithubOrigin>
1514
+ policy: NetworkPolicy
1515
+ }> {
1516
+ const origin = this.origins.get(ref) ?? cleanGithubOrigin(await this.origin())
1517
+ this.origins.set(ref, origin)
1518
+ return {
1519
+ origin,
1520
+ policy: uploadPackPolicy(
1521
+ origin,
1522
+ this.options.config.gitPasswordEnv === undefined
1523
+ ? undefined
1524
+ : `Basic ${Buffer.from(`${requireVercelEnvironmentValue(this.options.env, this.options.config.gitUsernameEnv!)}:${requireVercelEnvironmentValue(this.options.env, this.options.config.gitPasswordEnv)}`).toString('base64')}`,
1525
+ ),
1526
+ }
1527
+ }
1528
+
1529
+ private async start(input: BuildExecutionStart): Promise<BuildExecutionHandle> {
1530
+ const ref = input.workspaceRef
1531
+ if (this.active.has(ref)) throw new Error(`sandbox ${ref} already has a live execution`)
1532
+ const sandbox = this.sessions.get(ref) ?? (await this.facade.get(ref, this.operationSignal()))
1533
+ if (sandbox === null) throw new Error(`sandbox ${ref} no longer exists`)
1534
+ // A prior publication restore may have failed. Reassert the
1535
+ // receive-pack-free policy before any guest command can run.
1536
+ const { policy } = await this.normalNetworkPolicy(ref)
1537
+ await sandbox.update({ networkPolicy: policy }, { signal: this.operationSignal() })
1538
+ if (this.uncertain.has(ref)) {
1539
+ // A prior wait/stop failure may have left agent code alive. Confirm a
1540
+ // stop before starting another runner in the same environment.
1541
+ await sandbox.stop({ signal: this.operationSignal() })
1542
+ this.uncertain.delete(ref)
1543
+ }
1544
+ await preflightBun(sandbox, this.options.config.image)
1545
+ await bootstrapRuntimes(
1546
+ sandbox,
1547
+ this.options.config,
1548
+ runtimeEnvironment(this.options.config, this.options.env),
1549
+ currentRuntimeReferences(this.options.runtimeReferences),
1550
+ false,
1551
+ )
1552
+ this.sessions.set(ref, sandbox)
1553
+ const env: Record<string, string> = {
1554
+ AB_STORE: this.options.storeRef,
1555
+ AB_TOKEN: this.options.storeToken,
1556
+ [BUILD_RUNNER_OPTIONS_ENV]: JSON.stringify({
1557
+ ...input,
1558
+ supervision: { kind: 'environment' },
1559
+ }),
1560
+ }
1561
+ for (const name of this.options.config.environmentVariables)
1562
+ env[name] = requireVercelEnvironmentValue(this.options.env, name)
1563
+ const command = (await sandbox.runCommand({
1564
+ cmd: 'sh',
1565
+ args: [
1566
+ '-c',
1567
+ `PATH=${VERCEL_BUN_BIN_PATH}:$PATH exec ${VERCEL_BUN_EXECUTABLE} ${VERCEL_AUTOBUILD_PATH}/bin/ab-build-runner.ts`,
1568
+ ],
1569
+ cwd: VERCEL_WORKSPACE_PATH,
1570
+ env,
1571
+ detached: true,
1572
+ signal: this.operationSignal(),
1573
+ })) as VercelCommand
1574
+ return this.environmentSupervisedHandle(ref, sandbox, command)
1575
+ }
1576
+
1577
+ /** Shared post-launch supervision of one detached guest command: command
1578
+ * wait with lifetime-bounded long-poll retries, environment-owned teardown,
1579
+ * and the durable identity a later process re-observes. Used by both the
1580
+ * build path and the hosted harvest path. */
1581
+ private environmentSupervisedHandle(
1582
+ ref: string,
1583
+ sandbox: VercelSandboxHandle,
1584
+ command: VercelCommand,
1585
+ ): BuildExecutionHandle {
1586
+ this.active.add(ref)
1587
+ let environmentStop: Promise<void> | undefined
1588
+ const stopEnvironment = async (): Promise<void> => {
1589
+ environmentStop ??= (async () => {
1590
+ try {
1591
+ await sandbox.stop({ signal: this.operationSignal() })
1592
+ } catch (error) {
1593
+ // Do not retain a potentially expired/stale SDK handle. A later
1594
+ // execution must first re-resolve and confirm teardown.
1595
+ this.uncertain.add(ref)
1596
+ this.sessions.delete(ref)
1597
+ throw error
1598
+ } finally {
1599
+ this.active.delete(ref)
1600
+ }
1601
+ })()
1602
+ await environmentStop
1603
+ }
1604
+ let stopping: Promise<void> | undefined
1605
+ const stop = async () => {
1606
+ stopping ??= (async () => {
1607
+ try {
1608
+ await command.kill('SIGTERM', { abortSignal: this.operationSignal() })
1609
+ } catch {
1610
+ /* already exited */
1611
+ }
1612
+ await stopEnvironment()
1613
+ })()
1614
+ await stopping
1615
+ }
1616
+ // Retry interrupted long-polls only within the environment's own
1617
+ // lifetime (plus a margin for stop/snapshot); past that the command
1618
+ // cannot still be running and the failure is real.
1619
+ const lifetimeDeadline =
1620
+ Date.now() + this.options.config.timeoutSeconds * 1000 + VERCEL_LIFETIME_MARGIN_MS
1621
+ // Detach (teardown of the LOCAL supervision only) aborts this wait so the
1622
+ // process can exit while the guest keeps running and heartbeating its
1623
+ // lease. A later invocation settles the execution from the Store plus
1624
+ // provider liveness.
1625
+ const localWait = new AbortController()
1626
+ let detached = false
1627
+ const completion = waitForCommandExit(command, () => Date.now() > lifetimeDeadline, {
1628
+ signal: localWait.signal,
1629
+ }).then(
1630
+ async (result): Promise<BuildExecutionExit> => {
1631
+ if (detached) return { exitCode: result.exitCode }
1632
+ await stopEnvironment()
1633
+ return { exitCode: result.exitCode }
1634
+ },
1635
+ async (error): Promise<BuildExecutionExit> => {
1636
+ if (detached) return { exitCode: null }
1637
+ await stopEnvironment()
1638
+ throw error
1639
+ },
1640
+ )
1641
+ const sessionId = sandbox.currentSession?.().sessionId
1642
+ return {
1643
+ identity: {
1644
+ provider: this.name,
1645
+ workspaceRef: ref,
1646
+ environmentId: sandbox.name,
1647
+ ...(sessionId !== undefined ? { sessionId } : {}),
1648
+ ...(command.cmdId !== undefined && command.cmdId !== ''
1649
+ ? { commandId: command.cmdId }
1650
+ : {}),
1651
+ },
1652
+ supervision: 'environment',
1653
+ completion,
1654
+ stop: async () => {
1655
+ try {
1656
+ await stop()
1657
+ return { outcome: 'confirmed' }
1658
+ } catch (error) {
1659
+ return {
1660
+ outcome: 'unknown',
1661
+ error: error instanceof Error ? error.message : String(error),
1662
+ }
1663
+ }
1664
+ },
1665
+ detach: async () => {
1666
+ detached = true
1667
+ localWait.abort()
1668
+ },
1669
+ }
1670
+ }
1671
+
1672
+ /** Hosted harvest (AUT-305): provision (or reuse) the repository's one
1673
+ * disposable environment from the remote base head, launch the guest
1674
+ * harvest runner as one detached command, and supervise it exactly like a
1675
+ * build execution. Provisioning reuses the module-local build chain so a
1676
+ * harvest guest is identical to a build guest; release is the provider's
1677
+ * existing reap path (stop, snapshot purge, delete). */
1678
+ private async startHarvestExecution(input: HarvestExecutionStart): Promise<BuildExecutionHandle> {
1679
+ const origin = cleanGithubOrigin(await this.origin())
1680
+ const name = harvestSandboxName(origin.url)
1681
+ if (this.active.has(name)) throw new Error(`sandbox ${name} already has a live execution`)
1682
+ // Resolved once, before the create/reuse branch: the version the current
1683
+ // archive source would deliver, against which both a fresh install's
1684
+ // marker and a reused environment's marker are compared.
1685
+ const distributionVersion = await (
1686
+ this.options.distributionVersion ?? readDistributionIdentity
1687
+ )()
1688
+ let sandbox = await this.facade.get(name, this.operationSignal())
1689
+ if (sandbox !== null) {
1690
+ const marker = await sandbox.runCommand({
1691
+ cmd: 'test',
1692
+ args: ['-f', VERCEL_PROVISIONED_MARKER],
1693
+ })
1694
+ if (marker.exitCode !== 0) {
1695
+ // A named VM without the marker is a crashed/legacy provisioning
1696
+ // attempt — e.g. a host that died mid-provision. Never expose its
1697
+ // potentially unscrubbed checkout to agents.
1698
+ await sandbox.delete({ signal: this.operationSignal() })
1699
+ sandbox = null
1700
+ } else {
1701
+ // A completed environment left by an earlier (interrupted) run is
1702
+ // stopped and reused. A reused guest whose installed distribution
1703
+ // disagrees with what the current system would deliver is refreshed
1704
+ // in place, mirroring the build reuse path.
1705
+ const installed = await readDistributionVersionMarker(sandbox, this.operationSignal())
1706
+ if (installed !== distributionVersion) {
1707
+ try {
1708
+ const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1709
+ await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1710
+ } catch (error) {
1711
+ try {
1712
+ this.sessions.set(name, sandbox)
1713
+ await this.reap(name)
1714
+ } catch (deleteError) {
1715
+ throw new AggregateError(
1716
+ [error, deleteError],
1717
+ `harvest sandbox ${name} distribution refresh failed and its incomplete environment could not be confirmed deleted`,
1718
+ )
1719
+ }
1720
+ throw error
1721
+ }
1722
+ }
1723
+ await sandbox.stop({ signal: this.operationSignal() })
1724
+ }
1725
+ }
1726
+ if (sandbox === null) {
1727
+ const head = await this.remoteBranchHead(input.baseBranch, `remote base ${input.baseBranch}`)
1728
+ if (head === null) {
1729
+ throw new Error(`remote base ${input.baseBranch} does not exist`)
1730
+ }
1731
+ const username =
1732
+ this.options.config.gitUsernameEnv === undefined
1733
+ ? undefined
1734
+ : requireVercelEnvironmentValue(this.options.env, this.options.config.gitUsernameEnv)
1735
+ const password =
1736
+ this.options.config.gitPasswordEnv === undefined
1737
+ ? undefined
1738
+ : requireVercelEnvironmentValue(this.options.env, this.options.config.gitPasswordEnv)
1739
+ const readAuth =
1740
+ password === undefined
1741
+ ? undefined
1742
+ : `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
1743
+ sandbox = await this.facade.create({
1744
+ name,
1745
+ source: {
1746
+ type: 'git',
1747
+ url: origin.url,
1748
+ revision: head,
1749
+ ...(username !== undefined && password !== undefined ? { username, password } : {}),
1750
+ },
1751
+ image: this.options.config.image,
1752
+ resources: { vcpus: this.options.config.vcpus },
1753
+ timeout: this.options.config.timeoutSeconds * 1000,
1754
+ persistent: true,
1755
+ ...(this.options.config.region !== undefined ? { region: this.options.config.region } : {}),
1756
+ ...(this.options.config.failoverRegions.length > 0
1757
+ ? { failoverRegions: this.options.config.failoverRegions }
1758
+ : {}),
1759
+ ...(this.options.config.snapshotExpirationSeconds === undefined
1760
+ ? {}
1761
+ : { snapshotExpiration: this.options.config.snapshotExpirationSeconds * 1000 }),
1762
+ networkPolicy: uploadPackPolicy(origin, readAuth),
1763
+ keepLastSnapshots: { count: VERCEL_KEEP_LAST_SNAPSHOTS, deleteEvicted: true },
1764
+ signal: this.operationSignal(),
1765
+ })
1766
+ try {
1767
+ await relocateCheckout(sandbox, origin.directory)
1768
+ await commandOrThrow(sandbox, {
1769
+ cmd: 'git',
1770
+ args: ['remote', 'set-url', 'origin', origin.url],
1771
+ cwd: VERCEL_WORKSPACE_PATH,
1772
+ })
1773
+ for (const key of [
1774
+ 'credential.helper',
1775
+ 'http.extraheader',
1776
+ `http.${origin.url}.extraheader`,
1777
+ ]) {
1778
+ const result = await sandbox.runCommand({
1779
+ cmd: 'git',
1780
+ args: ['config', '--local', '--unset-all', key],
1781
+ cwd: VERCEL_WORKSPACE_PATH,
1782
+ })
1783
+ if (result.exitCode !== 0 && result.exitCode !== 5)
1784
+ throw new Error(`failed to scrub git config ${key}`)
1785
+ }
1786
+ await provisionBun(sandbox, this.options.config.image)
1787
+ await runSystemProvisioning(sandbox, this.options.config.provisioning ?? [])
1788
+ const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1789
+ await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1790
+ // Repository dependencies precede runtime preflight; the fixed
1791
+ // bootstrap is the same one build provisioning runs.
1792
+ await commandOrThrow(sandbox, {
1793
+ cmd: 'sh',
1794
+ args: [
1795
+ '-c',
1796
+ `if [ -f bun.lock ] || [ -f bun.lockb ]; then ${VERCEL_BUN_EXECUTABLE} install --frozen-lockfile; elif [ -f package-lock.json ]; then npm ci; elif [ -f pnpm-lock.yaml ]; then corepack pnpm install --frozen-lockfile; elif [ -f yarn.lock ]; then corepack yarn install --immutable; fi`,
1797
+ ],
1798
+ cwd: VERCEL_WORKSPACE_PATH,
1799
+ })
1800
+ await bootstrapRuntimes(
1801
+ sandbox,
1802
+ this.options.config,
1803
+ runtimeEnvironment(this.options.config, this.options.env),
1804
+ currentRuntimeReferences(this.options.runtimeReferences),
1805
+ true,
1806
+ )
1807
+ await commandOrThrow(sandbox, {
1808
+ cmd: 'touch',
1809
+ args: [VERCEL_PROVISIONED_MARKER],
1810
+ })
1811
+ await sandbox.stop({ signal: this.operationSignal() })
1812
+ } catch (error) {
1813
+ try {
1814
+ this.sessions.set(name, sandbox)
1815
+ await this.reap(name)
1816
+ } catch (deleteError) {
1817
+ throw new AggregateError(
1818
+ [error, deleteError],
1819
+ `harvest sandbox ${name} setup failed and its incomplete environment could not be confirmed deleted`,
1820
+ )
1821
+ }
1822
+ throw error
1823
+ }
1824
+ }
1825
+ this.origins.set(name, origin)
1826
+ // Launch: reassert the normal network policy and preflight exactly as the
1827
+ // build start path does, then one detached guest runner command.
1828
+ const { policy } = await this.normalNetworkPolicy(name)
1829
+ await sandbox.update({ networkPolicy: policy }, { signal: this.operationSignal() })
1830
+ if (this.uncertain.has(name)) {
1831
+ // A prior wait/stop failure may have left agent code alive. Confirm a
1832
+ // stop before starting another runner in the same environment.
1833
+ await sandbox.stop({ signal: this.operationSignal() })
1834
+ this.uncertain.delete(name)
1835
+ }
1836
+ await preflightBun(sandbox, this.options.config.image)
1837
+ await bootstrapRuntimes(
1838
+ sandbox,
1839
+ this.options.config,
1840
+ runtimeEnvironment(this.options.config, this.options.env),
1841
+ currentRuntimeReferences(this.options.runtimeReferences),
1842
+ false,
1843
+ )
1844
+ this.sessions.set(name, sandbox)
1845
+ const sessionId = sandbox.currentSession?.().sessionId
1846
+ const envelope: HarvestRunnerLaunch = {
1847
+ ...input,
1848
+ supervision: { kind: 'environment' },
1849
+ environment: {
1850
+ provider: this.name,
1851
+ environmentId: sandbox.name,
1852
+ ...(sessionId !== undefined ? { sessionId } : {}),
1853
+ },
1854
+ }
1855
+ const env: Record<string, string> = {
1856
+ AB_STORE: this.options.storeRef,
1857
+ AB_TOKEN: this.options.storeToken,
1858
+ [HARVEST_RUNNER_OPTIONS_ENV]: JSON.stringify(envelope),
1859
+ }
1860
+ for (const envName of this.options.config.environmentVariables)
1861
+ env[envName] = requireVercelEnvironmentValue(this.options.env, envName)
1862
+ const command = (await sandbox.runCommand({
1863
+ cmd: 'sh',
1864
+ args: [
1865
+ '-c',
1866
+ `PATH=${VERCEL_BUN_BIN_PATH}:$PATH exec ${VERCEL_BUN_EXECUTABLE} ${VERCEL_AUTOBUILD_PATH}/bin/ab-harvest-runner.ts`,
1867
+ ],
1868
+ cwd: VERCEL_WORKSPACE_PATH,
1869
+ env,
1870
+ detached: true,
1871
+ signal: this.operationSignal(),
1872
+ })) as VercelCommand
1873
+ return this.environmentSupervisedHandle(name, sandbox, command)
1874
+ }
1875
+
1876
+ /** Liveness of a previously recorded execution, from provider state alone:
1877
+ * one bounded facade.get, one command lookup, and, when the lookup shows no
1878
+ * exit, one bounded wait on that command. A missing sandbox, a session that
1879
+ * is no longer running, or a command the current (resumed) session no
1880
+ * longer knows is `lost`; a non-null exit code — from the lookup or from
1881
+ * the bounded wait it triggered — is `ended`; an interrupted or timed-out
1882
+ * wait leaves the execution `running`. The lifetime bound (session expiry
1883
+ * plus the shared stop/snapshot margin) runs before the lookup and fails
1884
+ * with the environment named instead of waiting. Unknown provider errors
1885
+ * propagate — callers treat an unresolvable observation as running and
1886
+ * never reap on it. */
1887
+ private async observeExecution(identity: BuildExecutionIdentity): Promise<ExecutionObservation> {
1888
+ if (identity.commandId === undefined) {
1889
+ throw new Error(
1890
+ `execution on ${identity.workspaceRef} has no recorded command id and cannot be re-observed`,
1891
+ )
1892
+ }
1893
+ const sandbox = await this.facade.get(identity.workspaceRef, this.operationSignal())
1894
+ if (sandbox === null) return { state: 'lost' }
1895
+ if (sandbox.sessionStatus !== undefined && sandbox.sessionStatus !== 'running') {
1896
+ return { state: 'lost' }
1897
+ }
1898
+ // Mirrors the command path's VM-lifetime bound: past the environment's
1899
+ // session expiry (plus the shared stop/snapshot margin) the execution
1900
+ // cannot still be running, so re-issuing the long-poll would only end in
1901
+ // a generic transport failure. Fail with the environment named instead.
1902
+ if (sandbox.sessionExpiresAt !== undefined) {
1903
+ const deadline = sandbox.sessionExpiresAt + VERCEL_LIFETIME_MARGIN_MS
1904
+ if (Date.now() > deadline) {
1905
+ throw new Error(
1906
+ `execution on sandbox ${identity.environmentId ?? identity.workspaceRef} cannot still be running: ` +
1907
+ `the environment expired — its session expiry (${new Date(sandbox.sessionExpiresAt).toISOString()}) plus ` +
1908
+ `the ${VERCEL_LIFETIME_MARGIN_MS} ms stop/snapshot margin passed at ${new Date(deadline).toISOString()}`,
1909
+ )
1910
+ }
1911
+ }
1912
+ try {
1913
+ const command = await sandbox.getCommand(identity.commandId, {
1914
+ signal: this.operationSignal(),
1915
+ })
1916
+ if (command.exitCode !== null) return { state: 'ended', exitCode: command.exitCode }
1917
+ // The provider's plain lookup reports no exit code for a command that
1918
+ // finished while no client was waiting on it, and keeps reporting none
1919
+ // until some client waits — so "null" alone proves nothing. One bounded
1920
+ // wait turns it into evidence: a resolved wait is a proved end, an
1921
+ // interrupted or timed-out wait leaves the guest unobserved (`running`),
1922
+ // and a missing sandbox is `lost`. Never re-issued here; the next tick
1923
+ // retries. The bound composes the provider operation signal so one
1924
+ // observation never approaches the tick's operation timeout.
1925
+ try {
1926
+ const finished = await command.wait({
1927
+ signal: AbortSignal.any([
1928
+ AbortSignal.timeout(this.observeWaitMs),
1929
+ this.operationSignal(),
1930
+ ]),
1931
+ })
1932
+ return { state: 'ended', exitCode: finished.exitCode }
1933
+ } catch (error) {
1934
+ if (isMissingVercelSandbox(error)) return { state: 'lost' }
1935
+ if (isInterruptedLongPoll(error)) return { state: 'running' }
1936
+ throw error
1937
+ }
1938
+ } catch (error) {
1939
+ if (isMissingVercelSandbox(error)) return { state: 'lost' }
1940
+ throw error
1941
+ }
1942
+ }
1943
+
1944
+ // ── Operator sandbox capability (AUT-340) ─────────────────────────
1945
+ //
1946
+ // One persistent, credential-free environment per operator × repository,
1947
+ // provisioned with the build workspace's chain and exposed only through
1948
+ // the registry's sandbox tools. The environment receives no store, forge,
1949
+ // ticket-provider, or model credential: the guest environment below is
1950
+ // built from an empty record plus forwarded names, and the git read
1951
+ // credential enters only as a network-policy header transform.
1952
+
1953
+ /** The credential-free guest environment: one fresh record per call —
1954
+ * never the process environment — holding exactly the forwarded
1955
+ * `sandboxEnvironmentVariables`. A missing host value is a typed
1956
+ * `environment` failure rather than a silent absence. The toolchain PATH is
1957
+ * deliberately NOT part of the record: the SDK passes env values through
1958
+ * verbatim, so a literal `$PATH` there would never be expanded — the PATH
1959
+ * is prepended inside the guest shell instead (the same channel the build
1960
+ * runner uses). Shared by the setup command and every tool `exec`/`start`. */
1961
+ private sandboxEnv(): Record<string, string> {
1962
+ const env: Record<string, string> = {}
1963
+ for (const name of this.options.sandboxEnvironmentVariables ?? []) {
1964
+ const value = this.options.env[name]
1965
+ if (value === undefined || value === '') {
1966
+ throw new SandboxOperationError(
1967
+ 'environment',
1968
+ `operator sandbox environment variable ${name} is not set in the host environment`,
1969
+ )
1970
+ }
1971
+ env[name] = value
1972
+ }
1973
+ return env
1974
+ }
1975
+
1976
+ /** The toolchain PATH as a shell prefix: the guest shell expands `$PATH`
1977
+ * from its own ambient environment, so guests get the autobuild toolchain
1978
+ * ahead of the image's default PATH without the env record ever carrying an
1979
+ * unexpanded literal. */
1980
+ private sandboxPathPrefix(): string {
1981
+ return `PATH=${VERCEL_BUN_BIN_PATH}:${VERCEL_AUTOBUILD_PATH}/bin:$PATH; export PATH; `
1982
+ }
1983
+
1984
+ private async describeOperatorSandbox(input: {
1985
+ operator: string
1986
+ }): Promise<SandboxEnvironmentIdentity> {
1987
+ const origin = cleanGithubOrigin(await this.origin())
1988
+ return {
1989
+ provider: this.name,
1990
+ environmentId: sandboxEnvironmentName(origin.url, input.operator),
1991
+ workspacePath: VERCEL_WORKSPACE_PATH,
1992
+ }
1993
+ }
1994
+
1995
+ /** Provision-or-resume. A persistent sandbox auto-resumes on the first SDK
1996
+ * call that needs a running session, so the reuse path re-checks the
1997
+ * distribution marker exactly as the build reuse path does and then leaves
1998
+ * the session running for the tool call. A fresh provision runs the same
1999
+ * chain as a build workspace and ends by running the repository's setup
2000
+ * command; it leaves the environment running too (the tool call follows
2001
+ * immediately; the idle settlement stops it later). */
2002
+ private async ensureOperatorSandbox(input: {
2003
+ repo: string
2004
+ operator: string
2005
+ baseBranch: string
2006
+ }): Promise<SandboxEnvironmentIdentity> {
2007
+ const origin = cleanGithubOrigin(await this.origin())
2008
+ const name = sandboxEnvironmentName(origin.url, input.operator)
2009
+ const distributionVersion = await (
2010
+ this.options.distributionVersion ?? readDistributionIdentity
2011
+ )()
2012
+ let sandbox = await this.facade.get(name, this.operationSignal())
2013
+ if (sandbox !== null) {
2014
+ const marker = await sandbox.runCommand({
2015
+ cmd: 'test',
2016
+ args: ['-f', VERCEL_PROVISIONED_MARKER],
2017
+ })
2018
+ if (marker.exitCode !== 0) {
2019
+ // A named VM without the marker is a crashed/legacy provisioning
2020
+ // attempt. Never expose its potentially unscrubbed checkout.
2021
+ await sandbox.delete({ signal: this.operationSignal() })
2022
+ sandbox = null
2023
+ } else {
2024
+ const installed = await readDistributionVersionMarker(sandbox, this.operationSignal())
2025
+ if (installed !== distributionVersion) {
2026
+ // A reused environment whose installed distribution disagrees with
2027
+ // what the current system would deliver is refreshed in place,
2028
+ // exactly as build guests are. Refresh failure deletes the
2029
+ // environment so the next call rematerializes it cleanly.
2030
+ try {
2031
+ const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
2032
+ await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
2033
+ } catch (error) {
2034
+ try {
2035
+ this.sessions.set(name, sandbox)
2036
+ await this.reap(name)
2037
+ } catch (deleteError) {
2038
+ throw new AggregateError(
2039
+ [error, deleteError],
2040
+ `operator sandbox ${name} distribution refresh failed and its incomplete environment could not be confirmed deleted`,
2041
+ )
2042
+ }
2043
+ throw error
2044
+ }
2045
+ }
2046
+ const sessionId = sandbox.currentSession?.().sessionId
2047
+ this.sessions.set(name, sandbox)
2048
+ return {
2049
+ provider: this.name,
2050
+ environmentId: name,
2051
+ ...(sessionId !== undefined ? { sessionId } : {}),
2052
+ workspacePath: VERCEL_WORKSPACE_PATH,
2053
+ }
2054
+ }
2055
+ }
2056
+ const head = await this.remoteBranchHead(input.baseBranch, `remote base ${input.baseBranch}`)
2057
+ if (head === null) {
2058
+ throw new SandboxOperationError('provision', `remote base ${input.baseBranch} does not exist`)
2059
+ }
2060
+ const username =
2061
+ this.options.config.gitUsernameEnv === undefined
2062
+ ? undefined
2063
+ : requireVercelEnvironmentValue(this.options.env, this.options.config.gitUsernameEnv)
2064
+ const password =
2065
+ this.options.config.gitPasswordEnv === undefined
2066
+ ? undefined
2067
+ : requireVercelEnvironmentValue(this.options.env, this.options.config.gitPasswordEnv)
2068
+ const readAuth =
2069
+ password === undefined
2070
+ ? undefined
2071
+ : `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
2072
+ sandbox = await this.facade.create({
2073
+ name,
2074
+ source: {
2075
+ type: 'git',
2076
+ url: origin.url,
2077
+ revision: head,
2078
+ ...(username !== undefined && password !== undefined ? { username, password } : {}),
2079
+ },
2080
+ image: this.options.config.image,
2081
+ resources: { vcpus: this.options.config.vcpus },
2082
+ timeout: this.options.config.timeoutSeconds * 1000,
2083
+ persistent: true,
2084
+ ...(this.options.config.region !== undefined ? { region: this.options.config.region } : {}),
2085
+ ...(this.options.config.failoverRegions.length > 0
2086
+ ? { failoverRegions: this.options.config.failoverRegions }
2087
+ : {}),
2088
+ ...(this.options.config.snapshotExpirationSeconds === undefined
2089
+ ? {}
2090
+ : { snapshotExpiration: this.options.config.snapshotExpirationSeconds * 1000 }),
2091
+ networkPolicy: uploadPackPolicy(origin, readAuth),
2092
+ keepLastSnapshots: { count: VERCEL_KEEP_LAST_SNAPSHOTS, deleteEvicted: true },
2093
+ signal: this.operationSignal(),
2094
+ })
2095
+ try {
2096
+ await relocateCheckout(sandbox, origin.directory)
2097
+ await commandOrThrow(sandbox, {
2098
+ cmd: 'git',
2099
+ args: ['remote', 'set-url', 'origin', origin.url],
2100
+ cwd: VERCEL_WORKSPACE_PATH,
2101
+ })
2102
+ for (const key of [
2103
+ 'credential.helper',
2104
+ 'http.extraheader',
2105
+ `http.${origin.url}.extraheader`,
2106
+ ]) {
2107
+ const result = await sandbox.runCommand({
2108
+ cmd: 'git',
2109
+ args: ['config', '--local', '--unset-all', key],
2110
+ cwd: VERCEL_WORKSPACE_PATH,
2111
+ })
2112
+ if (result.exitCode !== 0 && result.exitCode !== 5)
2113
+ throw new Error(`failed to scrub git config ${key}`)
2114
+ }
2115
+ await provisionBun(sandbox, this.options.config.image)
2116
+ await runSystemProvisioning(sandbox, this.options.config.provisioning ?? [])
2117
+ const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
2118
+ await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
2119
+ // Repository dependencies precede the setup command; the fixed
2120
+ // bootstrap is the same one build provisioning runs.
2121
+ await commandOrThrow(sandbox, {
2122
+ cmd: 'sh',
2123
+ args: [
2124
+ '-c',
2125
+ `if [ -f bun.lock ] || [ -f bun.lockb ]; then ${VERCEL_BUN_EXECUTABLE} install --frozen-lockfile; elif [ -f package-lock.json ]; then npm ci; elif [ -f pnpm-lock.yaml ]; then corepack pnpm install --frozen-lockfile; elif [ -f yarn.lock ]; then corepack yarn install --immutable; fi`,
2126
+ ],
2127
+ cwd: VERCEL_WORKSPACE_PATH,
2128
+ })
2129
+ // Runtime provisioning precedes the setup command, exactly as the build
2130
+ // chain orders it. The guest env is the sandbox-forwarded set only: a
2131
+ // runtime provisioning command that needs a build-workspace variable
2132
+ // must have that name added to [orchestrator].sandbox.environmentVariables.
2133
+ const runtimeReferences = currentRuntimeReferences(this.options.runtimeReferences)
2134
+ if (runtimeReferences.length > 0) {
2135
+ await bootstrapRuntimes(
2136
+ sandbox,
2137
+ this.options.config,
2138
+ this.sandboxEnv(),
2139
+ runtimeReferences,
2140
+ true,
2141
+ this.operationSignal(),
2142
+ )
2143
+ }
2144
+ const setupCommand = this.options.setupCommand
2145
+ if (setupCommand !== undefined && setupCommand.trim() !== '') {
2146
+ await readableCommand(sandbox, {
2147
+ cmd: 'sh',
2148
+ args: ['-c', `${this.sandboxPathPrefix()}${setupCommand}`],
2149
+ cwd: VERCEL_WORKSPACE_PATH,
2150
+ env: this.sandboxEnv(),
2151
+ })
2152
+ }
2153
+ await commandOrThrow(sandbox, {
2154
+ cmd: 'touch',
2155
+ args: [VERCEL_PROVISIONED_MARKER],
2156
+ })
2157
+ } catch (error) {
2158
+ // Nothing unrecorded or half-built survives: reap the half-provisioned
2159
+ // environment through the same sessions.set + reap pattern as builds.
2160
+ try {
2161
+ this.sessions.set(name, sandbox)
2162
+ await this.reap(name)
2163
+ } catch (deleteError) {
2164
+ throw new AggregateError(
2165
+ [error, deleteError],
2166
+ `operator sandbox ${name} setup failed and its incomplete environment could not be confirmed deleted`,
2167
+ )
2168
+ }
2169
+ throw error instanceof SandboxOperationError
2170
+ ? error
2171
+ : new SandboxOperationError(
2172
+ 'provision',
2173
+ error instanceof Error ? error.message : String(error),
2174
+ { cause: error },
2175
+ )
2176
+ }
2177
+ this.origins.set(name, origin)
2178
+ const sessionId = sandbox.currentSession?.().sessionId
2179
+ this.sessions.set(name, sandbox)
2180
+ return {
2181
+ provider: this.name,
2182
+ environmentId: name,
2183
+ ...(sessionId !== undefined ? { sessionId } : {}),
2184
+ workspacePath: VERCEL_WORKSPACE_PATH,
2185
+ }
2186
+ }
2187
+
2188
+ /** Resolve the live handle behind a journal-carried environment id. A
2189
+ * missing environment is a typed `not-found`; the persistent sandbox
2190
+ * auto-resumes on the first SDK call that needs a session. */
2191
+ private async sandboxHandle(handle: SandboxEnvironmentIdentity): Promise<VercelSandboxHandle> {
2192
+ const sandbox = await this.facade.get(handle.environmentId, this.operationSignal())
2193
+ if (sandbox === null) {
2194
+ throw new SandboxOperationError(
2195
+ 'not-found',
2196
+ `operator sandbox ${handle.environmentId} no longer exists`,
2197
+ )
2198
+ }
2199
+ this.sessions.set(handle.environmentId, sandbox)
2200
+ return sandbox
2201
+ }
2202
+
2203
+ /** One bounded-wait mechanism shared by `exec` and `wait` (the
2204
+ * `waitForCommandExit` pattern with the caller's deadline): interrupted
2205
+ * long-polls are re-issued until the explicit deadline; any other failure
2206
+ * propagates immediately. `operationTimeoutMs` bounds only handle lookups
2207
+ * and kill — never the wait itself, so a 300 s command is not cut off at
2208
+ * the 30 s operation timeout. */
2209
+ private async waitForSandboxExit(
2210
+ command: Pick<VercelCommand, 'wait'>,
2211
+ deadlineMs: number,
2212
+ ): Promise<{ exitCode: number }> {
2213
+ for (;;) {
2214
+ try {
2215
+ return await command.wait({})
2216
+ } catch (error) {
2217
+ if (!isInterruptedLongPoll(error) || Date.now() > deadlineMs) throw error
2218
+ }
2219
+ }
2220
+ }
2221
+
2222
+ private async sandboxRootedPath(
2223
+ handle: SandboxEnvironmentIdentity,
2224
+ path: string,
2225
+ ): Promise<string> {
2226
+ if (path.includes('\\')) {
2227
+ throw new SandboxOperationError(
2228
+ 'exec',
2229
+ `sandbox path ${JSON.stringify(path)} must use "/" separators`,
2230
+ )
2231
+ }
2232
+ return `${handle.workspacePath.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
2233
+ }
2234
+
2235
+ private async sandboxLaunch(
2236
+ sandbox: VercelSandboxHandle,
2237
+ request: SandboxCommandRequest,
2238
+ ): Promise<VercelCommand> {
2239
+ const command = (await sandbox.runCommand({
2240
+ cmd: 'sh',
2241
+ // The toolchain PATH is prepended inside the guest shell — the only
2242
+ // channel where `$PATH` expands (the sandbox env doc comment explains
2243
+ // why it cannot ride in the env record).
2244
+ args: ['-c', `${this.sandboxPathPrefix()}${request.command}`],
2245
+ ...(request.cwd !== undefined
2246
+ ? { cwd: `${VERCEL_WORKSPACE_PATH}/${request.cwd.replace(/^\/+/, '')}` }
2247
+ : { cwd: VERCEL_WORKSPACE_PATH }),
2248
+ env: this.sandboxEnv(),
2249
+ detached: true,
2250
+ signal: this.operationSignal(),
2251
+ })) as VercelCommand
2252
+ return command
2253
+ }
2254
+
2255
+ private async sandboxExec(
2256
+ handle: SandboxEnvironmentIdentity,
2257
+ request: SandboxCommandRequest,
2258
+ ): Promise<SandboxCommandResult> {
2259
+ const sandbox = await this.sandboxHandle(handle)
2260
+ const timeoutSeconds = Math.min(
2261
+ Math.max(request.timeoutSeconds ?? 120, 1),
2262
+ SANDBOX_MAX_WAIT_SECONDS,
2263
+ )
2264
+ const command = await this.sandboxLaunch(sandbox, request)
2265
+ const deadline = Date.now() + timeoutSeconds * 1000
2266
+ let exit: { exitCode: number }
2267
+ try {
2268
+ exit = await this.waitForSandboxExit(command, deadline)
2269
+ } catch (error) {
2270
+ if (Date.now() >= deadline) {
2271
+ try {
2272
+ await command.kill('SIGTERM', { abortSignal: this.operationSignal() })
2273
+ } catch {
2274
+ /* already exited */
2275
+ }
2276
+ throw new SandboxOperationError(
2277
+ 'exec-timeout',
2278
+ `sandbox command exceeded its ${timeoutSeconds}s bound and was killed`,
2279
+ { cause: error },
2280
+ )
2281
+ }
2282
+ throw new SandboxOperationError(
2283
+ 'exec',
2284
+ error instanceof Error ? error.message : String(error),
2285
+ { cause: error },
2286
+ )
2287
+ }
2288
+ const [stdout, stderr] = await Promise.all([
2289
+ command.stdout?.() ?? Promise.resolve(''),
2290
+ command.stderr?.() ?? Promise.resolve(''),
2291
+ ])
2292
+ return { exitCode: exit.exitCode, stdout, stderr }
2293
+ }
2294
+
2295
+ private async sandboxStart(
2296
+ handle: SandboxEnvironmentIdentity,
2297
+ request: SandboxCommandRequest,
2298
+ ): Promise<{ commandId: string }> {
2299
+ const sandbox = await this.sandboxHandle(handle)
2300
+ const command = await this.sandboxLaunch(sandbox, request)
2301
+ return { commandId: command.cmdId }
2302
+ }
2303
+
2304
+ private async sandboxWait(
2305
+ handle: SandboxEnvironmentIdentity,
2306
+ input: { commandId: string; waitSeconds: number },
2307
+ ): Promise<SandboxWaitResult> {
2308
+ const sandbox = await this.sandboxHandle(handle)
2309
+ const waitSeconds = Math.min(Math.max(input.waitSeconds, 0), SANDBOX_MAX_WAIT_SECONDS)
2310
+ const lookup = await sandbox.getCommand(input.commandId, { signal: this.operationSignal() })
2311
+ if (lookup.exitCode !== null) {
2312
+ const [stdout, stderr] = await Promise.all([
2313
+ lookup.stdout?.() ?? Promise.resolve(''),
2314
+ lookup.stderr?.() ?? Promise.resolve(''),
2315
+ ])
2316
+ return { state: 'exited', exitCode: lookup.exitCode, stdout, stderr }
2317
+ }
2318
+ if (waitSeconds === 0) return { state: 'running' }
2319
+ const deadline = Date.now() + waitSeconds * 1000
2320
+ try {
2321
+ const exit = await this.waitForSandboxExit(lookup, deadline)
2322
+ const [stdout, stderr] = await Promise.all([
2323
+ lookup.stdout?.() ?? Promise.resolve(''),
2324
+ lookup.stderr?.() ?? Promise.resolve(''),
2325
+ ])
2326
+ return { state: 'exited', exitCode: exit.exitCode, stdout, stderr }
2327
+ } catch (error) {
2328
+ if (isMissingVercelSandbox(error)) {
2329
+ throw new SandboxOperationError(
2330
+ 'not-found',
2331
+ `operator sandbox ${handle.environmentId} no longer exists`,
2332
+ { cause: error },
2333
+ )
2334
+ }
2335
+ // Interrupted-poll exhaustion at the deadline leaves the command
2336
+ // provably unobserved: still running, no output (Vercel reports
2337
+ // output only after exit). Any other failure is real.
2338
+ if (Date.now() >= deadline || isInterruptedLongPoll(error)) return { state: 'running' }
2339
+ throw new SandboxOperationError(
2340
+ 'exec',
2341
+ error instanceof Error ? error.message : String(error),
2342
+ { cause: error },
2343
+ )
2344
+ }
2345
+ }
2346
+
2347
+ private async sandboxReadFile(
2348
+ handle: SandboxEnvironmentIdentity,
2349
+ path: string,
2350
+ ): Promise<Uint8Array> {
2351
+ const rooted = await this.sandboxRootedPath(handle, path)
2352
+ // Exec-mechanism read through a byte-preserving channel: `base64` on the
2353
+ // rooted path (the path rides as its own argv element), decoded after
2354
+ // exit. Reading the file through the string `stdout()` channel would
2355
+ // mangle arbitrary binary before the registry's utf8-else-base64 branch
2356
+ // could preserve it; base64 survives that channel exactly, and the
2357
+ // decoder strips the wrapping whitespace GNU base64 inserts.
2358
+ const sandbox = await this.sandboxHandle(handle)
2359
+ const command = (await sandbox.runCommand({
2360
+ cmd: 'base64',
2361
+ args: [rooted],
2362
+ cwd: VERCEL_WORKSPACE_PATH,
2363
+ detached: true,
2364
+ signal: this.operationSignal(),
2365
+ })) as VercelCommand
2366
+ const deadline = Date.now() + 120 * 1000
2367
+ let exit: { exitCode: number }
2368
+ try {
2369
+ exit = await this.waitForSandboxExit(command, deadline)
2370
+ } catch (error) {
2371
+ throw new SandboxOperationError(
2372
+ 'exec',
2373
+ error instanceof Error ? error.message : String(error),
2374
+ { cause: error },
2375
+ )
2376
+ }
2377
+ if (exit.exitCode !== 0) {
2378
+ const stderr = await command.stderr?.().catch(() => '')
2379
+ throw new SandboxOperationError(
2380
+ 'not-found',
2381
+ `could not read sandbox file ${JSON.stringify(path)}: exit ${exit.exitCode}${stderr ? `: ${stderr.trim()}` : ''}`,
2382
+ )
2383
+ }
2384
+ const stdout = await command.stdout?.().catch(() => '')
2385
+ return new Uint8Array(Buffer.from((stdout ?? '').replace(/\s+/g, ''), 'base64'))
2386
+ }
2387
+
2388
+ private async sandboxWriteFile(
2389
+ handle: SandboxEnvironmentIdentity,
2390
+ path: string,
2391
+ content: Uint8Array,
2392
+ ): Promise<void> {
2393
+ const sandbox = await this.sandboxHandle(handle)
2394
+ const rooted = await this.sandboxRootedPath(handle, path)
2395
+ try {
2396
+ await sandbox.writeFiles([{ path: rooted, content }], {
2397
+ signal: this.operationSignal(),
2398
+ })
2399
+ } catch (error) {
2400
+ throw new SandboxOperationError(
2401
+ 'exec',
2402
+ error instanceof Error ? error.message : String(error),
2403
+ { cause: error },
2404
+ )
2405
+ }
2406
+ }
2407
+
2408
+ private async sandboxStop(input: {
2409
+ operator: string
2410
+ environmentId: string
2411
+ }): Promise<{ outcome: 'stopped' | 'absent' | 'unsupported' }> {
2412
+ const sandbox = await this.facade.get(input.environmentId, this.operationSignal())
2413
+ if (sandbox === null) return { outcome: 'absent' }
2414
+ try {
2415
+ await sandbox.stop({ signal: this.operationSignal() })
2416
+ } catch (error) {
2417
+ // A stopped/expired session may reject a stop idempotently; absence is
2418
+ // proven by re-resolution, and any other failure propagates as typed.
2419
+ const stillThere = await this.facade.get(input.environmentId, this.operationSignal())
2420
+ if (stillThere === null) return { outcome: 'absent' }
2421
+ throw new SandboxOperationError(
2422
+ 'exec',
2423
+ error instanceof Error ? error.message : String(error),
2424
+ { cause: error },
2425
+ )
2426
+ }
2427
+ return { outcome: 'stopped' }
2428
+ }
2429
+
2430
+ private async sandboxRelease(input: {
2431
+ repo: string
2432
+ operator: string
2433
+ environmentId: string
2434
+ }): Promise<{
2435
+ snapshots: { outcome: 'confirmed' | 'unknown'; deleted?: number; error?: string }
2436
+ }> {
2437
+ const reap = await this.reap(input.environmentId)
2438
+ return {
2439
+ snapshots: {
2440
+ outcome: reap.snapshots.outcome === 'unknown' ? 'unknown' : 'confirmed',
2441
+ ...(reap.snapshots.deleted !== undefined ? { deleted: reap.snapshots.deleted } : {}),
2442
+ ...(reap.snapshots.error !== undefined ? { error: reap.snapshots.error } : {}),
2443
+ },
2444
+ }
2445
+ }
2446
+
2447
+ /** Host-seam fallbacks: the injected origin/branch-head readers when given
2448
+ * (checkout-less origin mode), otherwise host `git` executed from the
2449
+ * dispatcher-local checkout. */
2450
+ private async origin(): Promise<string> {
2451
+ if (this.options.origin !== undefined) return this.options.origin()
2452
+ return execOrThrow(this.exec, ['git', 'remote', 'get-url', 'origin'], this.options.repo)
2453
+ }
2454
+
2455
+ private async remoteBranchHead(branch: string, label: string): Promise<string | null> {
2456
+ if (this.options.remoteBranchHead !== undefined) {
2457
+ return (await this.options.remoteBranchHead(branch)) ?? null
2458
+ }
2459
+ return oneSha(
2460
+ await execOrThrow(
2461
+ this.exec,
2462
+ ['git', 'ls-remote', '--heads', 'origin', `refs/heads/${branch}`],
2463
+ this.options.repo,
2464
+ ),
2465
+ label,
2466
+ )
2467
+ }
2468
+
2469
+ private async isPublished(input: { sha: string; branch: string }): Promise<boolean> {
2470
+ const head = await this.remoteBranchHead(input.branch, `published branch ${input.branch}`)
2471
+ return head === input.sha
2472
+ }
2473
+
2474
+ private async publish(input: { ref: string; sha: string; branch: string }): Promise<void> {
2475
+ if (this.active.has(input.ref) || this.uncertain.has(input.ref)) {
2476
+ throw new Error('publication is forbidden until sandbox execution teardown is confirmed')
2477
+ }
2478
+ if (!/^[0-9a-f]{40,64}$/i.test(input.sha) || !/^ab\/[a-z0-9][a-z0-9-]*$/.test(input.branch)) {
2479
+ throw new Error('publication requires an exact commit SHA and canonical build branch')
2480
+ }
2481
+ const token = this.options.env.GITHUB_TOKEN || this.options.env.GH_TOKEN
2482
+ if (!token) {
2483
+ throw new Error('vercel-sandbox publication requires GITHUB_TOKEN or GH_TOKEN')
2484
+ }
2485
+ const { origin, policy: normal } = await this.normalNetworkPolicy(input.ref)
2486
+ const sandbox =
2487
+ this.sessions.get(input.ref) ?? (await this.facade.get(input.ref, this.operationSignal()))
2488
+ if (sandbox === null) throw new Error(`unknown sandbox ${input.ref}`)
2489
+ this.sessions.set(input.ref, sandbox)
2490
+ const publicationPolicy: NetworkPolicy = {
2491
+ allow: {
2492
+ [origin.host]: [
2493
+ {
2494
+ match: {
2495
+ method: ['GET'],
2496
+ path: { exact: `${origin.path}/info/refs` },
2497
+ queryString: [{ key: { exact: 'service' }, value: { exact: 'git-receive-pack' } }],
2498
+ },
2499
+ transform: [
2500
+ {
2501
+ headers: {
2502
+ authorization: `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
2503
+ },
2504
+ },
2505
+ ],
2506
+ },
2507
+ {
2508
+ match: { method: ['POST'], path: { exact: `${origin.path}/git-receive-pack` } },
2509
+ transform: [
2510
+ {
2511
+ headers: {
2512
+ authorization: `Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
2513
+ },
2514
+ },
2515
+ ],
2516
+ },
2517
+ ],
2518
+ },
2519
+ }
2520
+ try {
2521
+ await sandbox.update({ networkPolicy: publicationPolicy }, { signal: this.operationSignal() })
2522
+ await commandOrThrow(sandbox, {
2523
+ cmd: 'git',
2524
+ args: ['push', '--no-verify', 'origin', `${input.sha}:refs/heads/${input.branch}`],
2525
+ cwd: VERCEL_WORKSPACE_PATH,
2526
+ })
2527
+ await sandbox.stop({ signal: this.operationSignal() })
2528
+ // Post-push verification without host git: the injected branch-head
2529
+ // reader (origin mode) or the host ls-remote fallback answers.
2530
+ const published = await this.remoteBranchHead(input.branch, 'published branch')
2531
+ if (published !== input.sha)
2532
+ throw new Error(`published head ${published ?? '(missing)'} did not match ${input.sha}`)
2533
+ } finally {
2534
+ try {
2535
+ await sandbox.update({ networkPolicy: normal }, { signal: this.operationSignal() })
2536
+ } finally {
2537
+ await sandbox.stop({ signal: this.operationSignal() })
2538
+ }
2539
+ }
2540
+ }
2541
+ }