@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
@@ -3,79 +3,42 @@ import { tmpdir } from 'node:os'
3
3
  import { join, resolve } from 'node:path'
4
4
  import { effectiveRuntimeReferences } from '../config/roles'
5
5
  import type { Config } from '../config/schema'
6
- import { vercelSandboxConfigSchema } from '../config/schema'
7
6
  import { loadConfig } from '../config/load'
8
7
  import { createProductionRuntimes } from '../ports/runner/production'
9
8
  import { createRuntimeResolver } from '../ports/runner/routing'
10
9
  import type { RuntimeRegistry } from '../ports/runner/runtime'
11
10
  import type { Exec } from '../ports/workspace/git-worktree'
12
11
  import { spawnExec } from '../ports/workspace/git-worktree'
13
- import { type VercelSandboxFacade, validateVercelSandbox } from '../ports/workspace/vercel-sandbox'
14
- import { loadPlugins } from '../plugins/load'
12
+ import { builtinWorkspaceProviderCapabilities } from '../ports/workspace/builtin-capabilities'
13
+ import type {
14
+ GuestProbeReport,
15
+ InitValidationReport,
16
+ ReadinessCheck,
17
+ WorkspaceProviderCapabilities,
18
+ } from '../ports/workspace/provider-capabilities'
19
+ import { sandboxForbiddenEnvMessage } from '../ports/workspace/provider-capabilities'
20
+ import { SANDBOX_FORBIDDEN_ENV } from '../ports/workspace/operator-sandbox'
21
+ import type { VercelSandboxFacade } from '../ports/workspace/vercel-sandbox'
22
+ import { diagnosePlugins, loadPlugins } from '../plugins/load'
15
23
  import { materializePluginRuntimes } from '../plugins/runtimes'
16
24
  import { createTicketSource } from '../ports/tickets/create'
17
25
  import { inspectLocalStoreSnapshot } from '../store/local/store'
18
26
  import type { StoreOpener } from './store-opening'
19
27
  import { openProductionStore } from './store-opening'
20
28
  import { isRemoteStoreRef, resolveMainRepo, resolveRepoStatePaths } from './repo-state'
29
+ import { createReadinessRedactor, gitText } from './init-readiness-shared'
21
30
 
22
- export const INIT_PROBE_MARKER = 'AB_INIT_READINESS_V1='
23
-
24
- export interface ReadinessCheck {
25
- name: string
26
- status: 'pass' | 'fail' | 'absent'
27
- detail: string
28
- }
29
-
30
- export interface InitValidationReport {
31
- provider: string
32
- context: 'local worktree' | 'Vercel Sandbox'
33
- workspace?: string
34
- revision?: string
35
- checks: ReadinessCheck[]
36
- exitCode: number
37
- /** Vercel only: automatic snapshots deleted while releasing the disposable
38
- * environment; zero proves no snapshot storage was left behind. */
39
- snapshotsDeleted?: number
40
- }
41
-
42
- export interface GuestProbeReport {
43
- checks: ReadinessCheck[]
44
- }
45
-
46
- function message(error: unknown): string {
47
- if (error instanceof AggregateError) {
48
- return [error.message, ...error.errors.map(message)].filter(Boolean).join('; ')
49
- }
50
- if (error instanceof Error)
51
- return `${error.message}${error.cause === undefined ? '' : `; ${message(error.cause)}`}`
52
- return String(error)
53
- }
54
-
55
- /** Replace every supplied nonempty value, longest first, in all diagnostics. */
56
- export function createReadinessRedactor(
57
- env: Readonly<Record<string, string | undefined>>,
58
- explicitSecretNames: readonly string[] = [],
59
- ): (value: unknown) => string {
60
- const namedSecrets = new Set(explicitSecretNames)
61
- const secrets = [
62
- ...new Set(
63
- Object.entries(env)
64
- .filter(
65
- ([name, value]) =>
66
- value !== undefined &&
67
- value !== '' &&
68
- (namedSecrets.has(name) || /(?:TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH)/i.test(name)),
69
- )
70
- .map(([, value]) => value as string),
71
- ),
72
- ].sort((left, right) => right.length - left.length)
73
- return (value) => {
74
- let text = message(value)
75
- for (const secret of secrets) text = text.split(secret).join('[REDACTED]')
76
- return text
77
- }
78
- }
31
+ export {
32
+ INIT_PROBE_MARKER,
33
+ createReadinessRedactor,
34
+ gitText,
35
+ parseGuestOutput,
36
+ } from './init-readiness-shared'
37
+ export type {
38
+ GuestProbeReport,
39
+ InitValidationReport,
40
+ ReadinessCheck,
41
+ } from '../ports/workspace/provider-capabilities'
79
42
 
