@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
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // This module is the pure half, shared by every seat that needs it:
11
11
  //
12
- // standIn(name) the value the Harness sees — deterministic and public
12
+ // heldStandIn(...) a held secret's Stand-in, minted per pod by the pod's maker — secret
13
+ // standIn(name) the fetch Stand-in's form — deterministic and public, for that one alone
13
14
  // heldSecretsOf(...) `jr2 up`'s normalization of the claimed entries: every refusal (R1–R13),
14
15
  // every warning, and the engine-neutral bindings each pod's manifest
15
16
  // (`held-<pod>.json`) takes its own from
@@ -22,24 +23,47 @@
22
23
  import { createHash } from "node:crypto";
23
24
  import { readFile } from "node:fs/promises";
24
25
  import { isIP } from "node:net";
25
- import type { JR2Config, HarnessEnvVar } from "./config.ts";
26
- import { MODEL_KEYS, MODEL_KEY_PROVIDERS, SIGNING_CREDENTIALS } from "./model-keys.ts";
27
- import { HELD_CA_SECRET, HELD_SECRETS_SECRET, HELD_TLS_SECRET, INSTANCE_EFFECTOR, INSTANCE_SECRET } from "./names.ts";
26
+ import { isStdioServer, type JR2Config, type HarnessEnvVar } from "./config.ts";
27
+ import {
28
+ CREDENTIAL_FILES,
29
+ DOMAIN_WRITABLE_ROOTS,
30
+ MODEL_KEYS,
31
+ MODEL_KEY_PROVIDERS,
32
+ SIGNING_CREDENTIALS,
33
+ } from "./model-keys.ts";
34
+ import {
35
+ HELD_CA_SECRET,
36
+ HELD_SECRETS_SECRET,
37
+ HELD_TLS_SECRET,
38
+ INSTANCE_EFFECTOR,
39
+ INSTANCE_HARNESS_SERVICE,
40
+ INSTANCE_SECRET,
41
+ } from "./names.ts";
28
42
  import type { PlacementRoute } from "./parts.ts";
43
+ import { sign } from "./tokens.ts";
29
44
 
30
45
  /** The kit's Secrets no Harness container may read (R13): the Instance token and signing key, each
31
46
  * pod's literal held values, the leaves, the CA, and every Effector's (ADR-0068) — the Instance
32
47
  * Effector's Effect secrets and key, each Workspace's Sandbox Effector Secret (`jr2-effector-<pod>`),
33
48
  * each Sandbox's Effector key (`<sandbox>-effector`), and each `git.credentials` entry's clone and
34
- * push Secrets (`jr2-git-<hash>`, `jr2-git-push-<hash>`, ADR-0051). */
35
- const KIT_SECRETS: readonly string[] = [INSTANCE_SECRET, HELD_TLS_SECRET, HELD_CA_SECRET, INSTANCE_EFFECTOR];
49
+ * push Secrets (`jr2-git-<hash>`, `jr2-git-push-<hash>`, ADR-0051) — and each pod's own credentials
50
+ * (ADR-0076): the Instance Harness's (`jr2-instance-harness-token`) and each Sandbox's
51
+ * (`<sandbox>-token`), which hold the real control token beside the Stand-ins. */
52
+ const KIT_SECRETS: readonly string[] = [
53
+ INSTANCE_SECRET,
54
+ HELD_TLS_SECRET,
55
+ HELD_CA_SECRET,
56
+ INSTANCE_EFFECTOR,
57
+ `${INSTANCE_HARNESS_SERVICE}-token`,
58
+ ];
36
59
  const KIT_SECRET_PREFIXES: readonly string[] = [`${HELD_SECRETS_SECRET}-`, "jr2-effector-", "jr2-git-"];
37
60
  const isKitSecret = (name: string): boolean =>
38
61
  KIT_SECRETS.includes(name) ||
39
62
  name === HELD_SECRETS_SECRET ||
40
63
  KIT_SECRET_PREFIXES.some((p) => name.startsWith(p)) ||
41
- // A Sandbox's own Effector key: every Sandbox's name starts `ws-` (`workspaceName`).
42
- (name.startsWith("ws-") && name.endsWith("-effector"));
64
+ // A Sandbox's own Effector key and its credentials: every Sandbox's name starts `ws-`
65
+ // (`workspaceName`).
66
+ (name.startsWith("ws-") && (name.endsWith("-effector") || name.endsWith("-token")));
43
67
 
