@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
@@ -213,8 +213,14 @@ export function createGitHubFetchTransport(opts: {
213
213
  // a private repository is otherwise indistinguishable from a bad path,
214
214
  // and the operator's fix (export a token, or `gh auth login`) is the
215
215
  // point of the message.
216
+ // `in` rather than relying on the `token === undefined` discriminant:
217
+ // the plugin-sdk typecheck fixture compiles this chain without
218
+ // strictNullChecks, where `undefined` is assignable to `string` and
219
+ // the union stops discriminating (AUT-517).
216
220
  const anonymous =
217
- credential.token === undefined ? ` (request was unauthenticated: ${credential.reason})` : ''
221
+ credential.token === undefined && 'reason' in credential
222
+ ? ` (request was unauthenticated: ${credential.reason})`
223
+ : ''
218
224
  throw new GitHubApiError(
219
225
  response.status,
220
226
  `${githubErrorMessage(response.status, json, text)}${anonymous}`,
@@ -335,9 +341,14 @@ export async function resolveGitHubToken(
335
341
  if (fromEnv !== undefined) return { token: fromEnv }
336
342
  const probe = await githubTokenFromGhCli(exec)
337
343
  if (probe.token !== undefined) return probe
344
+ // `in` rather than relying on the `token !== undefined` discriminant: the
345
+ // plugin-sdk typecheck fixture compiles this chain without
346
+ // strictNullChecks, where `undefined` is assignable to `string` and the
347
+ // union stops discriminating (AUT-517). A miss always carries its reason.
348
+ const missReason = 'reason' in probe ? probe.reason : 'no credential source succeeded'
338
349
  return {
339
350
  token: undefined,
340
- reason: `GITHUB_TOKEN and GH_TOKEN are unset and ${probe.reason}`,
351
+ reason: `GITHUB_TOKEN and GH_TOKEN are unset and ${missReason}`,
341
352
  }
342
353
  }
343
354
 
@@ -470,6 +470,16 @@ function restMergeState(raw: string): MergeStateStatus {
470
470
 
471
471
  const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
472
472
 
473
+ /** Bounded UNKNOWN re-query schedule: 7 re-queries, ~30 s of nondecreasing
474
+ * backoff capped at 6 s steps. Sized after AUT-392 — the prior
475
+ * two-re-query/2 s budget (`[500, 1500]`) was exhausted by a live occurrence
476
+ * (build `give-repo-scoped`, PR #352) while GitHub was still computing
477
+ * mergeability after required checks completed. Single-occurrence evidence,
478
+ * so this is a deliberate sizing bet, not a measured latency distribution;
479
+ * the unchanged pendingAutoMerge tick backstop remains behind it for waits a
480
+ * single tick cannot absorb. */
481
+ export const MERGEABILITY_RETRY_DELAYS_MS = [1000, 2000, 4000, 5000, 6000, 6000, 6000]
482
+
473
483
  /** Hard cap on the janitor poll's per-PR ETag cache. One small entry per PR
474
484
  * ever polled in a dispatcher process lifetime makes hitting this in practice
475
485
  * implausible; the cap is hygiene, not a load-bearing bound. */
@@ -514,7 +524,8 @@ export class GitHubForge implements Forge {
514
524
  env?: Readonly<Record<string, string | undefined>>
515
525
  /** Bounded waits between mergeability re-queries when GitHub reports
516
526
  * `mergeable_state: 'unknown'` (mergeability still being computed).
517
- * Two retries default; tests inject `[0, 0]` so no test sleeps. */
527
+ * Defaults to `MERGEABILITY_RETRY_DELAYS_MS` (7 re-queries, ~30 s);
528
+ * tests inject `[0, 0]` or all-zero schedules so no test sleeps. */
518
529
  mergeabilityRetryDelaysMs?: number[]
519
530
  } = {},
520
531
  ) {
@@ -535,7 +546,7 @@ export class GitHubForge implements Forge {
535
546
  this.explicitRepository = this.env.AB_REPOSITORY
536
547
  }
537
548
  this.repoRoot = opts.repoRoot
538
- this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ?? [500, 1500]
549
+ this.mergeabilityRetryDelaysMs = opts.mergeabilityRetryDelaysMs ?? MERGEABILITY_RETRY_DELAYS_MS
539
550
  this.prAttachments = new GitHubPrAttachmentHosting({ transport: this.transport })
540
551
  }
541
552
 
@@ -40,9 +40,39 @@ function detail(error: unknown): string {
40
40
  export class LocalGitForge implements Forge {
41
41
  readonly name = 'local-git'
42
42
  private readonly exec: Exec
43
+ /** The repository the optional checkout-less read seam reads from. The
44
+ * factory passes the served checkout; without it the capability is absent
45
+ * and callers retain their checkout/git fallbacks. */
46
+ private readonly repoRoot?: string
43
47
 
44
- constructor(opts: { exec?: Exec } = {}) {
48
+ constructor(opts: { exec?: Exec; repoRoot?: string } = {}) {
45
49
  this.exec = opts.exec ?? bunExec
50
+ this.repoRoot = opts.repoRoot
51
+ }
52
+
53
+ /** Current tip of a local branch in the served repository. Throws when the
54
+ * branch does not exist — the same not-found contract as remote adapters. */
55
+ async remoteBranchSha(branch: string): Promise<string> {
56
+ if (this.repoRoot === undefined) {
57
+ throw new Error('local-git forge has no repository root for remote branch reads')
58
+ }
59
+ const result = await this.command(
60
+ ['rev-parse', '--verify', `refs/heads/${branch}`],
61
+ this.repoRoot,
62
+ true,
63
+ )
64
+ if (result.exitCode !== 0) throw new Error(`local-git: unknown remote branch ${branch}`)
65
+ return result.stdout.trim()
66
+ }
67
+
68
+ /** File bytes from the served repository. Ref omitted → the checkout's HEAD.
69
+ * Throws when the path does not exist at that ref. */
70
+ async readFile(path: string, ref?: string): Promise<string> {
71
+ if (this.repoRoot === undefined) {
72
+ throw new Error('local-git forge has no repository root for file reads')
73
+ }
74
+ const result = await this.command(['show', `${ref ?? 'HEAD'}:${path}`], this.repoRoot)
75
+ return result.stdout
46
76
  }
47
77
 
48
78
  private async command(args: string[], cwd: string, allowFailure = false) {
@@ -38,7 +38,7 @@ import {
38
38
  toolInputPart,
39
39
  toolOutputPart,
40
40
  } from './stream-parts'
41
- import { classifyProviderError, configurationFailure } from './provider-error'
41
+ import { classifyProviderError, missingExecutableFailure } from './provider-error'
42
42
  import { sessionEnv } from './session-env'
43
43
  import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
44
44
  import type { RuntimeUsabilityInput, RuntimeUsabilityResult } from './runtime'
@@ -652,7 +652,7 @@ function launchFailure(error: unknown): ClaudeTurn {
652
652
  return {
653
653
  text: '',
654
654
  usage: { inputTokens: 0, outputTokens: 0 },
655
- failure: missing ? configurationFailure(message) : classifyProviderError(message),
655
+ failure: missing ? missingExecutableFailure(message) : classifyProviderError(message),
656
656
  cli: {
657
657
  stdout: '',
658
658
  stderr: errorText(error),
@@ -17,7 +17,11 @@ import {
17
17
  type Transcript,
18
18
  } from '../types'
19
19
  import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
20
- import { classifyProviderError, configurationFailure, credentialFailure } from './provider-error'
20
+ import {
21
+ classifyProviderError,
22
+ credentialFailure,
23
+ missingExecutableFailure,
24
+ } from './provider-error'
21
25
  import {
22
26
  abortPart,
23
27
  errorPart,
@@ -694,7 +698,7 @@ function launchFailure(error: unknown): CodexTurn {
694
698
  return {
695
699
  text: '',
696
700
  usage: { inputTokens: 0, outputTokens: 0 },
697
- failure: missing ? configurationFailure(message) : classifyProviderError(message),
701
+ failure: missing ? missingExecutableFailure(message) : classifyProviderError(message),
698
702
  cli: { stdout: '', stderr: errorText(error), exitCode: -1 },
699
703
  events: [],
700
704
  malformedLines: [],
@@ -49,12 +49,18 @@ export function failedTurnResult(
49
49
  permanent: boolean,
50
50
  text = '',
51
51
  cause?: AgentFailureCause,
52
+ missingExecutable?: true,
52
53
  ): AgentTurnResult {
53
54
  return {
54
55
  kind: 'failed',
55
56
  text,
56
57
  usage: { inputTokens: 0, outputTokens: 0, turns: 1 },
57
- failure: { message, permanent, ...(cause !== undefined ? { cause } : {}) },
58
+ failure: {
59
+ message,
60
+ permanent,
61
+ ...(cause !== undefined ? { cause } : {}),
62
+ ...(missingExecutable === true ? { missingExecutable } : {}),
63
+ },
58
64
  }
59
65
  }
60
66
 
@@ -13,7 +13,12 @@ import {
13
13
  type Transcript,
14
14
  } from '../types'
15
15
  import type { OneShotCompletion, OneShotCompletionInput, OneShotCompletionResult } from './one-shot'
16
- import { classifyProviderError, configurationFailure, credentialFailure } from './provider-error'
16
+ import {
17
+ classifyProviderError,
18
+ configurationFailure,
19
+ credentialFailure,
20
+ missingExecutableFailure,
21
+ } from './provider-error'
17
22
  import { createPiRpcSession, PI_MODEL_ARG, PI_RPC_MODE_ARG } from './pi-rpc'
18
23
  import {
19
24
  abortPart,
@@ -586,11 +591,8 @@ export class PiAgentRunner implements AgentRunner, OneShotCompletion {
586
591
 
587
592
  function localPiFailure(error: unknown): AgentTurnFailure {
588
593
  const message = errorText(error)
589
- if (
590
- /executable "pi" was not found|requires Pi .* or newer|could not parse detected Pi version/.test(
591
- message,
592
- )
593
- ) {
594
+ if (/executable "pi" was not found/.test(message)) return missingExecutableFailure(message)
595
+ if (/requires Pi .* or newer|could not parse detected Pi version/.test(message)) {
594
596
  return configurationFailure(message)
595
597
  }
596
598
  if (/not ready|authentication|logged in|credentials/i.test(message))
@@ -77,9 +77,12 @@ export function classifyProviderError(
77
77
  /** Whether another declared target may be tried inside this phase attempt.
78
78
  * Legacy plugins have no cause: preserve their old safe split. */
79
79
  export function isAlternateEligible(failure: AgentTurnFailure): boolean {
80
- return failure.cause === undefined
81
- ? !failure.permanent
82
- : failure.cause === 'availability' || failure.cause === 'exhaustion'
80
+ if (failure.cause === undefined) return !failure.permanent
81
+ return (
82
+ failure.cause === 'availability' ||
83
+ failure.cause === 'exhaustion' ||
84
+ (failure.cause === 'configuration' && failure.missingExecutable === true)
85
+ )
83
86
  }
84
87
 
85
88
  /** Final-failure retry behavior remains governed by the compatibility bit. */
@@ -92,6 +95,13 @@ export function configurationFailure(message: string): AgentTurnFailure {
92
95
  return { message, permanent: true, cause: 'configuration' }
93
96
  }
94
97
 
98
+ /** The runtime's CLI executable is not installed. Still a permanent
99
+ * `configuration` failure (never retried), but a later declared target whose
100
+ * CLI is installed may serve the phase. */
101
+ export function missingExecutableFailure(message: string): AgentTurnFailure {
102
+ return { message, permanent: true, cause: 'configuration', missingExecutable: true }
103
+ }
104
+
95
105
  /** Explicit credential failures bypass both alternates and phase retries. */
96
106
  export function credentialFailure(message: string): AgentTurnFailure {
97
107
  return { message, permanent: true, cause: 'credentials' }
@@ -1,7 +1,8 @@
1
+ // Client half of the hosted ticket source (SPEC §13): the TicketSource a
2
+ // local CLI drives against a hosted deployment. The HTTP server half ships
3
+ // in `@defrex/autobuild-hosted-store-service`.
1
4
  export { HostedTicketSource } from './remote'
2
5
  export type { HostedTicketFetch, HostedTicketSourceOptions } from './remote'
3
- export { createTicketServer } from './remote-server'
4
- export type { TicketServerOptions } from './remote-server'
5
6
  export {
6
7
  HOSTED_TICKET_OPERATIONS,
7
8
  hostedTicketContextSchema,
@@ -177,8 +177,11 @@ export interface WorkspaceProvisionResult extends WorkspaceHandle {
177
177
 
178
178
  export interface WorkspacePublication {
179
179
  /** Observe whether the exact requested commit already reached the durable
180
- * remote branch after an interrupted acknowledgement. */
181
- isPublished?(input: { sha: string; branch: string }): Promise<boolean>
180
+ * remote branch after an interrupted acknowledgement. `ref` optionally
181
+ * names the workspace whose git database the probe runs against — used by
182
+ * the operator-sandbox publication path, whose provider probes from the
183
+ * sandbox checkout; the build-path callers omit it. */
184
+ isPublished?(input: { ref?: string; sha: string; branch: string }): Promise<boolean>
182
185
  /** Publish exactly one commit to exactly one branch; no arbitrary command
183
186
  * surface is exposed to the dispatcher or build child. */
184
187
  publish(input: { ref: string; sha: string; branch: string }): Promise<void>
@@ -221,6 +224,14 @@ export interface WorkspaceProvider {
221
224
  readonly orchestratorSandbox?: OperatorSandboxExecution
222
225
  /** Trusted dispatcher-only publication capability for remote workspaces. */
223
226
  readonly publication?: WorkspacePublication
227
+ /** Publication capability for the operator sandbox (PR-only publication):
228
+ * deliberately distinct from `publication`, which is the dispatcher's
229
+ * remote-workspace discriminator — its presence reclassifies every build
230
+ * workspace as remote and reroutes build publication. `sandboxPublication`
231
+ * is never consulted by the dispatcher; it is consumed only by the
232
+ * operator-sandbox publish service, and present only on providers that can
233
+ * host operator sandboxes. */
234
+ readonly sandboxPublication?: WorkspacePublication
224
235
  /** Optional only for remote/disposable providers. */
225
236
  readonly recovery?: WorkspaceRecovery
226
237
  provision(opts: {
@@ -424,6 +435,10 @@ export interface AgentTurnFailure {
424
435
  /** Additive structured routing cause. Legacy plugin runners may omit it;
425
436
  * their existing permanent bit then supplies the conservative behavior. */
426
437
  cause?: AgentFailureCause
438
+ /** Set with cause 'configuration' when the runtime's CLI executable is not
439
+ * installed. This is the one configuration failure a configured alternate
440
+ * may serve; every other configuration failure still stops the chain. */
441
+ missingExecutable?: true
427
442
  }
428
443
 
429
444
  /** A turn that reached a provider/runtime-declared failure while retaining an
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The full capability objects for the builtin workspace providers (AUT-516).
3
+ * The parse-time subset lives in `config/schema.ts`'s
4
+ * `BUILTIN_WORKSPACE_PROVIDER_CONFIG` because config validation must be
5
+ * statically reachable without the registry; this module assembles the FULL
6
+ * declarations — including the registry-seam behaviors — and is attached to
7
+ * the builtin registrations by `plugins/registry.ts`.
8
+ *
9
+ * Import graph discipline: this module imports `config/schema.ts`,
10
+ * `git-worktree.ts`, `vercel-sandbox.ts`, and the shared vercel capability
11
+ * module; nothing imports it from `schema.ts`, so no cycle exists. The
12
+ * `vercel-sandbox` entry's capabilities are the shared
13
+ * `VERCEL_SANDBOX_CAPABILITIES` object (AUT-517) — the plugin package
14
+ * references the same object, so the declarations cannot drift. The
15
+ * construction-site `configRefusal` for git-worktree is set EXPLICITLY here,
16
+ * not copied from the table's parse-site `configRefusalMessage` — the two
17
+ * strings differ by design (the parse-site message adds the remediation
18
+ * clause; the construction-site one does not).
19
+ *
20
+ * This `BUILTINS` record is the single source of truth for which builtins
21
+ * exist: `plugins/registry.ts` derives its builtin workspace-provider set
22
+ * from `builtinWorkspaceProviderNames()` (AUT-579), so adding an entry here
23
+ * (plus the config-table half that AUT-573's parity tests pin) completes
24
+ * the registration step — no registry edit is required.
25
+ */
26
+ import { join, resolve } from 'node:path'
27
+ import type { VercelSandboxConfig, WorkspaceConfig } from '../../config/schema'
28
+ import type { WorkspaceProviderCapabilities } from './provider-capabilities'
29
+ import type { CreateWorkspaceProviderOptions } from './create'
30
+ import { GitWorktreeProvider } from './git-worktree'
31
+ import { VercelSandboxProvider } from './vercel-sandbox'
32
+ import { VERCEL_SANDBOX_CAPABILITIES } from './vercel-capabilities'
33
+ import type { WorkspaceProvider } from '../types'
34
+
35
+ /** The git-worktree construction-site refusal: no remediation clause, unlike
36
+ * the parse-site message in the config table. */
37
+ const GIT_WORKTREE_CONSTRUCTION_REFUSAL =
38
+ '[workspace.config] is not supported by the builtin "git-worktree" provider'
39
+
40
+ interface BuiltinWorkspaceProviderDeclaration {
41
+ capabilities: WorkspaceProviderCapabilities
42
+ builtinFactory: (
43
+ config: WorkspaceConfig,
44
+ opts: CreateWorkspaceProviderOptions,
45
+ parsed: unknown,
46
+ ) => WorkspaceProvider | Promise<WorkspaceProvider>
47
+ }
48
+
49
+ const BUILTINS: Record<string, BuiltinWorkspaceProviderDeclaration> = {
50
+ 'git-worktree': {
51
+ capabilities: {
52
+ configRefusal: GIT_WORKTREE_CONSTRUCTION_REFUSAL,
53
+ },
54
+ builtinFactory(_config, opts) {
55
+ // Its provider config is always `{}`: the refusal (parse site and
56
+ // construction site alike) has already rejected anything else.
57
+ return new GitWorktreeProvider({
58
+ root: resolve(opts.worktreeRoot),
59
+ sandboxRoot:
60
+ opts.sandboxRoot ?? resolve(join(opts.worktreeRoot, '..', 'orchestrator-sandboxes')),
61
+ setupCommand: opts.sandboxSetupCommand,
62
+ sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
63
+ envSource: opts.env,
64
+ })
65
+ },
66
+ },
67
+ 'vercel-sandbox': {
68
+ capabilities: VERCEL_SANDBOX_CAPABILITIES,
69
+ builtinFactory(_config, opts, parsed) {
70
+ // storeRef/storeToken are guaranteed present: the pre-split
71
+ // storeRequirements check in createWorkspaceProvider ran before
72
+ // construction.
73
+ return new VercelSandboxProvider({
74
+ config: parsed as VercelSandboxConfig,
75
+ env: opts.env,
76
+ storeRef: opts.storeRef!,
77
+ storeToken: opts.storeToken!,
78
+ repo: resolve(opts.repoRoot),
79
+ runtimeReferences: opts.runtimeReferences ?? [],
80
+ setupCommand: opts.sandboxSetupCommand,
81
+ sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
82
+ ...(opts.origin !== undefined ? { origin: opts.origin } : {}),
83
+ ...(opts.remoteBranchHead !== undefined ? { remoteBranchHead: opts.remoteBranchHead } : {}),
84
+ })
85
+ },
86
+ },
87
+ }
88
+
89
+ /** Every builtin provider name, for parity checks over the declaration
90
+ * tables (AUT-573): the parse-time table in `config/schema.ts` and the FULL
91
+ * declarations here must cover the same provider set. */
92
+ export function builtinWorkspaceProviderNames(): readonly string[] {
93
+ return Object.keys(BUILTINS)
94
+ }
95
+
96
+ /** The FULL capability object of a builtin workspace provider, `undefined`
97
+ * for any other name. Used by pre-registry seams (guest probe, init
98
+ * preflight) that cannot consult the plugin registry. */
99
+ export function builtinWorkspaceProviderCapabilities(
100
+ name: string,
101
+ ): WorkspaceProviderCapabilities | undefined {
102
+ return BUILTINS[name]?.capabilities
103
+ }
104
+
105
+ /** The builtin registration extras for `plugins/registry.ts`: the capability
106
+ * object plus the host-owned construction closure. */
107
+ export function builtinWorkspaceProviderRegistration(
108
+ name: string,
109
+ ): BuiltinWorkspaceProviderDeclaration | undefined {
110
+ const builtin = BUILTINS[name]
111
+ return builtin === undefined
112
+ ? undefined
113
+ : { capabilities: builtin.capabilities, builtinFactory: builtin.builtinFactory }
114
+ }
@@ -1,11 +1,17 @@
1
- import { join, resolve } from 'node:path'
2
- import { vercelSandboxConfigSchema, type WorkspaceConfig } from '../../config/schema'
1
+ import { resolve } from 'node:path'
2
+ import { SANDBOX_FORBIDDEN_ENV } from './operator-sandbox'
3
+ import type { WorkspaceConfig } from '../../config/schema'
3
4
  import type { PluginRegistry } from '../../plugins/registry'
4
5
  import type { WorkspaceProvider } from '../types'
5
6
  import type { BuildExecution } from './build-execution'
6
- import { GitWorktreeProvider } from './git-worktree'
7
+ import {
8
+ currentRuntimeReferences,
9
+ runtimeProvisioningMap,
10
+ runtimeProvisioningMissingMessage,
11
+ sandboxForbiddenEnvMessage,
12
+ } from './provider-capabilities'
7
13
  import { LocalBuildExecution } from './local-build-execution'
8
- import { type RuntimeReferencesSource, VercelSandboxProvider } from './vercel-sandbox'
14
+ import type { RuntimeReferencesSource } from './vercel-sandbox'
9
15
 
10
16
  export interface CreateWorkspaceProviderOptions {
11
17
  registry: PluginRegistry
@@ -31,6 +37,11 @@ export interface CreateWorkspaceProviderOptions {
31
37
  sandboxSetupCommand?: string
32
38
  sandboxRoot?: string
33
39
  sandboxEnvironmentVariables?: readonly string[]
40
+ /** Whether `[orchestrator].enabled` is set at the call site. Gates the
41
+ * construction-site `sandboxForbiddenEnv` check so the parse-time rule is
42
+ * reproduced exactly (the config validation only runs when the orchestrator
43
+ * is enabled). */
44
+ orchestratorSandboxEnabled?: boolean
34
45
  }
35
46
 
36
47
  export interface WorkspaceRuntime {
@@ -53,44 +64,56 @@ export async function createWorkspaceProvider(
53
64
  )
54
65
  }
55
66
 
56
- if (registration.owner.kind === 'builtin') {
57
- if (config.provider === 'git-worktree') {
58
- if (Object.keys(config.config).length > 0) {
59
- throw new Error(
60
- '[workspace.config] is not supported by the builtin "git-worktree" provider',
61
- )
62
- }
63
- return new GitWorktreeProvider({
64
- root: resolve(opts.worktreeRoot),
65
- sandboxRoot:
66
- opts.sandboxRoot ?? resolve(join(opts.worktreeRoot, '..', 'orchestrator-sandboxes')),
67
- setupCommand: opts.sandboxSetupCommand,
68
- sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
69
- envSource: opts.env,
70
- })
67
+ // Uniform capability enforcement (AUT-516): every declaration that needs
68
+ // the registry is honoured here, before the builtin/plugin split, where
69
+ // `registration.capabilities`, `opts.storeRef`, and `opts.storeToken` are
70
+ // already in hand — a plugin declaring any of them is enforced too, never
71
+ // accepted by manifest parsing and silently ignored. Order preserves the
72
+ // builtin failure precedence.
73
+ const capabilities = registration.capabilities
74
+ const hasConfig = Object.keys(config.config).length > 0
75
+ if (capabilities?.configRefusal !== undefined && hasConfig) {
76
+ throw new Error(capabilities.configRefusal)
77
+ }
78
+ let parsed: { data: unknown } | undefined
79
+ if (capabilities?.configSchema !== undefined) {
80
+ const result = capabilities.configSchema.safeParse(config.config)
81
+ if (!result.success) {
82
+ throw new Error(`invalid ${config.provider} config: ${result.error.message}`)
71
83
  }
72
- if (config.provider === 'vercel-sandbox') {
73
- const parsed = vercelSandboxConfigSchema.safeParse(config.config)
74
- if (!parsed.success) throw new Error(`invalid vercel-sandbox config: ${parsed.error.message}`)
75
- if (opts.storeRef === undefined || opts.storeToken === undefined) {
76
- throw new Error('vercel-sandbox requires an HTTPS BuildStore and scoped AB_TOKEN authority')
84
+ parsed = result as { data: unknown }
85
+ }
86
+ if (capabilities?.requireRuntimeProvisioning === true && opts.runtimeReferences !== undefined) {
87
+ // `mcp.ts` passes no `runtimeReferences` because it references no runtimes;
88
+ // dispatch and init validation always do.
89
+ const provisioning = runtimeProvisioningMap(parsed !== undefined ? parsed.data : config.config)
90
+ for (const group of currentRuntimeReferences(opts.runtimeReferences)) {
91
+ if (Object.hasOwn(provisioning, group.runtime)) continue
92
+ throw new Error(runtimeProvisioningMissingMessage(group))
93
+ }
94
+ }
95
+ if (opts.orchestratorSandboxEnabled === true && capabilities?.sandboxForbiddenEnv !== undefined) {
96
+ for (const name of opts.sandboxEnvironmentVariables ?? []) {
97
+ if (SANDBOX_FORBIDDEN_ENV.includes(name) || capabilities.sandboxForbiddenEnv.includes(name)) {
98
+ throw new Error(sandboxForbiddenEnvMessage(name))
77
99
  }
78
- return new VercelSandboxProvider({
79
- config: parsed.data,
80
- env: opts.env,
81
- storeRef: opts.storeRef,
82
- storeToken: opts.storeToken,
83
- repo: resolve(opts.repoRoot),
84
- runtimeReferences: opts.runtimeReferences ?? [],
85
- setupCommand: opts.sandboxSetupCommand,
86
- sandboxEnvironmentVariables: opts.sandboxEnvironmentVariables,
87
- ...(opts.origin !== undefined ? { origin: opts.origin } : {}),
88
- ...(opts.remoteBranchHead !== undefined ? { remoteBranchHead: opts.remoteBranchHead } : {}),
89
- })
90
100
  }
91
- throw new Error(
92
- `workspace provider "${config.provider}" is registered as a builtin but has no constructor`,
93
- )
101
+ }
102
+ if (
103
+ capabilities?.storeRequirements !== undefined &&
104
+ (opts.storeRef === undefined || opts.storeToken === undefined)
105
+ ) {
106
+ throw new Error(capabilities.storeRequirements.constructionMessage)
107
+ }
108
+
109
+ if (registration.owner.kind === 'builtin') {
110
+ const builtinFactory = registration.builtinFactory
111
+ if (builtinFactory === undefined) {
112
+ throw new Error(
113
+ `workspace provider "${config.provider}" is registered as a builtin but has no constructor`,
114
+ )
115
+ }
116
+ return builtinFactory(config, opts, parsed !== undefined ? parsed.data : config.config)
94
117
  }
95
118
 
96
119
  const factory = registration.factory
@@ -101,10 +124,22 @@ export async function createWorkspaceProvider(
101
124
  }
102
125
 
103
126
  try {
127
+ // Conditional spreads keep the existing exactness guarantee: a call site
128
+ // that supplies no seams constructs exactly `{ config, env, repoRoot }`,
129
+ // so plugin factories see no `undefined`-valued additions. With seams,
130
+ // the context widens in place (AUT-560) — the seams the builtin factory
131
+ // receives through `opts` reach the plugin factory through the context.
104
132
  return await factory({
105
133
  config: config.config,
106
134
  env: opts.env,
107
135
  repoRoot: resolve(opts.repoRoot),
136
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
137
+ ...(opts.storeToken !== undefined ? { storeToken: opts.storeToken } : {}),
138
+ ...(opts.runtimeReferences !== undefined
139
+ ? { runtimeReferences: opts.runtimeReferences }
140
+ : {}),
141
+ ...(opts.origin !== undefined ? { origin: opts.origin } : {}),
142
+ ...(opts.remoteBranchHead !== undefined ? { remoteBranchHead: opts.remoteBranchHead } : {}),
108
143
  })
109
144
  } catch (error) {
110
145
  throw new Error(