@jr2/orchestrator 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/console/components/app.ts +55 -15
  2. package/console/components/bottom-drawer.ts +259 -0
  3. package/console/components/drawer.ts +14 -6
  4. package/console/components/fleet.ts +36 -13
  5. package/console/components/machine-pane.ts +61 -5
  6. package/console/components/nav.ts +54 -8
  7. package/console/conversation.ts +161 -0
  8. package/console/login.ts +123 -0
  9. package/console/main.ts +600 -60
  10. package/console/store.ts +611 -64
  11. package/console/style.css +231 -0
  12. package/docs/agents-block.md +2 -2
  13. package/docs/concepts/agents-and-turns.md +142 -29
  14. package/docs/concepts/architecture.md +60 -34
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +13 -8
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +7 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +133 -50
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +14 -4
  23. package/docs/concepts/pools-and-sources.md +193 -17
  24. package/docs/concepts/secrets-and-trust.md +186 -62
  25. package/docs/concepts/the-menu.md +14 -0
  26. package/docs/concepts/workspaces.md +135 -56
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +46 -35
  29. package/docs/get-started/connect-a-model.md +1 -1
  30. package/docs/get-started/create-an-instance.md +17 -14
  31. package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
  32. package/docs/get-started/install.md +4 -0
  33. package/docs/guides/add-tools-to-the-sandbox.md +72 -15
  34. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  35. package/docs/guides/answer-a-gate.md +74 -37
  36. package/docs/guides/bind-open-parts.md +29 -12
  37. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  38. package/docs/guides/cancel-a-run.md +26 -16
  39. package/docs/guides/cut-a-worktree.md +560 -0
  40. package/docs/guides/fix-a-failed-converge.md +19 -7
  41. package/docs/guides/give-a-caller-a-scope.md +203 -0
  42. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  43. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  44. package/docs/guides/let-an-agent-write-a-chart.md +14 -7
  45. package/docs/guides/look-inside-a-sandbox.md +115 -42
  46. package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
  47. package/docs/guides/open-the-console.md +63 -14
  48. package/docs/guides/push-and-sign-a-branch.md +67 -21
  49. package/docs/guides/read-a-conversation.md +225 -0
  50. package/docs/guides/run-a-chart-by-name.md +17 -16
  51. package/docs/guides/run-a-command-or-call-a-url.md +84 -41
  52. package/docs/guides/run-one-worker-per-item.md +34 -27
  53. package/docs/guides/set-an-agents-access-and-dials.md +69 -22
  54. package/docs/guides/size-a-workspace.md +19 -6
  55. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  56. package/docs/guides/start-a-run-from-a-run.md +18 -16
  57. package/docs/guides/start-a-run.md +24 -18
  58. package/docs/guides/steer-an-agent.md +332 -0
  59. package/docs/guides/use-a-hosted-provider.md +7 -1
  60. package/docs/guides/watch-a-run.md +146 -117
  61. package/docs/guides/write-a-hook-kind.md +238 -0
  62. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  63. package/docs/guides/write-a-workflow.md +18 -8
  64. package/docs/llms.txt +110 -23
  65. package/docs/reference/authoring-surface/agent.md +35 -12
  66. package/docs/reference/authoring-surface/answer.md +20 -4
  67. package/docs/reference/authoring-surface/arg.md +28 -6
  68. package/docs/reference/authoring-surface/basic.md +1 -1
  69. package/docs/reference/authoring-surface/bearer.md +1 -1
  70. package/docs/reference/authoring-surface/branchname.md +15 -0
  71. package/docs/reference/authoring-surface/carry.md +23 -0
  72. package/docs/reference/authoring-surface/chartschema.md +1 -1
  73. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  74. package/docs/reference/authoring-surface/command.md +37 -22
  75. package/docs/reference/authoring-surface/customize.md +4 -1
  76. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  77. package/docs/reference/authoring-surface/defineevent.md +1 -1
  78. package/docs/reference/authoring-surface/dispatch.md +5 -4
  79. package/docs/reference/authoring-surface/fail.md +18 -5
  80. package/docs/reference/authoring-surface/file.md +37 -17
  81. package/docs/reference/authoring-surface/frompick.md +1 -1
  82. package/docs/reference/authoring-surface/gate.md +118 -0
  83. package/docs/reference/authoring-surface/group-agents.md +15 -0
  84. package/docs/reference/authoring-surface/group-charts.md +16 -0
  85. package/docs/reference/authoring-surface/group-composition.md +25 -0
  86. package/docs/reference/authoring-surface/group-config.md +17 -0
  87. package/docs/reference/authoring-surface/group-effects.md +48 -0
  88. package/docs/reference/authoring-surface/group-failure.md +13 -0
  89. package/docs/reference/authoring-surface/group-gates.md +16 -0
  90. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  91. package/docs/reference/authoring-surface/group-steering.md +12 -0
  92. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  93. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  94. package/docs/reference/authoring-surface/http.md +109 -9
  95. package/docs/reference/authoring-surface/isfailure.md +1 -1
  96. package/docs/reference/authoring-surface/isready.md +14 -0
  97. package/docs/reference/authoring-surface/jr2setup.md +14 -4
  98. package/docs/reference/authoring-surface/mcp.md +107 -0
  99. package/docs/reference/authoring-surface/open.md +41 -8
  100. package/docs/reference/authoring-surface/openbody.md +1 -1
  101. package/docs/reference/authoring-surface/paramsof.md +23 -5
  102. package/docs/reference/authoring-surface/pool.md +79 -11
  103. package/docs/reference/authoring-surface/push.md +31 -13
  104. package/docs/reference/authoring-surface/readyof.md +21 -0
  105. package/docs/reference/authoring-surface/reread.md +19 -0
  106. package/docs/reference/authoring-surface/secret.md +26 -5
  107. package/docs/reference/authoring-surface/sentby.md +1 -1
  108. package/docs/reference/authoring-surface/skip.md +22 -0
  109. package/docs/reference/authoring-surface/source.md +59 -4
  110. package/docs/reference/authoring-surface/steer.md +93 -0
  111. package/docs/reference/authoring-surface/text.md +1 -1
  112. package/docs/reference/authoring-surface/type-agentdefinition.md +16 -3
  113. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  114. package/docs/reference/authoring-surface/type-agentslots.md +4 -1
  115. package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
  116. package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
  117. package/docs/reference/authoring-surface/type-bindactors.md +14 -0
  118. package/docs/reference/authoring-surface/type-binding.md +6 -7
  119. package/docs/reference/authoring-surface/type-caller.md +40 -0
  120. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  121. package/docs/reference/authoring-surface/type-carry.md +19 -0
  122. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  123. package/docs/reference/authoring-surface/type-commandinput.md +4 -3
  124. package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
  125. package/docs/reference/authoring-surface/type-commandoutput.md +23 -5
  126. package/docs/reference/authoring-surface/type-customize.md +20 -3
  127. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  128. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  129. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  130. package/docs/reference/authoring-surface/type-dispatchoutput.md +9 -2
  131. package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
  132. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  133. package/docs/reference/authoring-surface/type-filebody.md +4 -8
  134. package/docs/reference/authoring-surface/type-framepath.md +14 -0
  135. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  136. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  137. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  138. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  139. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  140. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  141. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  142. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  143. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  144. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  145. package/docs/reference/authoring-surface/type-hostinjectedinput.md +3 -5
  146. package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
  147. package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
  148. package/docs/reference/authoring-surface/type-httpoutput.md +8 -2
  149. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  150. package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
  151. package/docs/reference/authoring-surface/type-lends.md +5 -1
  152. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  153. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  154. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  155. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  156. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  157. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  158. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  159. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  160. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  161. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  162. package/docs/reference/authoring-surface/type-namestart.md +77 -0
  163. package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
  164. package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
  165. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  166. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  167. package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
  168. package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
  169. package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
  170. package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
  171. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  172. package/docs/reference/authoring-surface/type-pushinput.md +22 -3
  173. package/docs/reference/authoring-surface/type-pushoutput.md +33 -8
  174. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  175. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  176. package/docs/reference/authoring-surface/type-reposlot.md +6 -6
  177. package/docs/reference/authoring-surface/type-reread.md +18 -0
  178. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  179. package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
  180. package/docs/reference/authoring-surface/type-sender.md +26 -1
  181. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  182. package/docs/reference/authoring-surface/type-size.md +1 -1
  183. package/docs/reference/authoring-surface/type-skip.md +19 -0
  184. package/docs/reference/authoring-surface/type-slothandles.md +5 -13
  185. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  186. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  187. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  188. package/docs/reference/authoring-surface/type-sourcespec.md +26 -17
  189. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  190. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  191. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  192. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  193. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  194. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  195. package/docs/reference/authoring-surface/type-workspaced.md +30 -9
  196. package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
  197. package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
  198. package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
  199. package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
  200. package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
  201. package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
  202. package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
  203. package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
  204. package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
  205. package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
  206. package/docs/reference/authoring-surface/workspace.md +66 -38
  207. package/docs/reference/authoring-surface/worktree.md +71 -0
  208. package/docs/reference/authoring-surface.md +88 -147
  209. package/docs/reference/cli.md +413 -74
  210. package/docs/reference/config.md +248 -6
  211. package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
  212. package/docs/reference/decisions/0002-duplex-streaming-actor.md +81 -14
  213. package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
  214. package/docs/reference/decisions/0005-sandbox-pod-composition.md +39 -25
  215. package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
  216. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  217. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  218. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  219. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  220. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +39 -32
  221. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +54 -30
  222. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  223. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +26 -16
  224. package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
  225. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -10
  226. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  227. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +53 -27
  228. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +16 -2
  229. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  230. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +124 -43
  231. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +36 -38
  232. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  233. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +16 -7
  234. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +36 -19
  235. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  236. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  237. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -15
  238. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  239. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +22 -22
  240. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  241. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +13 -12
  242. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +23 -23
  243. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  244. package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
  245. package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
  246. package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
  247. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  248. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  249. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +31 -24
  250. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +18 -10
  251. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +137 -86
  252. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +165 -91
  253. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +7 -7
  254. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  255. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  256. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +53 -23
  257. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +221 -153
  258. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +161 -96
  259. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  260. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +48 -33
  261. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  262. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  263. package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
  264. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +296 -0
  265. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +347 -0
  266. package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
  267. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  268. package/docs/reference/decisions/0079-an-agent-holds-mcp-tools-a-machine-calls-them-as-an-effect-and-the-machine-is-the-approval.md +198 -0
  269. package/docs/reference/decisions.md +9 -4
  270. package/docs/reference/glossary.md +138 -45
  271. package/docs/reference/hooks.md +1077 -0
  272. package/docs/reference/http-api.md +988 -123
  273. package/docs/reference/machines.md +0 -1
  274. package/docs/why.md +7 -5
  275. package/package.json +10 -3
  276. package/src/actor.ts +194 -42
  277. package/src/agent.ts +56 -6
  278. package/src/ambient.ts +3 -5
  279. package/src/assembly-checks.ts +530 -4
  280. package/src/callers.ts +245 -0
  281. package/src/chart.ts +238 -26
  282. package/src/charts.ts +9 -2
  283. package/src/config.ts +566 -7
  284. package/src/conversations.ts +453 -0
  285. package/src/custodian.lua +17 -5
  286. package/src/custodian.ts +257 -77
  287. package/src/customize.ts +154 -7
  288. package/src/dispatch-effect.ts +142 -26
  289. package/src/durability.ts +2 -0
  290. package/src/duration.ts +20 -0
  291. package/src/effect-checks.ts +52 -4
  292. package/src/effect.ts +401 -132
  293. package/src/effector-wire.ts +278 -30
  294. package/src/fail.ts +17 -4
  295. package/src/gate-brand.ts +16 -0
  296. package/src/gate.ts +634 -48
  297. package/src/harness-client.ts +84 -35
  298. package/src/harness-env.ts +33 -0
  299. package/src/held-secrets.ts +197 -39
  300. package/src/holds.ts +5 -2
  301. package/src/hook-kinds/discord.ts +92 -0
  302. package/src/hook-kinds/door.ts +107 -0
  303. package/src/hook-kinds/github.ts +89 -0
  304. package/src/hook-kinds/gitlab.ts +72 -0
  305. package/src/hook-kinds/hook.ts +7 -0
  306. package/src/hook-kinds/index.ts +465 -0
  307. package/src/hook-kinds/slack.ts +151 -0
  308. package/src/hook-kinds/webhook.ts +113 -0
  309. package/src/hooks.ts +506 -0
  310. package/src/http-effect.ts +164 -70
  311. package/src/http.ts +933 -99
  312. package/src/index.ts +30 -5
  313. package/src/instance.ts +196 -26
  314. package/src/internal.ts +23 -3
  315. package/src/kube-client.ts +6 -3
  316. package/src/machine-doc.ts +29 -5
  317. package/src/mcp-effect.ts +424 -0
  318. package/src/model-keys.ts +24 -0
  319. package/src/mount-digest.ts +41 -7
  320. package/src/names.ts +82 -7
  321. package/src/notices.ts +576 -59
  322. package/src/open-body.ts +110 -30
  323. package/src/open-effect.ts +17 -4
  324. package/src/open.ts +40 -7
  325. package/src/parts.ts +125 -35
  326. package/src/party.ts +35 -1
  327. package/src/pick.ts +43 -9
  328. package/src/pool.ts +1172 -172
  329. package/src/registration.ts +249 -17
  330. package/src/repo-identity.ts +7 -3
  331. package/src/repos.ts +20 -2
  332. package/src/run-host.ts +1614 -190
  333. package/src/run-list.ts +191 -0
  334. package/src/sandbox-effect.ts +217 -66
  335. package/src/sandbox-kube.ts +116 -21
  336. package/src/secret-placement.ts +248 -35
  337. package/src/server.ts +53 -4
  338. package/src/setup.ts +158 -38
  339. package/src/snapshot-store.ts +565 -48
  340. package/src/steer.ts +111 -0
  341. package/src/stub-harness.ts +6 -2
  342. package/src/tokens.ts +362 -46
  343. package/src/vocabulary.ts +23 -4
  344. package/src/wire.ts +27 -10
  345. package/src/workspace.ts +314 -259
  346. package/src/worktree.ts +249 -0
  347. package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
  348. package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
  349. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  350. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
