@jr2/orchestrator 0.3.0 → 0.5.1

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 (354) hide show
  1. package/README.md +20 -2
  2. package/bin/server.ts +7 -4
  3. package/console/canvas.ts +27 -14
  4. package/console/components/app.ts +12 -1
  5. package/console/components/fleet.ts +52 -6
  6. package/console/components/machine-pane.ts +128 -2
  7. package/console/components/nav.ts +1 -1
  8. package/console/components/schema-form.ts +12 -4
  9. package/console/json-field.ts +23 -0
  10. package/console/main.ts +122 -39
  11. package/console/store.ts +321 -21
  12. package/console/style.css +111 -0
  13. package/docs/agents-block.md +5 -0
  14. package/docs/concepts/agents-and-turns.md +442 -0
  15. package/docs/concepts/architecture.md +230 -0
  16. package/docs/concepts/charts.md +143 -0
  17. package/docs/concepts/composition.md +357 -0
  18. package/docs/concepts/converge.md +254 -0
  19. package/docs/concepts/durable-runs.md +189 -0
  20. package/docs/concepts/effects.md +603 -0
  21. package/docs/concepts/gates.md +208 -0
  22. package/docs/concepts/machines-and-workflows.md +289 -0
  23. package/docs/concepts/pools-and-sources.md +230 -0
  24. package/docs/concepts/secrets-and-trust.md +222 -0
  25. package/docs/concepts/the-menu.md +378 -0
  26. package/docs/concepts/workspaces.md +678 -0
  27. package/docs/get-started/a-model-decides.md +563 -0
  28. package/docs/get-started/an-agent-in-a-sandbox.md +381 -0
  29. package/docs/get-started/connect-a-model.md +248 -0
  30. package/docs/get-started/create-an-instance.md +194 -0
  31. package/docs/get-started/have-your-assistant-write-a-machine.md +922 -0
  32. package/docs/get-started/install.md +126 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +295 -0
  34. package/docs/guides/answer-a-gate.md +304 -0
  35. package/docs/guides/bind-open-parts.md +223 -0
  36. package/docs/guides/cancel-a-run.md +146 -0
  37. package/docs/guides/clean-up.md +206 -0
  38. package/docs/guides/fix-a-failed-converge.md +361 -0
  39. package/docs/guides/give-the-cluster-git-credentials.md +222 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
  41. package/docs/guides/let-an-agent-write-a-chart.md +226 -0
  42. package/docs/guides/look-inside-a-sandbox.md +174 -0
  43. package/docs/guides/mirror-the-kit-images.md +176 -0
  44. package/docs/guides/move-from-kind-to-a-shared-cluster.md +302 -0
  45. package/docs/guides/open-the-console.md +91 -0
  46. package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
  47. package/docs/guides/push-and-sign-a-branch.md +486 -0
  48. package/docs/guides/run-a-chart-by-name.md +270 -0
  49. package/docs/guides/run-a-command-or-call-a-url.md +544 -0
  50. package/docs/guides/run-one-worker-per-item.md +321 -0
  51. package/docs/guides/set-an-agents-access-and-dials.md +375 -0
  52. package/docs/guides/size-a-workspace.md +204 -0
  53. package/docs/guides/start-a-run-from-a-run.md +237 -0
  54. package/docs/guides/start-a-run.md +207 -0
  55. package/docs/guides/upgrade-the-kit-version.md +240 -0
  56. package/docs/guides/use-a-hosted-provider.md +208 -0
  57. package/docs/guides/use-a-local-model.md +313 -0
  58. package/docs/guides/watch-a-run.md +326 -0
  59. package/docs/guides/write-a-workflow.md +318 -0
  60. package/docs/index.md +44 -0
  61. package/docs/llms.txt +216 -0
  62. package/docs/reference/authoring-surface/agent.md +128 -0
  63. package/docs/reference/authoring-surface/answer.md +140 -0
  64. package/docs/reference/authoring-surface/arg.md +60 -0
  65. package/docs/reference/authoring-surface/basic.md +62 -0
  66. package/docs/reference/authoring-surface/bearer.md +53 -0
  67. package/docs/reference/authoring-surface/chartschema.md +23 -0
  68. package/docs/reference/authoring-surface/chartschemafor.md +48 -0
  69. package/docs/reference/authoring-surface/command.md +191 -0
  70. package/docs/reference/authoring-surface/customize.md +64 -0
  71. package/docs/reference/authoring-surface/defineconfig.md +41 -0
  72. package/docs/reference/authoring-surface/defineevent.md +114 -0
  73. package/docs/reference/authoring-surface/dispatch.md +126 -0
  74. package/docs/reference/authoring-surface/fail.md +83 -0
  75. package/docs/reference/authoring-surface/file.md +122 -0
  76. package/docs/reference/authoring-surface/frompick.md +277 -0
  77. package/docs/reference/authoring-surface/http.md +159 -0
  78. package/docs/reference/authoring-surface/isfailure.md +58 -0
  79. package/docs/reference/authoring-surface/jr2setup.md +237 -0
  80. package/docs/reference/authoring-surface/open.md +74 -0
  81. package/docs/reference/authoring-surface/openbody.md +86 -0
  82. package/docs/reference/authoring-surface/paramsof.md +125 -0
  83. package/docs/reference/authoring-surface/pool.md +186 -0
  84. package/docs/reference/authoring-surface/push.md +112 -0
  85. package/docs/reference/authoring-surface/secret.md +118 -0
  86. package/docs/reference/authoring-surface/sentby.md +83 -0
  87. package/docs/reference/authoring-surface/source.md +51 -0
  88. package/docs/reference/authoring-surface/text.md +62 -0
  89. package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
  90. package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
  91. package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
  92. package/docs/reference/authoring-surface/type-agentslots.md +12 -0
  93. package/docs/reference/authoring-surface/type-answeraction.md +12 -0
  94. package/docs/reference/authoring-surface/type-arg.md +19 -0
  95. package/docs/reference/authoring-surface/type-argnames.md +12 -0
  96. package/docs/reference/authoring-surface/type-binding.md +62 -0
  97. package/docs/reference/authoring-surface/type-bindingof.md +12 -0
  98. package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
  99. package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
  100. package/docs/reference/authoring-surface/type-ceilings.md +41 -0
  101. package/docs/reference/authoring-surface/type-chainlink.md +19 -0
  102. package/docs/reference/authoring-surface/type-chartaction.md +46 -0
  103. package/docs/reference/authoring-surface/type-chartagent.md +28 -0
  104. package/docs/reference/authoring-surface/type-chartevent.md +20 -0
  105. package/docs/reference/authoring-surface/type-chartguard.md +20 -0
  106. package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
  107. package/docs/reference/authoring-surface/type-chartjson.md +14 -0
  108. package/docs/reference/authoring-surface/type-chartstate.md +46 -0
  109. package/docs/reference/authoring-surface/type-charttext.md +40 -0
  110. package/docs/reference/authoring-surface/type-charttransition.md +22 -0
  111. package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
  112. package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
  113. package/docs/reference/authoring-surface/type-childat.md +12 -0
  114. package/docs/reference/authoring-surface/type-childslots.md +12 -0
  115. package/docs/reference/authoring-surface/type-commandenv.md +15 -0
  116. package/docs/reference/authoring-surface/type-commandinput.md +19 -0
  117. package/docs/reference/authoring-surface/type-commandoptions.md +78 -0
  118. package/docs/reference/authoring-surface/type-commandoutput.md +104 -0
  119. package/docs/reference/authoring-surface/type-customize.md +109 -0
  120. package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
  121. package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
  122. package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
  123. package/docs/reference/authoring-surface/type-dispatchoutput.md +65 -0
  124. package/docs/reference/authoring-surface/type-duration.md +15 -0
  125. package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
  126. package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
  127. package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
  128. package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
  129. package/docs/reference/authoring-surface/type-eventdef.md +45 -0
  130. package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
  131. package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
  132. package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
  133. package/docs/reference/authoring-surface/type-failslot.md +14 -0
  134. package/docs/reference/authoring-surface/type-failure.md +19 -0
  135. package/docs/reference/authoring-surface/type-failureinput.md +20 -0
  136. package/docs/reference/authoring-surface/type-filebody.md +41 -0
  137. package/docs/reference/authoring-surface/type-frominput.md +14 -0
  138. package/docs/reference/authoring-surface/type-hostinjectedinput.md +58 -0
  139. package/docs/reference/authoring-surface/type-httpdefinition.md +101 -0
  140. package/docs/reference/authoring-surface/type-httpinput.md +12 -0
  141. package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
  142. package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
  143. package/docs/reference/authoring-surface/type-httpoutput.md +89 -0
  144. package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
  145. package/docs/reference/authoring-surface/type-jr2config.md +126 -0
  146. package/docs/reference/authoring-surface/type-lends.md +38 -0
  147. package/docs/reference/authoring-surface/type-logicat.md +12 -0
  148. package/docs/reference/authoring-surface/type-markedif.md +12 -0
  149. package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
  150. package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
  151. package/docs/reference/authoring-surface/type-noslotspec.md +26 -0
  152. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +32 -0
  153. package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
  154. package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
  155. package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
  156. package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
  157. package/docs/reference/authoring-surface/type-opencommandinput.md +17 -0
  158. package/docs/reference/authoring-surface/type-openeffect.md +12 -0
  159. package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
  160. package/docs/reference/authoring-surface/type-openof.md +12 -0
  161. package/docs/reference/authoring-surface/type-opensecret.md +23 -0
  162. package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
  163. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +31 -0
  164. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +32 -0
  165. package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
  166. package/docs/reference/authoring-surface/type-poolspec.md +60 -0
  167. package/docs/reference/authoring-surface/type-pushinput.md +15 -0
  168. package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
  169. package/docs/reference/authoring-surface/type-pushoutput.md +124 -0
  170. package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
  171. package/docs/reference/authoring-surface/type-quantity.md +14 -0
  172. package/docs/reference/authoring-surface/type-reached.md +12 -0
  173. package/docs/reference/authoring-surface/type-reposlot.md +70 -0
  174. package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
  175. package/docs/reference/authoring-surface/type-sandboxoptions.md +77 -0
  176. package/docs/reference/authoring-surface/type-secret.md +25 -0
  177. package/docs/reference/authoring-surface/type-sender.md +19 -0
  178. package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
  179. package/docs/reference/authoring-surface/type-size.md +85 -0
  180. package/docs/reference/authoring-surface/type-slothandles.md +32 -0
  181. package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
  182. package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
  183. package/docs/reference/authoring-surface/type-syncstate.md +12 -0
  184. package/docs/reference/authoring-surface/type-text.md +19 -0
  185. package/docs/reference/authoring-surface/type-textpart.md +24 -0
  186. package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
  187. package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
  188. package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
  189. package/docs/reference/authoring-surface/type-unknown.md +21 -0
  190. package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
  191. package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
  192. package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
  193. package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
  194. package/docs/reference/authoring-surface/type-workspaced.md +94 -0
  195. package/docs/reference/authoring-surface/type-workspacehandles.md +23 -0
  196. package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
  197. package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
  198. package/docs/reference/authoring-surface/type-workspaceoptions.md +39 -0
  199. package/docs/reference/authoring-surface/type-workspacespec.md +37 -0
  200. package/docs/reference/authoring-surface/workspace.md +259 -0
  201. package/docs/reference/authoring-surface.md +197 -0
  202. package/docs/reference/cli.md +433 -0
  203. package/docs/reference/config.md +756 -0
  204. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +113 -0
  205. package/docs/reference/decisions/0002-duplex-streaming-actor.md +110 -0
  206. package/docs/reference/decisions/0004-worktree-provisioning-model.md +124 -0
  207. package/docs/reference/decisions/0005-sandbox-pod-composition.md +230 -0
  208. package/docs/reference/decisions/0006-agent-control-surface.md +47 -0
  209. package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
  210. package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
  211. package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
  212. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
  213. package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
  214. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
  215. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +157 -0
  216. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
  217. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +265 -0
  218. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +124 -0
  219. package/docs/reference/decisions/0017-pool-over-a-source-port.md +69 -0
  220. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
  221. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +275 -0
  222. package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
  223. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +100 -0
  224. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
  225. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +176 -0
  226. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +439 -0
  227. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
  228. package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
  229. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +237 -0
  230. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +89 -0
  231. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
  232. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
  233. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +190 -0
  234. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
  235. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +127 -0
  236. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
  237. package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
  238. package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
  239. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
  240. package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
  241. package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
  242. package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
  243. package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
  244. package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
  245. package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
  246. package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
  247. package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
  248. package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
  249. package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
  250. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
  251. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
  252. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +227 -0
  253. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +81 -0
  254. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +111 -0
  255. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
  256. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
  257. package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
  258. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
  259. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +106 -0
  260. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +384 -0
  261. package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
  262. package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
  263. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +212 -0
  264. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +83 -0
  265. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
  266. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
  267. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +550 -0
  268. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +430 -0
  269. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +464 -0
  270. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
  271. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +323 -0
  272. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
  273. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
  274. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
  275. package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
  276. package/docs/reference/decisions.md +83 -0
  277. package/docs/reference/glossary.md +648 -0
  278. package/docs/reference/http-api.md +1348 -0
  279. package/docs/reference/machines.md +190 -0
  280. package/docs/reference/mechanism-events.md +72 -0
  281. package/docs/why.md +206 -0
  282. package/package.json +6 -4
  283. package/src/actor.ts +976 -61
  284. package/src/agent.ts +114 -38
  285. package/src/ambient.ts +4 -1
  286. package/src/assembly-checks.ts +347 -0
  287. package/src/assembly-tar.ts +494 -0
  288. package/src/assembly.ts +721 -0
  289. package/src/cause.ts +234 -0
  290. package/src/chart.ts +2182 -0
  291. package/src/charts.ts +748 -0
  292. package/src/config.ts +520 -162
  293. package/src/content-hash.ts +105 -0
  294. package/src/custodian.lua +6 -5
  295. package/src/custodian.ts +143 -39
  296. package/src/customize.ts +388 -55
  297. package/src/dispatch-effect.ts +945 -0
  298. package/src/door-compat.ts +217 -0
  299. package/src/durability.ts +11 -4
  300. package/src/effect-checks.ts +557 -0
  301. package/src/effect.ts +1202 -0
  302. package/src/effector-wire.ts +674 -0
  303. package/src/fail.ts +209 -0
  304. package/src/fingerprint.ts +4 -3
  305. package/src/gate.ts +8 -0
  306. package/src/git-clone.ts +65 -0
  307. package/src/git-push.ts +336 -0
  308. package/src/harness-client.ts +764 -48
  309. package/src/held-secrets.ts +82 -26
  310. package/src/holds.ts +156 -0
  311. package/src/http-effect.ts +1423 -0
  312. package/src/http.ts +916 -113
  313. package/src/images.ts +47 -21
  314. package/src/index.ts +62 -43
  315. package/src/instance.ts +505 -59
  316. package/src/internal.ts +84 -0
  317. package/src/kube-client.ts +333 -0
  318. package/src/machine-doc.ts +106 -6
  319. package/src/mechanism-routes.ts +168 -0
  320. package/src/model-keys.generated.ts +53 -0
  321. package/src/model-keys.ts +18 -58
  322. package/src/mount-digest.ts +53 -0
  323. package/src/names.ts +66 -8
  324. package/src/notices.ts +141 -0
  325. package/src/open-body.ts +1580 -0
  326. package/src/open-effect.ts +217 -0
  327. package/src/open.ts +59 -6
  328. package/src/parts.ts +709 -41
  329. package/src/party.ts +534 -0
  330. package/src/pick.ts +761 -0
  331. package/src/pool.ts +248 -37
  332. package/src/registration.ts +382 -19
  333. package/src/repo-fetch.ts +60 -85
  334. package/src/repos.ts +65 -75
  335. package/src/rest.ts +133 -0
  336. package/src/run-host.ts +2383 -115
  337. package/src/run-output.ts +38 -0
  338. package/src/sandbox-effect.ts +1126 -0
  339. package/src/sandbox-effector.ts +111 -0
  340. package/src/sandbox-kube.ts +1721 -0
  341. package/src/sandbox-watch.ts +260 -0
  342. package/src/secret-placement.ts +529 -0
  343. package/src/server.ts +284 -84
  344. package/src/setup.ts +819 -117
  345. package/src/size.ts +454 -0
  346. package/src/snapshot-store.ts +115 -20
  347. package/src/stub-harness.ts +49 -16
  348. package/src/sync-client.ts +54 -0
  349. package/src/timers.ts +191 -0
  350. package/src/tokens.ts +35 -4
  351. package/src/vocabulary.ts +135 -15
  352. package/src/wire.ts +238 -10
  353. package/src/workspace.ts +1195 -338
  354. package/src/sandbox-kubectl.ts +0 -1108
