@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
@@ -1,1108 +0,0 @@
1
- // The canonical SandboxPort (ADR-0012 / GAP(3)): drives the operator's Sandbox CRD through
2
- // `kubectl`, honoring the current kube context (ADR-0009: the kube target IS the kubectl
3
- // context; `--context` overrides). Shelling to kubectl instead of a client library keeps the
4
- // dependency surface at zero and the behavior identical to what a human debugging the cluster
5
- // would type; the `exec` process seam is injectable so the mapping logic is unit-testable
6
- // without a cluster. The kind e2e tier exercises the real thing.
7
- //
8
- // Reachability: the orchestrator always runs in-cluster (ADR-0019), so it dials
9
- // `status.endpoint` (`http://<name>.<ns>.svc:…`) directly — stable across orchestrator
10
- // restarts by nature, which is what ADR-0012's "same endpoint" re-attach promise rides on.
11
- //
12
- // All four operations are idempotent (SandboxPort contract): apply is create-or-update, attach
13
- // guards every clone/worktree, delete ignores absent.
14
- //
15
- // WHICH REPOS a Sandbox attaches arrive resolved from the `workspace()`'s Repo Slots (ADR-0051):
16
- // the CR names each by its cache key, the operator mounts the node's cache read-only at
17
- // `/repos/<key>` and gates Ready on it, and the attach clones off that mount. The one judgement
18
- // made here is the FENCE: a per-run url must match a `git.credentials` entry, or it is refused
19
- // before anything is applied.
20
- //
21
- // WHICH IMAGE a Sandbox runs is not an option here (ADR-0037/0038/0049). The request carries what
22
- // the `workspace()` wrapper statically declared — a `file:` docker context or a registry ref — and
23
- // the resolved key→ref map arrives as a mounted ConfigMap read on EVERY provision, so a `jr2 up`
24
- // that rebuilds an image reaches future Sandboxes without rolling this process.
25
- //
26
- // The pod's primary container is the Sandbox Image BYTE-FOR-BYTE (ADR-0037): no appended layers,
27
- // no rewritten Dockerfile, no jr2 knowledge inside it. jr2's runtime arrives at POD time instead —
28
- // an emptyDir at `/opt/jr2`, populated by an init container running the kit's Harness image — and
29
- // the container's COMMAND is overridden to start the Harness from that volume. The image's own
30
- // `USER` and `HOME` are respected (the human who execs in lands in the environment its author
31
- // built); only its `ENTRYPOINT`/`CMD` do not run, because a container has one command and the
32
- // Harness must own it — its death must be the container's death, which is what the operator's
33
- // Ready probe and restart semantics at `:8080` mean. A process the image WANTS running is not
34
- // lost: it has its own seat, the User Container (ADR-0005), composed here when the wrapper's static
35
- // `user` option names an image (ADR-0049).
36
- //
37
- // So this module composes the whole pod — two init steps and up to three containers:
38
- //
39
- // initContainer runtime the kit's Harness image → copies /opt/jr2 into the volume
40
- // initContainer preflight the USER'S image + that volume → ADR-0037's probe, the thing that
41
- // proves a registry ref, whose first appearance is this provision
42
- // container harness the Sandbox Image, command overridden, /work + /opt/jr2 mounted
43
- // container custodian jr2-composed, the pod's only credential holder (below)
44
- // container user optional, the image's own entrypoint, the checkouts (/work, plus
45
- // /repos and /opt/jr2 read-only) and NOTHING else
46
- //
47
- // This is also where the CUSTODIAN is composed (ADR-0013, ADR-0059). The operator needs no change
48
- // to carry it: ADR-0001 made `Sidecars` generic container fragments it schedules WITHOUT
49
- // understanding, so the Custodian is exactly that — a container with an image, its mounts, and a
50
- // readiness probe the pod's Ready waits on. What this module builds is the pod's asymmetry:
51
- //
52
- // harness container JR2_CUSTODIAN_URL + the Stand-in of every credential (no credential)
53
- // custodian container the Sandbox token and every held secret, as files (the credentials)
54
- //
55
- // The Agent has code execution in the first and none in the second. The token is minted here — a
56
- // signed Sandbox name (see tokens.ts), so re-provisioning after a restart yields the SAME token and
57
- // the Secret re-applies as a no-op. What else the Custodian holds is `jr2 up`'s resolution, read
58
- // per provision from the `jr2-held` mount (custodian.ts builds both placements' pods alike).
59
-
60
- import { execFile } from "node:child_process";
61
- import { join } from "node:path";
62
- import {
63
- matchCredential,
64
- type GitCredential,
65
- type HarnessEnvFromSource,
66
- type HarnessEnvVar,
67
- type SandboxPlacement,
68
- } from "./config.ts";
69
- import { custodianComposition, type CustodianComposition } from "./custodian.ts";
70
- import { readHeldManifest } from "./held-secrets.ts";
71
- import { readImageRefs, resolveSandboxImage, resolveUserImage, type ImageRefs } from "./images.ts";
72
- import { HELD_KEY, HELD_MOUNT, IMAGES_KEY, IMAGES_MOUNT, REPOS_MOUNT } from "./names.ts";
73
- import { repoIdentity } from "./repo-identity.ts";
74
- import type { RepoResources } from "./repos.ts";
75
- import { harnessTokenDigest, sandboxToken } from "./tokens.ts";
76
- import type { ProvisionedRepo, SandboxPort, WorkspaceSpec } from "./workspace.ts";
77
-
78
- /** Run one kubectl invocation to completion. `input` is piped to stdin (`apply -f -`). */
79
- export type KubectlExec = (args: string[], opts?: { input?: string }) => Promise<{ stdout: string; stderr: string }>;
80
-
81
- /** Where jr2's runtime lands in every container that gets it (ADR-0037). `/opt/jr2` and not `/app`
82
- * because a stranger's base may already use `/app`, and one layout must serve both the stock
83
- * Harness image and an arbitrary Sandbox Image. It is a PUBLISHED surface: `bin/` beside `lib/`
84
- * (node's rpath is `$ORIGIN/../lib`), `src/main.ts`, `node_modules/`. */
85
- export const RUNTIME_MOUNT = "/opt/jr2";
86
-
87
- /** Where the populate init container writes the runtime. NOT `/opt/jr2`: mounting the volume there
88
- * would shadow the very directory being copied out of the Harness image. */
89
- const RUNTIME_STAGE = "/mnt/jr2";
90
-
91
- /** The primary container's command (ADR-0037). Absolute, so it never depends on the image's
92
- * `WORKDIR`, and identical to the stock Harness image's own `CMD` — one runtime, two placements. */
93
- const HARNESS_COMMAND = [`${RUNTIME_MOUNT}/bin/node`, `${RUNTIME_MOUNT}/src/main.ts`];
94
-
95
- /** The program `origin`'s fetch url runs (ADR-0053), on the runtime volume beside `work-acl`. It
96
- * asks the node cache for a fetch and then serves the cache, so every seat that holds the
97
- * checkouts must hold this volume — which is why the User Container mounts it (ADR-0005). */
98
- const UPLOAD_PACK = `${RUNTIME_MOUNT}/bin/jr2-upload-pack`;
99
-
100
- /** ADR-0005's default work group. Convention, not config: the pod's `fsGroup` is granted to every
101
- * container as a supplemental group, so the Harness writes `/work` whatever the number and no
102
- * image's `/etc/group` needs to know it. The one override is the spec's `workGroup`. */
103
- const DEFAULT_WORK_GROUP = 2000;
104
-
105
- /**
106
- * The isolation baseline for a jr2-owned seat, spelled out HERE for the init containers because the
107
- * operator's hardened default covers the primary container and the sidecars only (ADR-0001/0005) —
108
- * init steps pass through verbatim, which is what keeps the operator agent-agnostic. Deliberately
109
- * not applied to the `user` container: that seat's identity is "what jr2 does not own".
110
- */
111
- const HARDENED = {
112
- runAsNonRoot: true,
113
- allowPrivilegeEscalation: false,
114
- capabilities: { drop: ["ALL"] },
115
- seccompProfile: { type: "RuntimeDefault" },
116
- };
117
-
118
- /**
119
- * ADR-0037's fallback seat, for a BUILT image that declares no `USER` (the converge's `docker
120
- * inspect` is what saw that; images.ts holds the record). jr2 sets `runAsUser` nowhere else — an
121
- * image's own `USER` decides its seat's uid (ADR-0005) — so this applies only where the image
122
- * chose nothing and the alternative is root, which the hardened context refuses.
123
- *
124
- * The home is a POD volume, not a directory in the image: uid 1000 on a stranger's base has no
125
- * home at all, and the floor needs a writable one (`git config --global` writes `$HOME/.gitconfig`
126
- * on every attach; a real toolchain wants `~/.npm`, `~/.cargo`, `~/.cache`). An emptyDir lands
127
- * group-writable under the pod's fsGroup, so uid 1000 owns it in practice without jr2 chown-ing
128
- * anything. `/home/jr2` and not `/home/node`: the number is jr2's choice here, so the path is too.
129
- */
130
- const FALLBACK_UID = 1000;
131
- const FALLBACK_HOME = "/home/jr2";
132
-
133
- /**
134
- * The kubelet's verdict on a container whose image resolves to root under `runAsNonRoot: true`.
135
- * Matched, not merely reported, because it is the ONE provision failure with no evidence anywhere
136
- * else: the container never starts, so it has no logs, and the pod sits in this waiting state until
137
- * the provision times out — which then blames the preflight for a container the preflight never got
138
- * to run. A BUILT image is caught earlier and cheaper (the converge's `docker inspect` recorded the
139
- * string; `resolveSandboxImage` in images.ts judges it into `refusedUser` before anything is
140
- * applied), so this is the brought ref's path: never inspected, never given the uid-1000 fallback,
141
- * knowable only from the cluster.
142
- *
143
- * Reason and message are BOTH required. The reason alone covers a missing Secret or ConfigMap key
144
- * too — a different fault with a different fix — and only the message distinguishes them.
145
- */
146
- const ROOT_IMAGE_REASON = "CreateContainerConfigError";
147
- const ROOT_IMAGE_MESSAGE = /runAsNonRoot/i;
148
-
149
- /** How many CR polls pass between two pod reads. See the provision loop for why it is not 1. */
150
- const POD_CHECK_EVERY = 5;
151
-
152
- /**
153
- * The kubelet's own words when a container's image resolves to root under `runAsNonRoot`, or
154
- * undefined for every other pod shape. A pure read of pod status: the caller supplies the parsed
155
- * `kubectl get pod -o json`, so the claim is testable without a cluster and the fault detection
156
- * cannot drift from the message the provision prints.
157
- *
158
- * Init containers are searched FIRST because they run first: the `preflight` step runs the user's
159
- * image before the Harness container ever exists, so that is where a root image dies. The primary
160
- * containers are searched too — the same image sits in the `harness` seat, and the `user` seat is
161
- * deliberately un-hardened (ADR-0005), so a fault there would mean something else entirely.
162
- *
163
- * Both the reason AND the message must match. `CreateContainerConfigError` is also what an absent
164
- * Secret key produces, and that fault has a different fix; a name with no evidence behind it is
165
- * worse than the timeout it replaces.
166
- */
167
- export function rootImageFault(pod: unknown): string | undefined {
168
- const status = (pod as { status?: Record<string, unknown> } | null)?.status;
169
- if (!status) return undefined;
170
- type Waiting = { name?: string; state?: { waiting?: { reason?: string; message?: string } } };
171
- const groups = [status["initContainerStatuses"], status["containerStatuses"]];
172
- for (const group of groups) {
173
- if (!Array.isArray(group)) continue;
174
- for (const cs of group as Waiting[]) {
175
- const waiting = cs?.state?.waiting;
176
- if (!waiting || waiting.reason !== ROOT_IMAGE_REASON) continue;
177
- if (!waiting.message || !ROOT_IMAGE_MESSAGE.test(waiting.message)) continue;
178
- return `container "${cs.name ?? "?"}": ${waiting.message}`;
179
- }
180
- }
181
- return undefined;
182
- }
183
-
184
- /**
185
- * The fix, not the symptom. The kubelet's message says what it refused; it cannot say that the
186
- * image is a Sandbox Image, that jr2 declined to patch a uid onto it, or where the one-line edit
187
- * goes — and without those three the reader has a Kubernetes error and no next step.
188
- *
189
- * It names the BROUGHT case specifically because that is the only one that reaches here: a built
190
- * image's `USER` was inspected at converge and judged before anything was applied (images.ts), and
191
- * an image declaring none gets the uid-1000 fallback. A ref is never inspected — that is the point
192
- * of refs (ADR-0037) — so it must declare a numeric non-root `USER` itself.
193
- */
194
- function rootImageError(name: string, fault: string): string {
195
- return (
196
- `Sandbox "${name}" cannot start: its image runs as ROOT, and every jr2-owned seat is hardened ` +
197
- `with runAsNonRoot (ADR-0005). The kubelet refused it — ${fault}\n` +
198
- ` - the fix is one line in the image: a NUMERIC non-root \`USER <uid>\` (e.g. \`USER 1000\`)\n` +
199
- ` - numeric because the kubelet does not read the image's /etc/passwd, so \`USER app\` is ` +
200
- `refused too — it cannot prove that name is non-root\n` +
201
- ` - jr2 does not supply a uid for a brought registry ref: it is never inspected and never ` +
202
- `modified, which is what "bring your own image" means (ADR-0037). Only an image jr2 BUILDS, ` +
203
- `and only one that declares no USER at all, gets the uid-${FALLBACK_UID} fallback.`
204
- );
205
- }
206
-
207
- /**
208
- * ADR-0037's preflight, VERBATIM: git present · `$HOME` writable · glibc new enough for jr2's node
209
- * (with the relocated `libstdc++`) · the vendored ripgrep, reached through the mounted `/opt/jr2`.
210
- *
211
- * The three commands are the floor, one each: `git config --global` proves git is on the system
212
- * PATH AND that `$HOME` is writable for the image's user; `node -e ""` proves the glibc is no
213
- * older than the one jr2's node was built against (this is where musl dies); `rg` UNQUALIFIED
214
- * proves the vendored static binary resolves THROUGH PATH, which is what the Harness's own append
215
- * buys at runtime.
216
- *
217
- * ONE prover, and this is it (ADR-0037/0041). A converge cannot hold an image to this floor: the
218
- * floor is a HARNESS-SEAT obligation, a built context may equally be destined for the User
219
- * Container seat — which owes no floor at all (ADR-0005) — and which seat a directory serves is
220
- * workflow-internal and statically unrecoverable (ADR-0031). Here the seat is known, and here is
221
- * also the only moment a registry ref exists at all, since jr2 never builds or inspects one.
222
- */
223
- const SANDBOX_PREFLIGHT = `git config --global safe.directory "*" && ${RUNTIME_MOUNT}/bin/node -e "" && rg --version`;
224
-
225
- /**
226
- * The probe as a shell line. The PATH append is mechanism, not part of the claim, and it is not
227
- * optional: nothing bakes `/opt/jr2/bin` into the user's image any more, so a probe that skipped it
228
- * would report `rg: not found` for every image on earth. APPENDED, never prepended — a toolchain
229
- * the image pinned wins, which is as much the property being proved as `rg`'s presence (ADR-0037).
230
- * The seat gets no login shell at either end, so `$PATH` is whatever the image itself set.
231
- */
232
- function preflightShell(): string {
233
- return `export PATH="$PATH:${RUNTIME_MOUNT}/bin"; ${SANDBOX_PREFLIGHT}`;
234
- }
235
-
236
- /**
237
- * The preflight as an init step IN THE USER'S IMAGE with `/opt/jr2` mounted. It fails the pod
238
- * before the Harness starts, instead of surfacing as a tool failure mid-turn on a pod nobody is
239
- * watching.
240
- *
241
- * It PROVES, it does not set up: the `.gitconfig` it writes lives in this container's own
242
- * ephemeral filesystem, so the attach script still runs the same line in the Harness container.
243
- *
244
- * `sh -c`, not `sh -ec`: the failure is handled below, because "node did not execute" is not
245
- * actionable and the message has to name the fix.
246
- */
247
- const PREFLIGHT_SCRIPT = [
248
- `${preflightShell()} && exit 0`,
249
- `echo "jr2: this Sandbox Image does not meet the floor (ADR-0037): a glibc base no older than ` +
250
- `jr2's node (musl is out entirely), git on the system PATH, a writable HOME for the image's ` +
251
- `USER, and /opt/jr2 + /work + :8080 unclaimed. jr2 vendors the rest." >&2`,
252
- `exit 1`,
253
- ].join("\n");
254
-
255
- export type KubectlSandboxOptions = {
256
- /** The mounted image map (ADR-0037/0038) — every ref this port can name, written by `jr2 up`.
257
- * Default: the `jr2-images` ConfigMap's mount. No image option here: which image a Sandbox runs
258
- * is the `workspace()` wrapper's static `image` option (ADR-0049) — carried on the Machine, read
259
- * off it at invoke time, handed to `provision()` as a string — and resolved against this map at
260
- * provision. The per-run spec never names one (ADR-0051). */
261
- imagesPath?: string;
262
- /** The held-secret manifest `jr2 up` resolved (ADR-0059) — read per provision like the image map,
263
- * so a held-secret edit reaches future Sandboxes without rolling this process. Default: the
264
- * `jr2-held` ConfigMap's mount. */
265
- heldPath?: string;
266
- /** Extra env for the HARNESS container (`harness.env`) — merged ahead of the
267
- * mechanism-owned vars, which win on collision. */
268
- env?: HarnessEnvVar[];
269
- /** Whole-Secret/ConfigMap env for the Harness container (`harness.envFrom`) — how a real
270
- * Harness gets its model API key without the value ever touching jr2 config. */
271
- envFrom?: HarnessEnvFromSource[];
272
- /** Where a Sandbox may land (ADR-0052): the Instance's `sandbox.nodeSelector` and
273
- * `sandbox.tolerations`, written on the CR verbatim and copied onto the pod by the operator, which
274
- * merges nothing with them. Absent → wherever an ordinary pod lands. */
275
- placement?: SandboxPlacement;
276
- /** The instance ships a private-CA bundle (ADR-0020): the HARNESS container trusts it, and the
277
- * Custodian verifies a bound host with it (custodian.ts). */
278
- caBundle?: boolean;
279
- /** The key Sandbox tokens are signed with — from the instance Secret (ADR-0013/0019). */
280
- signingKey?: Buffer;
281
- /** Kube namespace for Sandbox CRs. Default `default`. */
282
- namespace?: string;
283
- /** kubectl `--context` override. Default: the current context (ADR-0009). */
284
- context?: string;
285
- /** In-pod root for the pod-local clones + worktrees (ADR-0004 layout). Default `/work`. */
286
- workRoot?: string;
287
- /** CR `spec.idleTimeout` — the operator's abandoned-Sandbox GC backstop (ADR-0001). Default `30m`. */
288
- idleTimeout?: string;
289
- /** How often a workspace's lease actor renews (ADR-0001/0021): the cadence at which
290
- * `jr2.dev/keepalive` is re-stamped AND continuity is read back. Must be ≪ idleTimeout, since
291
- * a lapsed lease is what lets the operator reap. Default 5m. */
292
- leaseIntervalMs?: number;
293
- /** Await-Ready budget for the POD: from the CR apply until the operator reports the pod Ready.
294
- * Default 120s, polled every second. A pod that never comes up (an image that misses ADR-0037's
295
- * floor) is what this bounds; a pod that is up and waiting on its Repos is `repoTimeoutMs`'s. */
296
- readyTimeoutMs?: number;
297
- /**
298
- * Await-Ready budget for the REPOS (ADR-0051): once the operator holds a Sandbox whose pod is
299
- * Ready on a Repo reason — the node's cache agent is cloning a cold node, or fetching before the
300
- * attach — the wait is measured against this, from the same CR apply. Sized for a clone, not a
301
- * pod: the agent's clone budget is 20m, and this must exceed it so a clone that runs out of
302
- * time fails BY NAME (`RepoCloneFailed`, git's words) instead of as this port's timeout; and it
303
- * must stay inside `idleTimeout` (30m), because the lease starts after provision, so the operator
304
- * reaps a Sandbox that waits longer than that. Default 25m.
305
- */
306
- repoTimeoutMs?: number;
307
- pollMs?: number;
308
- /**
309
- * The Repo-resource port (ADR-0051, repos.ts): every Repo a provision names must exist as a
310
- * `Repo` resource before the CR names it, or the operator reports it missing and the Sandbox
311
- * never reaches Ready. So `provision` ensures each one here — a per-run url's resource is
312
- * created at first attach, a bound one is found as the boot stated it — and REFUSES to run
313
- * without the port: a Sandbox whose Repos nobody creates parks on `RepoMissing` for the whole
314
- * Ready budget. The other operations (attach, renew, destroy) need no port, so it is optional
315
- * at construction.
316
- */
317
- repos?: RepoResources;
318
- /**
319
- * The instance's `git.credentials` (ADR-0051) — THE FENCE. A per-run url is run input, a
320
- * ticket field, and otherwise a way to spend the cluster's credential against any host: one
321
- * whose identity matches no entry is refused here, before a Secret or a CR exists, naming the
322
- * list. A bound url is code the instance typechecked and deployed, admitted without a match.
323
- * Default: no entries, so every per-run url is refused.
324
- */
325
- credentials?: readonly GitCredential[];
326
- /** Process seam, injectable for tests. Defaults shell to the `kubectl` on PATH. */
327
- exec?: KubectlExec;
328
- };
329
-
330
- export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
331
- const ns = opts.namespace ?? "default";
332
- const imagesPath = opts.imagesPath ?? join(IMAGES_MOUNT, IMAGES_KEY);
333
- const heldPath = opts.heldPath ?? join(HELD_MOUNT, HELD_KEY);
334
- const workRoot = opts.workRoot ?? "/work";
335
- const readyTimeoutMs = opts.readyTimeoutMs ?? 120_000;
336
- const repoTimeoutMs = opts.repoTimeoutMs ?? 25 * 60_000;
337
- const pollMs = opts.pollMs ?? 1_000;
338
- const exec = opts.exec ?? defaultKubectlExec;
339
- const credentials = opts.credentials ?? [];
340
-
341
- const leaseIntervalMs = opts.leaseIntervalMs ?? 5 * 60_000;
342
- const base = ["--namespace", ns, ...(opts.context ? ["--context", opts.context] : [])];
343
-
344
- /** The Sandbox's token Secret — mounted into the Custodian container, and nothing else in the pod. */
345
- const secretName = (name: string) => `${name}-token`;
346
-
347
- /**
348
- * The User Container (ADR-0005): the opt-in third seat, composed only when the wrapper's static
349
- * `user` option names an image (ADR-0049). The ZERO-CONTRACT seat — jr2 injects nothing, probes
350
- * nothing, overrides nothing. So: no `command` (its own entrypoint runs, untouched), no `env`,
351
- * no `envFrom`, no CA bundle, no ports, no resources. Every key jr2 forwarded would be a crack in
352
- * "jr2 puts nothing in it", and widening the one authoring string to an object stays compatible
353
- * if a concrete need ever argues its own way in. (Git's dubious-ownership guard is the line's
354
- * cost, accepted with
355
- * eyes open — ADR-0005: safe.directory is honored only from files this seat's image owns, so an
356
- * image whose sessions run git carries its own line.)
357
- *
358
- * `/work` read-write plus the checkouts' two read-only halves are the single exception, and they
359
- * are not an injection but the point: this seat and the Harness mount ONE worktree, so the human
360
- * and the Agent see identical files — which is also why ADR-0005's cross-uid pair (the pod's
361
- * `fsGroup`, the attach's default ACL) exists at all. The caches ride along because they are
362
- * half of the same files: the worktrees are `--shared` clones whose alternates resolve objects
363
- * from `/repos/<key>` (ADR-0004/0051), so a seat with `/work` alone holds checkouts whose every
364
- * borrowed object is missing ("unable to normalize alternate object path"). `/opt/jr2` is the
365
- * other half (ADR-0053): `origin`'s fetch url is a program on that volume, so a seat without it
366
- * holds checkouts whose `git fetch` dies — and with it the human gets the same fetch as the
367
- * Agent, with no credential of their own. Nothing else follows it in: `ext::` names the program
368
- * by absolute path, so this seat still gets no env, no command, and no probe. The repo volumes
369
- * are the operator's — it defines `repo-<key>` for every key the CR names — so this seat mounts
370
- * them by name. The Custodian is deliberately not given any of the three: it reads no worktree, and it is the
371
- * container holding the pod's credentials, so it gets the narrowest mount set that works.
372
- * It also carries no `securityContext`, which the operator reads as the exemption — root is
373
- * ALLOWED here, because hardening a seat whose identity is "what jr2 does not own" is an opinion,
374
- * and the standard managed-access shape (a root sshd that setuids sessions down) must run
375
- * unmodified.
376
- */
377
- const userSidecar = async (refs: ImageRefs, image: string, keys: string[]) => ({
378
- name: "user",
379
- image: await resolveUserImage(refs, image),
380
- volumeMounts: [
381
- { name: "work", mountPath: workRoot },
382
- { name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true },
383
- ...keys.map((key) => ({ name: repoVolumeName(key), mountPath: repoMountPath(key), readOnly: true })),
384
- ],
385
- });
386
-
387
- /** The pod's sidecar list (ADR-0001: opaque fragments the operator schedules verbatim). The
388
- * Custodian is ALWAYS here: a Sandbox without one is a pod that comes up Ready and then parks its
389
- * Machine forever on a Menu it cannot read (ADR-0013). The User Container joins it only when the
390
- * spec named one. */
391
- const sidecarsFor = async (custodian: CustodianComposition, refs: ImageRefs, keys: string[], user?: string) => [
392
- custodian.custodianContainer,
393
- ...(user !== undefined ? [await userSidecar(refs, user, keys)] : []),
394
- ];
395
-
396
- // The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
397
- // config) first, then the mechanism-owned vars (the Custodian's address and every Stand-in, the
398
- // proxy and the trust bundles — custodian.ts), which win on collision. Note the asymmetry stands
399
- // (ADR-0013): user env/envFrom land on the HARNESS container only — never on the Custodian, whose
400
- // mounts are composed here and carry the pod's credentials.
401
- //
402
- // The gate rides LAST (ADR-0058): the digest of the bearer the Orchestrator derives for THIS
403
- // Sandbox. A digest because the Agent reads this env; last because a `harness.env` entry of the
404
- // same name must not be able to choose the Harness's credential.
405
- const harnessEnv = (name: string, custodian: CustodianComposition): HarnessEnvVar[] => {
406
- if (!opts.signingKey) throw new Error("kubectlSandbox: a Harness needs a signingKey to check its bearer");
407
- return [
408
- ...(opts.env ?? []),
409
- ...custodian.harnessEnv,
410
- { name: "JR2_HARNESS_TOKEN_SHA256", value: harnessTokenDigest(opts.signingKey, name) },
411
- ];
412
- };
413
-
414
- /**
415
- * The two init steps, in order (ADR-0037). They are plain container fragments the operator
416
- * schedules without understanding, exactly like sidecars — the operator stays agent-agnostic
417
- * (ADR-0001), so "how a Sandbox gets its runtime" is composed here, not reconciled there.
418
- *
419
- * 1. `runtime` — the kit's Harness image, copying its `/opt/jr2` into the shared emptyDir. This
420
- * is what makes the runtime's version ride the VOLUME rather than the image: a kit edit moves
421
- * the harness image's own tag and re-images future pods without touching a single Sandbox
422
- * Image tag, which is the only way a registry-ref image could ever follow a kit update.
423
- * 2. `preflight` — the USER'S image with that volume mounted, running the probe. Ordered second
424
- * because it needs what the first one wrote.
425
- *
426
- * Both carry jr2's hardened context explicitly, and `preflight` runs the probe in the SAME seat
427
- * the Harness will get — the image's own user, or ADR-0037's fallback — because a probe that
428
- * proved a different uid's `$HOME` proved nothing.
429
- */
430
- const initContainersFor = (refs: ImageRefs, seat: Seat) => [
431
- {
432
- name: "runtime",
433
- image: refs.harness,
434
- // The copy's rules live beside the tree they copy (`deploy/harness/init-copy`), not in a
435
- // string here: `/opt/jr2` is a published surface whose SHAPE is load-bearing — node's rpath
436
- // is `$ORIGIN/../lib`, so `bin/` and `lib/` must land as siblings — and the script proves
437
- // its own result by running the copied node before the pod moves on.
438
- command: [`${RUNTIME_MOUNT}/bin/init-copy`, RUNTIME_STAGE],
439
- volumeMounts: [{ name: "runtime", mountPath: RUNTIME_STAGE }],
440
- securityContext: HARDENED,
441
- },
442
- {
443
- name: "preflight",
444
- image: seat.image,
445
- command: ["/bin/sh", "-c", PREFLIGHT_SCRIPT],
446
- ...(seat.env.length ? { env: seat.env } : {}),
447
- volumeMounts: [{ name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true }, ...seat.homeMount],
448
- securityContext: seat.securityContext,
449
- },
450
- ];
451
-
452
- /**
453
- * The primary container's seat: which image runs, as whom, and with what home. One value, built
454
- * once per provision and shared by the Harness container and the preflight, so the probe cannot
455
- * drift from the thing it proves.
456
- */
457
- type Seat = {
458
- image: string;
459
- env: HarnessEnvVar[];
460
- homeVolume: Array<{ name: string; emptyDir: Record<string, never> }>;
461
- homeMount: Array<{ name: string; mountPath: string }>;
462
- securityContext: typeof HARDENED & { runAsUser?: number };
463
- };
464
-
465
- const seatFor = async (refs: ImageRefs, name?: string): Promise<Seat> => {
466
- // ONE resolution, so the ref and the two seat facts can never come off different legs of
467
- // ADR-0037's chain (images.ts). It is async because a `file:` context is keyed by its content
468
- // digest, which is a directory walk — the price of the host and the pod agreeing about an
469
- // image without a path table (ADR-0049).
470
- const { ref: image, fallbackSeat, refusedUser } = await resolveSandboxImage(refs, name);
471
- // Fail HERE, before a Secret or a CR exists, on a `USER` the kubelet will refuse (images.ts).
472
- // The alternative is the worst shape a failure has: the preflight container never starts, so
473
- // it has no logs, and the whole 120s Ready budget burns before anything is said. The converge
474
- // already inspected the image, so this is knowable at zero cost — and the message names the
475
- // edit, because the fix is one line of the caller's own Dockerfile.
476
- if (refusedUser !== undefined) {
477
- throw new Error(
478
- `the Sandbox Image "${name ?? "default"}" declares \`USER ${refusedUser}\`, which cannot run a jr2 seat: ` +
479
- "every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC " +
480
- "non-zero uid it can check without reading the image (ADR-0005). Change the Dockerfile's last " +
481
- "`USER` to that uid (e.g. `USER 1000`, or drop the line entirely and jr2 supplies uid 1000 with a " +
482
- "writable HOME — ADR-0037), then re-run `jr2 up`.",
483
- );
484
- }
485
- // The common case, and the one ADR-0037 is written around: the image chose its `USER` and its
486
- // `HOME`, and jr2 touches neither — the human who execs in lands in the environment the
487
- // image's author built, dotfiles included.
488
- if (!fallbackSeat) {
489
- return { image, env: [], homeVolume: [], homeMount: [], securityContext: HARDENED };
490
- }
491
- return {
492
- image,
493
- env: [{ name: "HOME", value: FALLBACK_HOME }],
494
- homeVolume: [{ name: "home", emptyDir: {} }],
495
- homeMount: [{ name: "home", mountPath: FALLBACK_HOME }],
496
- securityContext: { ...HARDENED, runAsUser: FALLBACK_UID },
497
- };
498
- };
499
-
500
- const crFor = async (
501
- req: { name: string; runId: string; workflow: string; image?: string; user?: string; workGroup?: number },
502
- refs: ImageRefs,
503
- repos: FencedRepo[],
504
- custodian: CustodianComposition,
505
- ) => {
506
- const seat = await seatFor(refs, req.image);
507
- const sidecars = await sidecarsFor(
508
- custodian,
509
- refs,
510
- repos.map((r) => r.key),
511
- req.user,
512
- );
513
- return {
514
- apiVersion: "core.jr2.dev/v1alpha1",
515
- kind: "Sandbox",
516
- metadata: {
517
- name: req.name,
518
- namespace: ns,
519
- // The run↔workspace link `jr2 ls` groups by (ADR-0009/0012) — readable without the host.
520
- labels: { "jr2.dev/run": req.runId, "jr2.dev/workflow": req.workflow },
521
- },
522
- spec: {
523
- // The Sandbox Image, unmodified (ADR-0037) — the user's tools, its own USER and HOME, and
524
- // jr2's runtime arriving beside it on a volume. This container is both the Harness and the
525
- // human's `exec` shell.
526
- image: seat.image,
527
- // The one thing jr2 takes from the image: its command. A container has exactly one, and it
528
- // must be the Harness's — a pod whose main process is the user's entrypoint keeps
529
- // "Running" through a Harness death, which makes the operator's Ready probe a lie.
530
- command: HARNESS_COMMAND,
531
- // Hardened, and the ONLY place jr2 ever names a uid: ADR-0037's fallback for an image that
532
- // declared none. Stating the whole context here rather than leaving it to the operator's
533
- // default is what makes that possible — the operator hardens only what says nothing.
534
- securityContext: seat.securityContext,
535
- idleTimeout: opts.idleTimeout ?? "30m",
536
- // Placement (ADR-0052): the Instance's word on which nodes are Sandbox nodes, raw pod-spec
537
- // shapes the operator copies verbatim. Absent keys are absent here too — the CR says
538
- // nothing, and the pod lands wherever an ordinary pod lands. The operator's own soft
539
- // affinity toward nodes holding this Sandbox's caches sits beside these untouched: a
540
- // preference never conflicts with a requirement.
541
- ...(opts.placement?.nodeSelector ? { nodeSelector: opts.placement.nodeSelector } : {}),
542
- ...(opts.placement?.tolerations?.length ? { tolerations: opts.placement.tolerations } : {}),
543
- // The work group (ADR-0005), the ownership half of cross-uid sharing on `/work`.
544
- // Kubernetes grants it as a supplemental group to every container, and puts a setgid
545
- // group on the volume root that propagates down; the WRITABILITY half is the default ACL
546
- // the attach stamps on each repo root (attachScript below), without which fsGroup gives
547
- // group-READ, which is the trap. Both are inert when the uids match.
548
- fsGroup: req.workGroup ?? DEFAULT_WORK_GROUP,
549
- // Ordered, and before any container starts: populate `/opt/jr2`, then prove the image on it.
550
- initContainers: initContainersFor(refs, seat),
551
- // Never empty: the Custodian's address is unconditional. The seat's own vars (the fallback
552
- // `HOME`) come FIRST, so the instance's `harness.env` can still override them the way it
553
- // overrides anything the image set.
554
- env: [...seat.env, ...harnessEnv(req.name, custodian)],
555
- ...(opts.envFrom?.length ? { envFrom: opts.envFrom } : {}),
556
- // What the AGENT gets: an address on its own loopback and a Stand-in for every credential,
557
- // and no credential anywhere. The Custodian beside it holds them (ADR-0013, ADR-0059).
558
- sidecars,
559
- // The Repos this Sandbox attaches, by cache key (ADR-0051). The operator does the rest: a
560
- // `repo-<key>` volume per entry — the node's cache, hostPath, read-only in the primary
561
- // container at `/repos/<key>` — a soft affinity toward nodes already holding them, and
562
- // `Ready` only once every one is present on the pod's node and fetched since this CR
563
- // asked. Read-only is load-bearing twice (ADR-0004): no write contention, and nothing in
564
- // a Sandbox can `gc` the object store its `--shared` clones borrow from.
565
- repos: repos.map(({ key, url }) => ({ key, url })),
566
- volumes: [
567
- // The worktree root is a POD volume, not a directory baked into the image. Two reasons,
568
- // both load-bearing: every jr2-owned seat runs as an unprivileged uid, which cannot mkdir
569
- // under `/` — so an image-owned `/work` would make every attach fail — and `/work` is
570
- // the one thing all three containers share (ADR-0005), so human and Agent see identical
571
- // files. An emptyDir lands group-writable under the pod's fsGroup, so it is writable
572
- // whatever uid the Sandbox Image runs as: `/work` unclaimed is the only image contract.
573
- { name: "work", emptyDir: {} },
574
- // jr2's runtime (ADR-0037). An emptyDir, so it lives and dies with the pod and carries
575
- // the version the pod STARTED with — a live Sandbox keeps its runtime across a kit
576
- // update, the same create-if-absent stance ADR-0038 takes for images.
577
- { name: "runtime", emptyDir: {} },
578
- // Only for ADR-0037's fallback seat: uid 1000 on a stranger's base has no home at all.
579
- ...seat.homeVolume,
580
- // The Custodian's (its values, leaves and config), and the trust ConfigMap (ADR-0020).
581
- ...custodian.volumes,
582
- ],
583
- // CR-level volumeMounts land on the HARNESS container only (the operator's contract): the
584
- // trust bundles, and none of the Custodian's volumes. The Repo caches are not listed: the
585
- // operator mounts each `repo-<key>` into this container itself.
586
- volumeMounts: [
587
- { name: "work", mountPath: workRoot },
588
- // Read-only: nothing writes under `/opt/jr2` at runtime, and the Agent has code execution
589
- // in this container — leaving its own runtime writable would let a turn edit it.
590
- { name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true },
591
- ...seat.homeMount,
592
- ...custodian.harnessMounts,
593
- ],
594
- },
595
- };
596
- };
597
-
598
- type SandboxStatus = {
599
- phase?: string;
600
- endpoint?: string;
601
- podUID?: string;
602
- uid?: string;
603
- conditions?: Condition[];
604
- };
605
-
606
- /** Parse a Sandbox CR off any kubectl call that printed one (`get -o json`, and the lease's
607
- * `annotate -o json` — which returns the object AFTER the patch, status included). */
608
- const readSandbox = (stdout: string): SandboxStatus => {
609
- const parsed = JSON.parse(stdout) as {
610
- metadata?: { uid?: string };
611
- status?: { phase?: string; endpoint?: string; podUID?: string; conditions?: Condition[] };
612
- };
613
- return { ...(parsed.status ?? {}), uid: parsed.metadata?.uid };
614
- };
615
-
616
- const conditionOf = (status: SandboxStatus | undefined, type: string): Condition | undefined =>
617
- status?.conditions?.find((c) => c.type === type);
618
-
619
- const getSandbox = async (name: string): Promise<SandboxStatus | undefined> => {
620
- try {
621
- const { stdout } = await exec(["get", "sandbox", name, ...base, "-o", "json"]);
622
- return readSandbox(stdout);
623
- } catch (err) {
624
- if (isNotFound(err)) return undefined;
625
- throw err;
626
- }
627
- };
628
-
629
- /**
630
- * Ask the POD whether it is stuck on a fault the Sandbox's phase cannot express (see
631
- * {@link rootImageFault}). The CR is the port's normal window on a provision; this is the one
632
- * question it cannot answer, because the operator reports "not Ready yet" for a pod that will
633
- * never be Ready and one that simply has not started.
634
- *
635
- * Absent or unreadable answers undefined: the pod trails the CR by a moment at every provision,
636
- * and a missing pod is a normal early poll, never evidence of a fault. This may only ever CONVERT
637
- * a failure that was already going to happen into a named one.
638
- */
639
- const podFault = async (name: string): Promise<string | undefined> => {
640
- try {
641
- const { stdout } = await exec(["get", "pod", name, ...base, "-o", "json"]);
642
- return rootImageFault(JSON.parse(stdout));
643
- } catch {
644
- return undefined;
645
- }
646
- };
647
-
648
- /**
649
- * Mint this Sandbox's token into a Secret, BEFORE the CR exists — the operator creates the pod
650
- * the moment it sees the CR, and a pod whose volume names an absent Secret never starts.
651
- * Idempotent by construction: the token is the Sandbox's name, signed (tokens.ts), so a
652
- * re-provision after an orchestrator restart re-applies the SAME value, and the Custodian that has
653
- * been holding it all along stays valid.
654
- */
655
- const applyTokenSecret = async (name: string): Promise<void> => {
656
- if (!opts.signingKey) throw new Error("kubectlSandbox: a Custodian needs a signingKey to mint its Sandbox token");
657
- const secret = {
658
- apiVersion: "v1",
659
- kind: "Secret",
660
- metadata: { name: secretName(name), namespace: ns, labels: { "jr2.dev/sandbox": name } },
661
- type: "Opaque",
662
- stringData: { JR2_SANDBOX_TOKEN: sandboxToken(opts.signingKey, name) },
663
- };
664
- await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(secret) });
665
- };
666
-
667
- /**
668
- * Make the Secret a child of the Sandbox CR, so Kubernetes reaps it whenever the CR goes — including
669
- * the paths no jr2 code observes (the operator's idle-timeout GC, a `kubectl delete sandbox` by hand).
670
- * Needs the CR's uid, so it can only happen after the apply; a failure here leaks a Secret, never a
671
- * pod, so it is not worth failing the provision over.
672
- */
673
- const ownSecret = async (name: string, uid: string | undefined): Promise<void> => {
674
- if (!uid) return;
675
- const ownerRef = [
676
- { apiVersion: "core.jr2.dev/v1alpha1", kind: "Sandbox", name, uid, controller: true, blockOwnerDeletion: false },
677
- ];
678
- await exec([
679
- "patch",
680
- "secret",
681
- secretName(name),
682
- ...base,
683
- "--type",
684
- "merge",
685
- "-p",
686
- JSON.stringify({ metadata: { ownerReferences: ownerRef } }),
687
- ]).catch(() => {});
688
- };
689
-
690
- /**
691
- * Every Repo the provision names, resolved to its identity and key — and FENCED (ADR-0051). A
692
- * per-run url is the run's input; one no `git.credentials` entry admits is refused here, before
693
- * anything is read or applied, naming the list. A bound url is admitted without a match: it is
694
- * code the instance typechecked and deployed. Two slots spelling one repository collapse to one
695
- * CR entry (first spelling wins) — one cache, however many slots borrow from it — and the
696
- * resource is BOUND when any of those slots is the Machine's: a per-run slot alone leaves it on
697
- * `jr2 gc`'s clock.
698
- */
699
- const fencedRepos = (name: string, repos: ProvisionedRepo[]): FencedRepo[] => {
700
- const byKey = new Map<string, FencedRepo>();
701
- for (const repo of repos) {
702
- const { identity, key } = repoIdentity(repo.url);
703
- if (repo.perRun && !matchCredential(identity, credentials)) {
704
- const entries = credentials.map((c) => c.match).join(", ") || "none";
705
- throw new Error(
706
- `Sandbox "${name}" refuses the per-run repo ${repo.url} for slot "${repo.slot}": no git.credentials ` +
707
- `entry matches "${identity}" (entries: ${entries}). A per-run url can spend the cluster's credential ` +
708
- "against any host, so jr2.config.ts must admit it by prefix (ADR-0051).",
709
- );
710
- }
711
- const seen = byKey.get(key);
712
- if (seen === undefined) byKey.set(key, { key, url: repo.url, identity, bound: !repo.perRun });
713
- else seen.bound ||= !repo.perRun;
714
- }
715
- return [...byKey.values()];
716
- };
717
-
718
- /**
719
- * What the operator will hold this Sandbox's Ready on, and what an attach reads afterwards
720
- * (ADR-0051). `Ready` with reason `RepoCloneFailed` is terminal for the provision — the cache
721
- * agent could not clone onto the node the pod landed on, and the reason names it — so the loop
722
- * fails on it by name rather than burning the budget. `ReposFresh=False` is the other verdict:
723
- * the caches are there but a fetch since this CR asked failed, so the attach proceeds STALE and
724
- * says so. Remembered per name until the Sandbox is destroyed, because the attach is a separate
725
- * call. The operator takes that verdict once per pod and keeps it, so a provision re-run on
726
- * snapshot restore reads the same one — unless the pod was replaced, which is the lease's news.
727
- */
728
- const staleByName = new Map<string, string>();
729
-
730
- return {
731
- async provision(req) {
732
- // The fence first: a refused url costs nothing — no map read, no Secret, no CR.
733
- const repos = fencedRepos(req.name, req.repos);
734
- if (opts.repos === undefined) {
735
- throw new Error(
736
- `Sandbox "${req.name}" cannot be provisioned: this port has no Repo-resource port (ADR-0051). The ` +
737
- "operator holds a Sandbox's Ready until every Repo it names exists as a resource, and creating " +
738
- "them is this provision's job — build the port with `repos: kubectlRepos(...)`.",
739
- );
740
- }
741
- // Read PER PROVISION, and next (ADR-0038). Not hoisted into `kubectlSandbox()`: a boot-time
742
- // read would freeze the map for the process lifetime, which is precisely the Deployment-env
743
- // behavior the ConfigMap mount was chosen over — the point of the mount is that a `jr2 up`
744
- // reaches future Sandboxes without rolling the Orchestrator. Reading before the Secret apply
745
- // also means an unknown image name costs nothing: no Secret, no CR, nothing to clean up.
746
- const refs = await readImageRefs(imagesPath);
747
- // The same stance for what the Custodian holds (ADR-0059): `jr2 up` resolved it host-side,
748
- // where `.env` exists, and this process reads the result — never its own config, whose
749
- // `.env` values are absent in-cluster.
750
- const manifest = await readHeldManifest(heldPath);
751
- const custodian = custodianComposition(manifest, {
752
- image: refs.custodian,
753
- token: { secret: secretName(req.name), key: "JR2_SANDBOX_TOKEN" },
754
- sandbox: req.name,
755
- caBundle: opts.caBundle === true,
756
- });
757
- const cr = await crFor(req, refs, repos, custodian);
758
-
759
- // The Repo resources, BEFORE the CR names them (ADR-0051): a bound one already exists from
760
- // the boot and only its eviction clock moves — this run's spelling never rewrites the spec
761
- // the boot stated; a per-run one is created here, at its first attach, and every later
762
- // attach anywhere finds it and restates its credential, so the `git.credentials` fix
763
- // `repoCloneError` names reaches the cache at the next run. After the image resolution, so
764
- // a refused image still costs nothing; before the token Secret, so no Secret is minted for
765
- // a Sandbox whose Repo could not be recorded.
766
- for (const repo of repos) await opts.repos.ensure(repo);
767
-
768
- await applyTokenSecret(req.name); // before the CR: the pod's Custodian mounts it at start
769
- await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(cr) });
770
-
771
- const applied = Date.now();
772
- // Two budgets from one instant (see the options): the pod's until the operator has seen the
773
- // pod Ready, the Repos' from the first poll that finds the Sandbox held on a Repo reason —
774
- // which the operator reports only once the pod IS Ready, so the preflight has already passed
775
- // and what remains is a clone or a fetch on the node. Sticky: a pod that came up once is not
776
- // a pod that will never come up, whatever it does afterwards.
777
- let held = false;
778
- let owned = false;
779
- let polls = 0;
780
- for (;;) {
781
- const status = await getSandbox(req.name);
782
- if (!owned && status?.uid) ((owned = true), await ownSecret(req.name, status.uid));
783
- // The pod is read at a COARSER cadence than the CR, and only while not Ready. The fault it
784
- // looks for is terminal — the kubelet never retries out of it — so learning about it a few
785
- // seconds late costs nothing, while reading the pod on every poll would double this port's
786
- // API traffic for every healthy provision in the cluster.
787
- if (status?.phase !== "Ready" && polls++ % POD_CHECK_EVERY === 0) {
788
- const fault = await podFault(req.name);
789
- if (fault) throw new Error(rootImageError(req.name, fault));
790
- }
791
- // Terminal for THIS provision: the cache agent tried to clone onto the pod's node and git
792
- // refused. The operator's message carries the key, the node, and git's own words; the
793
- // agent keeps retrying on its own, so `jr2 status` will show the same error until it is fixed.
794
- const ready = conditionOf(status, "Ready");
795
- if (status?.phase !== "Ready" && ready?.reason === REPO_CLONE_FAILED) {
796
- throw new Error(repoCloneError(req.name, ready.message ?? ready.reason));
797
- }
798
- if (ready?.reason !== undefined && REPO_HELD.has(ready.reason)) held = true;
799
- if (status?.phase === "Ready") {
800
- // Only `phase: Ready` means serving — status.endpoint appears earlier (ADR-0001).
801
- if (!status.endpoint) throw new Error(`Sandbox "${req.name}" is Ready but reports no endpoint`);
802
- // Freshness degrades, absence does not (ADR-0051): Ready with `ReposFresh=False` is a
803
- // Sandbox whose caches exist but could not be fetched since it asked. Remembered for the
804
- // attach, which is where a slot can be named; forgotten when the caches are fresh.
805
- const fresh = conditionOf(status, "ReposFresh");
806
- if (fresh?.status === "False" && fresh.message) staleByName.set(req.name, fresh.message);
807
- else staleByName.delete(req.name);
808
- // The identity the lease will hold this workspace to (ADR-0021). Ready means the pod
809
- // is up, so the operator has published it; an operator too old to do so leaves it
810
- // undefined and the lease falls back to presence.
811
- return { endpoint: status.endpoint, identity: status.podUID };
812
- }
813
- if (Date.now() >= applied + (held ? repoTimeoutMs : readyTimeoutMs)) {
814
- // A Sandbox the operator held on its Repos ran out the Repo budget: the pod is up and the
815
- // preflight passed, so the hint about the image would be a lie. What is true is the
816
- // operator's own verdict — which Repo, on which node — and that the agent is still at it.
817
- if (held) throw new Error(repoWaitError(req.name, repoTimeoutMs, ready));
818
- // One last look before falling back to the hint: the fault may have appeared inside the
819
- // final interval, and a named error beats a timeout in every case where both are true.
820
- const fault = await podFault(req.name);
821
- if (fault) throw new Error(rootImageError(req.name, fault));
822
- // Otherwise name where to look, because the next most likely cause is an image that
823
- // misses ADR-0037's floor, and that failure is an INIT container's — invisible in the
824
- // phase alone. A musl or git-less base dies INSIDE the preflight, on jr2's own message.
825
- // The operator's own verdict rides along when it has one: a Repo still pending on the
826
- // node (a slow clone) reads very differently from a preflight death.
827
- throw new Error(
828
- `Sandbox "${req.name}" never reached Ready (last phase: ${status?.phase ?? "absent"}) — if its ` +
829
- "Sandbox Image is new, check the preflight: `kubectl logs " +
830
- req.name +
831
- " -c preflight` (ADR-0037's floor: glibc, git, a writable HOME, a numeric non-root USER)." +
832
- (ready?.message
833
- ? `\n the operator's Ready condition says: ${ready.reason ?? "?"}: ${ready.message}`
834
- : ""),
835
- );
836
- }
837
- await sleep(pollMs);
838
- }
839
- },
840
-
841
- async attach(req) {
842
- const { script, repos, review } = attachScript(req.spec, req.repos, { reposMount: REPOS_MOUNT, workRoot });
843
- // `-c harness` is unchanged and still correct after ADR-0037: the primary container runs the
844
- // Sandbox Image, so `git` here is the git the user chose. Never `-c user` — that seat is
845
- // zero-contract, may hold no git at all, and jr2 commands nothing in it (ADR-0005).
846
- await exec(["exec", `pod/${req.name}`, ...base, "-c", "harness", "--", "sh", "-ec", script]);
847
- const stale = staleSlots(req.repos, staleByName.get(req.name));
848
- return { repos, ...(review ? { review } : {}), ...(stale ? { stale } : {}) };
849
- },
850
-
851
- leaseIntervalMs,
852
-
853
- async renew(name) {
854
- // ONE call, both directions: `annotate --overwrite -o json` writes the stamp and prints the
855
- // object as it stands afterwards, status included. So asserting liveness and learning
856
- // whether the workspace survived cost exactly one API round trip (ADR-0021).
857
- try {
858
- const { stdout } = await exec([
859
- "annotate",
860
- "sandbox",
861
- name,
862
- ...base,
863
- `jr2.dev/keepalive=${new Date().toISOString()}`,
864
- "--overwrite",
865
- "-o",
866
- "json",
867
- ]);
868
- return { present: true, identity: readSandbox(stdout).podUID };
869
- } catch (err) {
870
- if (isNotFound(err)) return { present: false };
871
- throw err; // anything else is UNKNOWN — the caller must not read it as loss
872
- }
873
- },
874
-
875
- async destroy(name) {
876
- staleByName.delete(name);
877
- // The Secret is an owned child of the CR, so deleting the CR reaps it — this is belt and
878
- // braces for the case where the ownerRef patch didn't land.
879
- await exec(["delete", "sandbox", name, ...base, "--ignore-not-found"]);
880
- await exec(["delete", "secret", secretName(name), ...base, "--ignore-not-found"]).catch(() => {});
881
- },
882
- };
883
- }
884
-
885
- /** The pod volume the operator defines for one Repo's node cache, and where it lands in the
886
- * primary container (ADR-0051). Two halves of one contract with the operator, spelled here so the
887
- * User Container's mounts and the attach's clone source agree with it by construction. */
888
- export const repoVolumeName = (key: string): string => `repo-${key}`;
889
- export const repoMountPath = (key: string): string => `${REPOS_MOUNT}/${key}`;
890
-
891
- /** One Repo as the provision names it: the CR entry, plus what its resource records — the
892
- * identity, and whether a Machine's slot (not only the run's) binds it. */
893
- type FencedRepo = { key: string; url: string; identity: string; bound: boolean };
894
-
895
- /** One entry of a Sandbox CR's `status.conditions`, as the operator writes it. */
896
- type Condition = { type: string; status: string; reason?: string; message?: string };
897
-
898
- /** The operator's Ready reason when the cache agent could not clone onto the pod's node
899
- * (sandbox_controller.go) — the one Ready verdict a provision cannot wait out. */
900
- const REPO_CLONE_FAILED = "RepoCloneFailed";
901
-
902
- /** The operator's Ready reasons that hold a Sandbox whose POD is Ready on its Repos
903
- * (sandbox_controller.go, `reposReadiness`): the resource not yet seen, or the node's cache
904
- * agent still cloning or fetching. The wait against them is the Repo budget, not the pod's. */
905
- const REPO_HELD: ReadonlySet<string> = new Set(["RepoMissing", "RepoPending"]);
906
-
907
- /**
908
- * The Repo budget ran out with the operator still holding the Sandbox. The pod is up, so the
909
- * preflight is not the question; the verdict names the Repo and the node, and the port adds
910
- * what the operator cannot say: the agent is still working, `jr2 status` shows it per node, and
911
- * the budget is the port's, not the clone's.
912
- */
913
- function repoWaitError(name: string, budgetMs: number, ready: Condition | undefined): string {
914
- const verdict = ready?.message ? `${ready.reason ?? "?"}: ${ready.message}` : "no Ready condition reported";
915
- return (
916
- `Sandbox "${name}" waited ${Math.round(budgetMs / 60_000)}m for its Repos and the operator still holds it — ` +
917
- `${verdict} (ADR-0051). The node's cache agent clones a cold node once and fetches before every attach; ` +
918
- "`jr2 status` reports each Repo per node. Start the run again once the cache is present, or raise " +
919
- "the port's `repoTimeoutMs` for a repository whose clone outlasts it."
920
- );
921
- }
922
-
923
- /**
924
- * The fix beside the symptom. The operator's message carries the Repo, the node, and git's own
925
- * words; what it cannot say is that the cache agent keeps retrying, that `jr2 status` reports the
926
- * same line per node, or where a credential is configured (ADR-0047/0051).
927
- */
928
- function repoCloneError(name: string, verdict: string): string {
929
- return (
930
- `Sandbox "${name}" cannot start: ${verdict} (ADR-0051). The node's cache agent keeps retrying on its own — ` +
931
- "fix the url or its git.credentials entry (an ssh url needs its deploy key registered with the host, " +
932
- "ADR-0047), then start the run again; `jr2 status` reports the same error per node until it clears."
933
- );
934
- }
935
-
936
- /**
937
- * The `ReposFresh=False` message, keyed back to SLOTS for the attach's `stale`. The operator
938
- * writes one clause per stale Repo — `Repo "<key>" on node <n> is stale: <error>` — joined by
939
- * `; `; each slot whose key a clause names gets that clause. A message that names no key at all
940
- * lands on every slot: a verdict with no address is still a verdict.
941
- */
942
- function staleSlots(
943
- repos: Array<{ slot: string; url: string }>,
944
- message: string | undefined,
945
- ): Record<string, string> | undefined {
946
- if (!message) return undefined;
947
- const byKey = new Map<string, string>();
948
- for (const clause of message.split("; ")) {
949
- const key = /^Repo "([^"]+)"/.exec(clause)?.[1];
950
- if (key !== undefined) byKey.set(key, clause);
951
- }
952
- const stale: Record<string, string> = {};
953
- for (const repo of repos) {
954
- const clause = byKey.size === 0 ? message : byKey.get(repoIdentity(repo.url).key);
955
- if (clause !== undefined) stale[repo.slot] = clause;
956
- }
957
- return Object.keys(stale).length ? stale : undefined;
958
- }
959
-
960
- /**
961
- * The post-Ready attach step as one idempotent in-pod script (ADR-0004, ADR-0051): per Repo Slot
962
- * in declaration order, a pod-local `git clone --shared` borrowing objects from the node's
963
- * read-only cache at `/repos/<key>` — `<slot>/default/`, a checkout of the Repo's default branch
964
- * — then the branch worktree as a sibling (gwtmux layout: `<slot>/default/` + `<slot>/<branch>/`). With a `reviewSha`, also the detached review worktree
965
- * (ADR-0028) — another sibling. `repos` keeps the slots' declaration order. Exported for the port's
966
- * tests; the workflow never sees it.
967
- */
968
- export function attachScript(
969
- spec: WorkspaceSpec,
970
- repos: Array<{ slot: string; url: string; ref?: string }>,
971
- paths: { reposMount: string; workRoot: string },
972
- ): { script: string; repos: Record<string, string>; review?: Record<string, string> } {
973
- const worktrees: Record<string, string> = {};
974
- const review: Record<string, string> = {};
975
- // The cache is written by the node's cache agent and read here as the Harness's unprivileged
976
- // uid (ADR-0001/0004/0051), so git's dubious-ownership guard would refuse the clone source.
977
- // safe.directory is only honored from global/system config (never `-c`), and inside the pod
978
- // every path is jr2-owned — trusting them all is the honest scope.
979
- // The attach runs via exec, not as a child of the Harness process, so it does NOT inherit the
980
- // Harness's `umask 002` — without its own, the repo roots it mkdirs land 755 and the work group
981
- // could never create a file at a tree's top. INSIDE the trees the umask stops mattering: the
982
- // default ACL stamped below governs everything created beneath a repo root (ADR-0005).
983
- const lines: string[] = [`umask 002`, `git config --global safe.directory '*'`];
984
- const branchDir = spec.branch.replace(/\//g, "-");
985
- for (const repo of repos) {
986
- const slotDir = `${paths.workRoot}/${repo.slot}`;
987
- const dflt = `${slotDir}/default`;
988
- const worktree = `${slotDir}/${branchDir}`;
989
- const { identity, key } = repoIdentity(repo.url);
990
- const cache = `${paths.reposMount}/${key}`;
991
- worktrees[repo.slot] = worktree;
992
- lines.push(
993
- `mkdir -p ${sq(slotDir)}`,
994
- // BEFORE the clone fills it: a default ACL is inherited at creation, never retrofitted, so
995
- // the stamp must exist while the tree is still empty. From here down, both seats' files land
996
- // group-writable with zero umask lines in any image (ADR-0005); on a filesystem without
997
- // POSIX ACLs the helper warns and exits 0, degrading to the umask sharing above.
998
- `/opt/jr2/bin/work-acl ${sq(slotDir)}`,
999
- `[ -d ${sq(`${dflt}/.git`)} ] || git clone --shared ${sq(cache)} ${sq(dflt)}`,
1000
- // No ref → the Repo's own default branch: this clone's `origin/HEAD` tracks the cache's
1001
- // HEAD, which the cache agent's clone pointed at the remote's default (ADR-0004).
1002
- `[ -d ${sq(worktree)} ] || git -C ${sq(dflt)} worktree add ${sq(worktree)} -b ${sq(spec.branch)} ${repo.ref === undefined ? sq("origin/HEAD") : baseOf(dflt, repo.ref)}`,
1003
- // Fetch/push split (ADR-0005). The FETCH url is a command, not a path (ADR-0053): git's
1004
- // built-in `ext::` transport runs the program on the runtime volume, which asks the node
1005
- // cache to fetch the remote, waits for the landing, then serves the cache — so every fetch
1006
- // inside the pod is a fetch of the remote's now, and a stale attach is stale only until the
1007
- // next fetch anyone in the pod runs. Git substitutes `%S` with the service it wants
1008
- // (`git-upload-pack`), and splits the rest on spaces with no quoting of its own, so the
1009
- // url's arguments carry none: the Repo's IDENTITY, never the cache key — that is the name a
1010
- // human reads in `git remote -v`, and a key is a derived directory name (ADR-0004). The
1011
- // program discovers the cache from the checkout's alternates, so the url says nothing about
1012
- // where the objects are. `git push` goes to the REAL remote — the Binding's own spelling, so
1013
- // a Machine that bound over ssh pushes over ssh even when the cache was cloned over https
1014
- // (ADR-0051). Push still succeeds only with a caller-supplied credential (a forwarded agent
1015
- // in the User Container); the pod itself holds none. `--` keeps the url an operand, never an
1016
- // option.
1017
- `git -C ${sq(dflt)} remote set-url origin -- ${sq(fetchUrl(identity))}`,
1018
- `git -C ${sq(dflt)} remote set-url --push origin -- ${sq(repo.url)}`,
1019
- // `ext` is on git's own "known scary" list, so its built-in default is `never` and the url
1020
- // above would die with `fatal: transport 'ext' not allowed` before the program ever ran.
1021
- // `user` is the policy ADR-0053 argues for, said out loud: a fetch A PERSON OR THE AGENT
1022
- // runs is allowed, and a recursive one git makes for itself (a submodule url, anything with
1023
- // `GIT_PROTOCOL_FROM_USER=0`) is still refused — so a repository cannot smuggle a program
1024
- // into this pod through a url jr2 did not write. Repo-level, on the pod-local clone: the
1025
- // linked worktrees share this config, so the branch worktree and the review worktree inherit
1026
- // it with no env and no `--global`.
1027
- `git -C ${sq(dflt)} config protocol.ext.allow user`,
1028
- );
1029
- if (spec.reviewSha) {
1030
- // The reviewer's seat (ADR-0028): a DETACHED HEAD at the sha under review, so a rogue write
1031
- // cannot move the branch and a rogue commit evaporates with the checkout. Forced checkout
1032
- // AND clean on every attach: a previous round's rogue edits (tracked) and leftovers
1033
- // (untracked) must not survive into this round — the review worktree's contents are the
1034
- // sha under review, period.
1035
- const reviewDir = `${worktree}-review`;
1036
- review[repo.slot] = reviewDir;
1037
- lines.push(
1038
- `[ -d ${sq(reviewDir)} ] || git -C ${sq(dflt)} worktree add --detach ${sq(reviewDir)} ${sq(spec.reviewSha)}`,
1039
- `git -C ${sq(reviewDir)} checkout --detach -f ${sq(spec.reviewSha)}`,
1040
- `git -C ${sq(reviewDir)} clean -fd`,
1041
- );
1042
- }
1043
- }
1044
- if (repos.length === 0)
1045
- throw new Error("the attach names no Repo Slot — nothing to attach (a workspace() declares at least one)");
1046
- return {
1047
- script: lines.join("\n"),
1048
- repos: worktrees,
1049
- ...(spec.reviewSha ? { review } : {}),
1050
- };
1051
- }
1052
-
1053
- /**
1054
- * `origin`'s fetch url for one Repo (ADR-0053): the `ext::` transport, the program's absolute path
1055
- * on the runtime volume, the service git asks for, and the Repo's identity. No address: the
1056
- * program asks the Custodian at its one loopback address, which every Harness pod has (ADR-0059).
1057
- */
1058
- function fetchUrl(identity: string): string {
1059
- return `ext::${UPLOAD_PACK} %S ${extArg(identity)}`;
1060
- }
1061
-
1062
- /**
1063
- * One argument of an `ext::` url, in git's own escaping. Git splits the url on spaces and reads
1064
- * `%` as a placeholder introducer — `%S` is the service it substitutes — so it DIES on a `%` it
1065
- * does not recognize (`fatal: Bad remote-ext placeholder '%2'`) and silently splits an argument
1066
- * that holds a space. An identity carries both: a forge path may be percent-encoded
1067
- * (`dev.azure.com/org/My%20Project/_git/repo`) and an scp-style url may hold a literal space. Git
1068
- * spells those two `%%` and `% `, and the program receives the identity back exactly as written —
1069
- * which it must, because the Orchestrator derives the cache key from that same string. The
1070
- * placeholder jr2 writes itself (`%S`) is not escaped: it is git's, not an argument's.
1071
- */
1072
- function extArg(value: string): string {
1073
- return value.replace(/%/g, "%%").replace(/ /g, "% ");
1074
- }
1075
-
1076
- /**
1077
- * The commit-ish a Binding's `ref` names inside the pod-local clone, as a shell expression: the
1078
- * remote-tracking branch `refs/remotes/origin/<ref>` when the clone has one, else `<ref>` as
1079
- * written (a tag, a sha). A fresh clone holds ONE local branch — the default — so a bare
1080
- * branch name is never a local ref here, and git's "worktree add" DWIM would then create the BASE
1081
- * branch tracking `origin/<ref>` and discard `-b`: the Agent would commit on, and push to, the base
1082
- * it was meant to branch FROM. Naming the remote-tracking ref outright leaves nothing to guess.
1083
- */
1084
- function baseOf(dflt: string, ref: string): string {
1085
- const remote = sq(`refs/remotes/origin/${ref}`);
1086
- return `"$(git -C ${sq(dflt)} rev-parse --verify -q ${remote} >/dev/null && printf %s ${remote} || printf %s ${sq(ref)})"`;
1087
- }
1088
-
1089
- /** POSIX single-quote an argument for the in-pod `sh -ec` script. */
1090
- function sq(s: string): string {
1091
- return `'${s.replace(/'/g, `'\\''`)}'`;
1092
- }
1093
-
1094
- function isNotFound(err: unknown): boolean {
1095
- return err instanceof Error && /NotFound|not found/i.test(err.message);
1096
- }
1097
-
1098
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
1099
-
1100
- /** The `kubectl` on PATH, as every kubectl-driven port shells to it (this one and repos.ts). */
1101
- export const defaultKubectlExec: KubectlExec = (args, opts) =>
1102
- new Promise((resolve, reject) => {
1103
- const child = execFile("kubectl", args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
1104
- if (err) reject(new Error(`kubectl ${args[0]} failed: ${stderr || err.message}`));
1105
- else resolve({ stdout, stderr });
1106
- });
1107
- if (opts?.input !== undefined) child.stdin?.end(opts.input);
1108
- });