package/src/steer.ts ADDED
@@ -0,0 +1,111 @@
1
+ // The Machine's Steering message (ADR-0002): free text to one Agent of the Machine, which the Agent
2
+ // hears once, at a step boundary of its open Turn or at its next Turn's admission. The run's notice
3
+ // ledger keeps it (ADR-0062); this module is only the action an author writes.
4
+
5
+ import type { ActionFunction, EventObject, MachineContext, ParameterizedObject, ProvidedActor } from "xstate";
6
+ import type { Sender } from "@jr2/agent-protocol";
7
+ import { boundRun } from "./registration.ts";
8
+
9
+ /** The most text one Steering message carries, in UTF-8 bytes: the bound of
10
+ * `POST /runs/:runId/steering`, whose larger text is 413. The `steer` action cuts a longer text. */
11
+ export const STEERING_TEXT_MAX = 16 * 1024;
12
+
13
+ /** What `steer` adds to the end of a text that it cut at {@link STEERING_TEXT_MAX}. */
14
+ const CUT_MARK = "\n[jr2 cut this message at 16 KiB]";
15
+
16
+ /** A text, cut to {@link STEERING_TEXT_MAX} UTF-8 bytes with the cut mark when it is longer. */
17
+ export function boundSteeringText(text: string): string {
18
+ const bytes = new TextEncoder().encode(text);
19
+ if (bytes.length <= STEERING_TEXT_MAX) return text;
20
+ const room = STEERING_TEXT_MAX - new TextEncoder().encode(CUT_MARK).length;
21
+ // `fatal: false` drops a character that the cut split in two.
22
+ const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, room)).replace(/�$/, "");
23
+ return `${head}${CUT_MARK}`;
24
+ }
25
+
26
+ /** The arguments a `steer` text or `from` function gets: the Machine's context and the event that
27
+ * the transition takes. */
28
+ type SteerArgs<TContext, TEvent> = { context: TContext; event: TEvent };
29
+
30
+ /**
31
+ * Send a Steering message to one Agent of this Machine: an action for a transition. The Agent hears
32
+ * the text once, without an interrupt. When a Turn of the Agent is open, its Harness adds the text
33
+ * at the Turn's next step boundary. Otherwise the text waits for the Agent's next Turn in this
34
+ * Machine instance. When this Machine instance ends first, the message is not delivered.
35
+ *
36
+ * A Steering message is not an event: no guard reads it, and no Gate takes it. The Agent decides
37
+ * what to do with it, and acts on the workflow only by its picks.
38
+ *
39
+ * Use it to relay what one Agent says to another. Give `from` the Sender of the event (the
40
+ * `sender` field that the Orchestrator writes on it): the Agent then reads the text as the word of
41
+ * a peer, not as an order. When the Sender is a Gate, the Agent reads the text as what the Gate
42
+ * received, not as an order. With no `from`, the Agent reads the text as the workflow's own.
43
+ *
44
+ * `agent` is the name of an Agent in this Machine's `actors`. A text longer than 16 KiB is cut.
45
+ * The Machine can steer an Agent whose definition says `steerable: false`: that refuses Callers
46
+ * only.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * import { z } from "zod";
51
+ * import { agent, defineEvent, jr2Setup, steer } from "@jr2/orchestrator";
52
+ *
53
+ * const done = defineEvent({ name: "done", description: "The draft is ready.", input: z.object({}) });
54
+ * const note = defineEvent({
55
+ * name: "note",
56
+ * description: "Send the draft back to the writer, with what to change.",
57
+ * input: z.object({ text: z.string().min(1) }),
58
+ * });
59
+ *
60
+ * export const machine = jr2Setup({
61
+ * events: [done, note],
62
+ * actors: {
63
+ * writer: agent({ model: "anthropic/claude-haiku-5-5", instructions: "You write.", workspace: "none" }),
64
+ * reviewer: agent({ model: "anthropic/claude-haiku-5-5", instructions: "You review.", workspace: "none" }),
65
+ * },
66
+ * }).createMachine({
67
+ * initial: "writing",
68
+ * states: {
69
+ * writing: {
70
+ * invoke: { src: "writer", input: { prompt: "Write the notes.", continue: true } },
71
+ * on: { done: "reviewing" },
72
+ * },
73
+ * reviewing: {
74
+ * invoke: { src: "reviewer", input: { prompt: "Review the notes." } },
75
+ * on: {
76
+ * // The writer's next Turn hears the note as the reviewer's word, not as an order.
77
+ * note: {
78
+ * target: "writing",
79
+ * actions: steer("writer", ({ event }) => event.text, { from: ({ event }) => event.sender }),
80
+ * },
81
+ * },
82
+ * },
83
+ * },
84
+ * });
85
+ * ```
86
+ *
87
+ * @see docs/guides/steer-an-agent.md
88
+ * @see docs/concepts/agents-and-turns.md
89
+ * @see docs/reference/decisions/0002-duplex-streaming-actor.md
90
+ */
91
+ export function steer<
92
+ TContext extends MachineContext,
93
+ TExpressionEvent extends EventObject,
94
+ TParams extends ParameterizedObject["params"] | undefined,
95
+ TEvent extends EventObject,
96
+ TActor extends ProvidedActor,
97
+ >(
98
+ agent: string,
99
+ text: string | ((args: SteerArgs<TContext, TExpressionEvent>) => string),
100
+ opts: { from?: Sender | ((args: SteerArgs<TContext, TExpressionEvent>) => Sender | undefined) } = {},
101
+ ): ActionFunction<TContext, TExpressionEvent, TEvent, TParams, TActor, never, never, never, never> {
102
+ return ({ context, event, self }) => {
103
+ const binding = boundRun(self.system);
104
+ // Outside a RunHost there is no ledger, and no Agent to hear it.
105
+ if (binding?.steer === undefined) return;
106
+ const said = typeof text === "function" ? text({ context, event }) : text;
107
+ if (typeof said !== "string" || said.length === 0) return;
108
+ const from = typeof opts.from === "function" ? opts.from({ context, event }) : opts.from;
109
+ binding.steer(self, agent, boundSteeringText(said), from);
110
+ };
111
+ }
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Wire (ADR-0027 — the normative model of the conversation endpoints; `@jr2/harness` serves it for
9
9
  // real), and the pod's memory kills, which a stub never has (ADR-0062):
10
- // POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?, notices?, kept?} → 200 { streamUrl, offset, submissionId }
10
+ // POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?, notices?, steering?, kept?} → 200 { streamUrl, offset, submissionId }
11
11
  // (`message` and `cwd` are the Turn's Frame — what it is about and where it works, ADR-0057;
12
12
  // `kept` admits as any POST does, because the stub keeps every conversation — the real Harness
13
13
  // answers 404 when it keeps none, ADR-0062)
@@ -38,7 +38,7 @@
38
38
 
39
39
  import { createServer } from "node:http";
40
40
  import type { AddressInfo, Socket } from "node:net";
41
- import type { Notice } from "./wire.ts";
41
+ import type { Notice, SteeringMessage } from "./wire.ts";
42
42
 
43
43
  /** One admission of an Agent: which slot, which durable exchange, this Turn's FRAME (the prompt
44
44
  * and where it works — ADR-0057), the DEFINITION the Machine's slot carried here (ADR-0049), and
@@ -58,6 +58,8 @@ export type Admission = {
58
58
  thinkingLevel?: string;
59
59
  /** The notices the admission carried (ADR-0062). Absent when it carried none. */
60
60
  notices?: Notice[];
61
+ /** The Steering messages the admission carried (ADR-0002). Absent when it carried none. */
62
+ steering?: SteeringMessage[];
61
63
  };