@@ -0,0 +1,1721 @@
1
+ // The canonical SandboxPort (ADR-0012 / GAP(3)): drives the operator's Sandbox CRD through the
2
+ // Orchestrator's own Kubernetes client (kube-client.ts, ADR-0063) — plain REST on built-in
3
+ // `fetch`, no dependency — and reads it back through the one watch (sandbox-watch.ts). Every wait
4
+ // that polled is now a watch event: the placing's node (ADR-0064), the provision's Ready,
5
+ // Continuity and the Harness's restarts (ADR-0021). The Orchestrator never reads a Pod: the
6
+ // operator owns it (ADR-0001) and publishes what this port needs on the Sandbox's status — the
7
+ // scheduler's word or a quota's refusal, `Lost` with the pod's reason, the Harness container's
8
+ // restarts and last end. The client and the watch are injectable, so the mapping is unit-testable against a
9
+ // fake API server; the kind e2e tier exercises the real thing.
10
+ //
11
+ // Reachability: the orchestrator always runs in-cluster (ADR-0019), so it dials
12
+ // `status.endpoint` (`http://<name>.<ns>.svc:…`) directly — stable across orchestrator
13
+ // restarts by nature, which is what ADR-0012's "same endpoint" re-attach promise rides on.
14
+ //
15
+ // Every operation is idempotent (SandboxPort contract): apply is create-or-update, the attach is
16
+ // the Harness's own `POST /attach`, which guards every clone and worktree (ADR-0063), delete
17
+ // ignores absent.
18
+ //
19
+ // WHICH REPOS a Sandbox attaches arrive resolved from the `workspace()`'s Repo Slots (ADR-0051):
20
+ // the CR names each by its cache key, the operator mounts the node's cache read-only at
21
+ // `/repos/<key>` and gates Ready on it, and the Harness's attach clones off that mount. The one judgement
22
+ // made here is the FENCE: a per-run url must match a `git.credentials` entry, or it is refused
23
+ // before anything is applied.
24
+ //
25
+ // WHICH IMAGE a Sandbox runs is not an option here (ADR-0037/0038/0049). The request carries what
26
+ // the `workspace()` wrapper statically declared — a `file:` docker context or a registry ref — and
27
+ // the resolved key→ref map arrives as a mounted ConfigMap read on EVERY provision, so a `jr2 up`
28
+ // that rebuilds an image reaches future Sandboxes without rolling this process.
29
+ //
30
+ // The pod's primary container is the Sandbox Image BYTE-FOR-BYTE (ADR-0037): no appended layers,
31
+ // no rewritten Dockerfile, no jr2 knowledge inside it. jr2's runtime arrives at POD time instead —
32
+ // an `image` volume of the kit's Harness image, its `/opt/jr2` mounted read-only at `/opt/jr2` —
33
+ // and the container's COMMAND is overridden to start the Harness from that volume. The kubelet
34
+ // mounts the image's own layers, so every Sandbox on a node reads one set of runtime pages. The
35
+ // image's own `USER` and `HOME` are respected (the human who execs in lands in the environment its
36
+ // author built); only its `ENTRYPOINT`/`CMD` do not run, because a container has one command and the
37
+ // Harness must own it — its death must be the container's death, which is what the operator's
38
+ // Ready probe and restart semantics at `:8080` mean. A process the image WANTS running is not
39
+ // lost: it has its own seat, the User Container (ADR-0005), composed here when the wrapper's static
40
+ // `user` option names an image (ADR-0049).
41
+ //
42
+ // So this module composes the whole pod — one volume from the kit, one init step, and up to three
43
+ // containers:
44
+ //
45
+ // volume runtime the kit's Harness image, as an `image` volume → /opt/jr2
46
+ // volume conversations an emptyDir → /conversations, the Harness container's alone
47
+ // (ADR-0031: a Harness restart inside the pod keeps its conversations)
48
+ // initContainer preflight the USER'S image + that volume → ADR-0037's probe, the thing that
49
+ // proves a registry ref, whose first appearance is this provision
50
+ // container harness the Sandbox Image, command overridden, /work + /opt/jr2 mounted
51
+ // container custodian jr2-composed, the pod's only credential holder (below)
52
+ // container user optional, the image's own entrypoint, the checkouts (/work, plus
53
+ // /repos and /opt/jr2 read-only) and NOTHING else
54
+ //
55
+ // and SIZES it (ADR-0060): the Workspace's Size, resolved down its chain, is the pod-level ceiling;
56
+ // the Custodian takes a fixed slice, the User Container its split if the Machine states one, and
57
+ // the Harness container the rest as its own limit. Every request equals its limit, so the pod is
58
+ // Guaranteed and the CPU limit is enforced.
59
+ //
60
+ // This is also where the CUSTODIAN is composed (ADR-0013, ADR-0059). The operator needs no change
61
+ // to carry it: ADR-0001 made `Sidecars` generic container fragments it schedules WITHOUT
62
+ // understanding, so the Custodian is exactly that — a container with an image, its mounts, and a
63
+ // readiness probe the pod's Ready waits on. What this module builds is the pod's asymmetry:
64
+ //
65
+ // harness container JR2_CUSTODIAN_URL + the Stand-in of every credential (no credential)
66
+ // custodian container the Sandbox token and the pod's held secrets, as files (the credentials)
67
+ //
68
+ // The Agent has code execution in the first and none in the second. The token is minted here — a
69
+ // signed Sandbox name (see tokens.ts), so re-provisioning after a restart yields the SAME token and
70
+ // the Secret re-applies as a no-op. What else the Custodian holds is `jr2 up`'s placement for THIS
71
+ // Workspace — the Held secrets its `workspace()` lists and its Agents' providers claim (ADR-0059) —
72
+ // read per provision from the `jr2-held` mount (custodian.ts builds both placements' pods alike).
73
+
74
+ import { join } from "node:path";
75
+ import {
76
+ matchCredential,
77
+ type GitCredential,
78
+ type HarnessEnvFromSource,
79
+ type HarnessEnvVar,
80
+ type SandboxPlacement,
81
+ } from "./config.ts";
82
+ import {
83
+ custodianBootstrap,
84
+ custodianComposition,
85
+ custodianScript,
86
+ CUSTODIAN_BOOTSTRAP_KEY,
87
+ CUSTODIAN_SCRIPT_KEY,
88
+ type CustodianComposition,
89
+ } from "./custodian.ts";
90
+ import { readHeldManifest, workspaceHeldPod, type HeldManifest } from "./held-secrets.ts";
91
+ import {
92
+ effectorManifestKey,
93
+ readSandboxEffectorManifest,
94
+ SANDBOX_EFFECTOR_GIT_DIR,
95
+ sandboxEffectorKeySecret,
96
+ sandboxEffectorService,
97
+ type SandboxEffectorManifest,
98
+ } from "./sandbox-effector.ts";
99
+ import { GIT_PUSH_MANIFEST_KEY, placedPushRefusal, readGitPushManifest } from "./git-push.ts";
100
+ import { readImageRefs, resolveSandboxImage, resolveUserImage, type HeldImageScope, type ImageRefs } from "./images.ts";
101
+ import {
102
+ CONVERSATIONS_DIR,
103
+ CONVERSATIONS_DIR_ENV,
104
+ CONVERSATIONS_VOLUME,
105
+ HELD_MOUNT,
106
+ CA_CONFIGMAP,
107
+ heldManifestKey,
108
+ IMAGES_KEY,
109
+ IMAGES_MOUNT,
110
+ ORCHESTRATOR_PORT,
111
+ ORCHESTRATOR_SERVICE,
112
+ PRIORITY_CLASS_SANDBOX,
113
+ REPOS_MOUNT,
114
+ sandboxCustodianConfigMap,
115
+ } from "./names.ts";
116
+ import {
117
+ CONFIGMAPS,
118
+ SANDBOXES,
119
+ SECRETS,
120
+ SERVICES,
121
+ isNotFound,
122
+ kubeClient,
123
+ type KubeClient,
124
+ type KubeObject,
125
+ } from "./kube-client.ts";
126
+ import { repoIdentity } from "./repo-identity.ts";
127
+ import type { RepoResources } from "./repos.ts";
128
+ import { resolveSize, splitSize, type Size, type SizeSplit } from "./size.ts";
129
+ import {
130
+ watchSandboxes,
131
+ type Condition,
132
+ type ContainerWaiting,
133
+ type SandboxObject,
134
+ type SandboxWatch,
135
+ } from "./sandbox-watch.ts";
136
+ import { effectorKey, harnessToken, harnessTokenDigest, sandboxToken } from "./tokens.ts";
137
+ import type { AttachError, AttachRequest, AttachResponse } from "./wire.ts";
138
+ import type { Continuity, MemoryKill, PlacingWait, ProvisionedRepo, SandboxLoss, SandboxPort } from "./workspace.ts";
139
+
140
+ /** Where jr2's runtime lands in every container that gets it (ADR-0037). `/opt/jr2` and not `/app`
141
+ * because a stranger's base may already use `/app`, and one layout must serve both the stock
142
+ * Harness image and an arbitrary Sandbox Image. It is a PUBLISHED surface: `bin/` beside `lib/`
143
+ * (node's rpath is `$ORIGIN/../lib`), `src/main.ts`, `node_modules/`. */
144
+ export const RUNTIME_MOUNT = "/opt/jr2";
145
+
146
+ /** How every seat that holds the runtime mounts it (ADR-0037). The volume is the WHOLE harness
147
+ * image, so `subPath` narrows it to that image's own `/opt/jr2` — the published surface, laid out
148
+ * exactly as the Harness image runs it, so node's `$ORIGIN/../lib` holds with nothing rearranged.
149
+ * Read-only in every seat: an image volume is read-only anyway, and the Agent has code execution
150
+ * in the Harness container, so the CR says so rather than leaving it to the volume type. The
151
+ * layers stay owned by root and the Sandbox Image may run any uid, so the image's MODE is the
152
+ * guarantee that any uid reads it — the harness Dockerfile's build-time check holds it there. */
153
+ const RUNTIME_VOLUME = "runtime";
154
+ const RUNTIME_VOLUME_MOUNT = { name: RUNTIME_VOLUME, mountPath: RUNTIME_MOUNT, subPath: "opt/jr2", readOnly: true };
155
+
156
+ /** The primary container's command (ADR-0037). Absolute, so it never depends on the image's
157
+ * `WORKDIR`, and identical to the stock Harness image's own `CMD` — one runtime, two placements.
158
+ * `tini` is PID 1 (ADR-0061): the Agent's processes are the Harness's children, and a tree the
159
+ * memory guard kills must leave no zombies behind. */
160
+ const HARNESS_COMMAND = [
161
+ `${RUNTIME_MOUNT}/bin/tini`,
162
+ "--",
163
+ `${RUNTIME_MOUNT}/bin/node`,
164
+ `${RUNTIME_MOUNT}/src/main.ts`,
165
+ ];
166
+
167
+ /** Where the Harness attaches (ADR-0004): the `work` volume's mount in every seat that holds the
168
+ * checkouts. Fixed, because the Harness's attach writes exactly here (ADR-0063). */
169
+ const WORK_ROOT = "/work";
170
+
171
+ /** ADR-0005's default work group. Convention, not config: the pod's `fsGroup` is granted to every
172
+ * container as a supplemental group, so the Harness writes `/work` whatever the number and no
173
+ * image's `/etc/group` needs to know it. The one override is the spec's `workGroup`. */
174
+ const DEFAULT_WORK_GROUP = 2000;
175
+
176
+ /**
177
+ * The isolation baseline for a jr2-owned seat, spelled out HERE for the init container because the
178
+ * operator's hardened default covers the primary container and the sidecars only (ADR-0001/0005) —
179
+ * init steps pass through verbatim, which is what keeps the operator agent-agnostic. Deliberately
180
+ * not applied to the `user` container: that seat's identity is "what jr2 does not own".
181
+ */
182
+ const HARDENED = {
183
+ runAsNonRoot: true,
184
+ allowPrivilegeEscalation: false,
185
+ capabilities: { drop: ["ALL"] },
186
+ seccompProfile: { type: "RuntimeDefault" },
187
+ };
188
+
189
+ /** The Sandbox Effector's port in its pod (ADR-0068's default). */
190
+ export const SANDBOX_EFFECTOR_PORT = 8082;
191
+
192
+ export { sandboxEffectorSecret } from "./sandbox-effector.ts";
193
+
194
+ /**
195
+ * The Sandbox Effector's container (ADR-0067, ADR-0068), for the Sandbox CR's `sidecars` beside the
196
+ * Custodian: the operator schedules it as it does any sidecar, and projects the CR's `gitCredentials`
197
+ * into it alone, by its name `effector`. The provision adds it for a `workspace()` whose body
198
+ * holds an Effect placed there (`push`, a file-body `http`, a credentialed `command`) — which `jr2 up` read
199
+ * off the Machines and declared in the pod's manifest. Its image is the Sandbox Image unless the
200
+ * `workspace()` names `effector: { image }`; its command is jr2's binary on the runtime volume; it
201
+ * runs as its image's own `USER`. Its mounts: `/work`, `/repos/<key>` and `/opt/jr2` read-only,
202
+ * the secrets its Effects name and the credentials its pushes spend at `/run/jr2/secrets` (0400),
203
+ * its per-pod key (derived from the Sandbox's name, like the Sandbox token) and `effector.json` at
204
+ * `/run/jr2/effector`, and an emptyDir scratch that is its `$HOME`. The push credentials are not
205
+ * the provision's to mount: the operator projects each entry Secret the CR's `gitCredentials`
206
+ * names into `/run/jr2/secrets/@git/<name>/` (ADR-0051). No Custodian, no `harness.env`, and no env
207
+ * beyond where its mounts are and the downward API's name, address and node — the node is what
208
+ * its Landlock refusal names. It says why it ended in its termination message, which the operator
209
+ * publishes on the Sandbox's status (`status.effector`).
210
+ */
211
+ export function sandboxEffectorContainer(opts: {
212
+ image: string;
213
+ keys: readonly string[];
214
+ securityContext: Record<string, unknown>;
215
+ secretVolume: string;
216
+ keyVolume: string;
217
+ scratchVolume: string;
218
+ /** The instance's trust ConfigMap's volume, when it ships a caBundle (ADR-0020). */
219
+ caVolume?: string;
220
+ }) {
221
+ const env = (name: string, value: string) => ({ name, value });
222
+ return {
223
+ name: "effector",
224
+ image: opts.image,
225
+ command: [`${RUNTIME_MOUNT}/bin/effector`],
226
+ env: [
227
+ env("JR2_EFFECTOR_PORT", String(SANDBOX_EFFECTOR_PORT)),
228
+ env("JR2_EFFECTOR_WORK", WORK_ROOT),
229
+ env("JR2_EFFECTOR_REPOS", REPOS_MOUNT),
230
+ env("JR2_EFFECTOR_KEY", "/run/jr2/effector/key"),
231
+ env("JR2_EFFECTOR_CONFIG", "/run/jr2/effector/effector.json"),
232
+ env("JR2_EFFECTOR_SECRETS", "/run/jr2/secrets"),
233
+ env("JR2_EFFECTOR_GIT", `/run/jr2/secrets/${SANDBOX_EFFECTOR_GIT_DIR}`),
234
+ env("JR2_EFFECTOR_SCRATCH", "/run/jr2/scratch"),
235
+ env("HOME", "/run/jr2/scratch"),
236
+ ...(opts.caVolume ? [env("JR2_CA_FILE", `${EFFECTOR_CA_MOUNT}/ca.crt`)] : []),
237
+ { name: "POD_NAME", valueFrom: { fieldRef: { fieldPath: "metadata.name" } } },
238
+ { name: "POD_IP", valueFrom: { fieldRef: { fieldPath: "status.podIP" } } },
239
+ { name: "NODE_NAME", valueFrom: { fieldRef: { fieldPath: "spec.nodeName" } } },
240
+ ],
241
+ ports: [{ name: "effector", containerPort: SANDBOX_EFFECTOR_PORT }],
242
+ // Pod Ready waits on it, and the Sandbox's Ready on pod Ready (ADR-0063): no Effect request is
243
+ // signed before the Effector process that verifies it started, which it would refuse, unsigned
244
+ // (ADR-0068). It answers only once it holds its key and listens. Every second, as the Harness's.
245
+ readinessProbe: { httpGet: { path: "/healthz", port: SANDBOX_EFFECTOR_PORT }, periodSeconds: 1 },
246
+ terminationMessagePolicy: "FallbackToLogsOnError",
247
+ volumeMounts: [
248
+ { name: "work", mountPath: WORK_ROOT, readOnly: true },
249
+ RUNTIME_VOLUME_MOUNT,
250
+ ...opts.keys.map((key) => ({ name: repoVolumeName(key), mountPath: repoMountPath(key), readOnly: true })),
251
+ { name: opts.secretVolume, mountPath: "/run/jr2/secrets", readOnly: true },
252
+ { name: opts.keyVolume, mountPath: "/run/jr2/effector", readOnly: true },
253
+ { name: opts.scratchVolume, mountPath: "/run/jr2/scratch" },
254
+ ...(opts.caVolume ? [{ name: opts.caVolume, mountPath: EFFECTOR_CA_MOUNT, readOnly: true }] : []),
255
+ ],
256
+ securityContext: opts.securityContext,
257
+ };
258
+ }
259
+
260
+ const EFFECTOR_CA_MOUNT = "/etc/jr2/effector-ca";
261
+
262
+ /** The Sandbox Effector's volumes, by the names its container mounts. */
263
+ export function sandboxEffectorVolumes(opts: {
264
+ sandbox: string;
265
+ manifest: SandboxEffectorManifest;
266
+ caBundle: boolean;
267
+ }) {
268
+ return [
269
+ { name: "effector-secrets", projected: { defaultMode: 0o400, sources: opts.manifest.sources } },
270
+ {
271
+ name: "effector-own",
272
+ projected: {
273
+ defaultMode: 0o400,
274
+ sources: [
275
+ { secret: { name: sandboxEffectorKeySecret(opts.sandbox), items: [{ key: "key", path: "key" }] } },
276
+ { secret: { name: opts.manifest.secret, items: [{ key: "effector.json", path: "effector.json" }] } },
277
+ ],
278
+ },
279
+ },
280
+ { name: "effector-scratch", emptyDir: {} },
281
+ ...(opts.caBundle ? [{ name: "effector-ca", configMap: { name: CA_CONFIGMAP } }] : []),
282
+ ];
283
+ }
284
+
285
+ /**
286
+ * ADR-0037's fallback seat, for a BUILT image that declares no `USER` (the converge's `docker
287
+ * inspect` is what saw that; images.ts holds the record). jr2 sets `runAsUser` nowhere else — an
288
+ * image's own `USER` decides its seat's uid (ADR-0005) — so this applies only where the image
289
+ * chose nothing and the alternative is root, which the hardened context refuses.
290
+ *
291
+ * The home is a POD volume, not a directory in the image: uid 1000 on a stranger's base has no
292
+ * home at all, and the floor needs a writable one (`git config --global` writes `$HOME/.gitconfig`
293
+ * on every attach; a real toolchain wants `~/.npm`, `~/.cargo`, `~/.cache`). An emptyDir lands
294
+ * group-writable under the pod's fsGroup, so uid 1000 owns it in practice without jr2 chown-ing
295
+ * anything. `/home/jr2` and not `/home/node`: the number is jr2's choice here, so the path is too.
296
+ */
297
+ const FALLBACK_UID = 1000;
298
+ const FALLBACK_HOME = "/home/jr2";
299
+
300
+ /**
301
+ * The CPU hints (ADR-0060), each the Harness container's own `limits.cpu` through the Downward API
302
+ * (divisor 1, so a fractional limit rounds UP to whole cpus). For the tools that read neither the
303
+ * cgroup's limit nor their affinity: `OMP_NUM_THREADS` also makes `nproc` answer N, and
304
+ * `PYTHON_CPU_COUNT` and `GOMAXPROCS` speak for their runtimes. `os.cpus()` still reports the node —
305
+ * a Machine that runs Playwright passes `--workers=$JR2_CPUS`.
306
+ *
307
+ * The Agent is told its CPU count and Size by the Briefing's standing part, never by the Frame
308
+ * (ADR-0060, ADR-0062) — and that needs no wire: the Harness reads `JR2_CPUS` from this env and its
309
+ * Size from its own cgroup, so the Orchestrator sends neither.
310
+ */
311
+ const CPU_HINTS = ["JR2_CPUS", "OMP_NUM_THREADS", "PYTHON_CPU_COUNT", "GOMAXPROCS"] as const;
312
+ const cpuHintEnv = (): HarnessEnvVar[] =>
313
+ CPU_HINTS.map((name) => ({ name, valueFrom: { resourceFieldRef: { resource: "limits.cpu", divisor: "1" } } }));
314
+
315
+ /**
316
+ * How many times the Harness container may end before the provision stops waiting for it
317
+ * (ADR-0063). The operator publishes its restarts and last end on the Sandbox's status, so a
318
+ * Harness that dies on start — a Sandbox Image that misses ADR-0037's floor in a way the preflight
319
+ * did not catch, a runtime the image cannot run — fails the provision by NAME, with the kubelet's
320
+ * reason and exit code, instead of burning the whole Ready budget. Two, not one: a single restart
321
+ * on the way up (a memory kill during a heavy start, ADR-0061) is a pod that may still come up.
322
+ */
323
+ const CRASH_LOOP_RESTARTS = 2;
324
+
325
+ /**
326
+ * The kubelet's verdict on a container whose image resolves to root, or to a non-numeric user,
327
+ * under `runAsNonRoot` (ADR-0005). A BUILT image is judged at converge from its recorded USER; a
328
+ * brought registry ref is never inspected (ADR-0037), so this waiting reason is the only place
329
+ * its USER is known. Reason AND message: the reason alone also covers a missing Secret or
330
+ * ConfigMap key, a different fault with a different fix.
331
+ */
332
+ const ROOT_IMAGE_REASON = "CreateContainerConfigError";
333
+ const ROOT_IMAGE_MESSAGE = /runAsNonRoot/i;
334
+
335
+ /** Waiting reasons that no retry mends: the kubelet will never start the container. A failing
336
+ * pull is not here — it may recover (a registry blip, node credentials), so the pod budget
337
+ * decides, and its expiry names it. */
338
+ const TERMINAL_WAITING = new Set(["InvalidImageName", "ErrImageNeverPull"]);
339
+
340
+ /** The kubelet's reason for a container the kernel killed at its memory limit (ADR-0061). */
341
+ const OOM_KILLED = "OOMKilled";
342
+
343
+ /** How long a lost conversation waits for the operator's word on a memory kill (ADR-0061). The
344
+ * Harness can be serving again — and answering 404 — a moment before the kubelet's status for its
345
+ * last run has reached the Sandbox, and this port never reads the Pod to learn it sooner. */
346
+ const MEMORY_FAULT_GRACE_MS = 5_000;
347
+
348
+ /** Clock skew allowed between a node's kubelet stamping `finishedAt` and this process's `since`. */
349
+ const MEMORY_FAULT_SKEW_MS = 10_000;
350
+
351
+ /** How long an attach keeps re-sending a request that never reached the Harness (ADR-0042): the
352
+ * attach is now the FIRST thing that dials a Sandbox's Service, and Ready is not routable. The
353
+ * same measured window the admission uses (harness-client.ts). */
354
+ const ATTACH_WINDOW_MS = 90_000;
355
+
356
+ /**
357
+ * ADR-0037's preflight, VERBATIM: git present · `$HOME` writable · glibc new enough for jr2's node
358
+ * (with the relocated `libstdc++`) · the vendored ripgrep, reached through the mounted `/opt/jr2`.
359
+ *
360
+ * The three commands are the floor, one each: `git config --global` proves git is on the system
361
+ * PATH AND that `$HOME` is writable for the image's user; `node -e ""` proves the glibc is no
362
+ * older than the one jr2's node was built against (this is where musl dies); `rg` UNQUALIFIED
363
+ * proves the vendored static binary resolves THROUGH PATH, which is what the Harness's own append
364
+ * buys at runtime.
365
+ *
366
+ * ONE prover, and this is it (ADR-0037/0041). A converge cannot hold an image to this floor: the
367
+ * floor is a HARNESS-SEAT obligation, a built context may equally be destined for the User
368
+ * Container seat — which owes no floor at all (ADR-0005) — and which seat a directory serves is
369
+ * workflow-internal and statically unrecoverable (ADR-0031). Here the seat is known, and here is
370
+ * also the only moment a registry ref exists at all, since jr2 never builds or inspects one.
371
+ */
372
+ const SANDBOX_PREFLIGHT = `git config --global safe.directory "*" && ${RUNTIME_MOUNT}/bin/node -e "" && rg --version`;
373
+
374
+ /**
375
+ * The probe as a shell line. The PATH append is mechanism, not part of the claim, and it is not
376
+ * optional: nothing bakes `/opt/jr2/bin` into the user's image any more, so a probe that skipped it
377
+ * would report `rg: not found` for every image on earth. APPENDED, never prepended — a toolchain
378
+ * the image pinned wins, which is as much the property being proved as `rg`'s presence (ADR-0037).
379
+ * The seat gets no login shell at either end, so `$PATH` is whatever the image itself set.
380
+ */
381
+ function preflightShell(): string {
382
+ return `export PATH="$PATH:${RUNTIME_MOUNT}/bin"; ${SANDBOX_PREFLIGHT}`;
383
+ }
384
+
385
+ /**
386
+ * The preflight as an init step IN THE USER'S IMAGE with `/opt/jr2` mounted. It fails the pod
387
+ * before the Harness starts, instead of surfacing as a tool failure mid-turn on a pod nobody is
388
+ * watching.
389
+ *
390
+ * It PROVES, it does not set up: the `.gitconfig` it writes lives in this container's own
391
+ * ephemeral filesystem, so the attach script still runs the same line in the Harness container.
392
+ *
393
+ * `sh -c`, not `sh -ec`: the failure is handled below, because "node did not execute" is not
394
+ * actionable and the message has to name the fix.
395
+ */
396
+ const PREFLIGHT_SCRIPT = [
397
+ `${preflightShell()} && exit 0`,
398
+ `echo "jr2: this Sandbox Image does not meet the floor (ADR-0037): a glibc base no older than ` +
399
+ `jr2's node (musl is out entirely), git on the system PATH, a writable HOME for the image's ` +
400
+ `USER, and /opt/jr2 + /work + :8080 unclaimed. jr2 vendors the rest." >&2`,
401
+ `exit 1`,
402
+ ].join("\n");
403
+
404
+ export type KubeSandboxOptions = {
405
+ /** The mounted image map (ADR-0037/0038) — every ref this port can name, written by `jr2 up`.
406
+ * Default: the `jr2-images` ConfigMap's mount. No image option here: which image a Sandbox runs
407
+ * is the `workspace()` wrapper's static `image` option (ADR-0049) — carried on the Machine, read
408
+ * off it at invoke time, handed to `provision()` as a string — and resolved against this map at
409
+ * provision. The per-run spec never names one (ADR-0051). */
410
+ imagesPath?: string;
411
+ /** Where each Workspace's held manifest is (ADR-0059): `held-<pod>.json` per pod `jr2 up`
412
+ * placed, read per provision like the image map, so a held-secret edit reaches future Sandboxes
413
+ * without rolling this process. Default: the `jr2-held` ConfigMap's mount. */
414
+ heldDir?: string;
415
+ /** Extra env for the HARNESS container (`harness.env`) — merged ahead of the
416
+ * mechanism-owned vars, which win on collision. */
417
+ env?: HarnessEnvVar[];
418
+ /** Whole-Secret/ConfigMap env for the Harness container (`harness.envFrom`) — how a real
419
+ * Harness gets its model API key without the value ever touching jr2 config. */
420
+ envFrom?: HarnessEnvFromSource[];
421
+ /** Where a Sandbox may land (ADR-0052): the Instance's `sandbox.nodeSelector` and
422
+ * `sandbox.tolerations`, written on the CR verbatim and copied onto the pod by the operator, which
423
+ * merges nothing with them. Absent → wherever an ordinary pod lands. */
424
+ placement?: SandboxPlacement;
425
+ /** The Instance's default Size (`sandbox.resources`, ADR-0060): applied only to a Workspace
426
+ * that states no Size, never under or over a stated one. Absent → the kit default. */
427
+ defaultSize?: Size;
428
+ /** The Sandbox pod's PriorityClass (ADR-0060): `priorityClasses.sandbox`, or the `jr2-sandbox`
429
+ * class `jr2 up` creates. */
430
+ priorityClassName?: string;
431
+ /** The instance ships a private-CA bundle (ADR-0020): the HARNESS container trusts it, and the
432
+ * Custodian verifies a bound host with it (custodian.ts). */
433
+ caBundle?: boolean;
434
+ /** The key Sandbox tokens are signed with — from the instance Secret (ADR-0013/0019). */
435
+ signingKey?: Buffer;
436
+ /** Kube namespace for Sandbox CRs. Default `default`. */
437
+ namespace?: string;
438
+ /** CR `spec.idleTimeout` — the operator's abandoned-Sandbox GC backstop (ADR-0001). Default `30m`. */
439
+ idleTimeout?: string;
440
+ /** How often a workspace's lease actor renews (ADR-0001/0021): the cadence at which
441
+ * `jr2.dev/keepalive` is re-stamped, ±20%. Must be ≪ idleTimeout, since a lapsed lease is what
442
+ * lets the operator reap. Default 5m. */
443
+ leaseIntervalMs?: number;
444
+ /** Await-Ready budget for the POD: from the pod's scheduling until the operator reports it Ready
445
+ * (ADR-0064) — the wait for a node is `placing`'s, with no deadline, and spends none of it.
446
+ * Default 120s, measured against watch events. A pod that never comes up (an image that misses
447
+ * ADR-0037's floor) is what this bounds; a pod that is up and waiting on its Repos is
448
+ * `repoTimeoutMs`'s. */
449
+ readyTimeoutMs?: number;
450
+ /**
451
+ * Await-Ready budget for the REPOS (ADR-0051): once the operator holds a Sandbox whose pod is
452
+ * Ready on a Repo reason — the node's cache agent is cloning a cold node, or fetching before the
453
+ * attach — the wait is measured against this, from the same instant as the pod's. Sized for a
454
+ * clone, not a pod: the agent's clone budget is 20m, and this must exceed it so a clone that
455
+ * runs out of time fails BY NAME (`RepoCloneFailed`, git's words) instead of as this port's
456
+ * timeout. The Lease renews from the CR's write (ADR-0064), so the idle timeout does not bound
457
+ * it. Default 25m.
458
+ */
459
+ repoTimeoutMs?: number;
460
+ /**
461
+ * The Repo-resource port (ADR-0051, repos.ts): every Repo a provision names must exist as a
462
+ * `Repo` resource before the CR names it, or the operator reports it missing and the Sandbox
463
+ * never reaches Ready. So `provision` ensures each one here — a per-run url's resource is
464
+ * created at first attach, a bound one is found as the boot stated it — and REFUSES to run
465
+ * without the port: a Sandbox whose Repos nobody creates parks on `RepoMissing` for the whole
466
+ * Ready budget. The other operations (attach, renew, destroy) need no port, so it is optional
467
+ * at construction.
468
+ */
469
+ repos?: RepoResources;
470
+ /**
471
+ * The instance's `git.credentials` (ADR-0051) — THE FENCE. A per-run url is run input, a
472
+ * ticket field, and otherwise a way to spend the cluster's credential against any host: one
473
+ * whose identity matches no entry is refused here, before a Secret or a CR exists, naming the
474
+ * list. A bound url is code the instance typechecked and deployed, admitted without a match.
475
+ * Default: no entries, so every per-run url is refused.
476
+ */
477
+ credentials?: readonly GitCredential[];
478
+ /** The Kubernetes client (ADR-0063), shared with the Repo ports so one write cap covers the
479
+ * process. Default: the in-cluster client. */
480
+ client?: KubeClient;
481
+ /** The one Sandbox watch (ADR-0063), shared with the fetch ask. Default: one started on first
482
+ * use, over `client`, in `namespace`. */
483
+ watch?: SandboxWatch;
484
+ /** The transport the attach dials the Harness with (ADR-0063). Injectable for tests. Default:
485
+ * global `fetch`. */
486
+ harnessFetch?: typeof fetch;
487
+ /** How long an attach re-sends a request that never reached the Harness. Default 90s. */
488
+ attachWindowMs?: number;
489
+ /**
490
+ * The config-derived options, read on every use instead of the static ones above (ADR-0069). The
491
+ * config that applies is always the current Assembly's, and a publish swaps it without a new
492
+ * port. So the fence, the Harness env, the CA bundle, the placement, the default Size and the
493
+ * PriorityClass that a provision reads are the ones current when it runs.
494
+ */
495
+ settings?: () => KubeSandboxSettings;
496
+ /** The current Assembly's hash, read per provision (ADR-0069). A provision for a run under
497
+ * another Assembly (a held run) whose `file:` context is not in the image map fails naming both
498
+ * hashes, because the map holds the current Assembly's contexts only. Absent → no current
499
+ * Assembly is known, and such a failure names none. */
500
+ currentAssembly?: () => string | undefined;
501
+ };
502
+
503
+ /** The options of {@link KubeSandboxOptions} that come from the Instance's config. */
504
+ export type KubeSandboxSettings = Pick<
505
+ KubeSandboxOptions,
506
+ "credentials" | "env" | "envFrom" | "caBundle" | "placement" | "defaultSize" | "priorityClassName"
507
+ >;
508
+
509
+ export function kubeSandbox(opts: KubeSandboxOptions = {}): SandboxPort {
510
+ // The config-derived options, read per use (ADR-0069): `cfg().credentials`, never a copy.
511
+ const cfg = (): KubeSandboxSettings => (opts.settings ? opts.settings() : opts);
512
+ const ns = opts.namespace ?? "default";
513
+ const imagesPath = opts.imagesPath ?? join(IMAGES_MOUNT, IMAGES_KEY);
514
+ const heldDir = opts.heldDir ?? HELD_MOUNT;
515
+ const readyTimeoutMs = opts.readyTimeoutMs ?? 120_000;
516
+ const repoTimeoutMs = opts.repoTimeoutMs ?? 25 * 60_000;
517
+ const leaseIntervalMs = opts.leaseIntervalMs ?? 5 * 60_000;
518
+ const harnessFetch = opts.harnessFetch ?? fetch;
519
+ const attachWindowMs = opts.attachWindowMs ?? ATTACH_WINDOW_MS;
520
+
521
+ // Built on first use, never at construction: a boot that builds this port has not yet reached
522
+ // the cluster, and must not need to (ADR-0048's stance for the Repos, held for the Sandboxes).
523
+ let client: KubeClient | undefined = opts.client;
524
+ const kube = () => (client ??= kubeClient());
525
+ let watch: SandboxWatch | undefined = opts.watch;
526
+ const sandboxes = () => (watch ??= watchSandboxes(kube(), { namespace: ns }));
527
+
528
+ /** The Sandbox's token Secret — mounted into the Custodian container, and nothing else in the pod. */
529
+ const secretName = (name: string) => `${name}-token`;
530
+
531
+ /**
532
+ * The User Container (ADR-0005): the opt-in third seat, composed only when the wrapper's static
533
+ * `user` option names an image (ADR-0049). The ZERO-CONTRACT seat — jr2 injects nothing, probes
534
+ * nothing, overrides nothing. So: no `command` (its own entrypoint runs, untouched), no `env`,
535
+ * no `envFrom`, no CA bundle, no ports, no resources. Every key jr2 forwarded would be a crack in
536
+ * "jr2 puts nothing in it", and widening the one authoring string to an object stays compatible
537
+ * if a concrete need ever argues its own way in. (Git's dubious-ownership guard is the line's
538
+ * cost, accepted with
539
+ * eyes open — ADR-0005: safe.directory is honored only from files this seat's image owns, so an
540
+ * image whose sessions run git carries its own line.)
541
+ *
542
+ * `/work` read-write plus the checkouts' two read-only halves are the single exception, and they
543
+ * are not an injection but the point: this seat and the Harness mount ONE worktree, so the human
544
+ * and the Agent see identical files — which is also why ADR-0005's cross-uid pair (the pod's
545
+ * `fsGroup`, the attach's default ACL) exists at all. The caches ride along because they are
546
+ * half of the same files: the worktrees are `--shared` clones whose alternates resolve objects
547
+ * from `/repos/<key>` (ADR-0004/0051), so a seat with `/work` alone holds checkouts whose every
548
+ * borrowed object is missing ("unable to normalize alternate object path"). `/opt/jr2` is the
549
+ * other half (ADR-0053): `origin`'s fetch url is a program on that volume, so a seat without it
550
+ * holds checkouts whose `git fetch` dies — and with it the human gets the same fetch as the
551
+ * Agent, with no credential of their own. Nothing else follows it in: `ext::` names the program
552
+ * by absolute path, so this seat still gets no env, no command, and no probe. The repo volumes
553
+ * are the operator's — it defines `repo-<key>` for every key the CR names — so this seat mounts
554
+ * them by name. The Custodian is deliberately not given any of the three: it reads no worktree, and it is the
555
+ * container holding the pod's credentials, so it gets the narrowest mount set that works.
556
+ * It also carries no `securityContext`, which the operator reads as the exemption — root is
557
+ * ALLOWED here, because hardening a seat whose identity is "what jr2 does not own" is an opinion,
558
+ * and the standard managed-access shape (a root sshd that setuids sessions down) must run
559
+ * unmodified.
560
+ */
561
+ const userSidecar = async (
562
+ refs: ImageRefs,
563
+ image: string,
564
+ keys: string[],
565
+ split: SizeSplit,
566
+ held: HeldImageScope | undefined,
567
+ ) => ({
568
+ name: "user",
569
+ image: await resolveUserImage(refs, image, held),
570
+ volumeMounts: [
571
+ { name: "work", mountPath: WORK_ROOT },
572
+ RUNTIME_VOLUME_MOUNT,
573
+ ...keys.map((key) => ({ name: repoVolumeName(key), mountPath: repoMountPath(key), readOnly: true })),
574
+ ],
575
+ // Its split of the Size, only when the Machine states one (ADR-0060) — a fact the Machine
576
+ // wrote, not an injection. Otherwise the seat has no limit of its own and shares the pod's.
577
+ ...(split.user ? { resources: split.user } : {}),
578
+ });
579
+
580
+ /** The pod's sidecar list (ADR-0001: opaque fragments the operator schedules verbatim). The
581
+ * Custodian is ALWAYS here: a Sandbox without one is a pod that comes up Ready and then parks its
582
+ * Machine forever on a Menu it cannot read (ADR-0013). The User Container joins it only when the
583
+ * spec named one. */
584
+ const sidecarsFor = async (
585
+ custodian: CustodianComposition,
586
+ refs: ImageRefs,
587
+ keys: string[],
588
+ split: SizeSplit,
589
+ held: HeldImageScope | undefined,
590
+ user?: string,
591
+ effector?: Record<string, unknown>,
592
+ ) => [
593
+ custodian.custodianContainer,
594
+ ...(user !== undefined ? [await userSidecar(refs, user, keys, split, held)] : []),
595
+ ...(effector !== undefined ? [effector] : []),
596
+ ];
597
+
598
+ /**
599
+ * The Sandbox Effector (ADR-0068), when `jr2 up` placed an Effect there: its
600
+ * container and volumes. Its image is the one the `workspace()` names, resolved like the User
601
+ * Container's (a `file:` context to its built ref), or the Sandbox Image; it runs as that image's
602
+ * own `USER` — the seat rule every jr2 seat follows, ADR-0037's fallback included — hardened.
603
+ */
604
+ const effectorFor = async (
605
+ req: { name: string; effector?: string },
606
+ refs: ImageRefs,
607
+ keys: string[],
608
+ seat: Seat,
609
+ manifest: SandboxEffectorManifest,
610
+ held: HeldImageScope | undefined,
611
+ ) => {
612
+ let image = seat.image;
613
+ let securityContext: Record<string, unknown> = seat.securityContext;
614
+ if (req.effector !== undefined) {
615
+ const named = await resolveSandboxImage(refs, req.effector, held);
616
+ if (named.refusedUser !== undefined) {
617
+ throw new Error(
618
+ `the Sandbox Effector's image "${req.effector}" declares \`USER ${named.refusedUser}\`, which cannot run a jr2 ` +
619
+ "seat: every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC " +
620
+ "non-zero uid (ADR-0005). Change the Dockerfile's last `USER` to that uid, then re-run `jr2 up`.",
621
+ );
622
+ }
623
+ image = named.ref;
624
+ securityContext = named.fallbackSeat ? { ...HARDENED, runAsUser: FALLBACK_UID } : HARDENED;
625
+ }
626
+ return {
627
+ container: sandboxEffectorContainer({
628
+ image,
629
+ keys,
630
+ securityContext,
631
+ secretVolume: "effector-secrets",
632
+ keyVolume: "effector-own",
633
+ scratchVolume: "effector-scratch",
634
+ ...(cfg().caBundle ? { caVolume: "effector-ca" } : {}),
635
+ }),
636
+ volumes: sandboxEffectorVolumes({ sandbox: req.name, manifest, caBundle: cfg().caBundle === true }),
637
+ };
638
+ };
639
+
640
+ // The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
641
+ // config) first, then the mechanism-owned vars (the Custodian's address and every Stand-in, the
642
+ // proxy and the trust bundles — custodian.ts), which win on collision. Note the asymmetry stands
643
+ // (ADR-0013): user env/envFrom land on the HARNESS container only — never on the Custodian, whose
644
+ // mounts are composed here and carry the pod's credentials.
645
+ //
646
+ // The gate rides LAST (ADR-0058): the digest of the bearer the Orchestrator derives for THIS
647
+ // Sandbox. A digest because the Agent reads this env; last because a `harness.env` entry of the
648
+ // same name must not be able to choose the Harness's credential.
649
+ const harnessEnv = (name: string, custodian: CustodianComposition, hints: HarnessEnvVar[]): HarnessEnvVar[] => {
650
+ if (!opts.signingKey) throw new Error("kubeSandbox: a Harness needs a signingKey to check its bearer");
651
+ return [
652
+ ...(cfg().env ?? []),
653
+ ...custodian.harnessEnv,
654
+ // The CPU hints (ADR-0060) are mechanism: they say what the Size gives, so they win over a
655
+ // `harness.env` entry of the same name.
656
+ ...hints,
657
+ // Where the Harness persists its conversations (ADR-0031) — the volume below, mechanism, so
658
+ // no `harness.env` entry chooses it.
659
+ { name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
660
+ { name: "JR2_HARNESS_TOKEN_SHA256", value: harnessTokenDigest(opts.signingKey, name) },
661
+ ];
662
+ };
663
+
664
+ /**
665
+ * The one init step (ADR-0037): `preflight`, the USER'S image with the runtime volume mounted,
666
+ * running the probe. It is a plain container fragment the operator schedules without
667
+ * understanding, exactly like a sidecar — the operator stays agent-agnostic (ADR-0001), so "how a
668
+ * Sandbox proves its image" is composed here, not reconciled there. Nothing populates the volume
669
+ * first: it is the kit's image, mounted (see `runtimeVolume`).
670
+ *
671
+ * It carries jr2's hardened context explicitly, and runs the probe in the SAME seat the Harness
672
+ * will get — the image's own user, or ADR-0037's fallback — because a probe that proved a
673
+ * different uid's `$HOME` proved nothing.
674
+ */
675
+ const initContainersFor = (seat: Seat, split: SizeSplit) => [
676
+ {
677
+ name: "preflight",
678
+ image: seat.image,
679
+ command: ["/bin/sh", "-c", PREFLIGHT_SCRIPT],
680
+ ...(seat.env.length ? { env: seat.env } : {}),
681
+ volumeMounts: [RUNTIME_VOLUME_MOUNT, ...seat.homeMount],
682
+ // Small and fixed, inside the pod's budget (ADR-0060): it runs before the others start.
683
+ resources: split.preflight,
684
+ securityContext: seat.securityContext,
685
+ },
686
+ ];
687
+
688
+ /**
689
+ * jr2's runtime (ADR-0037): the kit's Harness image as an `image` volume. This is what makes the
690
+ * runtime's version ride the VOLUME rather than the image: a kit edit moves the harness image's
691
+ * own tag and re-images future pods without touching a single Sandbox Image tag, which is the
692
+ * only way a registry-ref image could ever follow a kit update. A live pod keeps the image it
693
+ * started with — the same create-if-absent stance ADR-0038 takes for images. No `pullPolicy`:
694
+ * the kubelet's default for a volume is its default for a container, so the ref is pulled
695
+ * exactly as the cluster already pulls the Harness image, and the volume adds nothing to deliver.
696
+ */
697
+ const runtimeVolume = (refs: ImageRefs) => ({ name: RUNTIME_VOLUME, image: { reference: refs.harness } });
698
+
699
+ /**
700
+ * The primary container's seat: which image runs, as whom, and with what home. One value, built
701
+ * once per provision and shared by the Harness container and the preflight, so the probe cannot
702
+ * drift from the thing it proves.
703
+ */
704
+ type Seat = {
705
+ image: string;
706
+ env: HarnessEnvVar[];
707
+ homeVolume: Array<{ name: string; emptyDir: Record<string, never> }>;
708
+ homeMount: Array<{ name: string; mountPath: string }>;
709
+ securityContext: typeof HARDENED & { runAsUser?: number };
710
+ };
711
+
712
+ const seatFor = async (
713
+ refs: ImageRefs,
714
+ name: string | undefined,
715
+ held: HeldImageScope | undefined,
716
+ ): Promise<Seat> => {
717
+ // ONE resolution, so the ref and the two seat facts can never come off different legs of
718
+ // ADR-0037's chain (images.ts). It is async because a `file:` context is keyed by its content
719
+ // digest, which is a directory walk — the price of the host and the pod agreeing about an
720
+ // image without a path table (ADR-0049).
721
+ const { ref: image, fallbackSeat, refusedUser } = await resolveSandboxImage(refs, name, held);
722
+ // Fail HERE, before a Secret or a CR exists, on a `USER` the kubelet will refuse (images.ts).
723
+ // The alternative is the worst shape a failure has: the preflight container never starts, so
724
+ // it has no logs, and the whole 120s Ready budget burns before anything is said. The converge
725
+ // already inspected the image, so this is knowable at zero cost — and the message names the
726
+ // edit, because the fix is one line of the caller's own Dockerfile.
727
+ if (refusedUser !== undefined) {
728
+ throw new Error(
729
+ `the Sandbox Image "${name ?? "default"}" declares \`USER ${refusedUser}\`, which cannot run a jr2 seat: ` +
730
+ "every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC " +
731
+ "non-zero uid it can check without reading the image (ADR-0005). Change the Dockerfile's last " +
732
+ "`USER` to that uid (e.g. `USER 1000`, or drop the line entirely and jr2 supplies uid 1000 with a " +
733
+ "writable HOME — ADR-0037), then re-run `jr2 up`.",
734
+ );
735
+ }
736
+ // The common case, and the one ADR-0037 is written around: the image chose its `USER` and its
737
+ // `HOME`, and jr2 touches neither — the human who execs in lands in the environment the
738
+ // image's author built, dotfiles included.
739
+ if (!fallbackSeat) {
740
+ return { image, env: [], homeVolume: [], homeMount: [], securityContext: HARDENED };
741
+ }
742
+ return {
743
+ image,
744
+ env: [{ name: "HOME", value: FALLBACK_HOME }],
745
+ homeVolume: [{ name: "home", emptyDir: {} }],
746
+ homeMount: [{ name: "home", mountPath: FALLBACK_HOME }],
747
+ securityContext: { ...HARDENED, runAsUser: FALLBACK_UID },
748
+ };
749
+ };
750
+
751
+ const crFor = async (
752
+ req: {
753
+ name: string;
754
+ runId: string;
755
+ workflow: string;
756
+ image?: string;
757
+ user?: string;
758
+ workGroup?: number;
759
+ userResources?: Size;
760
+ effector?: string;
761
+ assembly?: string;
762
+ },
763
+ refs: ImageRefs,
764
+ repos: FencedRepo[],
765
+ custodian: CustodianComposition,
766
+ split: SizeSplit,
767
+ effectorManifest?: SandboxEffectorManifest,
768
+ gitCredentials: readonly string[] = [],
769
+ ) => {
770
+ const placement = cfg().placement;
771
+ // A run under a held Assembly (ADR-0069): an image this map lacks is that case, not a stale
772
+ // deployment, and the failure says so, naming both hashes.
773
+ const current = opts.currentAssembly?.();
774
+ const held: HeldImageScope | undefined =
775
+ req.assembly !== undefined && req.assembly !== current
776
+ ? { runId: req.runId, assembly: req.assembly, current }
777
+ : undefined;
778
+ const seat = await seatFor(refs, req.image, held);
779
+ const keys = repos.map((r) => r.key);
780
+ const effector = effectorManifest ? await effectorFor(req, refs, keys, seat, effectorManifest, held) : undefined;
781
+ const sidecars = await sidecarsFor(custodian, refs, keys, split, held, req.user, effector?.container);
782
+ return {
783
+ apiVersion: "core.jr2.dev/v1alpha1",
784
+ kind: "Sandbox",
785
+ metadata: {
786
+ name: req.name,
787
+ namespace: ns,
788
+ // The run↔workspace link `jr2 ls` groups by (ADR-0009/0012) — readable without the host.
789
+ labels: { "jr2.dev/run": req.runId, "jr2.dev/workflow": req.workflow },
790
+ },
791
+ spec: {
792
+ // The Sandbox Image, unmodified (ADR-0037) — the user's tools, its own USER and HOME, and
793
+ // jr2's runtime arriving beside it on a volume. This container is both the Harness and the
794
+ // human's `exec` shell.
795
+ image: seat.image,
796
+ // The one thing jr2 takes from the image: its command. A container has exactly one, and it
797
+ // must be the Harness's — a pod whose main process is the user's entrypoint keeps
798
+ // "Running" through a Harness death, which makes the operator's Ready probe a lie.
799
+ command: HARNESS_COMMAND,
800
+ // Hardened, and the ONLY place jr2 ever names a uid: ADR-0037's fallback for an image that
801
+ // declared none. Stating the whole context here rather than leaving it to the operator's
802
+ // default is what makes that possible — the operator hardens only what says nothing.
803
+ securityContext: seat.securityContext,
804
+ idleTimeout: opts.idleTimeout ?? "30m",
805
+ // Placement (ADR-0052): the Instance's word on which nodes are Sandbox nodes, raw pod-spec
806
+ // shapes the operator copies verbatim. Absent keys are absent here too — the CR says
807
+ // nothing, and the pod lands wherever an ordinary pod lands. The operator's own soft
808
+ // affinity toward nodes holding this Sandbox's caches sits beside these untouched: a
809
+ // preference never conflicts with a requirement.
810
+ ...(placement?.nodeSelector ? { nodeSelector: placement.nodeSelector } : {}),
811
+ ...(placement?.tolerations?.length ? { tolerations: placement.tolerations } : {}),
812
+ // Priority (ADR-0060): a pod of ordinary priority cannot preempt a live Workspace, and a
813
+ // waiting Sandbox evicts nobody (the class's preemptionPolicy is Never).
814
+ priorityClassName: cfg().priorityClassName ?? PRIORITY_CLASS_SANDBOX,
815
+ // The Size (ADR-0060). `resources` is the HARNESS container's (the operator's contract):
816
+ // the rest of the Size after the Custodian and the User Container, requests = limits, plus
817
+ // the `/work` disk as an ephemeral-storage request with no limit. `podResources` is the
818
+ // whole Size, pod-level (KEP-2837) — the ceiling the pod never passes.
819
+ resources: split.harness,
820
+ podResources: split.pod,
821
+ // `/dev/shm` (ADR-0060): the operator mounts a memory-backed emptyDir there, in the Harness
822
+ // container only, with this `sizeLimit` — a quarter of the Harness share, minimum 64Mi.
823
+ // Charged inside the Harness container's limit, so it reserves nothing extra; full, it is
824
+ // ENOSPC or SIGBUS — a tool error, never a memory kill.
825
+ shmSize: split.shmSizeLimit,
826
+ // The work group (ADR-0005), the ownership half of cross-uid sharing on `/work`.
827
+ // Kubernetes grants it as a supplemental group to every container, and puts a setgid
828
+ // group on the volume root that propagates down; the WRITABILITY half is the default ACL
829
+ // the Harness's attach stamps on each repo root (ADR-0063), without which fsGroup gives
830
+ // group-READ, which is the trap. Both are inert when the uids match.
831
+ fsGroup: req.workGroup ?? DEFAULT_WORK_GROUP,
832
+ // Before any container starts: prove the image on the mounted `/opt/jr2`.
833
+ initContainers: initContainersFor(seat, split),
834
+ // Never empty: the Custodian's address is unconditional. The seat's own vars (the fallback
835
+ // `HOME`) come FIRST, so the instance's `harness.env` can still override them the way it
836
+ // overrides anything the image set.
837
+ env: [...seat.env, ...harnessEnv(req.name, custodian, cpuHintEnv())],
838
+ ...(cfg().envFrom?.length ? { envFrom: cfg().envFrom } : {}),
839
+ // What the AGENT gets: an address on its own loopback and a Stand-in for every credential,
840
+ // and no credential anywhere. The Custodian beside it holds them (ADR-0013, ADR-0059).
841
+ sidecars,
842
+ // The Repos this Sandbox attaches, by cache key (ADR-0051). The operator does the rest: a
843
+ // `repo-<key>` volume per entry — the node's cache, hostPath, read-only in the primary
844
+ // container at `/repos/<key>` — a soft affinity toward nodes already holding them, and
845
+ // `Ready` only once every one is present on the pod's node and fetched since this CR
846
+ // asked. Read-only is load-bearing twice (ADR-0004): no write contention, and nothing in
847
+ // a Sandbox can `gc` the object store its `--shared` clones borrow from.
848
+ repos: repos.map(({ key, url }) => ({ key, url })),
849
+ // The push Secret of each `git.credentials` entry this Sandbox's pushes spend (ADR-0051), by
850
+ // name, as a Repo CR's `secretRef` names one: the operator projects each into the Sandbox
851
+ // Effector alone, at `/run/jr2/secrets/@git/<name>/`. No value passes through here.
852
+ ...(gitCredentials.length ? { gitCredentials: gitCredentials.map((name) => ({ name })) } : {}),
853
+ volumes: [
854
+ // The worktree root is a POD volume, not a directory baked into the image. Two reasons,
855
+ // both load-bearing: every jr2-owned seat runs as an unprivileged uid, which cannot mkdir
856
+ // under `/` — so an image-owned `/work` would make every attach fail — and `/work` is
857
+ // the one thing all three containers share (ADR-0005), so human and Agent see identical
858
+ // files. An emptyDir lands group-writable under the pod's fsGroup, so it is writable
859
+ // whatever uid the Sandbox Image runs as: `/work` unclaimed is the only image contract.
860
+ { name: "work", emptyDir: {} },
861
+ // jr2's runtime (ADR-0037): the kit's Harness image, mounted.
862
+ runtimeVolume(refs),
863
+ // The conversations' directory (ADR-0031): a Harness container that restarts inside a
864
+ // living pod rebuilds its conversations from here. An emptyDir, so it goes with the pod —
865
+ // that Workspace is `Lost` (ADR-0021) — and mounted below into the Harness container
866
+ // ALONE: the User Container must not read an Agent's history.
867
+ { name: CONVERSATIONS_VOLUME, emptyDir: {} },
868
+ // Only for ADR-0037's fallback seat: uid 1000 on a stranger's base has no home at all.
869
+ ...seat.homeVolume,
870
+ // The Custodian's (its values, leaves and config), and the trust ConfigMap (ADR-0020).
871
+ ...custodian.volumes,
872
+ // The Sandbox Effector's (ADR-0068): its secrets, its key and config, its scratch.
873
+ ...(effector?.volumes ?? []),
874
+ ],
875
+ // CR-level volumeMounts land on the HARNESS container only (the operator's contract): the
876
+ // trust bundles, and none of the Custodian's volumes. The Repo caches are not listed: the
877
+ // operator mounts each `repo-<key>` into this container itself.
878
+ volumeMounts: [
879
+ { name: "work", mountPath: WORK_ROOT },
880
+ RUNTIME_VOLUME_MOUNT,
881
+ { name: CONVERSATIONS_VOLUME, mountPath: CONVERSATIONS_DIR },
882
+ ...seat.homeMount,
883
+ ...custodian.harnessMounts,
884
+ ],
885
+ },
886
+ };
887
+ };
888
+
889
+ const conditionOf = (sandbox: SandboxObject | undefined, type: string): Condition | undefined =>
890
+ sandbox?.status?.conditions?.find((c) => c.type === type);
891
+
892
+ /**
893
+ * Mint this Sandbox's token into a Secret, AFTER the CR exists and owned by it from birth
894
+ * (ADR-0001): the owner is the uid the CR's apply answered with, so Kubernetes reaps the Secret
895
+ * whenever the CR goes — the operator's idle-timeout GC, a `kubectl delete sandbox` by hand — and
896
+ * no path (an owner patch that fails, an Orchestrator that dies between two writes) leaves a
897
+ * token behind. The operator creates the pod the moment it sees the CR, so the pod may reach its
898
+ * mount first; kubelet retries the mount, and the Secret lands one API round trip after the CR,
899
+ * well before the scheduler and kubelet get there. Idempotent by construction: the token is the
900
+ * Sandbox's name, signed (tokens.ts), so a re-provision after an Orchestrator restart re-applies
901
+ * the SAME value and owner, and the Custodian that has been holding it all along stays valid.
902
+ * `data`, not `stringData`: a server-side apply owns the fields it names, and `stringData` is
903
+ * write-only — it never reads back as the field applied.
904
+ */
905
+ const applyTokenSecret = async (name: string, owner: KubeObject): Promise<void> => {
906
+ if (!opts.signingKey) throw new Error("kubeSandbox: a Custodian needs a signingKey to mint its Sandbox token");
907
+ const uid = owner.metadata?.uid;
908
+ if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its token Secret`);
909
+ await kube().apply(SECRETS, ns, {
910
+ apiVersion: "v1",
911
+ kind: "Secret",
912
+ metadata: {
913
+ name: secretName(name),
914
+ namespace: ns,
915
+ labels: { "jr2.dev/sandbox": name },
916
+ ownerReferences: [
917
+ {
918
+ apiVersion: "core.jr2.dev/v1alpha1",
919
+ kind: "Sandbox",
920
+ name,
921
+ uid,
922
+ controller: true,
923
+ blockOwnerDeletion: false,
924
+ },
925
+ ],
926
+ },
927
+ type: "Opaque",
928
+ data: { JR2_SANDBOX_TOKEN: Buffer.from(sandboxToken(opts.signingKey, name)).toString("base64") },
929
+ });
930
+ };
931
+
932
+ /**
933
+ * This Sandbox's Effector key (ADR-0068): `effectorKey(signing key, <Sandbox name>)`,
934
+ * derived like the Sandbox token, so a re-provision re-applies the same value, and written into a
935
+ * Secret the CR owns, which only the `effector` container mounts.
936
+ */
937
+ const applyEffectorKey = async (name: string, owner: KubeObject): Promise<void> => {
938
+ if (!opts.signingKey) throw new Error("kubeSandbox: a Sandbox Effector needs a signingKey to derive its key");
939
+ const uid = owner.metadata?.uid;
940
+ if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its Effector key`);
941
+ await kube().apply(SECRETS, ns, {
942
+ apiVersion: "v1",
943
+ kind: "Secret",
944
+ metadata: {
945
+ name: sandboxEffectorKeySecret(name),
946
+ namespace: ns,
947
+ labels: { "jr2.dev/sandbox": name },
948
+ ownerReferences: [
949
+ {
950
+ apiVersion: "core.jr2.dev/v1alpha1",
951
+ kind: "Sandbox",
952
+ name,
953
+ uid,
954
+ controller: true,
955
+ blockOwnerDeletion: false,
956
+ },
957
+ ],
958
+ },
959
+ type: "Opaque",
960
+ data: { key: Buffer.from(effectorKey(opts.signingKey, name)).toString("base64") },
961
+ });
962
+ };
963
+
964
+ /**
965
+ * This Sandbox's Effector Service (ADR-0068): headless, so its name resolves to the pod's own
966
+ * address — the one each request is signed for — and publishing an address that is not ready, so
967
+ * a restore asks the Effector for an Effect while the Harness beside it restarts. Owned by the
968
+ * CR, so it goes with the pod. Ingress is `jr2-sandbox-ingress`'s: the Orchestrator's alone.
969
+ */
970
+ const applyEffectorService = async (name: string, owner: KubeObject): Promise<void> => {
971
+ const uid = owner.metadata?.uid;
972
+ if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its Effector Service`);
973
+ await kube().apply(SERVICES, ns, {
974
+ apiVersion: "v1",
975
+ kind: "Service",
976
+ metadata: {
977
+ name: sandboxEffectorService(name),
978
+ namespace: ns,
979
+ labels: { "jr2.dev/sandbox": name },
980
+ ownerReferences: [
981
+ {
982
+ apiVersion: "core.jr2.dev/v1alpha1",
983
+ kind: "Sandbox",
984
+ name,
985
+ uid,
986
+ controller: true,
987
+ blockOwnerDeletion: false,
988
+ },
989
+ ],
990
+ },
991
+ spec: {
992
+ clusterIP: "None",
993
+ publishNotReadyAddresses: true,
994
+ // The operator's label on every Sandbox pod (operator helpers.go `sandboxLabels`).
995
+ selector: { "sandbox.jr2.dev/name": name },
996
+ ports: [{ name: "effector", port: SANDBOX_EFFECTOR_PORT, targetPort: SANDBOX_EFFECTOR_PORT }],
997
+ },
998
+ });
999
+ };
1000
+
1001
+ /**
1002
+ * This Sandbox's Custodian files — Envoy's bootstrap and the `HELD` table — rendered HERE, from
1003
+ * the pod's manifest, into a ConfigMap owned by the CR like its token (ADR-0059). Who makes the
1004
+ * pod renders its files: `jr2 up` declares what each pod holds and nothing per Workspace lands in
1005
+ * `jr2-held`, so an Instance with many Workspaces never grows one object toward its 1 MiB limit.
1006
+ * Names, hosts, headers — never a value — so a ConfigMap is the right kind. Same owner, same
1007
+ * idempotence and same race as the token: the kubelet retries the mount until this lands.
1008
+ */
1009
+ const applyCustodianConfig = async (name: string, owner: KubeObject, manifest: HeldManifest): Promise<void> => {
1010
+ const uid = owner.metadata?.uid;
1011
+ if (!uid) throw new Error(`Sandbox "${name}": the CR's apply answered with no uid to own its Custodian ConfigMap`);
1012
+ const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${ns}.svc`, port: ORCHESTRATOR_PORT };
1013
+ await kube().apply(CONFIGMAPS, ns, {
1014
+ apiVersion: "v1",
1015
+ kind: "ConfigMap",
1016
+ metadata: {
1017
+ name: sandboxCustodianConfigMap(name),
1018
+ namespace: ns,
1019
+ labels: { "jr2.dev/sandbox": name },
1020
+ ownerReferences: [
1021
+ {
1022
+ apiVersion: "core.jr2.dev/v1alpha1",
1023
+ kind: "Sandbox",
1024
+ name,
1025
+ uid,
1026
+ controller: true,
1027
+ blockOwnerDeletion: false,
1028
+ },
1029
+ ],
1030
+ },
1031
+ data: {
1032
+ [CUSTODIAN_BOOTSTRAP_KEY]: JSON.stringify(custodianBootstrap(manifest, orchestrator)),
1033
+ [CUSTODIAN_SCRIPT_KEY]: custodianScript(manifest),
1034
+ },
1035
+ });
1036
+ };
1037
+
1038
+ /**
1039
+ * Every Repo the provision names, resolved to its identity and key — and FENCED (ADR-0051). A
1040
+ * per-run url is the run's input; one no `git.credentials` entry admits is refused here, before
1041
+ * anything is read or applied, naming the list. A bound url is admitted without a match: it is
1042
+ * code the instance typechecked and deployed. Two slots spelling one repository collapse to one
1043
+ * CR entry (first spelling wins) — one cache, however many slots borrow from it — and the
1044
+ * resource is BOUND when any of those slots is the Machine's: a per-run slot alone leaves it on
1045
+ * `jr2 gc`'s clock.
1046
+ */
1047
+ const fencedRepos = (name: string, repos: ProvisionedRepo[]): FencedRepo[] => {
1048
+ const byKey = new Map<string, FencedRepo>();
1049
+ for (const repo of repos) {
1050
+ const { identity, key } = repoIdentity(repo.url);
1051
+ if (repo.perRun && !matchCredential(identity, cfg().credentials ?? [])) {
1052
+ const entries = (cfg().credentials ?? []).map((c) => c.match).join(", ") || "none";
1053
+ throw new Error(
1054
+ `Sandbox "${name}" refuses the per-run repo ${repo.url} for slot "${repo.slot}": no git.credentials ` +
1055
+ `entry matches "${identity}" (entries: ${entries}). A per-run url can spend the cluster's credential ` +
1056
+ "against any host, so jr2.config.ts must admit it by prefix (ADR-0051).",
1057
+ );
1058
+ }
1059
+ const seen = byKey.get(key);
1060
+ if (seen === undefined) byKey.set(key, { key, url: repo.url, identity, bound: !repo.perRun });
1061
+ else seen.bound ||= !repo.perRun;
1062
+ }
1063
+ return [...byKey.values()];
1064
+ };
1065
+
1066
+ /**
1067
+ * The push Secrets a Sandbox's pushes spend (ADR-0051): for each pushed Repo Slot, the url
1068
+ * this provision attaches — static, per-run or `customize()`-bound alike — matched to its
1069
+ * `git.credentials` entry (longest prefix, the attach match), and that entry's Secret as `jr2 up`
1070
+ * wrote it (`git-push.json`: names only). Refused with the fix, by the same rule `jr2 up` applies
1071
+ * to a static Binding: no entry, a local path or `git://`, `http://`, an entry whose credentials
1072
+ * the url's scheme cannot spend or that `jr2 up` placed none for, `sign` with no `sign` field, an
1073
+ * unsigned squash on an entry with neither `sign` nor `committer`.
1074
+ */
1075
+ const gitCredentialsFor = async (
1076
+ name: string,
1077
+ repos: readonly ProvisionedRepo[],
1078
+ pushes: ReadonlyArray<{ slot: string; sign: boolean; squash?: true }>,
1079
+ ): Promise<string[]> => {
1080
+ if (pushes.length === 0) return [];
1081
+ const manifest = await readGitPushManifest(join(heldDir, GIT_PUSH_MANIFEST_KEY));
1082
+ const names: string[] = [];
1083
+ for (const { slot, sign, squash } of pushes) {
1084
+ const repo = repos.find((r) => r.slot === slot);
1085
+ if (repo === undefined) {
1086
+ throw new Error(
1087
+ `Sandbox "${name}" cannot be provisioned: its Workspace pushes Repo Slot "${slot}", which this ` +
1088
+ "provision does not attach",
1089
+ );
1090
+ }
1091
+ const at = `Sandbox "${name}" refuses the push of Repo Slot "${slot}" (${repo.url})`;
1092
+ const out = placedPushRefusal(
1093
+ { at, url: repo.url, sign, squash: squash === true },
1094
+ cfg().credentials ?? [],
1095
+ manifest,
1096
+ );
1097
+ if ("refusal" in out) throw new Error(out.refusal);
1098
+ if (!names.includes(out.entry.secret)) names.push(out.entry.secret);
1099
+ }
1100
+ return names;
1101
+ };
1102
+
1103
+ /**
1104
+ * What the operator will hold this Sandbox's Ready on, and what an attach reads afterwards
1105
+ * (ADR-0051). `Ready` with reason `RepoCloneFailed` is terminal for the provision — the cache
1106
+ * agent could not clone onto the node the pod landed on, and the reason names it — so the wait
1107
+ * fails on it by name rather than burning the budget. `ReposFresh=False` is the other verdict:
1108
+ * the caches are there but a fetch since this CR asked failed, so the attach proceeds STALE and
1109
+ * says so. Remembered per name until the Sandbox is destroyed, because the attach is a separate
1110
+ * call. The operator takes that verdict once per pod and keeps it, so a provision re-run on
1111
+ * snapshot restore reads the same one — unless the pod was replaced, which is the lease's news.
1112
+ */
1113
+ const staleByName = new Map<string, string>();
1114
+
1115
+ /** The Harness's `POST /attach` (ADR-0063), re-sent while it demonstrably never reached the
1116
+ * Harness. Idempotent on the Harness's side (every step guarded, calls serialized), so a re-send
1117
+ * after a request that DID land is harmless too — but only a transport failure is retried: an
1118
+ * answer is an answer. */
1119
+ const postAttach = async (name: string, endpoint: string, body: AttachRequest): Promise<AttachResponse> => {
1120
+ if (!opts.signingKey) throw new Error("kubeSandbox: the attach needs a signingKey for the Harness bearer");
1121
+ const url = new URL("/attach", endpoint).toString();
1122
+ const headers = {
1123
+ "content-type": "application/json",
1124
+ authorization: `Bearer ${harnessToken(opts.signingKey, name)}`,
1125
+ };
1126
+ const until = Date.now() + attachWindowMs;
1127
+ let backoff = 250;
1128
+ for (;;) {
1129
+ let res: Response;
1130
+ try {
1131
+ res = await harnessFetch(url, { method: "POST", headers, body: JSON.stringify(body) });
1132
+ } catch (err) {
1133
+ // Ready is not routable (ADR-0042): the EndpointSlice behind the Service is programmed after
1134
+ // the pod passes its probe, and the attach is now the first thing that dials it.
1135
+ if (Date.now() >= until) {
1136
+ throw new Error(
1137
+ `Sandbox "${name}": the attach never reached its Harness at ${endpoint}: ${(err as Error).message}`,
1138
+ );
1139
+ }
1140
+ await sleep(backoff);
1141
+ backoff = Math.min(backoff * 2, 5_000);
1142
+ continue;
1143
+ }
1144
+ const text = await res.text();
1145
+ if (res.ok) return JSON.parse(text) as AttachResponse;
1146
+ // The Harness's own words: the slot, the step, and git's stderr.
1147
+ let error = text;
1148
+ try {
1149
+ error = (JSON.parse(text) as AttachError).error ?? text;
1150
+ } catch {
1151
+ // not an AttachError: the raw body is the most there is
1152
+ }
1153
+ throw new Error(`Sandbox "${name}": the attach failed (${res.status}): ${error}`);
1154
+ }
1155
+ };
1156
+
1157
+ /**
1158
+ * A wait, as watch events (ADR-0063): every change the operator publishes on this Sandbox is
1159
+ * judged as it lands. `judge` answers a result, throws a named failure, or answers undefined to
1160
+ * keep waiting. `budget`, when given, is a timer re-read after every judgement, because the Repo
1161
+ * budget replaces the pod's the moment the operator holds the Sandbox on its Repos; without one
1162
+ * the wait has no deadline (ADR-0064's `placing`). `signal` ends it early, rejecting with its
1163
+ * reason.
1164
+ */
1165
+ const waitFor = <T>(
1166
+ name: string,
1167
+ judge: (sandbox: SandboxObject | undefined) => T | undefined,
1168
+ budget?: () => { deadline: number; expire: (last: SandboxObject | undefined) => Error },
1169
+ signal?: AbortSignal,
1170
+ ): Promise<T> =>
1171
+ new Promise<T>((resolve, reject) => {
1172
+ let settled = false;
1173
+ let last: SandboxObject | undefined;
1174
+ let timer: ReturnType<typeof setTimeout> | undefined;
1175
+ const finish = (fn: () => void) => {
1176
+ if (settled) return;
1177
+ settled = true;
1178
+ clearTimeout(timer);
1179
+ unsubscribe();
1180
+ signal?.removeEventListener("abort", onAbort);
1181
+ fn();
1182
+ };
1183
+ const onAbort = () => finish(() => reject(signal!.reason));
1184
+ const arm = () => {
1185
+ if (!budget) return;
1186
+ clearTimeout(timer);
1187
+ const { deadline, expire } = budget();
1188
+ timer = setTimeout(() => finish(() => reject(expire(last))), Math.max(0, deadline - Date.now()));
1189
+ };
1190
+ const unsubscribe = sandboxes().subscribe(name, (sandbox) => {
1191
+ last = sandbox;
1192
+ try {
1193
+ const out = judge(sandbox);
1194
+ if (out !== undefined) return finish(() => resolve(out));
1195
+ } catch (err) {
1196
+ return finish(() => reject(err));
1197
+ }
1198
+ arm();
1199
+ });
1200
+ arm();
1201
+ if (signal?.aborted) onAbort();
1202
+ else signal?.addEventListener("abort", onAbort, { once: true });
1203
+ });
1204
+
1205
+ /**
1206
+ * A Sandbox the operator made Lost, or one the watch saw and then lost, fails the wait at once and
1207
+ * by name (ADR-0021): it never comes back. `seen` is the caller's: a wait that began with the
1208
+ * write may hear nothing before the watch lists the new CR, and that is not a deletion.
1209
+ */
1210
+ const judgeLoss = (name: string, sandbox: SandboxObject | undefined, seen: boolean, doing: string): void => {
1211
+ if (sandbox === undefined) {
1212
+ if (seen) throw new Error(`Sandbox "${name}" was deleted while it ${doing} (ADR-0021)`);
1213
+ return;
1214
+ }
1215
+ const lost = lossOf(sandbox);
1216
+ if (lost) {
1217
+ throw new Error(
1218
+ `Sandbox "${name}" is Lost while it ${doing}: ${lost.reason}: ${lost.message} — a Sandbox has one pod ` +
1219
+ "for its life and the operator never gives it another (ADR-0021)",
1220
+ );
1221
+ }
1222
+ };
1223
+
1224
+ return {
1225
+ async place(req, { onWait, signal }) {
1226
+ // The fence first: a refused url costs nothing — no map read, no Secret, no CR.
1227
+ const repos = fencedRepos(req.name, req.repos);
1228
+ // The Size next (ADR-0060), for the same price: the Machine's, then the Instance default, then
1229
+ // the kit's, split inside the pod — or refused before anything exists. `jr2 up` makes the
1230
+ // same refusal at the converge; this one covers a Machine it never walked.
1231
+ let split: SizeSplit;
1232
+ try {
1233
+ split = splitSize(resolveSize(req.resources, cfg().defaultSize), req.userResources);
1234
+ } catch (err) {
1235
+ throw new Error(`Sandbox "${req.name}" cannot be provisioned: ${(err as Error).message}`);
1236
+ }
1237
+ if (opts.repos === undefined) {
1238
+ throw new Error(
1239
+ `Sandbox "${req.name}" cannot be provisioned: this port has no Repo-resource port (ADR-0051). The ` +
1240
+ "operator holds a Sandbox's Ready until every Repo it names exists as a resource, and creating " +
1241
+ "them is this provision's job — build the port with `repos: kubeRepos(...)`.",
1242
+ );
1243
+ }
1244
+ // Read PER PROVISION, and next (ADR-0038). Not hoisted into `kubeSandbox()`: a boot-time
1245
+ // read would freeze the map for the process lifetime, which is precisely the Deployment-env
1246
+ // behavior the ConfigMap mount was chosen over — the point of the mount is that a `jr2 up`
1247
+ // reaches future Sandboxes without rolling the Orchestrator. Reading before the Secret apply
1248
+ // also means an unknown image name costs nothing: no Secret, no CR, nothing to clean up.
1249
+ const refs = await readImageRefs(imagesPath);
1250
+ // The same stance for what the Custodian holds (ADR-0059): `jr2 up` placed it host-side,
1251
+ // where `.env` exists, per Workspace — the one its workflow and its route name — and this
1252
+ // process reads that pod's manifest, never its own config, whose `.env` values are absent
1253
+ // in-cluster. A Workspace `jr2 up` never walked has none, and is refused by name.
1254
+ const pod = workspaceHeldPod({ workflow: req.workflow, route: req.route });
1255
+ const manifest = await readHeldManifest(join(heldDir, heldManifestKey(pod)));
1256
+ const custodian = custodianComposition(manifest, {
1257
+ image: refs.custodian,
1258
+ pod,
1259
+ config: sandboxCustodianConfigMap(req.name),
1260
+ token: { secret: secretName(req.name), key: "JR2_SANDBOX_TOKEN" },
1261
+ sandbox: req.name,
1262
+ caBundle: cfg().caBundle === true,
1263
+ resources: split.custodian,
1264
+ });
1265
+ // The Sandbox Effector (ADR-0068): present when `jr2 up` placed an Effect of this Workspace
1266
+ // there, and declared it in the pod's manifest beside the Held one; absent otherwise.
1267
+ const effectorManifest = await readSandboxEffectorManifest(join(heldDir, effectorManifestKey(pod)));
1268
+ // Its pushes' credentials (ADR-0051): each pushed slot's url, as this run binds it, is
1269
+ // matched against the fence, and the entry's push Secret is named on the CR — or the push is
1270
+ // refused here, before anything is written, as a per-run url outside the fence is.
1271
+ const gitCredentials = await gitCredentialsFor(req.name, req.repos, effectorManifest?.pushes ?? []);
1272
+ const cr = await crFor(req, refs, repos, custodian, split, effectorManifest, gitCredentials);
1273
+
1274
+ // The Repo resources, BEFORE the CR names them (ADR-0051): a bound one already exists from
1275
+ // the boot and only its eviction clock moves — this run's spelling never rewrites the spec
1276
+ // the boot stated; a per-run one is created here, at its first attach, and every later
1277
+ // attach anywhere finds it and restates its credential, so the `git.credentials` fix
1278
+ // `repoCloneError` names reaches the cache at the next run. After the image resolution, so
1279
+ // a refused image still costs nothing; before the CR and its token Secret, so neither exists
1280
+ // for a Sandbox whose Repo could not be recorded.
1281
+ for (const repo of repos) await opts.repos.ensure(repo).catch(noOperator(req.name, "Repo"));
1282
+
1283
+ // The watch is started before the write (on first use), and it is level-based: whatever it
1284
+ // holds when the wait below subscribes is the first thing judged, so no event about this
1285
+ // Sandbox can fall between the write and the wait. Not awaited: an API server that cannot
1286
+ // be reached fails the apply below at once, rather than hanging on a list.
1287
+ sandboxes();
1288
+ const applied = await kube().apply(SANDBOXES, ns, cr).catch(noOperator(req.name, "Sandbox"));
1289
+ // After the CR: its uid owns both, so they go when it goes.
1290
+ await applyTokenSecret(req.name, applied);
1291
+ await applyCustodianConfig(req.name, applied, manifest);
1292
+ if (effectorManifest) {
1293
+ await applyEffectorKey(req.name, applied);
1294
+ await applyEffectorService(req.name, applied);
1295
+ }
1296
+
1297
+ // The wait for a node, with no deadline (ADR-0064): jr2 cannot tell "full now" from "never
1298
+ // fits", so a bound is the Machine's `after`. Its reason — the scheduler's, or a quota's
1299
+ // refusal of the pod create, which the operator keeps retrying — is said each time it changes.
1300
+ let seen = false;
1301
+ let said: PlacingWait | undefined;
1302
+ await waitFor<true>(
1303
+ req.name,
1304
+ (sandbox) => {
1305
+ judgeLoss(req.name, sandbox, seen, "waited for a node");
1306
+ if (sandbox === undefined) return undefined;
1307
+ seen = true;
1308
+ // Scheduled, or an operator too old to say so that already reports Ready.
1309
+ if (conditionOf(sandbox, "Scheduled")?.status === "True" || sandbox.status?.phase === "Ready") return true;
1310
+ const wait = placingWaitOf(sandbox);
1311
+ if (wait && (wait.on !== said?.on || wait.message !== said.message)) {
1312
+ said = wait;
1313
+ onWait(wait);
1314
+ }
1315
+ return undefined;
1316
+ },
1317
+ undefined,
1318
+ signal,
1319
+ );
1320
+ },
1321
+
1322
+ async provision(name) {
1323
+ // Two budgets from one instant — NOW, the pod scheduled (ADR-0064), so neither is spent on a
1324
+ // wait for capacity: the pod's until the operator has seen the pod Ready, the Repos' from the
1325
+ // first event that finds the Sandbox held on a Repo reason — which the operator reports only
1326
+ // once the pod IS Ready, so the preflight has already passed and what remains is a clone or a
1327
+ // fetch on the node. Sticky: a pod that came up once is not a pod that will never come up,
1328
+ // whatever it does afterwards.
1329
+ const at = Date.now();
1330
+ let held = false;
1331
+ return waitFor(
1332
+ name,
1333
+ (sandbox) => {
1334
+ // The pod was placed, so the watch has held this Sandbox: absent now is deleted.
1335
+ judgeLoss(name, sandbox, true, "provisioned");
1336
+ const status = sandbox?.status;
1337
+ const ready = conditionOf(sandbox, "Ready");
1338
+ if (status?.phase !== "Ready") {
1339
+ // A container the kubelet will never start (ADR-0063): no log, no termination — its
1340
+ // waiting reason, published by the operator, is the only evidence. Named at once.
1341
+ const fault = waitingFault(name, status?.waiting);
1342
+ if (fault) throw new Error(fault);
1343
+ }
1344
+ // Terminal for THIS provision: the cache agent tried to clone onto the pod's node and git
1345
+ // refused. The operator's message carries the key, the node, and git's own words; the
1346
+ // agent keeps retrying on its own, so `jr2 status` will show the same error until it is fixed.
1347
+ if (status?.phase !== "Ready" && ready?.reason === REPO_CLONE_FAILED) {
1348
+ throw new Error(repoCloneError(name, ready.message ?? ready.reason));
1349
+ }
1350
+ // A Harness that keeps dying before it is ever Ready (ADR-0063): the kubelet's reason and
1351
+ // exit code, published by the operator, instead of the rest of the budget.
1352
+ const harness = status?.harness;
1353
+ if (status?.phase !== "Ready" && (harness?.restartCount ?? 0) >= CRASH_LOOP_RESTARTS) {
1354
+ throw new Error(crashLoopError(name, harness!));
1355
+ }
1356
+ // A Sandbox Effector that ended before the pod was ever Ready (ADR-0068): it ends only
1357
+ // for a start it cannot serve — a node whose kernel cannot confine a credentialed
1358
+ // command's child, a mount it cannot read — and says so in its termination message,
1359
+ // which no retry mends. Named at once, before any Turn.
1360
+ const effector = status?.effector?.lastTerminated;
1361
+ if (status?.phase !== "Ready" && effector !== undefined) throw new Error(effectorStartError(name, effector));
1362
+ if (ready?.reason !== undefined && REPO_HELD.has(ready.reason)) held = true;
1363
+ if (status?.phase !== "Ready") return undefined;
1364
+ // Only `phase: Ready` means serving — status.endpoint appears earlier (ADR-0001).
1365
+ if (!status.endpoint) throw new Error(`Sandbox "${name}" is Ready but reports no endpoint`);
1366
+ // Freshness degrades, absence does not (ADR-0051): Ready with `ReposFresh=False` is a
1367
+ // Sandbox whose caches exist but could not be fetched since it asked. Remembered for the
1368
+ // attach, which is where a slot can be named; forgotten when the caches are fresh.
1369
+ const fresh = conditionOf(sandbox, "ReposFresh");
1370
+ if (fresh?.status === "False" && fresh.message) staleByName.set(name, fresh.message);
1371
+ else staleByName.delete(name);
1372
+ return { endpoint: status.endpoint };
1373
+ },
1374
+ () => ({
1375
+ deadline: at + (held ? repoTimeoutMs : readyTimeoutMs),
1376
+ // A Sandbox the operator held on its Repos ran out the Repo budget: the pod is up and the
1377
+ // preflight passed, so the hint about the image would be a lie. What is true is the
1378
+ // operator's own verdict — which Repo, on which node — and that the agent is still at it.
1379
+ expire: (last) =>
1380
+ new Error(
1381
+ held ? repoWaitError(name, repoTimeoutMs, conditionOf(last, "Ready")) : notReadyError(name, last),
1382
+ ),
1383
+ }),
1384
+ );
1385
+ },
1386
+
1387
+ async attach(req) {
1388
+ // The Harness's own route (ADR-0063): the same bearer as every Harness call (ADR-0058), and
1389
+ // the Repo's identity and cache key resolved HERE, so the cache the Harness clones from is
1390
+ // the one the operator mounted, by construction (ADR-0051). The address is the Sandbox's
1391
+ // own, off the watch — the one provision just waited on, or a restore's first list — or the
1392
+ // API server's, for one the watch has not listed yet.
1393
+ const sandbox = sandboxes().get(req.name) ?? (await kube().get<SandboxObject>(SANDBOXES, ns, req.name));
1394
+ const endpoint = sandbox?.status?.endpoint;
1395
+ if (!endpoint) throw new Error(`Sandbox "${req.name}" has no endpoint to attach through — is it gone?`);
1396
+ if (req.repos.length === 0) {
1397
+ throw new Error(
1398
+ "the attach names no Repo Slot — nothing to attach (a Workspace with no Repo Slot skips the attach, ADR-0051)",
1399
+ );
1400
+ }
1401
+ const body: AttachRequest = {
1402
+ slots: req.repos.map((r) => {
1403
+ const { identity, key } = repoIdentity(r.url);
1404
+ return { slot: r.slot, url: r.url, identity, key, ...(r.ref !== undefined ? { ref: r.ref } : {}) };
1405
+ }),
1406
+ branch: req.spec.branch,
1407
+ ...(req.spec.reviewSha ? { reviewSha: req.spec.reviewSha } : {}),
1408
+ };
1409
+ const { repos, review } = await postAttach(req.name, endpoint, body);
1410
+ const stale = staleSlots(req.repos, staleByName.get(req.name));
1411
+ return { repos, ...(review ? { review } : {}), ...(stale ? { stale } : {}) };
1412
+ },
1413
+
1414
+ leaseIntervalMs,
1415
+
1416
+ async renew(name) {
1417
+ // A write, and only that (ADR-0021): the stamp the operator's idle GC reads. What the
1418
+ // workspace IS comes from the watch, never from this answer.
1419
+ await kube().patch(SANDBOXES, ns, name, {
1420
+ metadata: { annotations: { "jr2.dev/keepalive": new Date().toISOString() } },
1421
+ });
1422
+ },
1423
+
1424
+ continuity(name, listener) {
1425
+ // The watch's word on this name (ADR-0063): the first list, then every event. A dropped
1426
+ // watch says nothing, so this says nothing either — unknown is never loss.
1427
+ return sandboxes().subscribe(name, (sandbox) => listener(continuityOf(sandbox)));
1428
+ },
1429
+
1430
+ harnessRestarts(name, listener) {
1431
+ // The same watch (ADR-0021): the operator publishes the Harness container's restarts and
1432
+ // its last end on the Sandbox's status. A name the watch does not hold says nothing — that is
1433
+ // Continuity's news, or the Instance Harness, which is not a Sandbox.
1434
+ return sandboxes().subscribe(name, (sandbox) => {
1435
+ const harness = sandbox?.status?.harness;
1436
+ if (sandbox === undefined) return;
1437
+ listener({
1438
+ restartCount: harness?.restartCount ?? 0,
1439
+ ...(harness?.lastTerminated ? { lastTerminated: harness.lastTerminated } : {}),
1440
+ });
1441
+ });
1442
+ },
1443
+
1444
+ async memoryFault(name, since) {
1445
+ // Only a Sandbox the watch knows: the Instance Harness is a StatefulSet, not a Sandbox, and
1446
+ // waiting on a name that will never appear would only delay its fault (ADR-0031).
1447
+ if (!sandboxes().get(name)) return undefined;
1448
+ const judge = (sandbox: SandboxObject | undefined) => memoryFaultOf(sandbox, since);
1449
+ const now = judge(sandboxes().get(name));
1450
+ if (now !== undefined) return now;
1451
+ // The operator's word can trail the Harness's restart; give it a moment, then call it lost.
1452
+ return waitFor<MemoryKill>(name, judge, () => ({
1453
+ deadline: Date.now() + MEMORY_FAULT_GRACE_MS,
1454
+ expire: () => new Error("no memory kill"),
1455
+ })).catch(() => undefined);
1456
+ },
1457
+
1458
+ async destroy(name) {
1459
+ staleByName.delete(name);
1460
+ // The token Secret is the CR's child from birth, so deleting the CR reaps it (ADR-0001).
1461
+ await kube().delete(SANDBOXES, ns, name);
1462
+ },
1463
+ };
1464
+ }
1465
+
1466
+ /** A Sandbox as Continuity (ADR-0021): gone, or present — and Lost, when the operator says so. */
1467
+ function continuityOf(sandbox: SandboxObject | undefined): Continuity {
1468
+ if (!sandbox) return { present: false };
1469
+ const lost = lossOf(sandbox);
1470
+ return lost ? { present: true, lost } : { present: true };
1471
+ }
1472
+
1473
+ /**
1474
+ * The operator's verdict that this Sandbox's one pod is gone or terminal (ADR-0021): phase `Lost`,
1475
+ * with the `Lost` condition's reason and the pod's own words. Terminal — the operator never gives
1476
+ * the Sandbox another pod, so nothing here waits for one.
1477
+ */
1478
+ export function lossOf(sandbox: SandboxObject | undefined): SandboxLoss | undefined {
1479
+ if (sandbox?.status?.phase !== "Lost") return undefined;
1480
+ const condition = sandbox.status.conditions?.find((c) => c.type === "Lost");
1481
+ return {
1482
+ reason: condition?.reason || "Lost",
1483
+ message: condition?.message || "the operator reports its pod gone or ended",
1484
+ };
1485
+ }
1486
+
1487
+ /**
1488
+ * Why a Sandbox with no node waits (ADR-0064), off the operator's `Scheduled` condition: `False`
1489
+ * with `QuotaExceeded` is a ResourceQuota refusing the pod create, any other `False` the scheduler
1490
+ * finding no node. `Unknown` (the scheduler has not looked yet) is no reason at all: most pods are
1491
+ * placed in milliseconds, and a wait worth a feed line is one the cluster has explained.
1492
+ */
1493
+ export function placingWaitOf(sandbox: SandboxObject | undefined): PlacingWait | undefined {
1494
+ const scheduled = sandbox?.status?.conditions?.find((c) => c.type === "Scheduled");
1495
+ if (scheduled?.status !== "False") return undefined;
1496
+ return {
1497
+ on: scheduled.reason === QUOTA_EXCEEDED ? "quota" : "node",
1498
+ message: scheduled.message || scheduled.reason || "no reason given",
1499
+ };
1500
+ }
1501
+
1502
+ /** The operator's `Scheduled` reason while a ResourceQuota refuses the pod create (ADR-0064). */
1503
+ const QUOTA_EXCEEDED = "QuotaExceeded";
1504
+
1505
+ /**
1506
+ * The memory kill (ADR-0061), or undefined: the Harness container's last run ended
1507
+ * `OOMKilled`, at or after `since` (less the skew a node's clock may carry). The prefix is FIXED —
1508
+ * `memory limit` — so a Machine, `jr2 status` and the feed can tell a memory kill from a Turn lost
1509
+ * to any other restart (ADR-0021) and from a conversation lost (a 404); the limit named is the
1510
+ * Harness container's own, the one the kernel enforced. `limit` and `at` ride beside the reason as data: the next Turn's notice is made
1511
+ * of them (ADR-0062), and `at` tells two Turns ended by one kill that it was one.
1512
+ */
1513
+ export function memoryFaultOf(sandbox: SandboxObject | undefined, since: Date): MemoryKill | undefined {
1514
+ const last = sandbox?.status?.harness?.lastTerminated;
1515
+ if (last?.reason !== OOM_KILLED) return undefined;
1516
+ const finished = last.finishedAt === undefined ? NaN : Date.parse(last.finishedAt);
1517
+ if (Number.isNaN(finished) || finished < since.getTime() - MEMORY_FAULT_SKEW_MS) return undefined;
1518
+ const limit = sandbox?.spec?.resources?.limits?.memory;
1519
+ const reason =
1520
+ `memory limit (${OOM_KILLED}${limit ? `, limit ${limit}` : ""}): the Workspace's processes passed the ` +
1521
+ "Harness container's memory limit and the kernel killed the container, the conversation with it. " +
1522
+ "Use fewer workers, or give the Workspace a larger Size (ADR-0060, ADR-0061).";
1523
+ return { reason, ...(limit ? { limit } : {}), at: last.finishedAt! };
1524
+ }
1525
+
1526
+ /** The pod volume the operator defines for one Repo's node cache, and where it lands in the
1527
+ * primary container (ADR-0051). Two halves of one contract with the operator, spelled here so the
1528
+ * User Container's mounts and the attach's clone source agree with it by construction. */
1529
+ export const repoVolumeName = (key: string): string => `repo-${key}`;
1530
+ export const repoMountPath = (key: string): string => `${REPOS_MOUNT}/${key}`;
1531
+
1532
+ /** One Repo as the provision names it: the CR entry, plus what its resource records — the
1533
+ * identity, and whether a Machine's slot (not only the run's) binds it. */
1534
+ type FencedRepo = { key: string; url: string; identity: string; bound: boolean };
1535
+
1536
+ /** The operator's Ready reason when the cache agent could not clone onto the pod's node
1537
+ * (sandbox_controller.go) — the one Ready verdict a provision cannot wait out. */
1538
+ const REPO_CLONE_FAILED = "RepoCloneFailed";
1539
+
1540
+ /** The operator's Ready reasons that hold a Sandbox whose POD is Ready on its Repos
1541
+ * (sandbox_controller.go, `reposReadiness`): the resource not yet seen, or the node's cache
1542
+ * agent still cloning or fetching. The wait against them is the Repo budget, not the pod's. */
1543
+ const REPO_HELD: ReadonlySet<string> = new Set(["RepoMissing", "RepoPending"]);
1544
+
1545
+ /**
1546
+ * The Repo budget ran out with the operator still holding the Sandbox. The pod is up, so the
1547
+ * preflight is not the question; the verdict names the Repo and the node, and the port adds
1548
+ * what the operator cannot say: the agent is still working, `jr2 status` shows it per node, and
1549
+ * the budget is the port's, not the clone's.
1550
+ */
1551
+ function repoWaitError(name: string, budgetMs: number, ready: Condition | undefined): string {
1552
+ const verdict = ready?.message ? `${ready.reason ?? "?"}: ${ready.message}` : "no Ready condition reported";
1553
+ return (
1554
+ `Sandbox "${name}" waited ${Math.round(budgetMs / 60_000)}m for its Repos and the operator still holds it — ` +
1555
+ `${verdict} (ADR-0051). The node's cache agent clones a cold node once and fetches before every attach; ` +
1556
+ "`jr2 status` reports each Repo per node. Start the run again once the cache is present, or raise " +
1557
+ "the port's `repoTimeoutMs` for a repository whose clone outlasts it."
1558
+ );
1559
+ }
1560
+
1561
+ /**
1562
+ * The fix beside the symptom. The operator's message carries the Repo, the node, and git's own
1563
+ * words; what it cannot say is that the cache agent keeps retrying, that `jr2 status` reports the
1564
+ * same line per node, or where a credential is configured (ADR-0047/0051).
1565
+ */
1566
+ function repoCloneError(name: string, verdict: string): string {
1567
+ return (
1568
+ `Sandbox "${name}" cannot start: ${verdict} (ADR-0051). The node's cache agent keeps retrying on its own — ` +
1569
+ "fix the url or its git.credentials entry (an ssh url needs its deploy key registered with the host, " +
1570
+ "ADR-0047), then start the run again; `jr2 status` reports the same error per node until it clears."
1571
+ );
1572
+ }
1573
+
1574
+ /**
1575
+ * A Harness that never came up (ADR-0063): the kubelet's reason and exit code for its last run,
1576
+ * as the operator published them, and where the rest is — the container's own log. A preflight
1577
+ * that passes and a Harness that still dies is a Sandbox Image the floor did not fully prove, or
1578
+ * a Harness that ran out of memory on the way up (ADR-0061).
1579
+ */
1580
+ function crashLoopError(name: string, harness: NonNullable<NonNullable<SandboxObject["status"]>["harness"]>): string {
1581
+ const last = harness.lastTerminated;
1582
+ const how = last ? `${last.reason ?? "?"}, exit ${last.exitCode ?? "?"}` : "no reason reported";
1583
+ // The Harness's own last words (ADR-0063): its termination message falls back to its last log
1584
+ // lines (`FallbackToLogsOnError`, set by the operator), so the fault quotes them rather than
1585
+ // sending the reader to a log a gone pod no longer has. Quoted, never interpreted.
1586
+ const words = last?.message?.trim();
1587
+ return (
1588
+ `Sandbox "${name}" cannot start: its Harness has ended ${harness.restartCount} times before it was ever ` +
1589
+ `Ready (last: ${how}).` +
1590
+ (words
1591
+ ? ` Its last output:\n${words
1592
+ .split("\n")
1593
+ .map((line) => ` ${line}`)
1594
+ .join("\n")}\n`
1595
+ : " ") +
1596
+ `See its output: \`kubectl logs ${name} -c harness --previous\`` +
1597
+ (last?.reason === OOM_KILLED
1598
+ ? " — it passed its memory limit, so give the Workspace a larger Size (ADR-0060)."
1599
+ : ".")
1600
+ );
1601
+ }
1602
+
1603
+ /** A Sandbox Effector that could not start, in its own words (its termination message). */
1604
+ function effectorStartError(
1605
+ name: string,
1606
+ last: NonNullable<NonNullable<NonNullable<SandboxObject["status"]>["effector"]>["lastTerminated"]>,
1607
+ ): string {
1608
+ const words = last.message?.trim();
1609
+ return (
1610
+ `Sandbox "${name}" cannot start: its Sandbox Effector ended before the pod was Ready ` +
1611
+ `(${last.reason ?? "?"}, exit ${last.exitCode ?? "?"})` +
1612
+ (words ? `: ${words}` : "") +
1613
+ ` — see \`kubectl logs ${name} -c effector --previous\` (ADR-0068)`
1614
+ );
1615
+ }
1616
+
1617
+ /**
1618
+ * The provision's verdict on a waiting container, or undefined to keep waiting (ADR-0063). Init
1619
+ * containers come first in the list, and they run first: the `preflight` step runs the Sandbox
1620
+ * Image before the Harness container exists, so that is where a root image dies.
1621
+ */
1622
+ function waitingFault(name: string, waiting: ContainerWaiting[] | undefined): string | undefined {
1623
+ for (const w of waiting ?? []) {
1624
+ if (w.reason === ROOT_IMAGE_REASON && w.message && ROOT_IMAGE_MESSAGE.test(w.message)) {
1625
+ return rootImageError(name, `container "${w.container}": ${w.message}`);
1626
+ }
1627
+ if (w.reason !== undefined && TERMINAL_WAITING.has(w.reason)) {
1628
+ return `Sandbox "${name}" cannot start: container "${w.container}" ${w.reason}${w.message ? `: ${w.message}` : ""}`;
1629
+ }
1630
+ }
1631
+ return undefined;
1632
+ }
1633
+
1634
+ /**
1635
+ * The fix, not the symptom. The kubelet's message says what it refused; it cannot say that the
1636
+ * image is a Sandbox Image, that jr2 declined to patch a uid onto it, or where the one-line edit
1637
+ * goes. Only a brought ref reaches here: a built image's USER was judged at converge, and one that
1638
+ * declares none gets the uid fallback.
1639
+ */
1640
+ function rootImageError(name: string, fault: string): string {
1641
+ return (
1642
+ `Sandbox "${name}" cannot start: its image runs as ROOT, and every jr2-owned seat is hardened ` +
1643
+ `with runAsNonRoot (ADR-0005). The kubelet refused it — ${fault}\n` +
1644
+ ` - the fix is one line in the image: a NUMERIC non-root \`USER <uid>\` (e.g. \`USER 1000\`)\n` +
1645
+ ` - numeric because the kubelet does not read the image's /etc/passwd, so \`USER app\` is ` +
1646
+ `refused too — it cannot prove that name is non-root\n` +
1647
+ ` - jr2 does not supply a uid for a brought registry ref: it is never inspected and never ` +
1648
+ `modified, which is what "bring your own image" means (ADR-0037). Only an image jr2 BUILDS, ` +
1649
+ `and only one that declares no USER at all, gets the uid-${FALLBACK_UID} fallback.`
1650
+ );
1651
+ }
1652
+
1653
+ /**
1654
+ * The pod budget ran out. The most likely cause is an image that misses ADR-0037's floor, and that
1655
+ * failure is an INIT container's — invisible in the phase alone. A musl or git-less base dies
1656
+ * INSIDE the preflight, on jr2's own message; a root image never starts it. The budget starts once
1657
+ * the pod is scheduled (ADR-0064), so the scheduler is never the answer here.
1658
+ */
1659
+ function notReadyError(name: string, last: SandboxObject | undefined): string {
1660
+ const said = (c: Condition | undefined) => (c?.message ? `${c.reason ?? "?"}: ${c.message}` : undefined);
1661
+ const ready = said(last?.status?.conditions?.find((c) => c.type === "Ready"));
1662
+ // Routine waits (the init step still running) say nothing; anything else is what the kubelet
1663
+ // was stuck on — a failing pull, a config error.
1664
+ const stuck = (last?.status?.waiting ?? [])
1665
+ .filter((w) => w.reason && w.reason !== "PodInitializing" && w.reason !== "ContainerCreating")
1666
+ .map((w) => `\n container "${w.container}" is waiting: ${w.reason}${w.message ? `: ${w.message}` : ""}`)
1667
+ .join("");
1668
+ return (
1669
+ `Sandbox "${name}" never reached Ready (last phase: ${last?.status?.phase ?? "absent"}) — if its ` +
1670
+ "Sandbox Image is new, check the preflight: `kubectl logs " +
1671
+ name +
1672
+ " -c preflight` (ADR-0037's floor: glibc, git, a writable HOME, a numeric non-root USER — an image " +
1673
+ "that runs as root never starts it, and `kubectl describe pod` says so)." +
1674
+ stuck +
1675
+ (ready ? `\n the operator's Ready condition says: ${ready}` : "")
1676
+ );
1677
+ }
1678
+
1679
+ /**
1680
+ * The `ReposFresh=False` message, keyed back to SLOTS for the attach's `stale`. The operator
1681
+ * writes one clause per stale Repo — `Repo "<key>" on node <n> is stale: <error>` — joined by
1682
+ * `; `; each slot whose key a clause names gets that clause. A message that names no key at all
1683
+ * lands on every slot: a verdict with no address is still a verdict.
1684
+ */
1685
+ function staleSlots(
1686
+ repos: Array<{ slot: string; url: string }>,
1687
+ message: string | undefined,
1688
+ ): Record<string, string> | undefined {
1689
+ if (!message) return undefined;
1690
+ const byKey = new Map<string, string>();
1691
+ for (const clause of message.split("; ")) {
1692
+ const key = /^Repo "([^"]+)"/.exec(clause)?.[1];
1693
+ if (key !== undefined) byKey.set(key, clause);
1694
+ }
1695
+ const stale: Record<string, string> = {};
1696
+ for (const repo of repos) {
1697
+ const clause = byKey.size === 0 ? message : byKey.get(repoIdentity(repo.url).key);
1698
+ if (clause !== undefined) stale[repo.slot] = clause;
1699
+ }
1700
+ return Object.keys(stale).length ? stale : undefined;
1701
+ }
1702
+
1703
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
1704
+
1705
+ /**
1706
+ * The refusal of a provision on a cluster with no operator (ADR-0069). The data plane is always
1707
+ * wired, but the Repo and Sandbox kinds are the operator's to install: with none, the API server
1708
+ * answers 404 on the first create of either, so the first Repo write fails before the Sandbox CR
1709
+ * does. `jr2 up` deploys the operator on every converge unless `operator.manage: false`, so that
1710
+ * setting is the one way to reach this, and the fix names it. Any other error passes through.
1711
+ */
1712
+ function noOperator(sandbox: string, kind: "Repo" | "Sandbox"): (err: unknown) => never {
1713
+ return (err) => {
1714
+ if (!isNotFound(err)) throw err;
1715
+ throw new Error(
1716
+ `Sandbox "${sandbox}" cannot be provisioned: no operator on this cluster — install it, or remove ` +
1717
+ `\`operator.manage: false\` so \`jr2 up\` deploys it (the API server does not serve the ${kind} kind: ` +
1718
+ `${(err as Error).message})`,
1719
+ );
1720
+ };
1721
+ }