@defrex/autobuild 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The route-level glue the operator HTTP server applies around the shared
3
+ * services, re-derived for the agent tool registry (operator/registry.ts).
4
+ *
5
+ * This module is registry-owned on purpose: the operator API routes were
6
+ * declared out of scope for the registry ticket, so the parity between a
7
+ * tool and its route is *proven* by the contract suite (registry.contract
8
+ * .test.ts) rather than obtained by extraction. Every refusal text here is
9
+ * load-bearing — it must stay byte-identical to `operator/server.ts` so the
10
+ * suite's full-body comparisons hold.
11
+ */
12
+ import { reduceBuild } from '../kernel/reducer'
13
+ import { effectiveStatus } from '../cli/dashboard/model'
14
+ import { BuildControlError, type BuildControlAction } from '../cli/build-control'
15
+ import type { BuildStore } from '../store/types'
16
+ import type { OperatorAnswerRequest } from './protocol'
17
+
18
+ /**
19
+ * The failure body an operator API route emits when its pre-service glue
20
+ * refuses (HttpError). Domain errors carry a `code`; these pre-service
21
+ * refusals never do, and the registry's mapped failure must match.
22
+ */
23
+ export interface RouteRefusal {
24
+ kind: 'validation' | 'auth' | 'not-found' | 'conflict'
25
+ error: string
26
+ }
27
+
28
+ /** Thrown by the registry-owned glue with the route's exact failure body. */
29
+ export class RouteRefusalError extends Error {
30
+ override readonly name = 'RouteRefusalError'
31
+
32
+ constructor(readonly refusal: RouteRefusal) {
33
+ super(refusal.error)
34
+ }
35
+ }
36
+
37
+ /** `requireRouteBuild` (operator/server.ts): 404 unless the slug exists and
38
+ * belongs to the named repository. */
39
+ export async function requireRouteBuild(
40
+ store: BuildStore,
41
+ repo: string,
42
+ slug: string,
43
+ ): Promise<void> {
44
+ const record = await store.getBuild(slug)
45
+ if (record === null || record.repo !== repo) {
46
+ throw new RouteRefusalError({ kind: 'not-found', error: `unknown build "${slug}"` })
47
+ }
48
+ }
49
+
50
+ /** The artifact revision query rule the artifact route applies to `?rev`. */
51
+ export function parseArtifactRevision(raw: string): number {
52
+ if (!/^[0-9]+$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
53
+ throw new RouteRefusalError({
54
+ kind: 'validation',
55
+ error: 'rev must be a nonnegative integer',
56
+ })
57
+ }
58
+ return Number(raw)
59
+ }
60
+
61
+ /**
62
+ * The route's control-request prechecks: build existence/repo, then the
63
+ * pending-state checks for pause and cancel-pause. Byte-identical to the
64
+ * `POST …/control` branch in operator/server.ts.
65
+ */
66
+ export async function controlPrechecks(
67
+ store: BuildStore,
68
+ repo: string,
69
+ slug: string,
70
+ action:
71
+ | 'pause'
72
+ | 'cancel-pause'
73
+ | 'resume'
74
+ | 'auto-merge-on'
75
+ | 'auto-merge-off'
76
+ | 'abort'
77
+ | 'discard',
78
+ ): Promise<void> {
79
+ await requireRouteBuild(store, repo, slug)
80
+ if (action === 'pause' || action === 'cancel-pause') {
81
+ const state = reduceBuild(await store.getEvents(slug))
82
+ const display = effectiveStatus(state)
83
+ if (action === 'pause' && display === 'pausing') {
84
+ throw new BuildControlError(
85
+ 'inactive',
86
+ `build "${slug}" cannot pause (status: pausing); pause is already pending`,
87
+ )
88
+ }
89
+ if (action === 'cancel-pause' && display !== 'pausing') {
90
+ throw new BuildControlError(
91
+ 'inactive',
92
+ `build "${slug}" cannot cancel pause (status: ${state.status}); cancel pause requires a pending pause`,
93
+ )
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The route's answer request → `BuildControlAction` translation, byte-identical
100
+ * to the `POST …/answer` branch in operator/server.ts. The ticket-origin body
101
+ * reader travels separately, exactly as the route passes `readTicketBody`.
102
+ */
103
+ export function answerAction(request: OperatorAnswerRequest): {
104
+ action: BuildControlAction
105
+ readTicketBody?: () => Promise<string>
106
+ } {
107
+ const action: BuildControlAction =
108
+ request.resolution === 'guidance'
109
+ ? { kind: 'answer', text: request.text }
110
+ : request.resolution === 'retry'
111
+ ? { kind: 'answer' }
112
+ : request.resolution === 'dismiss'
113
+ ? { kind: 'answer', text: request.text, resolve: { kind: 'dismiss-finding' } }
114
+ : request.resolution === 'review-round-ceiling'
115
+ ? { kind: 'answer', text: request.text, reviewRoundCeiling: request.ceiling }
116
+ : {
117
+ kind: 'answer',
118
+ text: request.text,
119
+ ...(request.ceiling !== undefined ? { reviewRoundCeiling: request.ceiling } : {}),
120
+ resolve: {
121
+ kind: 'revise-spec',
122
+ body:
123
+ request.origin === 'body'
124
+ ? {
125
+ kind: 'supplied',
126
+ origin: 'operator API body',
127
+ read: async () => request.body,
128
+ }
129
+ : { kind: 'ticket' },
130
+ },
131
+ }
132
+ return {
133
+ action,
134
+ ...(request.resolution === 'revise-spec' && request.origin === 'ticket'
135
+ ? { readTicketBody: async () => request.body }
136
+ : {}),
137
+ }
138
+ }
@@ -0,0 +1,458 @@
1
+ /**
2
+ * The operator sandbox service (AUT-340): the single validation,
3
+ * serialization, journaling, and typed-error point behind the registry's
4
+ * `sandbox.*` tools. The registry hands it only the caller's attributed
5
+ * identity plus the tool input; the service owns every journal append and
6
+ * derives lifecycle state from the repository journal (`sandboxStates`), so
7
+ * a later dispatcher invocation can settle what this process left behind.
8
+ *
9
+ * Credential freeness is structural upstream (the providers build guest
10
+ * environments from empty records); redaction here keeps credential VALUES
11
+ * out of typed error messages that travel to tool callers.
12
+ */
13
+ import { humanActor } from '../events/envelope'
14
+ import {
15
+ SANDBOX_FORBIDDEN_ENV,
16
+ SandboxOperationError,
17
+ type SandboxCommandResult,
18
+ type SandboxEnvironmentIdentity,
19
+ type SandboxWaitResult,
20
+ } from '../ports/workspace/operator-sandbox'
21
+ import type { WorkspaceProvider } from '../ports/types'
22
+ import { sandboxStates } from '../processes/sandbox-state'
23
+ import { systemClock, type BuildStore, type Clock } from '../store/types'
24
+
25
+ /** Hard bound on one sandbox command wait, in seconds — shared with the
26
+ * adapters (`SANDBOX_MAX_WAIT_SECONDS`). */
27
+ export const SANDBOX_EXEC_MAX_SECONDS = 300
28
+ /** Per-stream truncation bound for tool-returned output. */
29
+ export const SANDBOX_OUTPUT_LIMIT_BYTES = 65_536
30
+ export const SANDBOX_TRUNCATION_MARKER = '[truncated by autobuild: output exceeded 65536 bytes]'
31
+ /** Maximum `write_file` content size. */
32
+ export const SANDBOX_WRITE_LIMIT_BYTES = 1_048_576
33
+ /** Minimum journal interval between two `orchestrator.sandbox.activity`
34
+ * facts for one environment. */
35
+ export const SANDBOX_ACTIVITY_INTERVAL_MS = 60_000
36
+
37
+ /** Truncate one output stream to the byte bound, head-kept, with the
38
+ * documented marker appended past the bound. Exactly-at-bound output is
39
+ * returned unmodified. */
40
+ export function truncateSandboxOutput(output: string): string {
41
+ const bytes = Buffer.from(output, 'utf8')
42
+ if (bytes.length <= SANDBOX_OUTPUT_LIMIT_BYTES) return output
43
+ const head = bytes.subarray(0, SANDBOX_OUTPUT_LIMIT_BYTES).toString('utf8')
44
+ // Drop a possibly split trailing code unit so the marker rides on valid text.
45
+ const repaired =
46
+ head.length > 0 && Buffer.from(head, 'utf8').length > SANDBOX_OUTPUT_LIMIT_BYTES
47
+ ? head.slice(0, -1)
48
+ : head
49
+ return `${repaired}\n${SANDBOX_TRUNCATION_MARKER}`
50
+ }
51
+
52
+ /** Pure lexical sandbox-path check: reject absolute paths, empty components,
53
+ * and any `..` after normalization. Not a sandbox boundary — the credential
54
+ * free rule bounds what a guest escape could reach — but it keeps tool file
55
+ * paths inside the checkout. */
56
+ export function resolveSandboxRelativePath(path: string): string {
57
+ if (path.length === 0) {
58
+ throw new SandboxOperationError('exec', 'sandbox path must be nonempty')
59
+ }
60
+ if (path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)) {
61
+ throw new SandboxOperationError(
62
+ 'exec',
63
+ `sandbox path ${JSON.stringify(path)} must be relative to the checkout`,
64
+ )
65
+ }
66
+ if (path.includes('\\')) {
67
+ throw new SandboxOperationError(
68
+ 'exec',
69
+ `sandbox path ${JSON.stringify(path)} must use "/" separators`,
70
+ )
71
+ }
72
+ const segments: string[] = []
73
+ for (const segment of path.split('/')) {
74
+ if (segment === '' || segment === '.') continue
75
+ if (segment === '..') {
76
+ if (segments.length === 0) {
77
+ throw new SandboxOperationError(
78
+ 'exec',
79
+ `sandbox path ${JSON.stringify(path)} escapes the checkout`,
80
+ )
81
+ }
82
+ segments.pop()
83
+ continue
84
+ }
85
+ segments.push(segment)
86
+ }
87
+ if (segments.length === 0) {
88
+ throw new SandboxOperationError('exec', `sandbox path ${JSON.stringify(path)} names no file`)
89
+ }
90
+ return segments.join('/')
91
+ }
92
+
93
+ /** Redact values of credential-named environment variables from a provider
94
+ * message (the existing credential-redaction discipline): the message may
95
+ * travel to a tool caller, so a value of any forbidden name never rides on
96
+ * it. Values are read from the host environment of the serving process. */
97
+ export function redactSandboxMessage(message: string): string {
98
+ let redacted = message
99
+ for (const name of SANDBOX_FORBIDDEN_ENV) {
100
+ const value = process.env[name]
101
+ if (value !== undefined && value !== '') redacted = redacted.split(value).join('[REDACTED]')
102
+ }
103
+ return redacted
104
+ }
105
+
106
+ function describeError(error: unknown): string {
107
+ return redactSandboxMessage(error instanceof Error ? error.message : String(error))
108
+ }
109
+
110
+ export interface OperatorSandboxConfig {
111
+ idleMinutes: number
112
+ environmentVariables: readonly string[]
113
+ }
114
+
115
+ export interface OperatorSandboxService {
116
+ exec(
117
+ identity: string,
118
+ input: { repo: string; command: string; cwd?: string; timeoutSeconds?: number },
119
+ ): Promise<SandboxCommandResult>
120
+ start(
121
+ identity: string,
122
+ input: { repo: string; command: string; cwd?: string },
123
+ ): Promise<{ commandId: string }>
124
+ wait(
125
+ identity: string,
126
+ input: { repo: string; commandId: string; waitSeconds: number },
127
+ ): Promise<SandboxWaitResult>
128
+ readFile(identity: string, input: { repo: string; path: string }): Promise<Uint8Array>
129
+ writeFile(
130
+ identity: string,
131
+ input: { repo: string; path: string; content: string; encoding: 'utf8' | 'base64' },
132
+ ): Promise<void>
133
+ /** Destructive: journal `reset`, full teardown + snapshot purge, then a
134
+ * fresh provision from the CURRENT base head. */
135
+ reset(identity: string, input: { repo: string }): Promise<void>
136
+ /** Full teardown + snapshot purge of the operator's environment, without
137
+ * provisioning. A no-op when the journal shows no environment. Used by
138
+ * `reset` and the session-archive hook. */
139
+ release(identity: string, input: { repo: string }): Promise<void>
140
+ }
141
+
142
+ export interface OperatorSandboxServiceOptions {
143
+ store: BuildStore
144
+ repo: string
145
+ provider: WorkspaceProvider
146
+ sandbox: OperatorSandboxConfig
147
+ /** The binding's parsed `[baseBranch]`: reset always provisions from this
148
+ * branch's current head. A hot baseBranch reload is picked up when the
149
+ * `ab mcp` process restarts. */
150
+ baseBranch: string
151
+ clock?: Clock
152
+ capability?: WorkspaceProvider['orchestratorSandbox']
153
+ }
154
+
155
+ export async function createOperatorSandboxService(
156
+ options: OperatorSandboxServiceOptions,
157
+ ): Promise<OperatorSandboxService> {
158
+ const store = options.store
159
+ const repo = options.repo
160
+ const provider = options.provider
161
+ const capability = options.capability ?? provider.orchestratorSandbox
162
+ if (capability === undefined) {
163
+ throw new Error(
164
+ `workspace provider "${provider.name}" cannot host operator sandboxes; sandbox tools are unavailable`,
165
+ )
166
+ }
167
+ const clock = options.clock ?? systemClock
168
+ await store.ensureRepo(repo)
169
+
170
+ /** Per-environment serialization: one in-process promise chain keyed by
171
+ * (repo, operator) — equivalent to keying by environment because the
172
+ * mapping is 1:1 — so calls against one environment serialize. */
173
+ const chains = new Map<string, Promise<unknown>>()
174
+ const serialize = async <T>(key: string, operation: () => Promise<T>): Promise<T> => {
175
+ const previous = chains.get(key) ?? Promise.resolve()
176
+ const next = previous.then(operation, operation)
177
+ chains.set(
178
+ key,
179
+ next.catch(() => {}),
180
+ )
181
+ return next
182
+ }
183
+
184
+ const append = async (
185
+ identity: string,
186
+ type:
187
+ | 'orchestrator.sandbox.provisioned'
188
+ | 'orchestrator.sandbox.resumed'
189
+ | 'orchestrator.sandbox.activity'
190
+ | 'orchestrator.sandbox.released'
191
+ | 'orchestrator.sandbox.reset',
192
+ payload: Record<string, unknown>,
193
+ ): Promise<void> => {
194
+ await store.appendRepo(repo, {
195
+ actor: humanActor(identity),
196
+ type,
197
+ payload: payload as never,
198
+ })
199
+ }
200
+
201
+ const journalEvents = async (): Promise<Awaited<ReturnType<typeof store.getRepoEvents>>> => {
202
+ if ((await store.getRepo(repo)) === null) return []
203
+ return store.getRepoEvents(repo)
204
+ }
205
+
206
+ /** The operator's journal state, or undefined when no environment exists. */
207
+ const operatorState = (identity: string, events: Awaited<ReturnType<typeof journalEvents>>) =>
208
+ sandboxStates(events)
209
+ .filter((state) => state.operator === identity && state.state !== 'released')
210
+ .at(-1)
211
+
212
+ /** Ensure (provision or resume) with the journal facts the transition
213
+ * produces. Returns the resolved identity. */
214
+ const ensureWithFacts = async (
215
+ identity: string,
216
+ state: ReturnType<typeof operatorState>,
217
+ ): Promise<SandboxEnvironmentIdentity> => {
218
+ const resolved = await capability.ensure({
219
+ repo,
220
+ operator: identity,
221
+ baseBranch: options.baseBranch,
222
+ })
223
+ if (state === undefined) {
224
+ await append(identity, 'orchestrator.sandbox.provisioned', {
225
+ operator: identity,
226
+ environmentId: resolved.environmentId,
227
+ provider: resolved.provider,
228
+ ...(resolved.sessionId !== undefined ? { sessionId: resolved.sessionId } : {}),
229
+ workspacePath: resolved.workspacePath,
230
+ })
231
+ } else if (state.state === 'stopped') {
232
+ await append(identity, 'orchestrator.sandbox.resumed', {
233
+ operator: identity,
234
+ environmentId: resolved.environmentId,
235
+ provider: resolved.provider,
236
+ ...(resolved.sessionId !== undefined ? { sessionId: resolved.sessionId } : {}),
237
+ })
238
+ }
239
+ return resolved
240
+ }
241
+
242
+ /** Rate-limited activity evidence: at most one fact per interval per
243
+ * environment, compared against the latest journaled evidence. */
244
+ const maybeAppendActivity = async (
245
+ identity: string,
246
+ state: ReturnType<typeof operatorState>,
247
+ ): Promise<void> => {
248
+ if (state === undefined) return
249
+ const last = Date.parse(state.lastEvidenceTs)
250
+ if (Number.isFinite(last) && clock().getTime() - last < SANDBOX_ACTIVITY_INTERVAL_MS) return
251
+ const resolved = await capability.describe({ repo, operator: identity })
252
+ await append(identity, 'orchestrator.sandbox.activity', {
253
+ operator: identity,
254
+ environmentId: resolved.environmentId,
255
+ })
256
+ }
257
+
258
+ const requireRepo = (inputRepo: string | undefined): void => {
259
+ if (inputRepo !== undefined && inputRepo !== repo) {
260
+ throw new SandboxOperationError(
261
+ 'exec',
262
+ `sandbox tools serve repository "${repo}"; refusing "${inputRepo}"`,
263
+ )
264
+ }
265
+ }
266
+
267
+ const run = async <T>(
268
+ identity: string,
269
+ stage: 'provision' | 'resume' | 'exec',
270
+ operation: (
271
+ resolved: SandboxEnvironmentIdentity,
272
+ state: ReturnType<typeof operatorState>,
273
+ ) => Promise<T>,
274
+ ): Promise<T> => {
275
+ const key = `${repo}\0${identity}`
276
+ return serialize(key, async () => {
277
+ const events = await journalEvents()
278
+ const state = operatorState(identity, events)
279
+ const resolved = await ensureWithFacts(identity, state).catch((error: unknown) => {
280
+ if (error instanceof SandboxOperationError) throw error
281
+ throw new SandboxOperationError(
282
+ state === undefined ? 'provision' : 'resume',
283
+ describeError(error),
284
+ { cause: error },
285
+ )
286
+ })
287
+ const freshEvents = await journalEvents()
288
+ const freshState = operatorState(identity, freshEvents)
289
+ try {
290
+ const result = await operation(resolved, freshState)
291
+ await maybeAppendActivity(identity, freshState).catch(() => {})
292
+ return result
293
+ } catch (error) {
294
+ if (error instanceof SandboxOperationError) throw error
295
+ throw new SandboxOperationError(stage, describeError(error), { cause: error })
296
+ }
297
+ })
298
+ }
299
+
300
+ return {
301
+ async exec(identity, input) {
302
+ requireRepo(input.repo)
303
+ const timeoutSeconds = validateBoundedInt(
304
+ input.timeoutSeconds ?? 120,
305
+ 1,
306
+ SANDBOX_EXEC_MAX_SECONDS,
307
+ 'timeoutSeconds',
308
+ )
309
+ const cwd = input.cwd === undefined ? undefined : resolveSandboxRelativePath(input.cwd)
310
+ return run(identity, 'exec', async (resolved) => {
311
+ const result = await capability.exec(resolved, {
312
+ command: input.command,
313
+ ...(cwd !== undefined ? { cwd } : {}),
314
+ timeoutSeconds,
315
+ })
316
+ return {
317
+ exitCode: result.exitCode,
318
+ stdout: truncateSandboxOutput(result.stdout),
319
+ stderr: truncateSandboxOutput(result.stderr),
320
+ }
321
+ })
322
+ },
323
+
324
+ async start(identity, input) {
325
+ requireRepo(input.repo)
326
+ const cwd = input.cwd === undefined ? undefined : resolveSandboxRelativePath(input.cwd)
327
+ return run(identity, 'exec', async (resolved) =>
328
+ capability.start(resolved, {
329
+ command: input.command,
330
+ ...(cwd !== undefined ? { cwd } : {}),
331
+ }),
332
+ )
333
+ },
334
+
335
+ async wait(identity, input) {
336
+ requireRepo(input.repo)
337
+ const waitSeconds = validateBoundedInt(
338
+ input.waitSeconds,
339
+ 0,
340
+ SANDBOX_EXEC_MAX_SECONDS,
341
+ 'waitSeconds',
342
+ )
343
+ return run(identity, 'exec', async (resolved) => {
344
+ const result = await capability.wait(resolved, {
345
+ commandId: input.commandId,
346
+ waitSeconds,
347
+ })
348
+ if (result.state === 'running') {
349
+ // Partial output so far rides on `running` too: providers that
350
+ // stream (the local faces) supply their buffers here, and Vercel —
351
+ // which reports output only after exit — supplies none. Truncation
352
+ // bounds apply to both shapes.
353
+ return {
354
+ state: 'running' as const,
355
+ ...(result.stdout !== undefined
356
+ ? { stdout: truncateSandboxOutput(result.stdout) }
357
+ : {}),
358
+ ...(result.stderr !== undefined
359
+ ? { stderr: truncateSandboxOutput(result.stderr) }
360
+ : {}),
361
+ }
362
+ }
363
+ return {
364
+ state: 'exited' as const,
365
+ exitCode: result.exitCode,
366
+ stdout: truncateSandboxOutput(result.stdout ?? ''),
367
+ stderr: truncateSandboxOutput(result.stderr ?? ''),
368
+ }
369
+ })
370
+ },
371
+
372
+ async readFile(identity, input) {
373
+ requireRepo(input.repo)
374
+ const path = resolveSandboxRelativePath(input.path)
375
+ return run(identity, 'exec', async (resolved) => capability.readFile(resolved, path))
376
+ },
377
+
378
+ async writeFile(identity, input) {
379
+ requireRepo(input.repo)
380
+ const path = resolveSandboxRelativePath(input.path)
381
+ const content =
382
+ input.encoding === 'base64'
383
+ ? Buffer.from(input.content, 'base64')
384
+ : Buffer.from(input.content, 'utf8')
385
+ if (content.length > SANDBOX_WRITE_LIMIT_BYTES) {
386
+ throw new SandboxOperationError(
387
+ 'exec',
388
+ `sandbox write exceeds the ${SANDBOX_WRITE_LIMIT_BYTES}-byte bound`,
389
+ )
390
+ }
391
+ await run(identity, 'exec', async (resolved) => {
392
+ await capability.writeFile(resolved, path, new Uint8Array(content))
393
+ })
394
+ },
395
+
396
+ async reset(identity, input) {
397
+ requireRepo(input.repo)
398
+ const key = `${repo}\0${identity}`
399
+ await serialize(key, async () => {
400
+ const events = await journalEvents()
401
+ const state = operatorState(identity, events)
402
+ const resolved = await capability.describe({ repo, operator: identity })
403
+ await append(identity, 'orchestrator.sandbox.reset', {
404
+ operator: identity,
405
+ environmentId: resolved.environmentId,
406
+ })
407
+ // Release first (snapshot purge), then reprovision from the CURRENT
408
+ // base head. A release failure still surfaces typed; the journal
409
+ // carries reset → released → provisioned as the evidence chain.
410
+ const { snapshots } = await capability
411
+ .release({ repo, operator: identity, environmentId: resolved.environmentId })
412
+ .catch((error: unknown) => {
413
+ throw new SandboxOperationError('reset', describeError(error), { cause: error })
414
+ })
415
+ await append(identity, 'orchestrator.sandbox.released', {
416
+ operator: identity,
417
+ environmentId: resolved.environmentId,
418
+ snapshots,
419
+ })
420
+ await ensureWithFacts(identity, undefined).catch((error: unknown) => {
421
+ if (error instanceof SandboxOperationError) throw error
422
+ throw new SandboxOperationError('provision', describeError(error), { cause: error })
423
+ })
424
+ void state
425
+ })
426
+ },
427
+
428
+ async release(identity, input) {
429
+ requireRepo(input.repo)
430
+ const key = `${repo}\0${identity}`
431
+ await serialize(key, async () => {
432
+ const events = await journalEvents()
433
+ const state = operatorState(identity, events)
434
+ // No environment ever provisioned: a no-op, with no fact and no
435
+ // provider traffic beyond the pure describe.
436
+ if (state === undefined) return
437
+ const resolved = await capability.describe({ repo, operator: identity })
438
+ const { snapshots } = await capability
439
+ .release({ repo, operator: identity, environmentId: resolved.environmentId })
440
+ .catch((error: unknown) => {
441
+ throw new SandboxOperationError('release', describeError(error), { cause: error })
442
+ })
443
+ await append(identity, 'orchestrator.sandbox.released', {
444
+ operator: identity,
445
+ environmentId: resolved.environmentId,
446
+ snapshots,
447
+ })
448
+ })
449
+ },
450
+ }
451
+ }
452
+
453
+ function validateBoundedInt(value: number, min: number, max: number, field: string): number {
454
+ if (!Number.isInteger(value) || value < min || value > max) {
455
+ throw new SandboxOperationError('exec', `${field} must be an integer between ${min} and ${max}`)
456
+ }
457
+ return value
458
+ }