62
64
 
63
65
  /** One settled submission, in the shape `history()` reports it. The stub settles submissions for
@@ -165,6 +167,7 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
165
167
  model?: string;
166
168
  thinkingLevel?: string;
167
169
  notices?: Notice[];
170
+ steering?: SteeringMessage[];
168
171
  }
169
172
  | undefined;
170
173
  const admission: Admission = {
@@ -178,6 +181,7 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
178
181
  ...(sent?.model ? { model: sent.model } : {}),
179
182
  ...(sent?.thinkingLevel ? { thinkingLevel: sent.thinkingLevel } : {}),
180
183
  ...(sent?.notices?.length ? { notices: sent.notices } : {}),
184
+ ...(sent?.steering?.length ? { steering: sent.steering } : {}),
181
185
  };
182
186
  admissions.push(admission);
183
187
  const submissionId = `stub-${++submissionSeq}`;
package/src/tokens.ts CHANGED
@@ -6,10 +6,12 @@
6
6
  // Three principals. The asymmetry between the first two is the whole design; the third is a door
7
7
  // key, not a seat:
8
8
  //
9
- // Instance token the human/CLI credential. Full trust: gates, run control, agent surfaces.
10
- // From the instance Secret (`JR2_INSTANCE_TOKEN`); minted per boot when absent,
11
- // announced once on stdout (fixtures capture it). An Agent never has it — it
12
- // never enters a Sandbox.
9
+ // Instance token the human/CLI credential: the Caller `token/instance` (ADR-0078), with every
10
+ // scope, and the one credential of the routes no scope names. From the instance
11
+ // Secret (`JR2_INSTANCE_TOKEN`); minted per boot when absent, announced once on
12
+ // stdout (fixtures capture it). An Agent never has it — it never enters a Sandbox.
13
+ // The other Callers (named `jr2c_` tokens, JWTs) are below, under Callers:
14
+ // each acts within its scopes.
13
15
  //
14
16
  // Publish token `jr2 up`'s credential for `PUT /assembly` alone (ADR-0069). A publish imports
15
17
  // code into the process that holds the ServiceAccount, so it must cost what an
@@ -17,17 +19,20 @@
17
19
  // (`JR2_PUBLISH_TOKEN`), read by `jr2 up` through the kube API only. Every other
18
20
  // route answers it 403: it is a known token, but not one that route accepts.
19
21
  //
20
- // Sandbox token the Custodian's credential. Authorizes exactly: deliver to `kind: "agent"`
21
- // registrations whose Sandbox is THIS one. Never a Gate (a compromised Agent
22
- // must not approve its own review), never another Sandbox (`coding.ts`'s iids
23
- // are derivable and feature ids are readable from the Work Source, so a merely
24
- // run-scoped token would let one feature's coder inject a verdict into another
25
- // feature's reviewer). Delivered as a Secret mounted into the Custodian
26
- // container ALONE, which the Agent cannot read; the Harness holds its
27
- // Stand-in (ADR-0059).
28
- // The signed name is a POD hosting Turns, not a Sandbox CR per se: the Instance
29
- // Harness's Custodian holds one signed for that placement's Service name
30
- // (ADR-0031), scoping it to the Menu-only registrations placed there.
22
+ // Sandbox token the Custodian's credentials, two of them, each valid on its own routes alone
23
+ // (ADR-0076). Both are signed names of a POD hosting Turns — a Sandbox, or the
24
+ // Instance Harness's Service name (ADR-0031) — and both live in the Custodian
25
+ // container ALONE, which no Workspace code reaches (ADR-0059):
26
+ // control token `agent:<pod>.<sig>`. Delivers to `kind: "agent"` registrations whose pod is
27
+ // THIS one, and only for the slot the Harness states (`X-Jr2-Slot`), on the
28
+ // five agent routes. The Harness process alone holds its secret Stand-in.
29
+ // fetch token `<pod>.<sig>`. Asks for a fetch for THIS Sandbox (ADR-0053), on
30
+ // `POST /sandboxes/<pod>/fetch` alone. Its Stand-in is public: it can do
31
+ // nothing else.
32
+ // Neither is ever a Gate's (a compromised Agent must not approve its own review),
33
+ // and neither reaches another pod (`coding.ts`'s iids are derivable and feature
34
+ // ids are readable from the Work Source, so a merely run-scoped token would let
35
+ // one feature's coder inject a verdict into another feature's reviewer).
31
36
  //
32
37
  // And one bearer the other way (ADR-0058), which is no principal here because this server never
33
38
  // checks it:
@@ -43,27 +48,279 @@
43
48
  // after one, which is exactly what ADR-0012's re-attach promises the still-running Custodian.
44
49
 
45
50
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
51
+ import { readFileSync, unwatchFile, watchFile } from "node:fs";
46
52
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
47
- import { join } from "node:path";
53
+ import { basename, join } from "node:path";
54
+ import { configMapDigest } from "./mount-digest.ts";
55
+ import { SCOPES, type AnswerGrant, type Scope, type Scopes } from "./config.ts";
56
+
57
+ // ---- Callers (ADR-0078) -----------------------------------------------------------------------
58
+ // A Caller acts on the Instance from outside its Machines: the CLI, a CI job, a bridge, a person in
59
+ // the Console. One seam makes it: an ORDERED list of authenticators. Each one takes the bearer and
60
+ // answers a Caller, "not mine", or "mine, and bad". The first that claims the bearer decides, so a
61
+ // bearer that one claims and finds bad is 401 and never falls through to the next. The Instance
62
+ // token is the first authenticator, the named `jr2c_` tokens the second, and the JWT issuers
63
+ // (callers.ts) the third. The principals inside the Orchestrator (the publish token, the control and fetch tokens)
64
+ // are outside the seam: no authenticator makes a Caller from one of them.
65
+
66
+ /** A request's Caller (ADR-0078): who acts, through which authenticator, and what it may do. */
67
+ export type Caller = {
68
+ /** The authenticator that made the Caller: `token` (the Instance token or a named token), `jwt`
69
+ * or `hook`. */
70
+ via: "token" | "jwt" | "hook";
71
+ /** The Caller's name for that authenticator: `instance`, a named token's name, a JWT's `sub`, a
72
+ * Hook's name. */
73
+ name: string;
74
+ /** The name that jr2 shows, when it is not `name` (a JWT's `email`). */
75
+ shown?: string;
76
+ /** What the Caller may do. */
77
+ may: Scopes;
78
+ /** The Caller's claim about the party it acts for. jr2 records it and never checks it. */
79
+ onBehalfOf?: string;
80
+ };
81
+
82
+ /** The header in which a Caller states the party it acts for (ADR-0078). A claim: jr2 records it
83
+ * and shows it, and never checks it. */
84
+ export const ON_BEHALF_OF_HEADER = "jr2-on-behalf-of";
85
+
86
+ /** The longest `jr2-on-behalf-of` the guarded band takes: a user name, not a document. */
87
+ export const ON_BEHALF_OF_MAX = 256;
88
+
89
+ /** The prefix of a named Caller token's value (ADR-0078). It lets the seam say "not mine" with no
90
+ * lookup, and lets a secret scanner find a value that leaked. */
91
+ export const CALLER_TOKEN_PREFIX = "jr2c_";
92
+
93
+ /** Every scope, for every Workflow: the Instance token's grant. */
94
+ const EVERY_SCOPE: Scopes = Object.fromEntries(SCOPES.map((s) => [s, "all"])) as Scopes;
95
+
96
+ /** The Instance token's Caller (ADR-0078): `token/instance`, with every scope for every Workflow. */
97
+ export function instanceCaller(): Caller {
98
+ return { via: "token", name: "instance", may: { ...EVERY_SCOPE } };
99
+ }
100
+
101
+ /** Whether a Caller is the Instance token's. The config refuses a named token called `instance`,
102
+ * so the pair names it alone. */
103
+ export function isInstanceCaller(caller: Caller): boolean {
104
+ return caller.via === "token" && caller.name === "instance";
105
+ }
106
+
107
+ /** Whether a list item's Workflow name opens `workflow`. A wrapper's name opens each of its named
108
+ * Charts (`plan` opens `plan/weekly`), because a named Chart runs its wrapper's Machine (ADR-0070). */
109
+ function nameOpens(name: string, workflow: string): boolean {
110
+ if (name === workflow) return true;
111
+ const cut = workflow.indexOf("/");
112
+ return cut > 0 && name === workflow.slice(0, cut);
113
+ }
114
+
115
+ /** Whether `grant` opens `workflow`: for the `answer` scope, at least one Gate of it (ADR-0078). */
116
+ export function grantOpens(grant: AnswerGrant | undefined, workflow: string): boolean {
117
+ if (grant === undefined) return false;
118
+ if (grant === "all") return true;
119
+ return grant.some((item) => nameOpens(typeof item === "string" ? item : item.workflow, workflow));
120
+ }
121
+
122
+ /** Whether an `answer` grant opens the Gate named `gate` (its invoke id, ADR-0011) of `workflow`:
123
+ * a bare Workflow name opens each of its Gates, and a `{ workflow, gates }` item the Gates that it
124
+ * names (ADR-0078). */
125
+ export function grantOpensGate(grant: AnswerGrant | undefined, workflow: string, gate: string): boolean {
126
+ if (grant === undefined) return false;
127
+ if (grant === "all") return true;
128
+ return grant.some((item) =>
129
+ typeof item === "string"
130
+ ? nameOpens(item, workflow)
131
+ : nameOpens(item.workflow, workflow) && item.gates.includes(gate),
132
+ );
133
+ }
134
+
135
+ /** Whether a Caller has `scope` for `workflow`. For `answer`, that is at least one Gate of it:
136
+ * {@link callerMayAnswer} asks for one Gate. */
137
+ export function callerMay(caller: Caller, scope: Scope, workflow: string): boolean {
138
+ return grantOpens(caller.may[scope], workflow);
139
+ }
140
+
141
+ /** Whether a Caller may answer the Gate named `gate` (its invoke id) of a run of `workflow`
142
+ * (ADR-0078). */
143
+ export function callerMayAnswer(caller: Caller, workflow: string, gate: string): boolean {
144
+ return grantOpensGate(caller.may.answer, workflow, gate);
145
+ }
146
+
147
+ /** Whether a Caller has `scope` for at least one Workflow. */
148
+ export function callerHasScope(caller: Caller, scope: Scope): boolean {
149
+ const grant = caller.may[scope];
150
+ return grant !== undefined && (grant === "all" || grant.length > 0);
151
+ }
152
+
153
+ /** Whether `workflow` is in any scope of a Caller: what `GET /runs/resolve` answers for. */
154
+ export function callerMayAny(caller: Caller, workflow: string): boolean {
155
+ return SCOPES.some((s) => callerMay(caller, s, workflow));
156
+ }
157
+
158
+ /** One authenticator's answer: a Caller; `{ refused }`, which is "mine, and bad" (401, and it never
159
+ * falls through); or `undefined`, which is "not mine". */
160
+ export type CallerAnswer = Caller | { refused: string } | undefined;
161
+
162
+ /** One authenticator of the seam. An authenticator that must fetch (a JWT issuer's keys) answers
163
+ * with a Promise. */
164
+ export type CallerAuthenticator = (bearer: string) => CallerAnswer | Promise<CallerAnswer>;
165
+
166
+ /** The Instance token's authenticator (ADR-0078). */
167
+ export function instanceTokenAuthenticator(instanceToken: string): CallerAuthenticator {
168
+ return (bearer) => (constantTimeEqual(bearer, instanceToken) ? instanceCaller() : undefined);
169
+ }
170
+
171
+ /** A named token's entry as the Orchestrator holds it: its name, the sha-256 of its value, and its
172
+ * scopes. Never the value (ADR-0078). */
173
+ export type NamedToken = { name: string; digest: string; may: Scopes };
174
+
175
+ /** The digest of a named token's value, as `jr2 up` gives it to the Orchestrator: sha-256, hex. */
176
+ export function callerTokenDigest(value: string): string {
177
+ return createHash("sha256").update(value).digest("hex");
178
+ }
179
+
180
+ /** Make a named token's value: `jr2c_` and 32 random bytes in base64url (ADR-0078). */
181
+ export function mintCallerToken(): string {
182
+ return CALLER_TOKEN_PREFIX + randomBytes(32).toString("base64url");
183
+ }
184
+
185
+ /**
186
+ * The named tokens' authenticator (ADR-0078). It claims every bearer with the `jr2c_` prefix, so a
187
+ * value that no entry knows is 401 here, never a bearer for the next authenticator. `tokens` is read
188
+ * on each request: a rotation or a new config applies with no restart.
189
+ */
190
+ export function namedTokenAuthenticator(tokens: () => readonly NamedToken[]): CallerAuthenticator {
191
+ return (bearer) => {
192
+ if (!bearer.startsWith(CALLER_TOKEN_PREFIX)) return undefined;
193
+ const digest = callerTokenDigest(bearer);
194
+ // Each entry is compared, in constant time: the digest is not secret, but the loop does not
195
+ // stop early, so the time does not say which entry is near.
196
+ let found: NamedToken | undefined;
197
+ for (const entry of tokens()) if (constantTimeEqual(digest, entry.digest)) found = entry;
198
+ if (!found) return { refused: "no named Caller token has this value — it was rotated, or never made" };
199
+ return { via: "token", name: found.name, may: found.may };
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Join the named tokens that the config declares with the digests that `jr2 up` gave the
205
+ * Orchestrator (ADR-0078). A declared name with no digest has no value yet, and a digest whose name
206
+ * the config does not declare opens nothing.
207
+ */
208
+ export function namedTokens(
209
+ declared: Readonly<Record<string, { may: Scopes }>> | undefined,
210
+ digests: Readonly<Record<string, string>>,
211
+ ): NamedToken[] {
212
+ return Object.entries(declared ?? {}).flatMap(([name, { may }]) => {
213
+ const digest = digests[name];
214
+ return typeof digest === "string" && digest !== "" ? [{ name, digest, may }] : [];
215
+ });
216
+ }
217
+
218
+ /**
219
+ * The digests of the named tokens, by name, from the file that `jr2 up` mounts into the
220
+ * Orchestrator (`{ "<name>": "<sha-256 hex>" }`). The file is read again when it changes, so a
221
+ * rotation needs no restart: the kubelet updates a mounted Secret on its own period, about a
222
+ * minute, and until then the old value works (ADR-0078). `loaded` tells which content the process
223
+ * holds, so `jr2 up` and `jr2 caller rotate` wait for it. An absent file reads as no digests. A file
224
+ * that does not parse is logged, and the digests read last stay.
225
+ */
226
+ export function watchCallerDigests(path: string, opts: { intervalMs?: number } = {}) {
227
+ let digests: Record<string, string> = {};
228
+ // The text that gave `digests`, so that `loaded` tells what the process holds now.
229
+ let text: string | undefined;
230
+ const read = () => {
231
+ try {
232
+ const raw = readFileSync(path, "utf8");
233
+ const parsed = JSON.parse(raw) as unknown;
234
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a JSON object");
235
+ digests = Object.fromEntries(
236
+ Object.entries(parsed as Record<string, unknown>).filter(
237
+ (e): e is [string, string] => typeof e[1] === "string",
238
+ ),
239
+ );
240
+ text = raw;
241
+ } catch (err) {
242
+ // Absent is the ordinary case for an Instance that declares no named token: no digest. A file
243
+ // that does not parse keeps the digests read last, so a write seen half done stops no Caller.
244
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
245
+ digests = {};
246
+ text = undefined;
247
+ } else console.error(`caller digests at ${path} not read: ${err instanceof Error ? err.message : err}`);
248
+ }
249
+ };
250
+ read();
251
+ // A poll of the file's stat, not `fs.watch`: the kubelet swaps a symlink above the file, which an
252
+ // inotify watch on the old target never sees. `persistent: false` holds no process alive.
253
+ const listener = () => read();
254
+ watchFile(path, { interval: opts.intervalMs ?? 2000, persistent: false }, listener);
255
+ return {
256
+ /** The digests now. */
257
+ current: (): Readonly<Record<string, string>> => digests,
258
+ /**
259
+ * The digest of the file content that gave the digests now, keyed by the file's name as a
260
+ * mount's digest is (`configMapDigest`), so that `jr2 up` and `jr2 caller rotate` can wait
261
+ * until a value they wrote works (ADR-0078). Undefined when no file was read.
262
+ */
263
+ loaded: (): string | undefined => (text === undefined ? undefined : configMapDigest({ [basename(path)]: text })),
264
+ /** Read the file now (a test does not wait for the poll). */
265
+ reload: read,
266
+ stop: () => unwatchFile(path, listener),
267
+ };
268
+ }
48
269
 
49
270
  /** Who a request is, once its bearer token checks out. */
50
271
  export type Principal =
51
- /** The instance's own operator: the CLI, a human, a webhook translator holding the token. */
52
- | { kind: "instance" }
272
+ /** A Caller (ADR-0078): the Instance token, a named token, or a JWT. */
273
+ | { kind: "caller"; caller: Caller }
53
274
  /** `jr2 up`, publishing an Assembly (ADR-0069). Only `PUT /assembly` accepts it: it is not an
54
275
  * Instance principal, so it reads no run and starts none. */
55
276
  | { kind: "publisher" }
56
- /** One Sandbox's Custodian, speaking for the Agent in that pod — and for no one else. */
57
- | { kind: "sandbox"; sandbox: string };
277
+ /** One Sandbox's fetch token (ADR-0053, ADR-0076): asks for a fetch for that Sandbox, and does
278
+ * nothing else. */
279
+ | { kind: "sandbox"; sandbox: string }
280
+ /** One pod's control token (ADR-0076), with the slot the Harness stated for the conversation
281
+ * (`X-Jr2-Slot`): speaks on the agent routes for that slot's registrations in that pod, and for no
282
+ * other. */
283
+ | { kind: "agent"; sandbox: string; slot: string };
58
284
 
59
- /** Resolve a bearer token to a principal; undefined = not a token we minted (→ 401). */
60
- export type Authenticator = (bearer: string | undefined) => Principal | undefined;
285
+ /** The header the Harness states a control request's slot in (ADR-0076): the Agent's actor path
286
+ * and agent name, its continued conversation's Instance ID without the run id. */
287
+ export const SLOT_HEADER = "x-jr2-slot";
61
288
 
62
- /** The Sandbox token for one Sandbox: its name, signed. Same name + key → same token, always. */
289
+ /** Resolve a bearer token, and the slot a control request states, to a principal; undefined = not a
290
+ * token we minted, or a control token with no slot (→ 401). */
291
+ export type Authenticator = (bearer: string | undefined, slot?: string) => Promise<Principal | undefined>;
292
+
293
+ /** The fetch token for one Sandbox: its name, signed. Same name + key → same token, always. The
294
+ * bare form of ADR-0013's Sandbox token; since ADR-0076 it opens the fetch route alone. */
63
295
  export function sandboxToken(key: Buffer, sandbox: string): string {
64
296
  return `${sandbox}.${sign(key, sandbox)}`;
65
297
  }
66
298
 
299
+ /** The domain of a control token's signed name (ADR-0076). No DNS label holds a `:`, so a control
300
+ * token is never read as a fetch token, nor the reverse. */
301
+ const CONTROL_PREFIX = "agent:";
302
+
303
+ /**
304
+ * The control token for one pod (ADR-0076): what the Custodian puts in place of the secret control
305
+ * Stand-in on the agent routes. A signed name like the fetch token — same key, same deterministic
306
+ * re-mint — in its own `agent:` domain. No slot is inside it: the Harness states the slot on each
307
+ * request, and the Orchestrator checks it against the registration's.
308
+ */
309
+ export function controlToken(key: Buffer, pod: string): string {
310
+ const name = `${CONTROL_PREFIX}${pod}`;
311
+ return `${name}.${sign(key, name)}`;
312
+ }
313
+
314
+ /**
315
+ * The secret control Stand-in for one pod (ADR-0076, ADR-0059): the bearer the Harness process
316
+ * alone holds, as a file under `/run/jr2/` that no confined child can read, and that the Custodian
317
+ * swaps for {@link controlToken} on the agent routes. A 43-character base64url HMAC, so no
318
+ * Workspace code can guess it. Its own `control-standin:` domain keeps it apart from every token.
319
+ */
320
+ export function controlStandIn(key: Buffer, pod: string): string {
321
+ return sign(key, `control-standin:${pod}`);
322
+ }
323
+
67
324
  /**
68
325
  * The Harness bearer for one placement (ADR-0058): what the Orchestrator presents on every Harness
69
326
  * wire call — admit, stream, abort, echo — to the Harness in the pod `placement` names (a
@@ -128,55 +385,114 @@ export async function loadSigningKey(dir: string): Promise<Buffer> {
128
385
  }
129
386
 
130
387
  /**
131
- * The instance's authenticator: the Instance token, plus any Sandbox token this key signed.
132
- * A token we did not mint resolves to nothing, and the caller is refused — there is no anonymous
133
- * principal, and no route decides for itself whether it needs one.
388
+ * The instance's authenticator: the principals inside the Orchestrator (the publish token, and any
389
+ * Sandbox token this key signed), then the Caller seam (ADR-0078) in its order — the Instance
390
+ * token, the named tokens, then `callers`. A token we did not mint resolves to nothing, and the
391
+ * caller is refused — there is no anonymous principal, and no route decides for itself whether it
392
+ * needs one.
393
+ *
394
+ * The principals come first because each is an exact value or a signed name: no Caller credential
395
+ * has the form of one (a named token holds no `.`), so the order changes no answer, and no
396
+ * authenticator of the seam ever sees a principal's token.
134
397
  */
135
398
  export function createAuthenticator(opts: {
136
399
  instanceToken: string;
137
400
  signingKey: Buffer;
138
401
  /** The publish token (ADR-0069). Absent → no bearer resolves to the publisher. */
139
402
  publishToken?: string;
403
+ /** The named tokens, read on each request (ADR-0078). Absent → none. */
404
+ namedTokens?: () => readonly NamedToken[];
405
+ /** More authenticators of the seam, after the named tokens: the JWT issuers (callers.ts). */
406
+ callers?: readonly CallerAuthenticator[];
140
407
  }): Authenticator {
141
- return (bearer) => {
408
+ const seam: CallerAuthenticator[] = [
409
+ instanceTokenAuthenticator(opts.instanceToken),
410
+ ...(opts.namedTokens ? [namedTokenAuthenticator(opts.namedTokens)] : []),
411
+ ...(opts.callers ?? []),
412
+ ];
413
+ const caller = async (bearer: string): Promise<Principal | undefined> => {
414
+ for (const authenticate of seam) {
415
+ const answer = await authenticate(bearer);
416
+ if (answer === undefined) continue;
417
+ // Claimed: the first claim decides, and a bad one never falls through.
418
+ return "refused" in answer ? undefined : { kind: "caller", caller: answer };
419
+ }
420
+ return undefined;
421
+ };
422
+ return async (bearer, slot) => {
142
423
  if (!bearer) return undefined;
143
- if (constantTimeEqual(bearer, opts.instanceToken)) return { kind: "instance" };
144
424
  if (opts.publishToken !== undefined && constantTimeEqual(bearer, opts.publishToken)) return { kind: "publisher" };
145
425
  const cut = bearer.lastIndexOf(".");
146
- if (cut <= 0) return undefined;
147
- const sandbox = bearer.slice(0, cut);
148
- if (!constantTimeEqual(bearer.slice(cut + 1), sign(opts.signingKey, sandbox))) return undefined;
149
- return { kind: "sandbox", sandbox };
426
+ if (cut <= 0) return caller(bearer);
427
+ const name = bearer.slice(0, cut);
428
+ if (!constantTimeEqual(bearer.slice(cut + 1), sign(opts.signingKey, name))) return caller(bearer);
429
+ // A control token (ADR-0076), read BEFORE the bare form. A control request states its slot; one
430
+ // that states none is refused like an unknown token, so no route ever sees a control principal
431
+ // without a slot.
432
+ if (name.startsWith(CONTROL_PREFIX)) {
433
+ const pod = name.slice(CONTROL_PREFIX.length);
434
+ if (!isPodName(pod) || !slot) return undefined;
435
+ return { kind: "agent", sandbox: pod, slot };
436
+ }
437
+ // The bare fetch token, in its own domain alone: a name in any other domain (`harness:`,
438
+ // `control-standin:`, `held-standin:` …) is not a Sandbox's, whatever key signed it.
439
+ if (!isPodName(name)) return undefined;
440
+ return { kind: "sandbox", sandbox: name };
150
441
  };
151
442
  }
152
443
 
444
+ /** A pod's name as a token signs it: a DNS-1123 name, so it holds no `:` and no `/` — the marks of
445
+ * every other signed domain (ADR-0076). */
446
+ function isPodName(name: string): boolean {
447
+ return /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(name);
448
+ }
449
+
153
450
  /**
154
- * May this principal deliver to this agent registration? The Instance token may (it is the
155
- * operator). A Sandbox token may only when the registration records ITS name — which is why
156
- * an Agent registration carries `sandbox` at all (ADR-0013). The recorded name is the pod hosting the Turn:
157
- * a Workspace's Sandbox, or `jr2-instance-harness` for a Menu-only registration (ADR-0031). An
158
- * agent registration with NO name at all is an explicit-`endpoint` run (the stub Harness on the
159
- * host, in no pod): no Sandbox token can claim it.
451
+ * May this principal speak on this agent registration's routes — its surface, a pick, a delivery's
452
+ * poll, a notice's acknowledgment? The Instance token may (it is the operator). A control token may
453
+ * only when the registration records ITS pod — which is why an Agent registration carries `sandbox`
454
+ * at all (ADR-0013) — AND the slot the Harness stated is the registration's (ADR-0076). The recorded
455
+ * pod is the one hosting the Turn: a Workspace's Sandbox, or `jr2-instance-harness` for a Menu-only
456
+ * registration (ADR-0031). An agent registration with NO pod at all is an explicit-`endpoint` run
457
+ * (the stub Harness on the host, in no pod): no pod's token can claim it. A fetch token never may:
458
+ * it opens the fetch route alone.
459
+ *
460
+ * The slot check finds a routing fault. It is not the boundary: only the Harness process holds the
461
+ * control Stand-in, so only the Harness can state a slot at all (ADR-0076).
160
462
  */
161
- export function mayDeliverToAgent(principal: Principal, registrationSandbox: string | undefined): boolean {
162
- if (principal.kind === "instance") return true;
163
- if (principal.kind !== "sandbox") return false;
164
- return registrationSandbox !== undefined && registrationSandbox === principal.sandbox;
463
+ export function mayDeliverToAgent(principal: Principal, registration: { sandbox?: string; slot?: string }): boolean {
464
+ if (isInstancePrincipal(principal)) return true;
465
+ if (principal.kind !== "agent") return false;
466
+ return (
467
+ registration.sandbox !== undefined &&
468
+ registration.sandbox === principal.sandbox &&
469
+ registration.slot !== undefined &&
470
+ registration.slot === principal.slot
471
+ );
165
472
  }
166
473
 
167
474
  /**
168
- * May this principal ask for a fetch on this Sandbox (ADR-0053)? A Sandbox token may ask for its
475
+ * May this principal ask for a fetch on this Sandbox (ADR-0053)? A fetch token may ask for its
169
476
  * OWN pod and no other — the scope is the caches that pod mounts, and the route refuses a Repo it
170
477
  * does not. The Instance token may, on the same grounds it may deliver to any agent surface: it is
171
- * the operator. The publish token may not: it opens the publish door and no other (ADR-0069).
478
+ * the operator. A control token may not: it opens the agent routes alone (ADR-0076). The publish
479
+ * token may not: it opens the publish door and no other (ADR-0069).
172
480
  */
173
481
  export function mayAskForSandbox(principal: Principal, sandbox: string): boolean {
174
- if (principal.kind === "instance") return true;
482
+ if (isInstancePrincipal(principal)) return true;
175
483
  if (principal.kind !== "sandbox") return false;
176
484
  return principal.sandbox === sandbox;
177
485
  }
178
486
 
179
- function sign(key: Buffer, value: string): string {
487
+ /** Whether a principal is the Instance token's Caller: the operator, which the routes that no
488
+ * scope names take alone (ADR-0014). A named token is never it, whatever its scopes. */
489
+ export function isInstancePrincipal(principal: Principal): boolean {
490
+ return principal.kind === "caller" && isInstanceCaller(principal.caller);
491
+ }
492
+
493
+ /** An HMAC of `value` under the signing key, base64url (43 characters). Exported so every signed
494
+ * name and Stand-in derives one way. */
495
+ export function sign(key: Buffer, value: string): string {
180
496
  return createHmac("sha256", key).update(value).digest("base64url");
181
497
  }
182
498