@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.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- 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
|
+
}
|