@defrex/autobuild 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The single shared capability object for the `vercel-sandbox` workspace
3
+ * provider (AUT-517). During the transitional state in which the builtin
4
+ * hosts the provider implementation, both the builtin registration (in
5
+ * `builtin-capabilities.ts`) and the `@defrex/autobuild-vercel-sandbox`
6
+ * plugin reference THIS object, so the two declarations cannot drift: any
7
+ * future divergence is a compile-time split, not a silently duplicated
8
+ * closure.
9
+ *
10
+ * The parse-time subset comes from `BUILTIN_WORKSPACE_PROVIDER_CONFIG` in
11
+ * `config/schema.ts`; the registry-seam behaviors and the closures
12
+ * (`validateOrigin`, `guestEnvNames`, `describeEnvironment`,
13
+ * `validateReadiness`) are assembled here once.
14
+ *
15
+ * Import graph discipline: this module imports `config/schema.ts`,
16
+ * `github-origin.ts`, and `init-readiness-remote.ts`; nothing imports it
17
+ * from `schema.ts`, so no cycle exists.
18
+ */
19
+ import { BUILTIN_WORKSPACE_PROVIDER_CONFIG, type VercelSandboxConfig } from '../../config/schema'
20
+ import { validateRemoteReadiness } from '../../cli/init-readiness-remote'
21
+ import type {
22
+ WorkspaceProviderCapabilities,
23
+ WorkspaceReadinessContext,
24
+ } from './provider-capabilities'
25
+ import { validateVercelGithubOrigin } from './github-origin'
26
+
27
+ /** The parse-time declaration subset from the config table, without the
28
+ * parse-site refusal text (which never belongs on the capability). */
29
+ function pickConfigDeclaration(name: string): WorkspaceProviderCapabilities {
30
+ const declaration = BUILTIN_WORKSPACE_PROVIDER_CONFIG.get(name)
31
+ if (declaration === undefined) return {}
32
+ return {
33
+ ...(declaration.configSchema !== undefined ? { configSchema: declaration.configSchema } : {}),
34
+ ...(declaration.requireRuntimeProvisioning === true
35
+ ? { requireRuntimeProvisioning: true }
36
+ : {}),
37
+ ...(declaration.sandboxForbiddenEnv !== undefined
38
+ ? { sandboxForbiddenEnv: declaration.sandboxForbiddenEnv }
39
+ : {}),
40
+ }
41
+ }
42
+
43
+ export const VERCEL_SANDBOX_CAPABILITIES: WorkspaceProviderCapabilities = {
44
+ // Parse-time subset comes from the config table; the capability carries
45
+ // no configRefusal because the provider accepts [workspace.config]
46
+ // through its schema.
47
+ ...pickConfigDeclaration('vercel-sandbox'),
48
+ supportedForges: ['github'],
49
+ forgeDispatchMessage: 'vercel-sandbox requires the builtin github forge',
50
+ forgeValidationMessage:
51
+ 'vercel-sandbox supports forge = "github" only; configure GitHub publication before validating',
52
+ requiredEnv: [
53
+ {
54
+ alternatives: [['GITHUB_TOKEN'], ['GH_TOKEN']],
55
+ dispatchMessage:
56
+ 'vercel-sandbox publication requires GITHUB_TOKEN or GH_TOKEN in the dispatcher environment',
57
+ validationMessage:
58
+ 'vercel-sandbox publication requires push-capable GITHUB_TOKEN or GH_TOKEN',
59
+ },
60
+ {
61
+ alternatives: [
62
+ ['VERCEL_OIDC_TOKEN'],
63
+ ['VERCEL_TOKEN', 'VERCEL_TEAM_ID', 'VERCEL_PROJECT_ID'],
64
+ ],
65
+ validationMessage:
66
+ 'Vercel authentication requires VERCEL_OIDC_TOKEN or the durable VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID set',
67
+ },
68
+ ],
69
+ processEnvOnly: [
70
+ {
71
+ name: 'VERCEL_OIDC_TOKEN',
72
+ message:
73
+ 'VERCEL_OIDC_TOKEN loaded only from the target .env is unavailable to the Vercel SDK; export it in the launcher environment or configure VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID',
74
+ },
75
+ ],
76
+ storeRequirements: {
77
+ constructionMessage:
78
+ 'vercel-sandbox requires an HTTPS BuildStore and scoped AB_TOKEN authority',
79
+ storeRefMessage: 'vercel-sandbox requires AB_STORE to be an HTTPS URL reachable from Vercel',
80
+ storeTokenMessage: 'vercel-sandbox requires nonempty AB_TOKEN for the hosted Store',
81
+ },
82
+ validateOrigin: validateVercelGithubOrigin,
83
+ originReadFailureMessage: 'vercel-sandbox requires a readable Git origin',
84
+ guestEnvNames: (raw) => {
85
+ const config = raw as VercelSandboxConfig
86
+ return [
87
+ ...config.environmentVariables,
88
+ ...(config.gitUsernameEnv === undefined ? [] : [config.gitUsernameEnv]),
89
+ ...(config.gitPasswordEnv === undefined ? [] : [config.gitPasswordEnv]),
90
+ ]
91
+ },
92
+ describeEnvironment: (raw, env) => {
93
+ const config = raw as VercelSandboxConfig
94
+ return [
95
+ `Vercel auth: ${env.VERCEL_OIDC_TOKEN ? 'OIDC' : 'access token'}; team=${env.VERCEL_TEAM_ID ?? '(linked)'}; project=${env.VERCEL_PROJECT_ID ?? '(linked)'}`,
96
+ `Private clone variables: ${
97
+ config.gitUsernameEnv === undefined
98
+ ? '(public repository)'
99
+ : `${config.gitUsernameEnv}, ${config.gitPasswordEnv}`
100
+ }`,
101
+ `Guest environment variable names: ${config.environmentVariables.join(', ') || '(none)'}`,
102
+ `Runtime provisioning names: ${
103
+ Object.keys(config.runtimeProvisioning ?? {})
104
+ .sort()
105
+ .join(', ') || '(none)'
106
+ }`,
107
+ ]
108
+ },
109
+ validateReadiness: (ctx: WorkspaceReadinessContext) => validateRemoteReadiness(ctx),
110
+ }
@@ -1,7 +1,4 @@
1
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
2
  import { Sandbox, Snapshot, type NetworkPolicy, type SandboxRegion } from '@vercel/sandbox'