44
68
  /**
45
69
  * The pod key of one Workspace (ADR-0059): its registered workflow and the placement route to its
@@ -70,18 +94,36 @@ export function providerClaims(config: JR2Config, models: readonly string[]): st
70
94
  }
71
95
 
72
96
  /**
73
- * The Stand-in for one held secret: what its env var holds in the Harness container.
74
- *
75
- * Deterministic and public — it is not a secret, the Agent reads it from its env, and it means
76
- * something only to the Custodian and only toward a bound host. Unique per name, so one request can
77
- * carry two secrets' Stand-ins and the Custodian can tell them apart. ASCII letters, digits, `-`
78
- * and `_` only, so it is a valid header and env value; and it never contains `sk-ant-oat`, which pi
79
- * reads as an Anthropic OAuth token and answers with different auth headers.
97
+ * The PUBLIC Stand-in form, `jr2-held-<NAME>`. Since ADR-0076 one Stand-in alone takes it: the fetch
98
+ * Stand-in, `standIn("JR2_SANDBOX_TOKEN")`, which `jr2-upload-pack` holds as a constant and the
99
+ * Custodian swaps on the fetch route alone. Every other Stand-in is secret ({@link heldStandIn}).
80
100
  */
81
101
  export function standIn(name: string): string {
82
102
  return `jr2-held-${name}`;
83
103
  }
84
104
 
105
+ /** What a Stand-in must never contain (ADR-0059): pi reads a key with it as an Anthropic OAuth
106
+ * token, and answers with different auth headers. */
107
+ const OAUTH_MARK = "sk-ant-oat";
108
+
109
+ /**
110
+ * The SECRET Stand-in of one held secret in one pod (ADR-0059, ADR-0076): what the Harness process
111
+ * holds for it, as the file `/run/jr2/standins/<NAME>`, and what the Custodian of that pod swaps for
112
+ * the value. `jr2-held-<NAME>.<hmac>` — the readable prefix keeps a log scrub's pattern, and the
113
+ * HMAC under the signing key makes it unguessable, so Workspace code can spend only the secrets its
114
+ * `workspace()` grants. `pod` is the Sandbox's name, or `instance` for the Instance Harness. The
115
+ * pod's maker mints it: the Orchestrator for a Sandbox, `jr2 up` for the Instance Harness and a
116
+ * check pod. ASCII letters, digits, `-`, `_` and `.` only, so it is a valid header and env value,
117
+ * and it never contains `sk-ant-oat`: a signature that would is signed again under a counter.
118
+ */
119
+ export function heldStandIn(key: Buffer, pod: string, name: string): string {
120
+ for (let n = 0; ; n++) {
121
+ const domain = `held-standin:${pod}/${name}${n === 0 ? "" : `#${n}`}`;
122
+ const value = `${standIn(name)}.${sign(key, domain)}`;
123
+ if (!value.includes(OAUTH_MARK)) return value;
124
+ }
125
+ }
126
+
85
127
  /** The request headers a Stand-in is swapped in when a held secret names none — the four places a
86
128
  * model API carries its key: `Authorization: Bearer` (OpenAI-compatible, `ANTHROPIC_AUTH_TOKEN`),
87
129
  * `x-api-key` (Anthropic), `x-goog-api-key` (Gemini), `api-key` (Azure). */