80
43
  async function shell(
81
44
  exec: Exec,
@@ -123,14 +86,38 @@ export async function runGuestReadinessProbe(opts: {
123
86
  openStore?: StoreOpener
124
87
  exec?: Exec
125
88
  signal?: AbortSignal
89
+ /**
90
+ * Guest tolerance mode (default `true`): a configured package plugin that
91
+ * cannot be resolved from the candidate roots is skipped with a notice
92
+ * instead of failing readiness. Host-side call sites pass `false` so
93
+ * resolution failures stay fatal.
94
+ */
95
+ guest?: boolean
126
96
  }): Promise<GuestProbeReport> {
127
97
  const checks: ReadinessCheck[] = []
128
98
  const config = await loadConfig(join(opts.repo, 'autobuild.toml'))
129
- const vercelConfig =
130
- config.workspace.provider === 'vercel-sandbox'
131
- ? vercelSandboxConfigSchema.parse(config.workspace.config)
99
+ // Pre-registry site: plugins are not loaded yet, so provider behavior comes
100
+ // from the builtin capability table (AUT-516).
101
+ const builtinCaps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
102
+ const providerConfig =
103
+ builtinCaps?.configSchema !== undefined
104
+ ? builtinCaps.configSchema.parse(config.workspace.config)
132
105
  : undefined
133
- const redact = createReadinessRedactor(opts.env, vercelConfig?.environmentVariables)
106
+ // Redaction scope is the provider's declared `guestEnvNames` (AUT-539) —
107
+ // for vercel-sandbox that is `environmentVariables` plus
108
+ // `gitUsernameEnv`/`gitPasswordEnv`, deliberately wider than
109
+ // `environmentVariables` alone: the capability contract documents
110
+ // `guestEnvNames` as the readiness-redaction surface, and a git credential
111
+ // echoed into a guest check detail (e.g. a failed clone printing an
112
+ // authenticated URL) is a secret we know the guest holds. Note the
113
+ // byte-level consequence: repos whose git env values appear in a guest
114
+ // check detail now see `[REDACTED]` there. Providers without a
115
+ // `guestEnvNames`/`configSchema` declaration fall back to the name-heuristic
116
+ // redaction only.
117
+ const redact = createReadinessRedactor(
118
+ opts.env,
119
+ providerConfig !== undefined ? (builtinCaps?.guestEnvNames?.(providerConfig) ?? []) : [],
120
+ )
134
121
  try {
135
122
  const setup = config.commands.setup
136
123
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error('validation cancelled')
@@ -156,16 +143,54 @@ export async function runGuestReadinessProbe(opts: {
156
143
  let runtimes: RuntimeRegistry
157
144
  try {
158
145
  const packageRoot = await resolveMainRepo(opts.repo, opts.exec ?? spawnExec)
159
- const plugins = await loadPlugins(config.plugins, opts.repo, { packageRoot })
146
+ // This function IS the guest probe (AUT-517): it runs inside the
147
+ // disposable environment and never constructs workspace providers, so a
148
+ // configured provider plugin the guest cannot resolve is skipped with a
149
+ // notice instead of failing readiness. The validateInitReadiness call
150
+ // sites stay strict — the git-worktree branch below passes `guest: false`
151
+ // (AUT-561); the private probe `bin/ab-init-probe.ts` keeps the default
152
+ // guest tolerance. diagnosePlugins shares attemptPlugin with
153
+ // loadPlugins — same resulting registry — but returns per-module reports,
154
+ // so a resolution-stage skip (the guest tolerance path) can be surfaced
155
+ // in the check detail instead of degrading silently.
156
+ const diagnosis = await diagnosePlugins(config.plugins, opts.repo, {
157
+ packageRoot,
158
+ guest: opts.guest ?? true,
159
+ })
160
+ // loadPlugins throws on the first failed report; diagnosePlugins does
161
+ // not, so restore that fail-closed precedence here: repo-path resolution
162
+ // failures and every post-resolution failure (evaluation, manifest,
163
+ // registration) stay fatal for the probe exactly as before, rendered by
164
+ // the surrounding catch into a `configuration and plugins` fail check.
165
+ const failedReport = diagnosis.reports.find((report) => report.status === 'failed')
166
+ if (failedReport !== undefined)
167
+ throw new Error(failedReport.error, { cause: failedReport.cause })
168
+ // Notice parity with loadPlugins: skipped loads were announced on stderr.
169
+ // Only resolution-stage skips alter the check detail; the transitional
170
+ // registration-stage skip (a configured plugin re-registering a builtin
171
+ // workspace provider) is the documented arrangement, not a problem, so it
172
+ // stays out of the report.
173
+ for (const report of diagnosis.reports) {
174
+ if (report.status === 'skipped' && report.notice !== undefined) console.error(report.notice)
175
+ }
176
+ const unresolved = diagnosis.reports.filter(
177
+ (report) => report.status === 'skipped' && report.stage === 'resolution',
178
+ )
179
+ let pluginDetail = `${config.plugins.length} configured plugin(s) loaded`
180
+ for (const report of unresolved) {
181
+ pluginDetail +=
182
+ `; ${report.notice}; to make "${report.module}" available to guests, ` +
183
+ "add the package to the repository's dependencies so it resolves from the repository install during commands.setup"
184
+ }
160
185
  runtimes = await materializePluginRuntimes(
161
186
  opts.runtimes ?? createProductionRuntimes().runtimes,
162
- plugins,
187
+ diagnosis.registry,
163
188
  { repoRoot: opts.repo, env: opts.env },
164
189
  )
165
190
  checks.push({
166
191
  name: 'configuration and plugins',
167
192
  status: 'pass',
168
- detail: `${config.plugins.length} configured plugin(s) loaded`,
193
+ detail: pluginDetail,
169
194
  })
170
195
  } catch (error) {
171
196
  return {
@@ -181,7 +206,7 @@ export async function runGuestReadinessProbe(opts: {
181
206
  }
182
207
 
183
208
  const runtimeRemediation = (runtime: string, preflightOnly: boolean): string =>
184
- vercelConfig === undefined
209
+ builtinCaps?.requireRuntimeProvisioning !== true
185
210
  ? 'install/authenticate this runtime in the local validation environment'
186
211
  : `fix workspace.config.runtimeProvisioning.${runtime}${preflightOnly ? '.preflight' : ''} and expose API credential names in workspace.config.environmentVariables`
187
212
 
@@ -277,56 +302,79 @@ export async function runGuestReadinessProbe(opts: {
277
302
  return { checks }
278
303
  }
279
304
 
280
- function parseGuestOutput(output: string): GuestProbeReport {
281
- const line = output.split(/\r?\n/).find((candidate) => candidate.startsWith(INIT_PROBE_MARKER))
282
- if (line === undefined)
283
- throw new Error('remote readiness probe returned malformed output (result marker absent)')
284
- const value = JSON.parse(line.slice(INIT_PROBE_MARKER.length)) as GuestProbeReport
285
- if (!Array.isArray(value.checks))
286
- throw new Error('remote readiness probe returned malformed checks')
287
- return value
288
- }
289
-
290
- async function gitText(
291
- exec: Exec,
292
- repo: string,
293
- args: string[],
294
- signal?: AbortSignal,
295
- ): Promise<string> {
296
- const result = await exec(['git', ...args], {
297
- cwd: repo,
298
- ...(signal === undefined ? {} : { signal }),
299
- })
300
- if (result.exitCode !== 0)
305
+ /** Forge and required-environment checks driven by one provider's capability
306
+ * declarations. Shared by the pre-registry builtin preflight and the
307
+ * registry-aware check after plugin load so a plugin provider's declarations
308
+ * are honoured at the same seam. */
309
+ function declaredForgeEnvChecks(
310
+ caps: WorkspaceProviderCapabilities | undefined,
311
+ config: Config,
312
+ env: Record<string, string | undefined>,
313
+ ): void {
314
+ if (caps?.supportedForges !== undefined && !caps.supportedForges.includes(config.forge)) {
301
315
  throw new Error(
302
- `git ${args.join(' ')} exited ${result.exitCode}: ${result.stderr.trim() || result.stdout.trim()}`,
316
+ caps.forgeValidationMessage ??
317
+ `workspace provider "${config.workspace.provider}" does not support forge "${config.forge}"`,
318
+ )
319
+ }
320
+ for (const group of caps?.requiredEnv ?? []) {
321
+ if (group.validationMessage === undefined) continue
322
+ const satisfied = group.alternatives.some((names) =>
323
+ names.every((name) => env[name] !== undefined && env[name] !== ''),
303
324
  )
304
- return result.stdout.trim()
325
+ if (!satisfied) throw new Error(group.validationMessage)
326
+ }
327
+ }
328
+
329
+ /** Sandbox-forwarding check driven by one provider's declared
330
+ * `sandboxForbiddenEnv` extras. Enforced at the registry-aware init seam so a
331
+ * plugin-declared forbidden name is refused before any infrastructure is
332
+ * provisioned, mirroring `createWorkspaceProvider`'s construction-site check:
333
+ * same gate (`[orchestrator].enabled`), same name union
334
+ * (`SANDBOX_FORBIDDEN_ENV` plus the declared extras), same message. A no-op
335
+ * repeat for builtins — config parse already rejected every shared and
336
+ * builtin-declared name — and a no-op when the declaration is absent. */
337
+ function declaredSandboxForbiddenEnvCheck(
338
+ caps: WorkspaceProviderCapabilities | undefined,
339
+ config: Config,
340
+ ): void {
341
+ if (!config.orchestrator.enabled || caps?.sandboxForbiddenEnv === undefined) return
342
+ for (const name of config.orchestrator.sandbox.environmentVariables) {
343
+ if (SANDBOX_FORBIDDEN_ENV.includes(name) || caps.sandboxForbiddenEnv.includes(name)) {
344
+ throw new Error(sandboxForbiddenEnvMessage(name))
345
+ }
346
+ }
305
347
  }
306
348
 
307
349
  function hostPreflight(config: Config, env: Record<string, string | undefined>): void {
308
- if (config.workspace.provider !== 'vercel-sandbox') return
309
- if (config.forge !== 'github')
310
- throw new Error(
311
- 'vercel-sandbox supports forge = "github" only; configure GitHub publication before validating',
312
- )
313
- if (!env.GITHUB_TOKEN && !env.GH_TOKEN)
314
- throw new Error('vercel-sandbox publication requires push-capable GITHUB_TOKEN or GH_TOKEN')
350
+ // Pre-registry site: the builtin capability table drives the provider's
351
+ // declared forge and environment requirements (AUT-516). Plugin providers
352
+ // are checked registry-aware after plugin load in validateInitReadiness.
353
+ // The sandbox-forwarding rule is never enforced here: it is enforced at
354
+ // config parse for builtins (plugins are not loaded when config is parsed)
355
+ // and at the two post-load seams — this file's registry-aware check and
356
+ // createWorkspaceProvider construction — for plugin-declared names.
357
+ const caps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
358
+ declaredForgeEnvChecks(caps, config, env)
359
+ // Stays exactly where today's remote-provider branch put it: only a
360
+ // provider with remote readiness preflights its ticket-source credential
361
+ // here, because remote provisioning acquires tickets before allocating
362
+ // disposable infrastructure.
315
363
  if (
316
- !env.VERCEL_OIDC_TOKEN &&
317
- (!env.VERCEL_TOKEN || !env.VERCEL_TEAM_ID || !env.VERCEL_PROJECT_ID)
318
- ) {
319
- throw new Error(
320
- 'Vercel authentication requires VERCEL_OIDC_TOKEN or the durable VERCEL_TOKEN, VERCEL_TEAM_ID, and VERCEL_PROJECT_ID set',
321
- )
322
- }
323
- if (config.tickets.source === 'linear' && !env.LINEAR_API_KEY)
364
+ caps?.validateReadiness !== undefined &&
365
+ config.tickets.source === 'linear' &&
366
+ !env.LINEAR_API_KEY
367
+ )
324
368
  throw new Error('ticket source "linear" requires LINEAR_API_KEY')
325
369
  }
326
370
 
327
371
  export async function validateInitReadiness(opts: {
328
372
  targetRepo: string
329
373
  env: Record<string, string | undefined>
374
+ /** The raw launcher process environment, alongside the dotenv-augmented
375
+ * `env`. When supplied, a declared `processEnvOnly` variable present only in
376
+ * a loaded `.env` fails the registry-aware check below. */
377
+ processEnv?: Record<string, string | undefined>
330
378
  stdout?: (line: string) => void
331
379
  exec?: Exec
332
380
  openStore?: StoreOpener
@@ -342,20 +390,65 @@ export async function validateInitReadiness(opts: {
342
390
  const configBytes = await readFile(configPath, 'utf8')
343
391
  const config = await loadConfig(configPath)
344
392
  const state = resolveRepoStatePaths({ repo, envStore: opts.env.AB_STORE })
345
- const vercelConfig =
346
- config.workspace.provider === 'vercel-sandbox'
347
- ? vercelSandboxConfigSchema.parse(config.workspace.config)
393
+ // Pre-registry parse and redaction come from the builtin capability table;
394
+ // after plugin load the registration's own declarations take over (AUT-516).
395
+ const builtinCaps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
396
+ let providerConfig =
397
+ builtinCaps?.configSchema !== undefined
398
+ ? builtinCaps.configSchema.parse(config.workspace.config)
348
399
  : undefined
349
- const redact = createReadinessRedactor(opts.env, [
350
- ...(vercelConfig?.environmentVariables ?? []),
351
- ...(vercelConfig?.gitUsernameEnv === undefined ? [] : [vercelConfig.gitUsernameEnv]),
352
- ...(vercelConfig?.gitPasswordEnv === undefined ? [] : [vercelConfig.gitPasswordEnv]),
353
- ])
400
+ let redact = createReadinessRedactor(
401
+ opts.env,
402
+ providerConfig !== undefined ? (builtinCaps?.guestEnvNames?.(providerConfig) ?? []) : [],
403
+ )
404
+ let caps: WorkspaceProviderCapabilities | undefined
354
405
  try {
355
406
  hostPreflight(config, opts.env)
356
407
  // Ticket acquisition is a host responsibility for both workspace providers.
357
408
  // Exercise its read surface before allocating disposable infrastructure.
358
409
  const hostPlugins = await loadPlugins(config.plugins, repo, { packageRoot: repo })
410
+ caps =
411
+ hostPlugins.workspaceProviders.get(config.workspace.provider)?.capabilities ?? builtinCaps
412
+ // Registry-aware declaration checks. The pre-registry preflight above could
413
+ // only consult the builtin table, so a plugin provider's declared forge and
414
+ // required-environment validations are honoured here (f_9d68b3e5); for the
415
+ // builtins the registration carries the same declarations, so the check is
416
+ // a no-op repeat after the preflight already passed.
417
+ declaredForgeEnvChecks(caps, config, opts.env)
418
+ // Same seam, declared sandbox-forbidden extras (AUT-536): forge/env
419
+ // failures keep today's precedence, ticket acquisition follows.
420
+ declaredSandboxForbiddenEnvCheck(caps, config)
421
+ // The registration's own configSchema and guestEnvNames take over from the
422
+ // builtin table, so a plugin provider's readiness context carries its
423
+ // parsed config and its declared names are redacted (f_55ba7591). A
424
+ // declared guestEnvNames with no declared configSchema still feeds the
425
+ // redactor — it is called with the raw `[workspace.config]` then, mirroring
426
+ // how createWorkspaceProvider falls back to the raw config for
427
+ // requireRuntimeProvisioning (f_1ddf1415). The post-report
428
+ // describeEnvironment call below receives that same raw fallback, so the
429
+ // two call shapes cannot drift apart again.
430
+ if (caps?.configSchema !== undefined)
431
+ providerConfig = caps.configSchema.parse(config.workspace.config)
432
+ if (caps?.guestEnvNames !== undefined)
433
+ redact = createReadinessRedactor(
434
+ opts.env,
435
+ caps.guestEnvNames(providerConfig !== undefined ? providerConfig : config.workspace.config),
436
+ )
437
+ // Registry-aware process-env-only check: a declared name present in the
438
+ // dotenv-augmented `env` but absent from the raw launcher map fails here,
439
+ // so plugin-declared requirements and non-CLI callers are covered too
440
+ // (the CLI path's own guard in main.ts runs before this and keeps its
441
+ // error precedence).
442
+ if (opts.processEnv !== undefined) {
443
+ for (const requirement of caps?.processEnvOnly ?? []) {
444
+ if (
445
+ opts.env[requirement.name] !== undefined &&
446
+ opts.processEnv[requirement.name] === undefined
447
+ ) {
448
+ throw new Error(requirement.message)
449
+ }
450
+ }
451
+ }
359
452
  if (config.tickets.source !== 'file') {
360
453
  const ticketSource = await createTicketSource(
361
454
  config.tickets,
@@ -399,9 +492,18 @@ export async function validateInitReadiness(opts: {
399
492
  throw new Error(
400
493
  `autobuild.toml differs from committed ${config.baseBranch}; commit setup changes before validating`,
401
494
  )
495
+ // The host's strict `loadPlugins(config.plugins, repo, { packageRoot: repo })`
496
+ // above is the operative gate: it already threw on the first unresolvable
497
+ // package plugin before this branch is entered, and it shares the probe's
498
+ // resolution root — `resolveMainRepo` maps a linked worktree back to the
499
+ // main checkout — so `guest: false` cannot change today's outcomes. It is
500
+ // defense in depth that keeps this call site strict per the "host-side
501
+ // sites stay strict" rationale even if that pre-check is ever weakened or
502
+ // reordered (AUT-561).
402
503
  const guest = await runGuestReadinessProbe({
403
504
  repo: workspace,
404
505
  env: { ...opts.env, AB_STORE: state.storeRef },
506
+ guest: false,
405
507
  ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
406
508
  ...(opts.runtimes !== undefined ? { runtimes: opts.runtimes } : {}),
407
509
  exec,
@@ -446,103 +548,33 @@ export async function validateInitReadiness(opts: {
446
548
  }
447
549
  if (failure !== undefined) throw failure
448
550
  if (cleanupFailure !== undefined) throw cleanupFailure
449
- } else if (config.workspace.provider === 'vercel-sandbox') {
450
- const vercel = vercelConfig!
451
- const storeRef = state.storeRef
452
- if (!/^https:\/\//i.test(storeRef))
453
- throw new Error('vercel-sandbox requires AB_STORE to be an HTTPS URL reachable from Vercel')
454
- const token = opts.env.AB_TOKEN
455
- if (!token) throw new Error('vercel-sandbox requires nonempty AB_TOKEN for the hosted Store')
456
- const remoteLine = await gitText(
457
- exec,
458
- repo,
459
- ['ls-remote', '--heads', 'origin', `refs/heads/${config.baseBranch}`],
460
- opts.signal,
461
- )
462
- const remoteRevision = remoteLine.split(/\s+/)[0]
463
- if (!/^[0-9a-f]{40,64}$/i.test(remoteRevision ?? '')) {
464
- throw new Error(
465
- `remote base ${config.baseBranch} does not exist; commit and push setup changes before validating`,
466
- )
551
+ } else if (caps?.validateReadiness !== undefined) {
552
+ // Store preconditions come from the registration's declaration, uniformly
553
+ // for builtin and plugin providers; the texts are byte-identical to the
554
+ // branch they replace.
555
+ if (caps.storeRequirements !== undefined) {
556
+ if (!/^https:\/\//i.test(state.storeRef))
557
+ throw new Error(caps.storeRequirements.storeRefMessage)
558
+ const token = opts.env.AB_TOKEN
559
+ if (!token) throw new Error(caps.storeRequirements.storeTokenMessage)
467
560
  }
468
- await gitText(
469
- exec,
561
+ report = await caps.validateReadiness({
562
+ config,
563
+ providerConfig,
564
+ env: opts.env,
565
+ storeRef: state.storeRef,
566
+ storeToken: opts.env.AB_TOKEN ?? '',
470
567
  repo,
471
- [
472
- 'fetch',
473
- '--no-tags',
474
- '--no-write-fetch-head',
475
- '--refmap=',
476
- 'origin',
477
- `refs/heads/${config.baseBranch}`,
478
- ],
479
- opts.signal,
480
- )
481
- const shownConfig = await exec(['git', 'show', `${remoteRevision}:autobuild.toml`], {
482
- cwd: repo,
483
- ...(opts.signal === undefined ? {} : { signal: opts.signal }),
568
+ baseBranch: config.baseBranch,
569
+ configBytes,
570
+ exec,
571
+ redact,
572
+ runtimeReferences: effectiveRuntimeReferences(config),
573
+ stdout: opts.stdout ?? (() => {}),
574
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
575
+ ...(opts.vercelFacade !== undefined ? { facade: opts.vercelFacade } : {}),
576
+ ...(opts.packageArchive !== undefined ? { packageArchive: opts.packageArchive } : {}),
484
577
  })
485
- if (shownConfig.exitCode !== 0) {
486
- throw new Error(
487
- `remote ${config.baseBranch} does not contain autobuild.toml; commit and push setup changes before validating`,
488
- )
489
- }
490
- if (shownConfig.stdout !== configBytes) {
491
- throw new Error(
492
- `remote ${config.baseBranch} autobuild.toml differs from this checkout; commit and push setup changes before validating`,
493
- )
494
- }
495
- let remote: Awaited<ReturnType<typeof validateVercelSandbox>>
496
- try {
497
- remote = await validateVercelSandbox({
498
- config: vercel,
499
- env: opts.env,
500
- storeRef,
501
- storeToken: token,
502
- repo,
503
- baseBranch: config.baseBranch,
504
- ...(opts.vercelFacade !== undefined ? { facade: opts.vercelFacade } : {}),
505
- exec,
506
- ...(opts.packageArchive !== undefined ? { packageArchive: opts.packageArchive } : {}),
507
- runtimeReferences: effectiveRuntimeReferences(config),
508
- ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
509
- onSandbox: (name) => opts.stdout?.(`Disposable Vercel Sandbox: ${name} (active)`),
510
- })
511
- } catch (error) {
512
- throw new Error(redact(error))
513
- }
514
- let guest: GuestProbeReport
515
- try {
516
- guest = parseGuestOutput(remote.output)
517
- } catch (error) {
518
- throw new Error(
519
- `disposable sandbox ${remote.sandbox} was deleted, but its readiness output was invalid: ${redact(error)}`,
520
- )
521
- }
522
- report = {
523
- provider: 'vercel-sandbox',
524
- context: 'Vercel Sandbox',
525
- workspace: remote.sandbox,
526
- revision: remote.revision,
527
- snapshotsDeleted: remote.snapshotsDeleted,
528
- checks: [
529
- {
530
- name: 'repository acquisition',
531
- status: 'pass',
532
- detail: `${remote.origin} ${remote.revision}`,
533
- },
534
- {
535
- name: 'system provisioning',
536
- status: 'pass',
537
- detail:
538
- remote.provisioning.length === 0
539
- ? 'no workspace.config.provisioning steps declared'
540
- : `completed: ${remote.provisioning.join(', ')}`,
541
- },
542
- ...guest.checks,
543
- ],
544
- exitCode: guest.checks.some((check) => check.status === 'fail') ? 1 : 0,
545
- }
546
578
  } else {
547
579
  throw new Error(
548
580
  `workspace provider "${config.workspace.provider}" does not support init readiness validation`,
@@ -554,20 +586,14 @@ export async function validateInitReadiness(opts: {
554
586
  stdout(`Readiness: ${report.context} (${report.provider})`)
555
587
  stdout(`Store: ${state.storeRef}`)
556
588
  stdout(`Forge: ${config.forge}`)
557
- if (vercelConfig !== undefined) {
558
- stdout(
559
- `Vercel auth: ${opts.env.VERCEL_OIDC_TOKEN ? 'OIDC' : 'access token'}; team=${opts.env.VERCEL_TEAM_ID ?? '(linked)'}; project=${opts.env.VERCEL_PROJECT_ID ?? '(linked)'}`,
560
- )
561
- stdout(
562
- `Private clone variables: ${vercelConfig.gitUsernameEnv === undefined ? '(public repository)' : `${vercelConfig.gitUsernameEnv}, ${vercelConfig.gitPasswordEnv}`}`,
563
- )
564
- stdout(
565
- `Guest environment variable names: ${vercelConfig.environmentVariables.join(', ') || '(none)'}`,
566
- )
567
- stdout(
568
- `Runtime provisioning names: ${Object.keys(vercelConfig.runtimeProvisioning).sort().join(', ') || '(none)'}`,
569
- )
570
- }
589
+ // Same raw `[workspace.config]` fallback guestEnvNames gets (f_55ba7591): a
590
+ // provider that declares describeEnvironment without a configSchema still
591
+ // receives a defined argument — the raw table — never undefined.
592
+ for (const line of caps?.describeEnvironment?.(
593
+ providerConfig !== undefined ? providerConfig : config.workspace.config,
594
+ opts.env,
595
+ ) ?? [])
596
+ stdout(line)
571
597
  if (report.workspace !== undefined)
572
598
  stdout(
573
599
  `Disposable environment: ${report.workspace} (released${report.snapshotsDeleted === undefined ? '' : `; ${report.snapshotsDeleted} snapshot(s) deleted`})`,