6
3
  import { displayName, tomlKey, type RuntimeReferenceGroup } from '../../config/roles'
7
4
  import {
@@ -9,12 +6,12 @@ import {
9
6
  type VercelSandboxConfig,
10
7
  vercelSandboxConfigSchema,
11
8
  } from '../../config/schema'
12
- import { distributionRoot } from '../../distribution'
13
- import { defaultDistributionArchive, readDistributionIdentity } from './distribution-archive'
9
+ import { distributionStampFor, resolveDistributionArchive } from './distribution-archive'
14
10
  import type {
15
11
  WorkspaceHandle,
16
12
  WorkspaceProvider,
17
13
  WorkspaceProvisionResult,
14
+ WorkspacePublication,
18
15
  WorkspaceReapOutcome,
19
16
  } from '../types'
20
17
  import {
@@ -42,15 +39,23 @@ import {
42
39
  import { BUILD_RUNNER_OPTIONS_ENV } from './local-build-execution'
43
40
  import type { Exec } from './git-worktree'
44
41
  import { spawnExec } from './git-worktree'
42
+ import { validateVercelGithubOrigin } from './github-origin'
43
+ import { currentRuntimeReferences, type RuntimeReferencesSource } from './provider-capabilities'
44
+
45
+ export { validateVercelGithubOrigin }
45
46
 
46
47
  export const VERCEL_WORKSPACE_PATH = '/vercel/sandbox/workspace'
47
48
  export const VERCEL_AUTOBUILD_PATH = '/opt/autobuild'
48
49
  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. */
50
+ /** Records the installed distribution's identity stamp — everything the
51
+ * hosted store's skew check compares (package version and remote-store
52
+ * protocol version) plus the sha256 digest of the exact packed archive the
53
+ * dispatcher installs (see `readDistributionIdentityStamp`). A reused sandbox
54
+ * whose marker disagrees with the stamp the current system would deliver is
55
+ * refreshed by reinstalling the archive, so an upgraded dispatcher retrofits
56
+ * its persistent guests (including legacy guests predating this marker)
57
+ * instead of resuming a stale distribution that the hosted store would
58
+ * reject. */
54
59
  export const VERCEL_DISTRIBUTION_VERSION_MARKER = `${VERCEL_AUTOBUILD_PATH}/.distribution-version`
55
60
  export const VERCEL_BUN_VERSION = '1.4.0'
56
61
  export const VERCEL_BUN_PREFIX = '/opt/autobuild-runtime'
@@ -447,35 +452,6 @@ export async function purgeEnvironmentSnapshots(
447
452
  }
448
453
  }
449
454
 
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
455
  const cleanGithubOrigin = validateVercelGithubOrigin
480
456
 
481
457
  function sandboxName(origin: string, branch: string, generation = 0): string {
@@ -759,23 +735,6 @@ async function preflightBun(sandbox: VercelSandboxHandle, image: string): Promis
759
735
  }
760
736
  }
761
737
 
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
738
  export interface VercelReadinessOptions {
780
739
  config: VercelSandboxConfig
781
740
  env: Record<string, string | undefined>
@@ -930,7 +889,7 @@ export async function validateVercelSandbox(
930
889
  }
931
890
  await provisionBun(sandbox, config.image, options.signal)
932
891
  const provisioning = await runSystemProvisioning(sandbox, config.provisioning, options.signal)
933
- const archive = await (options.packageArchive ?? defaultDistributionArchive)()
892
+ const archive = await effectiveArchive(options.packageArchive)
934
893
  checkCancellation()
935
894
  await sandbox.writeFiles([{ path: '/tmp/autobuild.tgz', content: archive }], {
936
895
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -1052,24 +1011,16 @@ export async function validateVercelSandbox(
1052
1011
  return { ...readiness!, snapshotsDeleted }
1053
1012
  }
1054
1013
 
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
- }
1014
+ export type { RuntimeReferencesSource } from './provider-capabilities'
1065
1015
 
1066
1016
  /** 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. */
1017
+ * identity marker. Shared by fresh provisioning, reuse-path refreshes, and
1018
+ * the restart path; the readback comparison guards a silently failed write or
1019
+ * extraction. */
1069
1020
  async function installDistribution(
1070
1021
  sandbox: VercelSandboxHandle,
1071
1022
  archive: Uint8Array,
1072
- version: string,
1023
+ identity: string,
1073
1024
  signal?: AbortSignal,
1074
1025
  ): Promise<void> {
1075
1026
  await sandbox.writeFiles([{ path: '/tmp/autobuild.tgz', content: archive }], {
@@ -1091,12 +1042,12 @@ async function installDistribution(
1091
1042
  cwd: VERCEL_AUTOBUILD_PATH,
1092
1043
  ...(signal === undefined ? {} : { signal }),
1093
1044
  })
1094
- await writeDistributionVersionMarker(sandbox, version, signal)
1045
+ await writeDistributionVersionMarker(sandbox, identity, signal)
1095
1046
  }
1096
1047
 
1097
1048
  async function writeDistributionVersionMarker(
1098
1049
  sandbox: VercelSandboxHandle,
1099
- version: string,
1050
+ identity: string,
1100
1051
  signal?: AbortSignal,
1101
1052
  ): Promise<void> {
1102
1053
  await commandOrThrow(sandbox, {
@@ -1105,7 +1056,7 @@ async function writeDistributionVersionMarker(
1105
1056
  '-c',
1106
1057
  'printf %s "$1" > "$2"',
1107
1058
  'distribution-version',
1108
- version,
1059
+ identity,
1109
1060
  VERCEL_DISTRIBUTION_VERSION_MARKER,
1110
1061
  ],
1111
1062
  ...(signal === undefined ? {} : { signal }),
@@ -1115,17 +1066,17 @@ async function writeDistributionVersionMarker(
1115
1066
  args: [VERCEL_DISTRIBUTION_VERSION_MARKER],
1116
1067
  ...(signal === undefined ? {} : { signal }),
1117
1068
  })
1118
- if (readback.trim() !== version) {
1069
+ if (readback.trim() !== identity) {
1119
1070
  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())}`,
1071
+ `distribution identity marker readback mismatch in ${VERCEL_DISTRIBUTION_VERSION_MARKER}: ` +
1072
+ `wrote ${JSON.stringify(identity)} but read ${JSON.stringify(readback.trim())}`,
1122
1073
  )
1123
1074
  }
1124
1075
  }
1125
1076
 
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. */
1077
+ /** The installed distribution's identity stamp, or `undefined` when the marker
1078
+ * is unreadable — which includes every guest provisioned before the marker
1079
+ * existed and is treated as a mismatch by the reuse and restart paths. */
1129
1080
  async function readDistributionVersionMarker(
1130
1081
  sandbox: VercelSandboxHandle,
1131
1082
  signal?: AbortSignal,
@@ -1143,6 +1094,17 @@ async function readDistributionVersionMarker(
1143
1094
  }
1144
1095
  }
1145
1096
 
1097
+ /** The archive bytes a provider installs: the injected test archive when
1098
+ * present, else the per-process memoized running distribution archive
1099
+ * (`resolveDistributionArchive`), so a deployment without an injected archive
1100
+ * packs or fetches once per process rather than once per install site. */
1101
+ async function effectiveArchive(
1102
+ packageArchive: (() => Promise<Uint8Array>) | undefined,
1103
+ ): Promise<Uint8Array> {
1104
+ if (packageArchive) return packageArchive()
1105
+ return (await resolveDistributionArchive()).archive
1106
+ }
1107
+
1146
1108
  export interface VercelSandboxProviderOptions {
1147
1109
  config: VercelSandboxConfig
1148
1110
  env: Record<string, string | undefined>
@@ -1163,11 +1125,14 @@ export interface VercelSandboxProviderOptions {
1163
1125
  * the branch does not exist. Default runs `git ls-remote` from `repo` on
1164
1126
  * the host. */
1165
1127
  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>
1128
+ /** Test seam: the distribution identity stamp the current archive source
1129
+ * delivers (package version plus remote-store protocol version plus the
1130
+ * sha256 digest of the archive bytes, see `readDistributionIdentityStamp`).
1131
+ * Default computes the stamp over the effective archive source (origin mode
1132
+ * keys the guest archive to the running version; source mode packs the same
1133
+ * tree `readDistributionIdentity` reads), so the stamp and the installed
1134
+ * bytes agree by construction. */
1135
+ distributionIdentity?: () => Promise<string>
1171
1136
  /** Test seam: the bound on the observation path's wait on a command whose
1172
1137
  * lookup shows no exit. Defaults to `VERCEL_OBSERVE_WAIT_MS`. */
1173
1138
  observeWaitMs?: number
@@ -1193,6 +1158,7 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1193
1158
  readonly harvestExecution: HarvestExecution
1194
1159
  readonly orchestratorSandbox: OperatorSandboxExecution
1195
1160
  readonly publication
1161
+ readonly sandboxPublication: WorkspacePublication
1196
1162
  readonly recovery
1197
1163
  private readonly facade: VercelSandboxFacade
1198
1164
  private readonly exec: Exec
@@ -1203,6 +1169,24 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1203
1169
  private readonly sessions = new Map<string, VercelSandboxHandle>()
1204
1170
  private readonly observeWaitMs: number
1205
1171
 
1172
+ /** The archive bytes this provider installs: the injected `packageArchive`
1173
+ * seam when set, else the per-process memoized running distribution
1174
+ * archive. Every install site routes through here, so the marker digest
1175
+ * always describes the bytes actually installed. */
1176
+ private archiveBytes(): Promise<Uint8Array> {
1177
+ return effectiveArchive(this.options.packageArchive)
1178
+ }
1179
+
1180
+ /** The distribution identity stamp to write and compare: the injected
1181
+ * `distributionIdentity` seam when set (which pins the stamp independent of
1182
+ * the archive), else the default stamp computed over `archiveBytes()` — so
1183
+ * a provider that injects only `packageArchive` stamps the bytes it
1184
+ * actually installs and the seam divergence class is unrepresentable. */
1185
+ private async identityStamp(): Promise<string> {
1186
+ if (this.options.distributionIdentity) return this.options.distributionIdentity()
1187
+ return distributionStampFor(await this.archiveBytes())
1188
+ }
1189
+
1206
1190
  constructor(private readonly options: VercelSandboxProviderOptions) {
1207
1191
  if (!/^https:\/\//i.test(options.storeRef) || options.storeToken === '') {
1208
1192
  throw new Error('vercel-sandbox requires an HTTPS BuildStore and scoped AB_TOKEN authority')
@@ -1243,6 +1227,14 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1243
1227
  isPublished: (input: { sha: string; branch: string }) => this.isPublished(input),
1244
1228
  publish: (input: { ref: string; sha: string; branch: string }) => this.publish(input),
1245
1229
  }
1230
+ // The operator-sandbox publication path reuses the SAME capability: its
1231
+ // publish pushes an exact sha from any sandbox ref, including an operator
1232
+ // environment, and its isPublished ignores the optional `ref` field.
1233
+ // Constructor-body assignment, never a field initializer — the class
1234
+ // assigns `publication` in the constructor too, and under the repo's
1235
+ // `useDefineForClassFields` semantics a field initializer would run
1236
+ // before that assignment and capture `undefined`.
1237
+ this.sandboxPublication = this.publication
1246
1238
  }
1247
1239
 
1248
1240
  async provision(opts: {
@@ -1255,12 +1247,10 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1255
1247
  const rawOrigin = await this.origin()
1256
1248
  const origin = cleanGithubOrigin(rawOrigin)
1257
1249
  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
- )()
1250
+ // Resolved once, before the create/reuse branch: the identity stamp the
1251
+ // current archive source would deliver, against which both a fresh
1252
+ // install's marker and a reused sandbox's marker are compared.
1253
+ const distributionIdentity = await this.identityStamp()
1264
1254
  let sandbox = await this.facade.get(name, this.operationSignal())
1265
1255
  const existing = await this.remoteBranchHead(opts.branch, `remote branch ${opts.branch}`)
1266
1256
  const base =
@@ -1279,7 +1269,7 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1279
1269
  })
1280
1270
  if (marker.exitCode === 0) {
1281
1271
  const installed = await readDistributionVersionMarker(sandbox, this.operationSignal())
1282
- if (installed === distributionVersion) {
1272
+ if (installed === distributionIdentity) {
1283
1273
  await sandbox.stop({ signal: this.operationSignal() })
1284
1274
  } else {
1285
1275
  // A completed sandbox whose installed distribution disagrees with
@@ -1287,8 +1277,13 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1287
1277
  // without a marker — is refreshed in place. Any refresh failure
1288
1278
  // deletes the sandbox so the next pass rematerializes cleanly.
1289
1279
  try {
1290
- const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1291
- await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1280
+ const archive = await this.archiveBytes()
1281
+ await installDistribution(
1282
+ sandbox,
1283
+ archive,
1284
+ distributionIdentity,
1285
+ this.operationSignal(),
1286
+ )
1292
1287
  } catch (error) {
1293
1288
  try {
1294
1289
  this.sessions.set(name, sandbox)
@@ -1374,8 +1369,8 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1374
1369
  }
1375
1370
  await provisionBun(sandbox, this.options.config.image)
1376
1371
  await runSystemProvisioning(sandbox, this.options.config.provisioning ?? [])
1377
- const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1378
- await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1372
+ const archive = await this.archiveBytes()
1373
+ await installDistribution(sandbox, archive, distributionIdentity, this.operationSignal())
1379
1374
  // Repository dependencies precede branch-owned package plugin loading.
1380
1375
  // The fixed bootstrap supports the consuming repository's lockfile; its
1381
1376
  // configured setup command still runs at every runner attachment.
@@ -1541,6 +1536,32 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1541
1536
  await sandbox.stop({ signal: this.operationSignal() })
1542
1537
  this.uncertain.delete(ref)
1543
1538
  }
1539
+ // Restart (the lease sweep's re-attach) launches guest code in an existing
1540
+ // sandbox, so it must compare the distribution identity exactly as the
1541
+ // reuse paths do: a guest installed by an older dispatcher — including one
1542
+ // stranded by a protocol-only bump or a missing/unreadable marker — is
1543
+ // refreshed before the runner starts, or the hosted store would 409 its
1544
+ // heartbeat and the build would strand. Refresh failure deletes the
1545
+ // sandbox so the next pass rematerializes it cleanly.
1546
+ const distributionIdentity = await this.identityStamp()
1547
+ const installed = await readDistributionVersionMarker(sandbox, this.operationSignal())
1548
+ if (installed !== distributionIdentity) {
1549
+ try {
1550
+ const archive = await this.archiveBytes()
1551
+ await installDistribution(sandbox, archive, distributionIdentity, this.operationSignal())
1552
+ } catch (error) {
1553
+ try {
1554
+ this.sessions.set(ref, sandbox)
1555
+ await this.reap(ref)
1556
+ } catch (deleteError) {
1557
+ throw new AggregateError(
1558
+ [error, deleteError],
1559
+ `sandbox ${ref} distribution refresh failed and its incomplete environment could not be confirmed deleted`,
1560
+ )
1561
+ }
1562
+ throw error
1563
+ }
1564
+ }
1544
1565
  await preflightBun(sandbox, this.options.config.image)
1545
1566
  await bootstrapRuntimes(
1546
1567
  sandbox,
@@ -1679,12 +1700,10 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1679
1700
  const origin = cleanGithubOrigin(await this.origin())
1680
1701
  const name = harvestSandboxName(origin.url)
1681
1702
  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
- )()
1703
+ // Resolved once, before the create/reuse branch: the identity stamp the
1704
+ // current archive source would deliver, against which both a fresh
1705
+ // install's marker and a reused environment's marker are compared.
1706
+ const distributionIdentity = await this.identityStamp()
1688
1707
  let sandbox = await this.facade.get(name, this.operationSignal())
1689
1708
  if (sandbox !== null) {
1690
1709
  const marker = await sandbox.runCommand({
@@ -1703,10 +1722,15 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1703
1722
  // disagrees with what the current system would deliver is refreshed
1704
1723
  // in place, mirroring the build reuse path.
1705
1724
  const installed = await readDistributionVersionMarker(sandbox, this.operationSignal())
1706
- if (installed !== distributionVersion) {
1725
+ if (installed !== distributionIdentity) {
1707
1726
  try {
1708
- const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1709
- await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1727
+ const archive = await this.archiveBytes()
1728
+ await installDistribution(
1729
+ sandbox,
1730
+ archive,
1731
+ distributionIdentity,
1732
+ this.operationSignal(),
1733
+ )
1710
1734
  } catch (error) {
1711
1735
  try {
1712
1736
  this.sessions.set(name, sandbox)
@@ -1785,8 +1809,8 @@ export class VercelSandboxProvider implements WorkspaceProvider {
1785
1809
  }
1786
1810
  await provisionBun(sandbox, this.options.config.image)
1787
1811
  await runSystemProvisioning(sandbox, this.options.config.provisioning ?? [])
1788
- const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
1789
- await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
1812
+ const archive = await this.archiveBytes()
1813
+ await installDistribution(sandbox, archive, distributionIdentity, this.operationSignal())
1790
1814
  // Repository dependencies precede runtime preflight; the fixed
1791
1815
  // bootstrap is the same one build provisioning runs.
1792
1816
  await commandOrThrow(sandbox, {
@@ -2006,9 +2030,7 @@ export class VercelSandboxProvider implements WorkspaceProvider {
2006
2030
  }): Promise<SandboxEnvironmentIdentity> {
2007
2031
  const origin = cleanGithubOrigin(await this.origin())
2008
2032
  const name = sandboxEnvironmentName(origin.url, input.operator)
2009
- const distributionVersion = await (
2010
- this.options.distributionVersion ?? readDistributionIdentity
2011
- )()
2033
+ const distributionIdentity = await this.identityStamp()
2012
2034
  let sandbox = await this.facade.get(name, this.operationSignal())
2013
2035
  if (sandbox !== null) {
2014
2036
  const marker = await sandbox.runCommand({
@@ -2022,14 +2044,19 @@ export class VercelSandboxProvider implements WorkspaceProvider {
2022
2044
  sandbox = null
2023
2045
  } else {
2024
2046
  const installed = await readDistributionVersionMarker(sandbox, this.operationSignal())
2025
- if (installed !== distributionVersion) {
2047
+ if (installed !== distributionIdentity) {
2026
2048
  // A reused environment whose installed distribution disagrees with
2027
2049
  // what the current system would deliver is refreshed in place,
2028
2050
  // exactly as build guests are. Refresh failure deletes the
2029
2051
  // environment so the next call rematerializes it cleanly.
2030
2052
  try {
2031
- const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
2032
- await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
2053
+ const archive = await this.archiveBytes()
2054
+ await installDistribution(
2055
+ sandbox,
2056
+ archive,
2057
+ distributionIdentity,
2058
+ this.operationSignal(),
2059
+ )
2033
2060
  } catch (error) {
2034
2061
  try {
2035
2062
  this.sessions.set(name, sandbox)
@@ -2114,8 +2141,8 @@ export class VercelSandboxProvider implements WorkspaceProvider {
2114
2141
  }
2115
2142
  await provisionBun(sandbox, this.options.config.image)
2116
2143
  await runSystemProvisioning(sandbox, this.options.config.provisioning ?? [])
2117
- const archive = await (this.options.packageArchive ?? defaultDistributionArchive)()
2118
- await installDistribution(sandbox, archive, distributionVersion, this.operationSignal())
2144
+ const archive = await this.archiveBytes()
2145
+ await installDistribution(sandbox, archive, distributionIdentity, this.operationSignal())
2119
2146
  // Repository dependencies precede the setup command; the fixed
2120
2147
  // bootstrap is the same one build provisioning runs.
2121
2148
  await commandOrThrow(sandbox, {
@@ -2182,6 +2209,7 @@ export class VercelSandboxProvider implements WorkspaceProvider {
2182
2209
  environmentId: name,
2183
2210
  ...(sessionId !== undefined ? { sessionId } : {}),
2184
2211
  workspacePath: VERCEL_WORKSPACE_PATH,
2212
+ baseSha: head,
2185
2213
  }
2186
2214
  }
2187
2215
 
@@ -10,6 +10,7 @@ import { openProductionStore } from '../cli/store-opening'
10
10
  import { BuildRunner, LeaseHeldError, SetupFailureError } from './build-runner'
11
11
  import {
12
12
  BUILD_EFFECTIVE_CONFIG_ARTIFACT,
13
+ buildOwnedSectionsDiffer,
13
14
  diagnosticArtifact,
14
15
  parseEffectiveBuildConfig,
15
16
  selectOpenWorkspace,
@@ -22,11 +23,17 @@ function message(error: unknown): string {
22
23
  }
23
24
 
24
25
  /** Child-side composition. The launch envelope supplies identity only; all
25
- * execution location/configuration is discovered through the scoped Store. */
26
+ * execution location/configuration is discovered through the scoped Store.
27
+ * `opts.guest` marks a structurally guest process (environment-supervised
28
+ * launch): plugin loading then tolerates a configured package specifier the
29
+ * guest cannot resolve — guests never construct workspace providers, so a
30
+ * provider plugin configured in autobuild.toml is skipped with a notice
31
+ * instead of failing the build (AUT-517). */
26
32
  export async function runBuildChild(
27
33
  input: BuildExecutionStart,
28
34
  env: Record<string, string | undefined> = process.env,
29
35
  openStore: (ref: string, token?: string) => BuildStore = openProductionStore,
36
+ opts: { guest?: boolean } = {},
30
37
  ): Promise<void> {
31
38
  const fullStore = openStore(input.storeRef, env.AB_TOKEN)
32
39
  const store = fullStore.scopeBuild(input.slug)
@@ -50,6 +57,7 @@ export async function runBuildChild(
50
57
  let currentConfig = parseEffectiveBuildConfig(configArtifact)
51
58
  const plugins = await loadPlugins(currentConfig.plugins, workspace.path, {
52
59
  packageRoot: record.repo,
60
+ ...(opts.guest === true ? { guest: true } : {}),
53
61
  })
54
62
  const { runtimes: builtins } = createProductionRuntimes()
55
63
  const runtimes = await materializePluginRuntimes(builtins, plugins, {
@@ -66,7 +74,14 @@ export async function runBuildChild(
66
74
  const latest = await store.getArtifact(input.slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
67
75
  if (latest === null) return currentConfig
68
76
  try {
69
- currentConfig = parseEffectiveBuildConfig(latest)
77
+ const parsed = parseEffectiveBuildConfig(latest)
78
+ // Defensive guard (SPEC §16.1): after pinning, a boundary deposit may
79
+ // move deployment-owned sections only. If build-owned sections change
80
+ // without the build's own branch advancing, the artifact was written
81
+ // by something other than a launch — keep the last valid boundary.
82
+ if (!buildOwnedSectionsDiffer(currentConfig, parsed)) {
83
+ currentConfig = parsed
84
+ }
70
85
  } catch {
71
86
  // The dispatcher publishes only validated snapshots. If storage is
72
87
  // externally corrupted, preserve the child's last valid boundary.