@defrex/autobuild 0.8.0 → 0.10.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 (182) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +323 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +166 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +175 -28
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +204 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +14 -0
  13. package/package.json +45 -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/facts.ts +215 -0
  19. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  20. package/packages/core/src/cli/dashboard/model.ts +141 -153
  21. package/packages/core/src/cli/dashboard/poll.ts +76 -12
  22. package/packages/core/src/cli/dashboard/render.ts +34 -5
  23. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  24. package/packages/core/src/cli/dispatch-process.ts +1 -13
  25. package/packages/core/src/cli/dispatch.ts +392 -75
  26. package/packages/core/src/cli/harvest.ts +122 -8
  27. package/packages/core/src/cli/help.ts +62 -9
  28. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  29. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  30. package/packages/core/src/cli/init-validation.ts +266 -222
  31. package/packages/core/src/cli/installation.ts +50 -8
  32. package/packages/core/src/cli/main.ts +70 -11
  33. package/packages/core/src/cli/mcp.ts +23 -1
  34. package/packages/core/src/cli/plugin.ts +7 -4
  35. package/packages/core/src/cli/remote-poll.ts +277 -0
  36. package/packages/core/src/cli/repo-state.ts +22 -0
  37. package/packages/core/src/cli/repository-status.ts +39 -1
  38. package/packages/core/src/cli/self-update.ts +77 -2
  39. package/packages/core/src/cli/status.ts +150 -28
  40. package/packages/core/src/cli/terminals.ts +2 -1
  41. package/packages/core/src/cli/ticket-assets.ts +247 -0
  42. package/packages/core/src/cli/ticket.ts +453 -8
  43. package/packages/core/src/cli/wait.ts +157 -41
  44. package/packages/core/src/cli/watch.ts +138 -168
  45. package/packages/core/src/config/live.ts +23 -0
  46. package/packages/core/src/config/pipeline-source.ts +388 -0
  47. package/packages/core/src/config/schema.ts +251 -19
  48. package/packages/core/src/events/catalog.ts +1 -0
  49. package/packages/core/src/events/globs.ts +47 -0
  50. package/packages/core/src/events/payloads.ts +52 -2
  51. package/packages/core/src/events/repository.ts +52 -1
  52. package/packages/core/src/events/sessions.ts +69 -10
  53. package/packages/core/src/events/workspace-remote.ts +12 -0
  54. package/packages/core/src/integration/harness.ts +115 -2
  55. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  56. package/packages/core/src/kernel/auto-merge-default.ts +157 -0
  57. package/packages/core/src/kernel/auto-merge.ts +99 -22
  58. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  59. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  60. package/packages/core/src/kernel/engine.ts +9 -438
  61. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  62. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  63. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  64. package/packages/core/src/kernel/harvest.ts +62 -18
  65. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  66. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  67. package/packages/core/src/kernel/incremental.ts +53 -0
  68. package/packages/core/src/kernel/log-index.ts +620 -0
  69. package/packages/core/src/kernel/phases.ts +16 -2
  70. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  71. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  72. package/packages/core/src/kernel/reducer.ts +254 -73
  73. package/packages/core/src/operator/annotations.ts +6 -0
  74. package/packages/core/src/operator/control.ts +19 -5
  75. package/packages/core/src/operator/index.ts +71 -6
  76. package/packages/core/src/operator/query.ts +253 -24
  77. package/packages/core/src/operator/read-view.ts +98 -0
  78. package/packages/core/src/operator/registry.ts +55 -10
  79. package/packages/core/src/operator/requests.ts +9 -6
  80. package/packages/core/src/operator/sandbox-host.ts +140 -0
  81. package/packages/core/src/operator/sandbox.ts +247 -14
  82. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  83. package/packages/core/src/orchestrator/approvals.ts +115 -0
  84. package/packages/core/src/orchestrator/conversation.ts +167 -0
  85. package/packages/core/src/orchestrator/model.ts +86 -0
  86. package/packages/core/src/orchestrator/prompt.ts +61 -0
  87. package/packages/core/src/orchestrator/tools.ts +117 -0
  88. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  89. package/packages/core/src/plugin-sdk/index.ts +51 -1
  90. package/packages/core/src/plugins/load.ts +135 -7
  91. package/packages/core/src/plugins/manifest.ts +183 -10
  92. package/packages/core/src/plugins/registry.ts +73 -4
  93. package/packages/core/src/ports/forge/create.ts +1 -1
  94. package/packages/core/src/ports/forge/fake.ts +31 -0
  95. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  96. package/packages/core/src/ports/forge/github.ts +13 -2
  97. package/packages/core/src/ports/forge/local-git.ts +31 -1
  98. package/packages/core/src/ports/runner/claude.ts +2 -2
  99. package/packages/core/src/ports/runner/codex.ts +6 -2
  100. package/packages/core/src/ports/runner/fake.ts +7 -1
  101. package/packages/core/src/ports/runner/pi.ts +8 -6
  102. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  103. package/packages/core/src/ports/tickets/fake.ts +5 -1
  104. package/packages/core/src/ports/tickets/file.ts +169 -48
  105. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  106. package/packages/core/src/ports/tickets/linear.ts +33 -1
  107. package/packages/core/src/ports/tickets/operations.ts +1 -1
  108. package/packages/core/src/ports/tickets/remote.ts +8 -1
  109. package/packages/core/src/ports/types.ts +49 -3
  110. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  111. package/packages/core/src/ports/workspace/create.ts +74 -39
  112. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  113. package/packages/core/src/ports/workspace/fake.ts +169 -4
  114. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  115. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  116. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  117. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  118. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  119. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  120. package/packages/core/src/processes/build-child.ts +17 -2
  121. package/packages/core/src/processes/build-execution-state.ts +68 -15
  122. package/packages/core/src/processes/build-runner.ts +238 -93
  123. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  124. package/packages/core/src/processes/dispatcher.ts +501 -201
  125. package/packages/core/src/processes/execution-settlement.ts +72 -39
  126. package/packages/core/src/processes/harvest-child.ts +5 -0
  127. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  128. package/packages/core/src/processes/harvest-runner.ts +66 -19
  129. package/packages/core/src/processes/harvest.ts +196 -24
  130. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  131. package/packages/core/src/processes/orchestrator-tick.ts +360 -0
  132. package/packages/core/src/processes/publication-state.ts +164 -52
  133. package/packages/core/src/processes/repo-view.ts +1436 -0
  134. package/packages/core/src/processes/sandbox-state.ts +36 -2
  135. package/packages/core/src/processes/tick-state.ts +33 -0
  136. package/packages/core/src/store/adapter.ts +41 -0
  137. package/packages/core/src/store/build-scope.ts +90 -0
  138. package/packages/core/src/store/contract.ts +1205 -0
  139. package/packages/core/src/store/digest.ts +102 -0
  140. package/packages/core/src/store/local/schema.ts +46 -2
  141. package/packages/core/src/store/local/store.ts +445 -4
  142. package/packages/core/src/store/memory.ts +246 -0
  143. package/packages/core/src/store/new-build.ts +17 -0
  144. package/packages/core/src/store/phase-session.ts +94 -0
  145. package/packages/core/src/store/remote/client.ts +310 -2
  146. package/packages/core/src/store/remote/index.ts +14 -2
  147. package/packages/core/src/store/remote/protocol.ts +115 -12
  148. package/packages/core/src/store/remote/version.ts +6 -2
  149. package/packages/core/src/store/repo-state-events.ts +205 -0
  150. package/packages/core/src/store/retention.ts +7 -0
  151. package/packages/core/src/store/session-handle.ts +94 -0
  152. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
  153. package/packages/core/src/store/snapshots.ts +88 -0
  154. package/packages/core/src/store/streams/session-writer.ts +42 -2
  155. package/packages/core/src/store/streams/wait.ts +4 -5
  156. package/packages/core/src/store/ticket-assets.ts +458 -0
  157. package/packages/core/src/store/types.ts +160 -1
  158. package/packages/core/src/testing/index.ts +59 -0
  159. package/skills/code-review/SKILL.md +5 -1
  160. package/skills/guide/SKILL.md +152 -40
  161. package/skills/guide/references/operator-api.md +2 -2
  162. package/skills/guide/references/plugin-authoring.md +17 -0
  163. package/skills/guide/references/remote-store-protocol.md +204 -16
  164. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  165. package/skills/guide/references/setup.md +12 -9
  166. package/skills/implement/SKILL.md +3 -0
  167. package/skills/operate/SKILL.md +126 -0
  168. package/skills/plan/SKILL.md +4 -0
  169. package/skills/plan-review/SKILL.md +5 -1
  170. package/skills/spec/SKILL.md +46 -3
  171. package/skills/tickets/SKILL.md +57 -5
  172. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  173. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  174. package/packages/core/src/cli/testkit.ts +0 -194
  175. package/packages/core/src/markdown.ts +0 -67
  176. package/packages/core/src/operator/client.ts +0 -311
  177. package/packages/core/src/operator/server.ts +0 -616
  178. package/packages/core/src/operator/sessions.ts +0 -223
  179. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  180. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  181. package/packages/core/src/store/remote/server.ts +0 -923
  182. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -3,79 +3,43 @@ 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'