@@ -99,10 +141,15 @@ export const CREDENTIAL_HEADERS: readonly string[] = [...DEFAULT_CREDENTIAL_HEAD
99
141
  /** The env var the custom provider's key rides as (ADR-0018) — held since ADR-0059, and the kit's. */
100
142
  export const PROVIDER_KEY_NAME = "JR2_PROVIDER_API_KEY";
101
143
 
102
- /** The Sandbox token's env var (ADR-0013) — a held secret of the kit's own, bound to the
103
- * Orchestrator (ADR-0059). The Harness container holds its Stand-in; the Custodian holds the token. */
144
+ /** The fetch token's held name (ADR-0013, ADR-0076) — a held secret of the kit's own, bound to the
145
+ * Orchestrator's fetch route (ADR-0059). Its Stand-in is the public `jr2-held-JR2_SANDBOX_TOKEN`,
146
+ * which `jr2-upload-pack` holds; the Custodian holds the token. */
104
147
  export const SANDBOX_TOKEN_NAME = "JR2_SANDBOX_TOKEN";
105
148
 
149
+ /** The control token's held name (ADR-0076) — the kit's own, bound to the agent routes. Its Stand-in
150
+ * is secret: the Harness process alone holds it, as `/run/jr2/control/standin`. */
151
+ export const CONTROL_TOKEN_NAME = "JR2_CONTROL_TOKEN";
152
+
106
153
  /** The env names the mechanism owns on a Harness container (ADR-0059): the proxy, the trust
107
154
  * bundles, and every `JR2_` name. A held secret may not choose them, and while a held secret exists,
108
155
  * `harness.env` may not either — the Custodian cannot chain to a second proxy yet (R11). */
@@ -233,6 +280,52 @@ export function heldSecretsOf(
233
280
  catalogHosts.set(id, target.port === 443 ? target.host : `${target.host}:${target.port}`);
234
281
  }
235
282
 
283
+ // R7 for an MCP server (ADR-0079): one with a `secret` needs an https url, because the Custodian
284
+ // carries TLS only. Its url's host is the default binding of the secret it names (`hostsOf`).
285
+ const serverHosts = new Map<string, Array<{ server: string; host: string; port: number; path: string }>>();
286
+ for (const [server, declared] of Object.entries(config.mcp ?? {})) {
287
+ if (isStdioServer(declared) || declared.secret === undefined || !claimed.has(declared.secret)) continue;
288
+ const target = httpsTarget(declared.url);
289
+ if (!target) {
290
+ throw new Error(
291
+ `mcp.${server}.url must be an https:// URL — the server has a secret, ${declared.secret}, and the Custodian ` +
292
+ "carries TLS only (ADR-0059, ADR-0079). Read it from .env: " +
293
+ `mcp: { "${server}": { url: process.env.${server.toUpperCase().replace(/-/g, "_")}_URL ?? "", secret: "${declared.secret}" } }`,
294
+ );
295
+ }
296
+ const path = new URL(declared.url).pathname || "/";
297
+ serverHosts.set(declared.secret, [...(serverHosts.get(declared.secret) ?? []), { server, ...target, path }]);
298
+ }
299
+
300
+ // jr2's own names (ADR-0076): the Harness's start takes a `JR2_` name from jr2's composition alone,
301
+ // and refuses to start on an envFrom key that starts with `JR2_` (`effector nocore --harness-env`).
302
+ // A user's `JR2_MENU_ONLY` or `JR2_START_ENV` would turn the domain off or replace its env.
303
+ for (const [i, v] of env.entries()) {
304
+ if (v.name.startsWith("JR2_")) {
305
+ throw new Error(
306
+ `harness.env[${i}] sets ${v.name}: JR2_ names are jr2's own, and the Harness takes them from jr2 alone ` +
307
+ "(ADR-0076). Name the env var your program reads.",
308
+ );
309
+ }
310
+ }
311
+ for (const [i, ref] of (harness.envFrom ?? []).entries()) {
312
+ const lookup = ref.secretRef
313
+ ? `secret/${ref.secretRef.name}`
314
+ : ref.configMapRef
315
+ ? `configmap/${ref.configMapRef.name}`
316
+ : "";
317
+ const own = (lookups.envFromKeys?.get(lookup) ?? [])
318
+ .map((key) => (ref.prefix ?? "") + key)
319
+ .find((key) => key.startsWith("JR2_"));
320
+ if (own !== undefined) {
321
+ const where = ref.secretRef ? `Secret "${ref.secretRef.name}"` : `ConfigMap "${ref.configMapRef?.name}"`;
322
+ throw new Error(
323
+ `harness.envFrom[${i}] loads ${where}, which carries ${own}: JR2_ names are jr2's own, and the Harness ` +
324
+ "does not start with one from envFrom (ADR-0076). Drop the key, or give the source a prefix.",
325
+ );
326
+ }
327
+ }
328
+
236
329
  // R1: a known model key the Agent could read.
237
330
  for (const [i, v] of env.entries()) {
238
331
  if (!(v.name in MODEL_KEYS)) continue;
@@ -268,6 +361,39 @@ export function heldSecretsOf(
268
361
  }
269
362
  }
270
363
 
364
+ // A credential file that the Harness process reads (ADR-0076): never one that the domain writes.
365
+ for (const [i, v] of env.entries()) {
366
+ if (!CREDENTIAL_FILES.includes(v.name)) continue;
367
+ const path = v.value;
368
+ if (path !== undefined) {
369
+ const writable = DOMAIN_WRITABLE_ROOTS.find((root) => path === root || path.startsWith(`${root}/`));
370
+ if (!path.startsWith("/") || writable !== undefined || path.split("/").includes("..")) {
371
+ throw new Error(
372
+ `harness.env[${i}] sets ${v.name} to "${path}", ${writable !== undefined ? `which is in ${writable}, a folder the Agent's code writes` : "which is not an absolute path"}: the Harness process reads that file, and code that writes it acts outside the Agent's confinement (ADR-0076). Point it at a file of the image that the Sandbox's user cannot write.`,
373
+ );
374
+ }
375
+ }
376
+ warnings.push(
377
+ `harness.env sets ${v.name}: the Harness process reads ${path !== undefined ? `"${path}"` : "the file it names"}, ` +
378
+ "so the Sandbox's user must not be able to write it — a folder of the image that the user owns is one the " +
379
+ "Agent's code writes (ADR-0076)",
380
+ );
381
+ }
382
+ for (const [i, ref] of (harness.envFrom ?? []).entries()) {
383
+ const lookup = ref.secretRef
384
+ ? `secret/${ref.secretRef.name}`
385
+ : ref.configMapRef
386
+ ? `configmap/${ref.configMapRef.name}`
387
+ : "";
388
+ for (const key of (lookups.envFromKeys?.get(lookup) ?? []).map((k) => (ref.prefix ?? "") + k)) {
389
+ if (!CREDENTIAL_FILES.includes(key)) continue;
390
+ warnings.push(
391
+ `harness.envFrom[${i}] sets ${key}: the Harness process reads the file it names, so the Sandbox's user must ` +
392
+ "not be able to write it — a folder of the image that the user owns is one the Agent's code writes (ADR-0076)",
393
+ );
394
+ }
395
+ }
396
+
271
397
  type Entry = {
272
398
  at: string;
273
399
  name: string;
@@ -344,8 +470,10 @@ export function heldSecretsOf(
344
470
  const source = sourceOf(e, lookups);
345
471
  if (source.kind === "literal") values[e.name] = e.value as string;
346
472
 
347
- // R6
348
- const headers = [...(e.headers ?? DEFAULT_CREDENTIAL_HEADERS)];
473
+ // R6. An MCP server's secret with no binding goes in `authorization` alone (ADR-0079): the
474
+ // Harness sends `Authorization: Bearer <Stand-in>`.
475
+ const mcpDefault = e.hosts === undefined && e.headers === undefined && serverHosts.has(e.name);
476
+ const headers = [...(e.headers ?? (mcpDefault ? ["authorization"] : DEFAULT_CREDENTIAL_HEADERS))];
349
477
  const seenHeader = new Set<string>();
350
478
  for (const [j, header] of headers.entries()) {
351
479
  if (!HEADER_TOKEN.test(header) || FRAMING_HEADERS.has(header) || header.startsWith("proxy-")) {
@@ -358,6 +486,16 @@ export function heldSecretsOf(
358
486
  if (seenHeader.has(header)) throw new Error(`${e.at}.headers lists "${header}" twice (ADR-0059)`);
359
487
  seenHeader.add(header);
360
488
  }
489
+ // R6 for an MCP server's secret (ADR-0079): the Harness sends its Stand-in in `authorization`
490
+ // alone, so a binding without that header removes it on each call.
491
+ for (const s of serverHosts.get(e.name) ?? []) {
492
+ if (headers.includes("authorization")) break;
493
+ throw new Error(
494
+ `${e.at}.headers leaves out "authorization", where the Harness sends the Stand-in of ${e.name} to ` +
495
+ `mcp.${s.server}.url — each call of that server would arrive with no credential. Add it, or drop headers ` +
496
+ "to take that default (ADR-0059, ADR-0079)",
497
+ );
498
+ }
361
499
 
362
500
  // R12
363
501
  const paths = e.paths === undefined ? undefined : [...e.paths];
@@ -369,9 +507,18 @@ export function heldSecretsOf(
369
507
  );
370
508
  }
371
509
  }
510
+ // R12 for an MCP server's secret (ADR-0079): every call goes to the url's path, so `paths` must
511
+ // cover it, by the prefix rule the Custodian applies.
512
+ for (const s of serverHosts.get(e.name) ?? []) {
513
+ if (paths === undefined || paths.some((p) => pathCovers(p, s.path))) continue;
514
+ throw new Error(
515
+ `${e.at}.paths does not cover ${s.path}, the path of mcp.${s.server}.url, whose secret ${e.name} is — ` +
516
+ `each call of that server would be refused with 403. Add "${s.path}", or drop paths (ADR-0059, ADR-0079)`,
517
+ );
518
+ }
372
519
 
373
520
  // R5 (+ the §3.4 default and R9)
374
- const hosts = hostsOf(e, catalogHosts, lookups.models ?? []);
521
+ const hosts = hostsOf(e, catalogHosts, lookups.models ?? [], serverHosts.get(e.name) ?? []);
375
522
  secrets.push({
376
523
  name: e.name,
377
524
  source,
@@ -429,24 +576,6 @@ export function heldSecretsOf(
429
576
  }
430
577
  }
431
578
 
432
- // R5: one header on one host names one secret.
433
- const byTarget = new Map<string, { name: string; headers: string[] }[]>();
434
- for (const s of secrets) {
435
- for (const h of s.hosts) {
436
- const key = `${h.host}:${h.port}`;
437
- for (const other of byTarget.get(key) ?? []) {
438
- const shared = s.headers.filter((header) => other.headers.includes(header));
439
- if (shared.length > 0) {
440
- throw new Error(
441
- `${s.name} and ${other.name} are both bound to ${key} in header ${shared.join(", ")} — one header on ` +
442
- "one host carries one secret; give each its own `headers` (ADR-0059)",
443
- );
444
- }
445
- }
446
- byTarget.set(key, [...(byTarget.get(key) ?? []), { name: s.name, headers: s.headers }]);
447
- }
448
- }
449
-
450
579
  return { manifest: { version: 1, secrets }, values, warnings };
451
580
  }
452
581
 
@@ -495,16 +624,31 @@ export function sourceOf(
495
624
 
496
625
  /** R5 and R9: where one secret may go. A known model key with no `hosts` goes where its provider's
497
626
  * calls go — the catalog override's host, else the table's — so the key and the calls read one
498
- * config and cannot drift. */
627
+ * config and cannot drift. An MCP server's secret with no `hosts` goes to the host of the url of
628
+ * each server that names it (ADR-0079), for the same reason. */
499
629
  function hostsOf(
500
630
  e: { at: string; name: string; hosts?: readonly string[] },
501
631
  catalogHosts: ReadonlyMap<string, string>,
502
632
  models: readonly string[],
633
+ servers: ReadonlyArray<{ server: string; host: string; port: number }>,
503
634
  ): Array<{ host: string; port: number }> {
504
635
  const known = MODEL_KEYS[e.name];
505
636
  const effective = new Map<string, string | undefined>();
506
637
  for (const provider of known?.providers ?? []) effective.set(provider, catalogHosts.get(provider) ?? known?.host);
507
638
 
639
+ if (e.hosts === undefined && servers.length > 0) {
640
+ const out: Array<{ host: string; port: number }> = [];
641
+ const add = (t: { host: string; port: number }) => {
642
+ if (!out.some((o) => o.host === t.host && o.port === t.port)) out.push({ host: t.host, port: t.port });
643
+ };
644
+ for (const s of servers) add(s);
645
+ // A model key that an MCP server also names keeps its provider's host beside the servers'.
646
+ const defaults = new Set(effective.values());
647
+ const only = defaults.size === 1 ? [...defaults][0] : undefined;
648
+ if (only !== undefined) add(parseTarget(`${e.at}.hosts`, only));
649
+ return out;
650
+ }
651
+
508
652
  if (e.hosts === undefined || e.hosts.length === 0) {
509
653
  const defaults = new Set(effective.values());
510
654
  const only = defaults.size === 1 ? [...defaults][0] : undefined;
@@ -527,9 +671,23 @@ function hostsOf(
527
671
  `or drop hosts to take that default (ADR-0059)`,
528
672
  );
529
673
  }
674
+ for (const s of servers) {
675
+ if (targets.some((t) => t.host === s.host && t.port === s.port)) continue;
676
+ throw new Error(
677
+ `${e.at}.hosts leaves out ${s.host}${s.port === 443 ? "" : `:${s.port}`}, the host of mcp.${s.server}.url, ` +
678
+ `whose secret ${e.name} is — each call of that server would arrive with the Stand-in and fail with 401. ` +
679
+ "Add it, or drop hosts to take that default (ADR-0059, ADR-0079)",
680
+ );
681
+ }
530
682
  return targets;
531
683
  }
532
684
 
685
+ /** Whether the `paths` entry `prefix` lets a request to `path` through: the Custodian's rule — the
686
+ * same path, or `prefix` and then a `/` (or `prefix` ends in `/`). */
687
+ function pathCovers(prefix: string, path: string): boolean {
688
+ return path === prefix || (path.startsWith(prefix) && (prefix.endsWith("/") || path.charAt(prefix.length) === "/"));
689
+ }
690
+
533
691
  /** An `https://` URL's host and port, or undefined for anything else. */
534
692
  function httpsTarget(url: string): { host: string; port: number } | undefined {
535
693
  let parsed: URL;
package/src/holds.ts CHANGED
@@ -31,13 +31,16 @@ export type Hold = {
31
31
  deliveryId: string;
32
32
  /** The pick's event name. */
33
33
  pick: string;
34
- /** The Agent slot that picked — the Console's "held since" names it. */
34
+ /** The Agent slot that picked — the Console's "outstanding since" names it. */
35
35
  agent: string;
36
- /** ISO time the pick was delivered: the Console's "held since". */
36
+ /** ISO time the pick was delivered: the Console's "outstanding since". */
37
37
  since: string;
38
38
  /** The Sandbox whose token may read the answer (ADR-0013) — the registration's, kept because the
39
39
  * answer outlives a registration that a route out of the Agent's state ends. */
40
40
  sandbox?: string;
41
+ /** The slot whose stated control requests may read the answer (ADR-0076) — the registration's,
42
+ * kept with `sandbox` for the same reason. */
43
+ slot?: string;
41
44
  /** The invoke id of the Agent's state when the pick was made: a re-registration of the same
42
45
  * address by another state is another Turn. */
43
46
  invokeId?: string;
@@ -0,0 +1,92 @@
1
+ // `@jr2/orchestrator/hooks/discord`: jr2's Discord kind, for an application's Interactions Endpoint URL.
2
+
3
+ import { z } from "zod";
4
+ import { at, defineHookKind, json } from "./index.ts";
5
+
6
+ const user = z.looseObject({ id: z.string() });
7
+
8
+ /** The schema of each event key. A command's name is part of its key, so `.*` covers them all. */
9
+ export const discordEvents = {
10
+ "application_command.*": z.looseObject({
11
+ channel_id: z.string(),
12
+ /** In a guild. */
13
+ member: z.looseObject({ user }).optional(),
14
+ /** In a direct message. */
15
+ user: user.optional(),
16
+ data: z.looseObject({
17
+ name: z.string(),
18
+ options: z
19
+ .array(z.looseObject({ name: z.string(), value: z.union([z.string(), z.number(), z.boolean()]).optional() }))
20
+ .optional(),
21
+ }),
22
+ }),
23
+ message_component: z.looseObject({
24
+ member: z.looseObject({ user }).optional(),
25
+ user: user.optional(),
26
+ data: z.looseObject({ custom_id: z.string() }),
27
+ message: z.looseObject({ id: z.string() }),
28
+ }),
29
+ };
30
+
31
+ const text = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
32
+
33
+ /** The interaction types that this kind reads (Discord's numbers). */
34
+ const PING = 1;
35
+ const APPLICATION_COMMAND = 2;
36
+ const MESSAGE_COMPONENT = 3;
37
+
38
+ /**
39
+ * - verifier: `ed25519` over `<X-Signature-Timestamp><raw body>`, `X-Signature-Ed25519` in hex,
40
+ * checked against the application's PUBLIC key in the Orchestrator. The signature covers the
41
+ * timestamp, so the kind gives it as `at`. Discord states no window; the door's 300 s window
42
+ * applies to each signed `at`, so it applies here too.
43
+ * - handshake: `PING` (type 1) gets `{ type: 1 }` (PONG).
44
+ * - event keys: `application_command.<data.name>` (type 2), `message_component` (type 3). Another
45
+ * type (an autocomplete, a modal) gets the key `interaction.<type>`, which has no schema, so it
46
+ * reaches nothing.
47
+ * - delivery id: the interaction's `id`. `onBehalfOf`: `member.user.id`, or `user.id` in a direct
48
+ * message. (Discord's docs do not say that Discord sends an interaction again.)
49
+ * - who reads the answer: a person, always.
50
+ * - answer: `{ type: 4, data: { content, flags: 64 } }`, an ephemeral message. Discord shows
51
+ * markdown, not HTML, so the text needs no escape. Text limit: 2000 characters.
52
+ */
53
+ export const discord = defineHookKind({
54
+ api: 1,
55
+ id: "discord",
56
+ events: discordEvents,
57
+ verify: {
58
+ ed25519: {
59
+ signed: (req) => {
60
+ const ts = req.headers["x-signature-timestamp"];
61
+ const sig = req.headers["x-signature-ed25519"];
62
+ if (ts === undefined || !/^\d+$/.test(ts) || sig === undefined) {
63
+ return { unsigned: "no Discord signature or timestamp" };
64
+ }
65
+ return { bytes: [ts, req.body], signature: { hex: sig }, at: Number(ts) };
66
+ },
67
+ },
68
+ },
69
+ read: (req) => {
70
+ const p = json(req);
71
+ const type = at(p, "type");
72
+ if (type === PING) return { handshake: { body: { type: 1 } } };
73
+ const id = text(at(p, "id"));
74
+ if (id === undefined) throw new Error("the interaction has no id");
75
+ const by = text(at(p, "member.user.id")) ?? text(at(p, "user.id"));
76
+ const key =
77
+ type === APPLICATION_COMMAND
78
+ ? `application_command.${text(at(p, "data.name")) ?? ""}`
79
+ : type === MESSAGE_COMPONENT
80
+ ? "message_component"
81
+ : `interaction.${String(type)}`;
82
+ return { key, id, payload: p, ...(by ? { onBehalfOf: by } : {}), readBy: "person" };
83
+ },
84
+ answer: (t) => ({ body: { type: 4, data: { content: t, flags: 64 } } }),
85
+ maxText: 2000,
86
+ });
87
+
88
+ declare module "./index.ts" {
89
+ interface HookKinds {
90
+ discord: typeof discord;
91
+ }
92
+ }
@@ -0,0 +1,107 @@
1
+ // `hook-kinds/door.ts`: what the Hook's door reads from a Hook and a kind. The package does not
2
+ // export it, because it is NOT part of a kind's interface: a kind imports `@jr2/orchestrator/hooks` only. It is here because
3
+ // `defineHookKind` decides the shape of a Hook, so the code that reads a Hook is beside it.
4
+
5
+ import type { z } from "zod";
6
+ import { HOOK } from "./hook.ts";
7
+ import { HOOK_KIND_APIS, type Hook, type HookKind, type HookParts, type HookRequest, type Signed } from "./index.ts";
8
+
9
+ /** True when `value` is a Hook that a kind's constructor made. */
10
+ export function isHook(value: unknown): value is Hook {
11
+ return typeof value === "object" && value !== null && Object.hasOwn(value, HOOK);
12
+ }
13
+
14
+ /** The kind, the credential and `may` of a Hook. It throws when `hook` is not a Hook. */
15
+ export function hookParts<K extends HookKind>(hook: Hook<K>): HookParts<K> {
16
+ if (!isHook(hook)) throw new Error("not a Hook: make it with its kind, such as github({ secret, may })");
17
+ return hook[HOOK];
18
+ }
19
+
20
+ /**
21
+ * The request that the door gives a kind. Header names become lowercase. A header with more than
22
+ * one value keeps them joined by ", ", as `Headers` does. `strip` names a header that the door
23
+ * removes (the `token` verifier's header), so no code of the kind can read it.
24
+ */
25
+ export function hookRequest(
26
+ headers: Headers | { readonly [name: string]: string | readonly string[] | undefined },
27
+ body: Uint8Array,
28
+ strip?: string,
29
+ ): HookRequest {
30
+ const out: { [name: string]: string } = Object.create(null) as { [name: string]: string };
31
+ const entries: Iterable<[string, string | readonly string[] | undefined]> =
32
+ headers instanceof Headers ? headers.entries() : Object.entries(headers);
33
+ for (const [name, value] of entries) {
34
+ if (value === undefined) continue;
35
+ const lower = name.toLowerCase();
36
+ const text = typeof value === "string" ? value : value.join(", ");
37
+ out[lower] = Object.hasOwn(out, lower) ? `${out[lower]}, ${text}` : text;
38
+ }
39
+ if (strip !== undefined) delete out[strip.toLowerCase()];
40
+ const bytes = new Uint8Array(body);
41
+ return Object.freeze({
42
+ headers: Object.freeze(out),
43
+ body: bytes,
44
+ text: new TextDecoder("utf-8").decode(bytes),
45
+ });
46
+ }
47
+
48
+ /**
49
+ * The schema of an event key: the exact key, else the longest `prefix.*` key that the event key
50
+ * starts with (`block_actions.*` for `block_actions.approve`). `undefined` when the kind names no
51
+ * schema for the key: the door does not parse such a delivery, and it reaches nothing.
52
+ */
53
+ export function schemaFor(kind: HookKind, key: string): z.ZodType | undefined {
54
+ if (Object.hasOwn(kind.events, key)) return kind.events[key];
55
+ let best: { readonly length: number; readonly schema: z.ZodType } | undefined;
56
+ for (const [k, schema] of Object.entries(kind.events)) {
57
+ if (!k.endsWith(".*")) continue;
58
+ const prefix = k.slice(0, -1); // keeps the "."
59
+ if (key.length > prefix.length && key.startsWith(prefix) && (!best || prefix.length > best.length)) {
60
+ best = { length: prefix.length, schema };
61
+ }
62
+ }
63
+ return best?.schema;
64
+ }
65
+
66
+ /** The bytes that the service signed, joined. A string part is UTF-8. */
67
+ export function signedBytes(signed: Signed): Uint8Array {
68
+ const parts = signed.bytes.map((b) => (typeof b === "string" ? new TextEncoder().encode(b) : b));
69
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
70
+ let i = 0;
71
+ for (const p of parts) {
72
+ out.set(p, i);
73
+ i += p.length;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** The signature as bytes, or `undefined` when its hex or base64 is not well formed. */
79
+ export function signatureBytes(signed: Signed): Uint8Array | undefined {
80
+ const s = signed.signature;
81
+ if ("hex" in s) {
82
+ if (!/^(?:[0-9a-fA-F]{2})+$/.test(s.hex)) return undefined;
83
+ return Uint8Array.from(Buffer.from(s.hex, "hex"));
84
+ }
85
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(s.base64) || s.base64.length % 4 !== 0) return undefined;
86
+ return Uint8Array.from(Buffer.from(s.base64, "base64"));
87
+ }
88
+
89
+ /**
90
+ * The kinds of one Assembly, by id. It throws when two different kinds give one id, or when a kind
91
+ * states an `api` that this copy of `@jr2/orchestrator/hooks` does not know. One kind that two Hooks use is one
92
+ * entry.
93
+ */
94
+ export function kindsById(kinds: Iterable<HookKind>): ReadonlyMap<string, HookKind> {
95
+ const out = new Map<string, HookKind>();
96
+ for (const kind of kinds) {
97
+ if (!HOOK_KIND_APIS.includes(kind.api)) {
98
+ throw new Error(`Hook kind ${kind.id} states api ${String(kind.api)}; this jr2 knows api 1 only`);
99
+ }
100
+ const had = out.get(kind.id);
101
+ if (had !== undefined && had !== kind) {
102
+ throw new Error(`two different Hook kinds have the id ${kind.id}: one id is one kind in an Assembly`);
103
+ }
104
+ out.set(kind.id, kind);
105
+ }
106
+ return out;
107
+ }
@@ -0,0 +1,89 @@
1
+ // `@jr2/orchestrator/hooks/github`: jr2's GitHub kind. It imports `@jr2/orchestrator/hooks` and nothing more of jr2, as a
2
+ // kind from another package does.
3
+
4
+ import { z } from "zod";
5
+ import { defineHookKind, json, type HookRequest } from "./index.ts";
6
+
7
+ const user = z.looseObject({ login: z.string() });
8
+
9
+ /**
10
+ * The schema of each event key. Each names only the fields that handlers read; a loose object keeps
11
+ * the rest. A value that GitHub can extend (a review's `state`) is a string, not an enum, so a new
12
+ * value fails no parse.
13
+ */
14
+ export const githubEvents = {
15
+ "pull_request_review.submitted": z.looseObject({
16
+ review: z.looseObject({
17
+ /** Lowercase in a webhook: `approved`, `changes_requested`, `commented`. */
18
+ state: z.string(),
19
+ body: z.string().nullable(),
20
+ user,
21
+ commit_id: z.string(),
22
+ }),
23
+ pull_request: z.looseObject({ html_url: z.string() }),
24
+ }),
25
+ "issue_comment.created": z.looseObject({
26
+ comment: z.looseObject({ body: z.string(), user, author_association: z.string() }),
27
+ issue: z.looseObject({
28
+ /** Only when the issue is a pull request. */
29
+ pull_request: z.looseObject({ html_url: z.string() }).optional(),
30
+ }),
31
+ }),
32
+ "check_suite.completed": z.looseObject({
33
+ check_suite: z.looseObject({
34
+ head_sha: z.string(),
35
+ /** `success`, `failure`, `neutral`, `cancelled`, `timed_out`, …; `null` while it runs. */
36
+ conclusion: z.string().nullable(),
37
+ }),
38
+ }),
39
+ };
40
+
41
+ const header = (req: HookRequest, name: string): string => {
42
+ const v = req.headers[name];
43
+ if (v === undefined || v === "") throw new Error(`no ${name} header`);
44
+ return v;
45
+ };
46
+
47
+ /**
48
+ * A GitHub repository, organization or App webhook, with the content type `application/json`. (A
49
+ * webhook with the content type `application/x-www-form-urlencoded` gets 400.)
50
+ * - verifier: `mac`, HMAC-SHA256 of the raw body, `X-Hub-Signature-256: sha256=<hex>`.
51
+ * - handshake: the `ping` event (sent when the webhook is made) gets an empty 200.
52
+ * - event key: `<X-GitHub-Event>.<action>`, or `<X-GitHub-Event>` when the body has no action.
53
+ * - delivery id: `X-GitHub-Delivery`. `onBehalfOf`: `sender.login`.
54
+ * - answer: the outcome (the default). No person reads it.
55
+ */
56
+ export const github = defineHookKind({
57
+ api: 1,
58
+ id: "github",
59
+ events: githubEvents,
60
+ verify: {
61
+ mac: {
62
+ alg: "sha256",
63
+ signed: (req) => {
64
+ const sig = req.headers["x-hub-signature-256"];
65
+ if (!sig?.startsWith("sha256=")) return { unsigned: "no X-Hub-Signature-256 header" };
66
+ return { bytes: [req.body], signature: { hex: sig.slice("sha256=".length) } };
67
+ },
68
+ },
69
+ },
70
+ read: (req) => {
71
+ const event = header(req, "x-github-event");
72
+ if (event === "ping") return { handshake: { body: null } };
73
+ const p = json(req) as { readonly action?: unknown; readonly sender?: { readonly login?: unknown } } | null;
74
+ const action = typeof p?.action === "string" && p.action !== "" ? p.action : undefined;
75
+ const by = p?.sender?.login;
76
+ return {
77
+ key: action ? `${event}.${action}` : event,
78
+ id: header(req, "x-github-delivery"),
79
+ payload: p,
80
+ ...(typeof by === "string" && by !== "" ? { onBehalfOf: by } : {}),
81
+ };
82
+ },
83
+ });
84
+
85
+ declare module "./index.ts" {
86
+ interface HookKinds {
87
+ github: typeof github;
88
+ }
89
+ }