25
+ import { LinearTicketSource } from '../ports/tickets/linear'
17
26
  import { inspectLocalStoreSnapshot } from '../store/local/store'
18
27
  import type { StoreOpener } from './store-opening'
19
28
  import { openProductionStore } from './store-opening'
20
29
  import { isRemoteStoreRef, resolveMainRepo, resolveRepoStatePaths } from './repo-state'
30
+ import { createReadinessRedactor, gitText } from './init-readiness-shared'
21
31
 
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
- }
32
+ export {
33
+ INIT_PROBE_MARKER,
34
+ createReadinessRedactor,
35
+ gitText,
36
+ parseGuestOutput,
37
+ } from './init-readiness-shared'
38
+ export type {
39
+ GuestProbeReport,
40
+ InitValidationReport,
41
+ ReadinessCheck,
42
+ } from '../ports/workspace/provider-capabilities'
79
43
 
80
44
  async function shell(
81
45
  exec: Exec,
@@ -123,14 +87,38 @@ export async function runGuestReadinessProbe(opts: {
123
87
  openStore?: StoreOpener
124
88
  exec?: Exec
125
89
  signal?: AbortSignal
90
+ /**
91
+ * Guest tolerance mode (default `true`): a configured package plugin that
92
+ * cannot be resolved from the candidate roots is skipped with a notice
93
+ * instead of failing readiness. Host-side call sites pass `false` so
94
+ * resolution failures stay fatal.
95
+ */
96
+ guest?: boolean
126
97
  }): Promise<GuestProbeReport> {
127
98
  const checks: ReadinessCheck[] = []
128
99
  const config = await loadConfig(join(opts.repo, 'autobuild.toml'))
129
- const vercelConfig =
130
- config.workspace.provider === 'vercel-sandbox'
131
- ? vercelSandboxConfigSchema.parse(config.workspace.config)
100
+ // Pre-registry site: plugins are not loaded yet, so provider behavior comes
101
+ // from the builtin capability table (AUT-516).
102
+ const builtinCaps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
103
+ const providerConfig =
104
+ builtinCaps?.configSchema !== undefined
105
+ ? builtinCaps.configSchema.parse(config.workspace.config)
132
106
  : undefined
133
- const redact = createReadinessRedactor(opts.env, vercelConfig?.environmentVariables)
107
+ // Redaction scope is the provider's declared `guestEnvNames` (AUT-539) —
108
+ // for vercel-sandbox that is `environmentVariables` plus
109
+ // `gitUsernameEnv`/`gitPasswordEnv`, deliberately wider than
110
+ // `environmentVariables` alone: the capability contract documents
111
+ // `guestEnvNames` as the readiness-redaction surface, and a git credential
112
+ // echoed into a guest check detail (e.g. a failed clone printing an
113
+ // authenticated URL) is a secret we know the guest holds. Note the
114
+ // byte-level consequence: repos whose git env values appear in a guest
115
+ // check detail now see `[REDACTED]` there. Providers without a
116
+ // `guestEnvNames`/`configSchema` declaration fall back to the name-heuristic
117
+ // redaction only.
118
+ const redact = createReadinessRedactor(
119
+ opts.env,
120
+ providerConfig !== undefined ? (builtinCaps?.guestEnvNames?.(providerConfig) ?? []) : [],
121
+ )
134
122
  try {
135
123
  const setup = config.commands.setup
136
124
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error('validation cancelled')
@@ -156,16 +144,54 @@ export async function runGuestReadinessProbe(opts: {
156
144
  let runtimes: RuntimeRegistry
157
145
  try {
158
146
  const packageRoot = await resolveMainRepo(opts.repo, opts.exec ?? spawnExec)
159
- const plugins = await loadPlugins(config.plugins, opts.repo, { packageRoot })
147
+ // This function IS the guest probe (AUT-517): it runs inside the
148
+ // disposable environment and never constructs workspace providers, so a
149
+ // configured provider plugin the guest cannot resolve is skipped with a
150
+ // notice instead of failing readiness. The validateInitReadiness call
151
+ // sites stay strict — the git-worktree branch below passes `guest: false`
152
+ // (AUT-561); the private probe `bin/ab-init-probe.ts` keeps the default
153
+ // guest tolerance. diagnosePlugins shares attemptPlugin with
154
+ // loadPlugins — same resulting registry — but returns per-module reports,
155
+ // so a resolution-stage skip (the guest tolerance path) can be surfaced
156
+ // in the check detail instead of degrading silently.
157
+ const diagnosis = await diagnosePlugins(config.plugins, opts.repo, {
158
+ packageRoot,
159
+ guest: opts.guest ?? true,
160
+ })
161
+ // loadPlugins throws on the first failed report; diagnosePlugins does
162
+ // not, so restore that fail-closed precedence here: repo-path resolution
163
+ // failures and every post-resolution failure (evaluation, manifest,
164
+ // registration) stay fatal for the probe exactly as before, rendered by
165
+ // the surrounding catch into a `configuration and plugins` fail check.
166
+ const failedReport = diagnosis.reports.find((report) => report.status === 'failed')
167
+ if (failedReport !== undefined)
168
+ throw new Error(failedReport.error, { cause: failedReport.cause })
169
+ // Notice parity with loadPlugins: skipped loads were announced on stderr.
170
+ // Only resolution-stage skips alter the check detail; the transitional
171
+ // registration-stage skip (a configured plugin re-registering a builtin
172
+ // workspace provider) is the documented arrangement, not a problem, so it
173
+ // stays out of the report.
174
+ for (const report of diagnosis.reports) {
175
+ if (report.status === 'skipped' && report.notice !== undefined) console.error(report.notice)
176
+ }
177
+ const unresolved = diagnosis.reports.filter(
178
+ (report) => report.status === 'skipped' && report.stage === 'resolution',
179
+ )
180
+ let pluginDetail = `${config.plugins.length} configured plugin(s) loaded`
181
+ for (const report of unresolved) {
182
+ pluginDetail +=
183
+ `; ${report.notice}; to make "${report.module}" available to guests, ` +
184
+ "add the package to the repository's dependencies so it resolves from the repository install during commands.setup"
185
+ }
160
186
  runtimes = await materializePluginRuntimes(
161
187
  opts.runtimes ?? createProductionRuntimes().runtimes,
162
- plugins,
188
+ diagnosis.registry,
163
189
  { repoRoot: opts.repo, env: opts.env },
164
190
  )
165
191
  checks.push({
166
192
  name: 'configuration and plugins',
167
193
  status: 'pass',
168
- detail: `${config.plugins.length} configured plugin(s) loaded`,
194
+ detail: pluginDetail,
169
195
  })
170
196
  } catch (error) {
171
197
  return {
@@ -181,7 +207,7 @@ export async function runGuestReadinessProbe(opts: {
181
207
  }
182
208
 
183
209
  const runtimeRemediation = (runtime: string, preflightOnly: boolean): string =>
184
- vercelConfig === undefined
210
+ builtinCaps?.requireRuntimeProvisioning !== true
185
211
  ? 'install/authenticate this runtime in the local validation environment'
186
212
  : `fix workspace.config.runtimeProvisioning.${runtime}${preflightOnly ? '.preflight' : ''} and expose API credential names in workspace.config.environmentVariables`
187
213
 
@@ -277,56 +303,79 @@ export async function runGuestReadinessProbe(opts: {
277
303
  return { checks }
278
304
  }
279
305
 
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)
306
+ /** Forge and required-environment checks driven by one provider's capability
307
+ * declarations. Shared by the pre-registry builtin preflight and the
308
+ * registry-aware check after plugin load so a plugin provider's declarations
309
+ * are honoured at the same seam. */
310
+ function declaredForgeEnvChecks(
311
+ caps: WorkspaceProviderCapabilities | undefined,
312
+ config: Config,
313
+ env: Record<string, string | undefined>,
314
+ ): void {
315
+ if (caps?.supportedForges !== undefined && !caps.supportedForges.includes(config.forge)) {
301
316
  throw new Error(
302
- `git ${args.join(' ')} exited ${result.exitCode}: ${result.stderr.trim() || result.stdout.trim()}`,
317
+ caps.forgeValidationMessage ??
318
+ `workspace provider "${config.workspace.provider}" does not support forge "${config.forge}"`,
319
+ )
320
+ }
321
+ for (const group of caps?.requiredEnv ?? []) {
322
+ if (group.validationMessage === undefined) continue
323
+ const satisfied = group.alternatives.some((names) =>
324
+ names.every((name) => env[name] !== undefined && env[name] !== ''),
303
325
  )
304
- return result.stdout.trim()
326
+ if (!satisfied) throw new Error(group.validationMessage)
327
+ }
328
+ }
329
+
330
+ /** Sandbox-forwarding check driven by one provider's declared
331
+ * `sandboxForbiddenEnv` extras. Enforced at the registry-aware init seam so a
332
+ * plugin-declared forbidden name is refused before any infrastructure is
333
+ * provisioned, mirroring `createWorkspaceProvider`'s construction-site check:
334
+ * same gate (`[orchestrator].enabled`), same name union
335
+ * (`SANDBOX_FORBIDDEN_ENV` plus the declared extras), same message. A no-op
336
+ * repeat for builtins — config parse already rejected every shared and
337
+ * builtin-declared name — and a no-op when the declaration is absent. */
338
+ function declaredSandboxForbiddenEnvCheck(
339
+ caps: WorkspaceProviderCapabilities | undefined,
340
+ config: Config,
341
+ ): void {
342
+ if (!config.orchestrator.enabled || caps?.sandboxForbiddenEnv === undefined) return
343
+ for (const name of config.orchestrator.sandbox.environmentVariables) {
344
+ if (SANDBOX_FORBIDDEN_ENV.includes(name) || caps.sandboxForbiddenEnv.includes(name)) {
345
+ throw new Error(sandboxForbiddenEnvMessage(name))
346
+ }
347
+ }
305
348
  }
306
349
 
307
350
  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')
351
+ // Pre-registry site: the builtin capability table drives the provider's
352
+ // declared forge and environment requirements (AUT-516). Plugin providers
353
+ // are checked registry-aware after plugin load in validateInitReadiness.
354
+ // The sandbox-forwarding rule is never enforced here: it is enforced at
355
+ // config parse for builtins (plugins are not loaded when config is parsed)
356
+ // and at the two post-load seams — this file's registry-aware check and
357
+ // createWorkspaceProvider construction — for plugin-declared names.
358
+ const caps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
359
+ declaredForgeEnvChecks(caps, config, env)
360
+ // Stays exactly where today's remote-provider branch put it: only a
361
+ // provider with remote readiness preflights its ticket-source credential
362
+ // here, because remote provisioning acquires tickets before allocating
363
+ // disposable infrastructure.
315
364
  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)
365
+ caps?.validateReadiness !== undefined &&
366
+ config.tickets.source === 'linear' &&
367
+ !env.LINEAR_API_KEY
368
+ )
324
369
  throw new Error('ticket source "linear" requires LINEAR_API_KEY')
325
370
  }
326
371
 
327
372
  export async function validateInitReadiness(opts: {
328
373
  targetRepo: string
329
374
  env: Record<string, string | undefined>
375
+ /** The raw launcher process environment, alongside the dotenv-augmented
376
+ * `env`. When supplied, a declared `processEnvOnly` variable present only in
377
+ * a loaded `.env` fails the registry-aware check below. */
378
+ processEnv?: Record<string, string | undefined>
330
379
  stdout?: (line: string) => void
331
380
  exec?: Exec
332
381
  openStore?: StoreOpener
@@ -342,20 +391,66 @@ export async function validateInitReadiness(opts: {
342
391
  const configBytes = await readFile(configPath, 'utf8')
343
392
  const config = await loadConfig(configPath)
344
393
  const state = resolveRepoStatePaths({ repo, envStore: opts.env.AB_STORE })
345
- const vercelConfig =
346
- config.workspace.provider === 'vercel-sandbox'
347
- ? vercelSandboxConfigSchema.parse(config.workspace.config)
394
+ // Pre-registry parse and redaction come from the builtin capability table;
395
+ // after plugin load the registration's own declarations take over (AUT-516).
396
+ const builtinCaps = builtinWorkspaceProviderCapabilities(config.workspace.provider)
397
+ let providerConfig =
398
+ builtinCaps?.configSchema !== undefined
399
+ ? builtinCaps.configSchema.parse(config.workspace.config)
348
400
  : undefined
349
- const redact = createReadinessRedactor(opts.env, [
350
- ...(vercelConfig?.environmentVariables ?? []),
351
- ...(vercelConfig?.gitUsernameEnv === undefined ? [] : [vercelConfig.gitUsernameEnv]),
352
- ...(vercelConfig?.gitPasswordEnv === undefined ? [] : [vercelConfig.gitPasswordEnv]),
353
- ])
401
+ let redact = createReadinessRedactor(
402
+ opts.env,
403
+ providerConfig !== undefined ? (builtinCaps?.guestEnvNames?.(providerConfig) ?? []) : [],
404
+ )
405
+ let caps: WorkspaceProviderCapabilities | undefined
406
+ let assigneeLine: string | undefined
354
407
  try {
355
408
  hostPreflight(config, opts.env)
356
409
  // Ticket acquisition is a host responsibility for both workspace providers.
357
410
  // Exercise its read surface before allocating disposable infrastructure.
358
411
  const hostPlugins = await loadPlugins(config.plugins, repo, { packageRoot: repo })
412
+ caps =
413
+ hostPlugins.workspaceProviders.get(config.workspace.provider)?.capabilities ?? builtinCaps
414
+ // Registry-aware declaration checks. The pre-registry preflight above could
415
+ // only consult the builtin table, so a plugin provider's declared forge and
416
+ // required-environment validations are honoured here (f_9d68b3e5); for the
417
+ // builtins the registration carries the same declarations, so the check is
418
+ // a no-op repeat after the preflight already passed.
419
+ declaredForgeEnvChecks(caps, config, opts.env)
420
+ // Same seam, declared sandbox-forbidden extras (AUT-536): forge/env
421
+ // failures keep today's precedence, ticket acquisition follows.
422
+ declaredSandboxForbiddenEnvCheck(caps, config)
423
+ // The registration's own configSchema and guestEnvNames take over from the
424
+ // builtin table, so a plugin provider's readiness context carries its
425
+ // parsed config and its declared names are redacted (f_55ba7591). A
426
+ // declared guestEnvNames with no declared configSchema still feeds the
427
+ // redactor — it is called with the raw `[workspace.config]` then, mirroring
428
+ // how createWorkspaceProvider falls back to the raw config for
429
+ // requireRuntimeProvisioning (f_1ddf1415). The post-report
430
+ // describeEnvironment call below receives that same raw fallback, so the
431
+ // two call shapes cannot drift apart again.
432
+ if (caps?.configSchema !== undefined)
433
+ providerConfig = caps.configSchema.parse(config.workspace.config)
434
+ if (caps?.guestEnvNames !== undefined)
435
+ redact = createReadinessRedactor(
436
+ opts.env,
437
+ caps.guestEnvNames(providerConfig !== undefined ? providerConfig : config.workspace.config),
438
+ )
439
+ // Registry-aware process-env-only check: a declared name present in the
440
+ // dotenv-augmented `env` but absent from the raw launcher map fails here,
441
+ // so plugin-declared requirements and non-CLI callers are covered too
442
+ // (the CLI path's own guard in main.ts runs before this and keeps its
443
+ // error precedence).
444
+ if (opts.processEnv !== undefined) {
445
+ for (const requirement of caps?.processEnvOnly ?? []) {
446
+ if (
447
+ opts.env[requirement.name] !== undefined &&
448
+ opts.processEnv[requirement.name] === undefined
449
+ ) {
450
+ throw new Error(requirement.message)
451
+ }
452
+ }
453
+ }
359
454
  if (config.tickets.source !== 'file') {
360
455
  const ticketSource = await createTicketSource(
361
456
  config.tickets,
@@ -367,7 +462,22 @@ export async function validateInitReadiness(opts: {
367
462
  await ticketSource.listReady({
368
463
  ...(config.tickets.readyLabels !== undefined ? { labels: config.tickets.readyLabels } : {}),
369
464
  state: config.tickets.readyState,
465
+ ...(config.tickets.readyAssignee !== undefined
466
+ ? { assignee: config.tickets.readyAssignee }
467
+ : {}),
370
468
  })
469
+ const assignee = config.tickets.readyAssignee
470
+ if (assignee !== undefined && ticketSource instanceof LinearTicketSource) {
471
+ const user = await ticketSource.resolveAssignee(assignee)
472
+ if (user === null) {
473
+ throw new Error(
474
+ assignee === 'me'
475
+ ? '[tickets].readyAssignee = "me" but LINEAR_API_KEY did not resolve to a Linear user — check the key'
476
+ : `[tickets].readyAssignee "${assignee}" matches no user in the Linear workspace — check the address or use "me"`,
477
+ )
478
+ }
479
+ assigneeLine = `Ticket assignee gate: ${assignee} → ${user.name} <${user.email}>`
480
+ }
371
481
  }
372
482
  } catch (error) {
373
483
  throw new Error(redact(error))
@@ -399,9 +509,18 @@ export async function validateInitReadiness(opts: {
399
509
  throw new Error(
400
510
  `autobuild.toml differs from committed ${config.baseBranch}; commit setup changes before validating`,
401
511
  )
512
+ // The host's strict `loadPlugins(config.plugins, repo, { packageRoot: repo })`
513
+ // above is the operative gate: it already threw on the first unresolvable
514
+ // package plugin before this branch is entered, and it shares the probe's
515
+ // resolution root — `resolveMainRepo` maps a linked worktree back to the
516
+ // main checkout — so `guest: false` cannot change today's outcomes. It is
517
+ // defense in depth that keeps this call site strict per the "host-side
518
+ // sites stay strict" rationale even if that pre-check is ever weakened or
519
+ // reordered (AUT-561).
402
520
  const guest = await runGuestReadinessProbe({
403
521
  repo: workspace,
404
522
  env: { ...opts.env, AB_STORE: state.storeRef },
523
+ guest: false,
405
524
  ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
406
525
  ...(opts.runtimes !== undefined ? { runtimes: opts.runtimes } : {}),
407
526
  exec,
@@ -446,103 +565,33 @@ export async function validateInitReadiness(opts: {
446
565
  }
447
566
  if (failure !== undefined) throw failure
448
567
  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
- )
568
+ } else if (caps?.validateReadiness !== undefined) {
569
+ // Store preconditions come from the registration's declaration, uniformly
570
+ // for builtin and plugin providers; the texts are byte-identical to the
571
+ // branch they replace.
572
+ if (caps.storeRequirements !== undefined) {
573
+ if (!/^https:\/\//i.test(state.storeRef))
574
+ throw new Error(caps.storeRequirements.storeRefMessage)
575
+ const token = opts.env.AB_TOKEN
576
+ if (!token) throw new Error(caps.storeRequirements.storeTokenMessage)
467
577
  }
468
- await gitText(
469
- exec,
578
+ report = await caps.validateReadiness({
579
+ config,
580
+ providerConfig,
581
+ env: opts.env,
582
+ storeRef: state.storeRef,
583
+ storeToken: opts.env.AB_TOKEN ?? '',
470
584
  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 }),
585
+ baseBranch: config.baseBranch,
586
+ configBytes,
587
+ exec,
588
+ redact,
589
+ runtimeReferences: effectiveRuntimeReferences(config),
590
+ stdout: opts.stdout ?? (() => {}),
591
+ ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
592
+ ...(opts.vercelFacade !== undefined ? { facade: opts.vercelFacade } : {}),
593
+ ...(opts.packageArchive !== undefined ? { packageArchive: opts.packageArchive } : {}),
484
594
  })
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
595
  } else {
547
596
  throw new Error(
548
597
  `workspace provider "${config.workspace.provider}" does not support init readiness validation`,
@@ -554,20 +603,15 @@ export async function validateInitReadiness(opts: {
554
603
  stdout(`Readiness: ${report.context} (${report.provider})`)
555
604
  stdout(`Store: ${state.storeRef}`)
556
605
  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
- }
606
+ if (assigneeLine !== undefined) stdout(assigneeLine)
607
+ // Same raw `[workspace.config]` fallback guestEnvNames gets (f_55ba7591): a
608
+ // provider that declares describeEnvironment without a configSchema still
609
+ // receives a defined argument — the raw table — never undefined.
610
+ for (const line of caps?.describeEnvironment?.(
611
+ providerConfig !== undefined ? providerConfig : config.workspace.config,
612
+ opts.env,
613
+ ) ?? [])
614
+ stdout(line)
571
615
  if (report.workspace !== undefined)
572
616
  stdout(
573
617
  `Disposable environment: ${report.workspace} (released${report.snapshotsDeleted === undefined ? '' : `; ${report.snapshotsDeleted} snapshot(s) deleted`})`,