@jr2/orchestrator 0.6.0 → 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 (301) 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 +86 -9
  14. package/docs/concepts/architecture.md +41 -28
  15. package/docs/concepts/callers-and-hooks.md +205 -0
  16. package/docs/concepts/charts.md +7 -5
  17. package/docs/concepts/composition.md +10 -1
  18. package/docs/concepts/converge.md +4 -2
  19. package/docs/concepts/durable-runs.md +43 -16
  20. package/docs/concepts/effects.md +50 -22
  21. package/docs/concepts/gates.md +95 -25
  22. package/docs/concepts/machines-and-workflows.md +5 -4
  23. package/docs/concepts/pools-and-sources.md +187 -11
  24. package/docs/concepts/secrets-and-trust.md +73 -33
  25. package/docs/concepts/the-menu.md +11 -0
  26. package/docs/concepts/workspaces.md +6 -5
  27. package/docs/get-started/a-model-decides.md +14 -13
  28. package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
  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/guides/add-tools-to-the-sandbox.md +55 -11
  32. package/docs/guides/answer-a-gate-from-a-forge.md +304 -0
  33. package/docs/guides/answer-a-gate.md +74 -37
  34. package/docs/guides/bind-open-parts.md +24 -8
  35. package/docs/guides/call-an-mcp-tool-from-a-machine.md +572 -0
  36. package/docs/guides/cancel-a-run.md +26 -16
  37. package/docs/guides/fix-a-failed-converge.md +18 -6
  38. package/docs/guides/give-a-caller-a-scope.md +203 -0
  39. package/docs/guides/give-an-agent-mcp-tools.md +422 -0
  40. package/docs/guides/iterate-on-a-machine-on-kind.md +10 -9
  41. package/docs/guides/let-an-agent-write-a-chart.md +9 -6
  42. package/docs/guides/look-inside-a-sandbox.md +101 -31
  43. package/docs/guides/open-the-console.md +63 -14
  44. package/docs/guides/read-a-conversation.md +225 -0
  45. package/docs/guides/run-a-chart-by-name.md +17 -16
  46. package/docs/guides/run-one-worker-per-item.md +15 -9
  47. package/docs/guides/set-an-agents-access-and-dials.md +2 -0
  48. package/docs/guides/start-a-run-for-each-alert.md +290 -0
  49. package/docs/guides/start-a-run-from-a-run.md +18 -16
  50. package/docs/guides/start-a-run.md +24 -18
  51. package/docs/guides/steer-an-agent.md +332 -0
  52. package/docs/guides/use-a-hosted-provider.md +1 -1
  53. package/docs/guides/watch-a-run.md +127 -121
  54. package/docs/guides/write-a-hook-kind.md +238 -0
  55. package/docs/guides/write-a-tool-as-an-mcp-server.md +351 -0
  56. package/docs/guides/write-a-workflow.md +17 -7
  57. package/docs/llms.txt +81 -15
  58. package/docs/reference/authoring-surface/agent.md +1 -1
  59. package/docs/reference/authoring-surface/answer.md +1 -1
  60. package/docs/reference/authoring-surface/arg.md +1 -1
  61. package/docs/reference/authoring-surface/basic.md +1 -1
  62. package/docs/reference/authoring-surface/bearer.md +1 -1
  63. package/docs/reference/authoring-surface/branchname.md +1 -1
  64. package/docs/reference/authoring-surface/carry.md +23 -0
  65. package/docs/reference/authoring-surface/chartschema.md +1 -1
  66. package/docs/reference/authoring-surface/chartschemafor.md +1 -1
  67. package/docs/reference/authoring-surface/command.md +1 -1
  68. package/docs/reference/authoring-surface/customize.md +4 -1
  69. package/docs/reference/authoring-surface/defineconfig.md +1 -1
  70. package/docs/reference/authoring-surface/defineevent.md +1 -1
  71. package/docs/reference/authoring-surface/dispatch.md +5 -4
  72. package/docs/reference/authoring-surface/fail.md +1 -1
  73. package/docs/reference/authoring-surface/file.md +1 -1
  74. package/docs/reference/authoring-surface/frompick.md +1 -1
  75. package/docs/reference/authoring-surface/gate.md +118 -0
  76. package/docs/reference/authoring-surface/group-agents.md +15 -0
  77. package/docs/reference/authoring-surface/group-charts.md +16 -0
  78. package/docs/reference/authoring-surface/group-composition.md +25 -0
  79. package/docs/reference/authoring-surface/group-config.md +17 -0
  80. package/docs/reference/authoring-surface/group-effects.md +48 -0
  81. package/docs/reference/authoring-surface/group-failure.md +13 -0
  82. package/docs/reference/authoring-surface/group-gates.md +16 -0
  83. package/docs/reference/authoring-surface/group-machine-and-events.md +15 -0
  84. package/docs/reference/authoring-surface/group-steering.md +12 -0
  85. package/docs/reference/authoring-surface/group-types-these-signatures-name.md +137 -0
  86. package/docs/reference/authoring-surface/group-workspaces-and-repos.md +15 -0
  87. package/docs/reference/authoring-surface/http.md +1 -1
  88. package/docs/reference/authoring-surface/isfailure.md +1 -1
  89. package/docs/reference/authoring-surface/isready.md +1 -1
  90. package/docs/reference/authoring-surface/jr2setup.md +13 -3
  91. package/docs/reference/authoring-surface/mcp.md +107 -0
  92. package/docs/reference/authoring-surface/open.md +5 -3
  93. package/docs/reference/authoring-surface/openbody.md +1 -1
  94. package/docs/reference/authoring-surface/paramsof.md +1 -1
  95. package/docs/reference/authoring-surface/pool.md +77 -9
  96. package/docs/reference/authoring-surface/push.md +1 -1
  97. package/docs/reference/authoring-surface/readyof.md +1 -1
  98. package/docs/reference/authoring-surface/reread.md +19 -0
  99. package/docs/reference/authoring-surface/secret.md +1 -1
  100. package/docs/reference/authoring-surface/sentby.md +1 -1
  101. package/docs/reference/authoring-surface/skip.md +22 -0
  102. package/docs/reference/authoring-surface/source.md +56 -1
  103. package/docs/reference/authoring-surface/steer.md +93 -0
  104. package/docs/reference/authoring-surface/text.md +1 -1
  105. package/docs/reference/authoring-surface/type-agentdefinition.md +14 -1
  106. package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
  107. package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
  108. package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
  109. package/docs/reference/authoring-surface/type-binding.md +1 -1
  110. package/docs/reference/authoring-surface/type-caller.md +40 -0
  111. package/docs/reference/authoring-surface/type-callerrecord.md +32 -0
  112. package/docs/reference/authoring-surface/type-carry.md +19 -0
  113. package/docs/reference/authoring-surface/type-chainlink.md +3 -3
  114. package/docs/reference/authoring-surface/type-commandoutput.md +1 -1
  115. package/docs/reference/authoring-surface/type-customize.md +16 -1
  116. package/docs/reference/authoring-surface/type-delivered.md +17 -0
  117. package/docs/reference/authoring-surface/type-deliveredevent.md +18 -0
  118. package/docs/reference/authoring-surface/type-dispatchdefinition.md +4 -3
  119. package/docs/reference/authoring-surface/type-dispatchoutput.md +1 -1
  120. package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
  121. package/docs/reference/authoring-surface/type-framepath.md +1 -1
  122. package/docs/reference/authoring-surface/type-gatebinding.md +15 -0
  123. package/docs/reference/authoring-surface/type-gatehooks.md +12 -0
  124. package/docs/reference/authoring-surface/type-gatelook.md +37 -0
  125. package/docs/reference/authoring-surface/type-gateoutcome.md +32 -0
  126. package/docs/reference/authoring-surface/type-gatereply.md +19 -0
  127. package/docs/reference/authoring-surface/type-gateslot.md +21 -0
  128. package/docs/reference/authoring-surface/type-gateslotinput.md +13 -0
  129. package/docs/reference/authoring-surface/type-gateslotparts.md +18 -0
  130. package/docs/reference/authoring-surface/type-gateslots.md +12 -0
  131. package/docs/reference/authoring-surface/type-gatetypesat.md +13 -0
  132. package/docs/reference/authoring-surface/type-hostinjectedinput.md +1 -1
  133. package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
  134. package/docs/reference/authoring-surface/type-jr2config.md +37 -2
  135. package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
  136. package/docs/reference/authoring-surface/type-lends.md +5 -1
  137. package/docs/reference/authoring-surface/type-lookoutput.md +14 -0
  138. package/docs/reference/authoring-surface/type-mcpcalloutput.md +78 -0
  139. package/docs/reference/authoring-surface/type-mcpnotreturned.md +42 -0
  140. package/docs/reference/authoring-surface/type-mcpoptions.md +47 -0
  141. package/docs/reference/authoring-surface/type-mcpoutput.md +50 -0
  142. package/docs/reference/authoring-surface/type-mcpreadoutput.md +53 -0
  143. package/docs/reference/authoring-surface/type-mcpremoteserver.md +50 -0
  144. package/docs/reference/authoring-surface/type-mcpserver.md +41 -0
  145. package/docs/reference/authoring-surface/type-mcpstdioserver.md +42 -0
  146. package/docs/reference/authoring-surface/type-namedsecret.md +4 -4
  147. package/docs/reference/authoring-surface/type-openbodydeclaration.md +1 -1
  148. package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
  149. package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
  150. package/docs/reference/authoring-surface/type-poolspec.md +11 -6
  151. package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
  152. package/docs/reference/authoring-surface/type-pushsourcespec.md +38 -0
  153. package/docs/reference/authoring-surface/type-readsourcespec.md +47 -0
  154. package/docs/reference/authoring-surface/type-reposlot.md +1 -1
  155. package/docs/reference/authoring-surface/type-reread.md +18 -0
  156. package/docs/reference/authoring-surface/type-runsconfig.md +41 -0
  157. package/docs/reference/authoring-surface/type-sender.md +26 -1
  158. package/docs/reference/authoring-surface/type-sendercallerfield.md +14 -0
  159. package/docs/reference/authoring-surface/type-size.md +1 -1
  160. package/docs/reference/authoring-surface/type-skip.md +19 -0
  161. package/docs/reference/authoring-surface/type-sourcehooks.md +23 -0
  162. package/docs/reference/authoring-surface/type-sourcein.md +25 -0
  163. package/docs/reference/authoring-surface/type-sourcereply.md +18 -0
  164. package/docs/reference/authoring-surface/type-sourcespec.md +16 -7
  165. package/docs/reference/authoring-surface/type-sourceverdict.md +49 -0
  166. package/docs/reference/authoring-surface/type-standingpoolmachine.md +12 -0
  167. package/docs/reference/authoring-surface/type-standingpoolspec.md +51 -0
  168. package/docs/reference/authoring-surface/type-steerargs.md +20 -0
  169. package/docs/reference/authoring-surface/type-thinkinglevel.md +1 -1
  170. package/docs/reference/authoring-surface/type-usercontainer.md +1 -1
  171. package/docs/reference/authoring-surface/type-workspaced.md +1 -1
  172. package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
  173. package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
  174. package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
  175. package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
  176. package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
  177. package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
  178. package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
  179. package/docs/reference/authoring-surface/workspace.md +1 -1
  180. package/docs/reference/authoring-surface/worktree.md +1 -1
  181. package/docs/reference/authoring-surface.md +88 -167
  182. package/docs/reference/cli.md +413 -74
  183. package/docs/reference/config.md +235 -2
  184. package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
  185. package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
  186. package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
  187. package/docs/reference/decisions/0007-durable-machine-state.md +1 -1
  188. package/docs/reference/decisions/0009-cli-and-instance-interface.md +150 -49
  189. package/docs/reference/decisions/0010-bdd-acceptance-tests.md +5 -3
  190. package/docs/reference/decisions/0011-workflow-defined-events.md +256 -12
  191. package/docs/reference/decisions/0012-workspace-wrapper-machine.md +9 -2
  192. package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
  193. package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +53 -27
  194. package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +18 -14
  195. package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
  196. package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +15 -7
  197. package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +32 -18
  198. package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
  199. package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +4 -4
  200. package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +116 -34
  201. package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
  202. package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +10 -5
  203. package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +7 -6
  204. package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
  205. package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +18 -10
  206. package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +3 -2
  207. package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +23 -18
  208. package/docs/reference/decisions/0032-the-console-unlocks-with-a-callers-credential.md +101 -0
  209. package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +4 -3
  210. package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +6 -6
  211. package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +5 -4
  212. package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
  213. package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
  214. package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +55 -40
  215. package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +14 -13
  216. package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +7 -0
  217. package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
  218. package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
  219. package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
  220. package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
  221. package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +2 -2
  222. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-kept-for-a-set-time.md +109 -0
  223. package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +51 -23
  224. package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
  225. package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
  226. package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +32 -9
  227. package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +17 -13
  228. package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +56 -30
  229. package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +35 -25
  230. package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
  231. package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
  232. package/docs/reference/decisions/0078-a-caller-comes-from-one-authenticator-seam-and-acts-within-its-scopes.md +285 -0
  233. 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
  234. package/docs/reference/decisions.md +6 -4
  235. package/docs/reference/glossary.md +96 -42
  236. package/docs/reference/hooks.md +1077 -0
  237. package/docs/reference/http-api.md +976 -149
  238. package/docs/why.md +2 -1
  239. package/package.json +10 -3
  240. package/src/actor.ts +36 -10
  241. package/src/agent.ts +50 -0
  242. package/src/assembly-checks.ts +530 -4
  243. package/src/callers.ts +245 -0
  244. package/src/chart.ts +52 -7
  245. package/src/config.ts +550 -1
  246. package/src/conversations.ts +453 -0
  247. package/src/custodian.ts +8 -2
  248. package/src/customize.ts +143 -4
  249. package/src/dispatch-effect.ts +142 -26
  250. package/src/durability.ts +2 -0
  251. package/src/duration.ts +20 -0
  252. package/src/effect-checks.ts +2 -1
  253. package/src/effect.ts +60 -7
  254. package/src/effector-wire.ts +272 -27
  255. package/src/gate-brand.ts +16 -0
  256. package/src/gate.ts +634 -48
  257. package/src/harness-client.ts +10 -4
  258. package/src/held-secrets.ts +72 -23
  259. package/src/holds.ts +2 -2
  260. package/src/hook-kinds/discord.ts +92 -0
  261. package/src/hook-kinds/door.ts +107 -0
  262. package/src/hook-kinds/github.ts +89 -0
  263. package/src/hook-kinds/gitlab.ts +72 -0
  264. package/src/hook-kinds/hook.ts +7 -0
  265. package/src/hook-kinds/index.ts +465 -0
  266. package/src/hook-kinds/slack.ts +151 -0
  267. package/src/hook-kinds/webhook.ts +113 -0
  268. package/src/hooks.ts +506 -0
  269. package/src/http-effect.ts +76 -38
  270. package/src/http.ts +889 -94
  271. package/src/index.ts +16 -5
  272. package/src/instance.ts +196 -26
  273. package/src/internal.ts +22 -3
  274. package/src/machine-doc.ts +26 -1
  275. package/src/mcp-effect.ts +424 -0
  276. package/src/mount-digest.ts +41 -7
  277. package/src/names.ts +64 -2
  278. package/src/notices.ts +138 -10
  279. package/src/open-body.ts +25 -11
  280. package/src/open-effect.ts +17 -4
  281. package/src/open.ts +4 -2
  282. package/src/parts.ts +87 -9
  283. package/src/party.ts +2 -1
  284. package/src/pick.ts +2 -2
  285. package/src/pool.ts +1142 -163
  286. package/src/registration.ts +203 -13
  287. package/src/run-host.ts +1509 -171
  288. package/src/run-list.ts +191 -0
  289. package/src/sandbox-kube.ts +53 -5
  290. package/src/secret-placement.ts +239 -33
  291. package/src/server.ts +53 -4
  292. package/src/setup.ts +72 -32
  293. package/src/snapshot-store.ts +565 -48
  294. package/src/steer.ts +111 -0
  295. package/src/stub-harness.ts +6 -2
  296. package/src/tokens.ts +263 -22
  297. package/src/wire.ts +17 -0
  298. package/src/workspace.ts +50 -6
  299. package/src/worktree.ts +3 -3
  300. package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +0 -61
  301. package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +0 -65
@@ -3,13 +3,20 @@
3
3
  //
4
4
  // - a Workspace's pod: the Held secrets its `workspace()` lists under `secrets: [...]`, and the
5
5
  // model keys of the Agents whose Turns run there — a model key is claimed by the PROVIDER;
6
- // - the Instance Harness: the model keys of the `"none"` Agents it hosts, and nothing else (they
7
- // cannot execute code, ADR-0031, so nothing else is theirs to use);
6
+ // - the Instance Harness: the model keys of the `"none"` Agents it hosts, and the secrets of the
7
+ // remote MCP servers they name (ADR-0079), and nothing else (they cannot execute code, ADR-0031,
8
+ // so nothing else is theirs to use);
9
+ // - a Workspace's pod also holds the secret of each MCP server an Agent there names (ADR-0079):
10
+ // "a Custodian holds what its pod's Agents name";
8
11
  // - an Effector: what its Effects name with `secret("name")` — the Instance Effector what its
9
12
  // `http` Effects name and the token of each `dispatch` with a target (ADR-0071; a dispatch with
10
13
  // no target runs in the Orchestrator's own process and names nothing); a Workspace's Sandbox Effector what its file-body `http`s and
11
14
  // credentialed `command`s name. The push credential its `push`es spend is not placed here: the provision
12
- // names the entry the slot's url matches on the Sandbox, by reference (git-push.ts, ADR-0051).
15
+ // names the entry the slot's url matches on the Sandbox, by reference (git-push.ts, ADR-0051);
16
+ // - the Instance Effector also holds the secret of each Hook with a `mac` verifier, and its fence
17
+ // takes each host that a Hook's kind declares in `posts`, with no secret (ADR-0068, ADR-0078);
18
+ // - the Orchestrator gets only the sha-256 digest of the secret of each Hook with a `token`
19
+ // verifier, in `jr2-hook-digests`, and never the value (ADR-0078).
13
20
  //
14
21
  // `hosts`, `headers` and `paths` are an entry's BINDING — where a Custodian may put the value — and
15
22
  // never its placement. Both is both, and `jr2 up` reports it per pod; an entry nothing names goes
@@ -19,6 +26,7 @@
19
26
  // the list does not hold, and an Effect secret shorter than 8 characters (ADR-0068): the Effector
20
27
  // replaces every match of a secret in what it returns, and a short one would match ordinary text.
21
28
 
29
+ import { createHash } from "node:crypto";
22
30
  import {
23
31
  providerClaims,
24
32
  sourceOf,
@@ -27,7 +35,8 @@ import {
27
35
  type HeldBinding,
28
36
  type HeldLookups,
29
37
  } from "./held-secrets.ts";
30
- import type { JR2Config } from "./config.ts";
38
+ import { isStdioServer, mcpTimeoutMs, type JR2Config, type McpRemoteServer } from "./config.ts";
39
+ import { hookParts } from "./hook-kinds/door.ts";
31
40
  import { needsASlot } from "./effect.ts";
32
41
  import { declaredPushKinds, pushRefusal } from "./git-push.ts";
33
42
  import { INSTANCE_HARNESS_HELD_POD } from "./names.ts";
@@ -42,6 +51,10 @@ export type EffectSecret = { name: string; source: HeldBinding["source"] };
42
51
  /** One target an Effect placed in an Effector may call, with the secrets it names there. */
43
52
  export type EffectorTargetConfig = { scheme: "http" | "https"; host: string; port: number; secrets: string[] };
44
53
 
54
+ /** One MCP server an `mcp` Effect placed in the Instance Effector calls (ADR-0079): `effector.json`'s
55
+ * `mcp` entry — its url, the name of its secret, and its `timeout` in milliseconds. Never a value. */
56
+ export type EffectorMcpServer = { url: string; secret?: string; timeoutMs: number };
57
+
45
58
  /** What one Effector placement holds and allows — what `jr2 up` converges from. */
46
59
  export type EffectorPlan = {
47
60
  placement: "instance";
@@ -51,12 +64,22 @@ export type EffectorPlan = {
51
64
  values: Record<string, string>;
52
65
  /** `effector.json`'s targets: one per distinct (target, secrets) an Effect placed there names. */
53
66
  targets: EffectorTargetConfig[];
67
+ /** `effector.json`'s `mcp`: each server an `mcp` Effect placed there names, by its name in the
68
+ * `mcp` block (ADR-0079). Empty when there is none. */
69
+ mcp: Record<string, EffectorMcpServer>;
54
70
  /** The longest timeout among the Effects placed there, in milliseconds. */
55
71
  longestTimeoutMs: number;
56
72
  /** The Effects placed there, as `<workflow>/<slot>`. */
57
73
  effects: string[];
74
+ /** The Hooks it serves (ADR-0068): each one with a `mac` verifier, or whose kind declares
75
+ * `posts`, by name. */
76
+ hooks: string[];
58
77
  };
59
78
 
79
+ /** The secret of a Hook with a `token` verifier (ADR-0078): `jr2 up` writes the sha-256 digest of
80
+ * its value into `jr2-hook-digests`, from the literal or from the key of the user's Secret. */
81
+ export type HookTokenSecret = { name: string } & ({ value: string } | { secret: string; key: string });
82
+
60
83
  /** One registered Machine's Effect, as `jr2 up` walked it. */
61
84
  export type PlacedEffect = { workflow: string; effect: CarriedEffect };
62
85
 
@@ -67,8 +90,9 @@ export type PlacedWorkspace = { workflow: string; pod: WorkspacePod };
67
90
  export type PlacementWalk = {
68
91
  effects: readonly PlacedEffect[];
69
92
  workspaces: readonly PlacedWorkspace[];
70
- /** The models of the `"none"` Agents — present when the Instance Harness is converged. */
71
- instanceHarness?: { models: readonly string[] };
93
+ /** The models of the `"none"` Agents, and the MCP servers they name (ADR-0079) — present when
94
+ * the Instance Harness is converged. */
95
+ instanceHarness?: { models: readonly string[]; servers?: readonly string[] };
72
96
  };
73
97
 
74
98
  /** One Harness pod's Held secrets: what its Custodian holds. */
@@ -79,8 +103,11 @@ export type HeldPodPlan = {
79
103
  label: string;
80
104
  /** The names it holds, in list order; the custom provider's key last. */
81
105
  names: string[];
82
- /** Of `names`, those a provider claims (the rest the `workspace()` lists). */
106
+ /** Of `names`, those a provider claims (the rest the `workspace()` lists, or an MCP server). */
83
107
  byProvider: string[];
108
+ /** Of `names`, the secrets of the MCP servers its Agents name (ADR-0079), each with those
109
+ * servers. Absent when there is none. */
110
+ byServer?: Record<string, string[]>;
84
111
  };
85
112
 
86
113
  /** One push of a Workspace (ADR-0067, ADR-0051): its Repo Slot, whether it signs, and — for a slot the
@@ -120,10 +147,12 @@ export type SecretPlacements = {
120
147
  held: HeldPodPlan[];
121
148
  /** Every name some pod holds — what `heldSecretsOf` normalizes. */
122
149
  claimed: Set<string>;
123
- /** The Instance Effector, when an Effect is placed there; absent otherwise. */
150
+ /** The Instance Effector, when an Effect is placed there or a Hook needs it; absent otherwise. */
124
151
  instance?: EffectorPlan;
125
152
  /** Each Workspace whose body places an Effect in its Sandbox Effector. */
126
153
  sandbox: SandboxEffectorPlan[];
154
+ /** The secrets of the `token` Hooks, in list order: only their digests reach the Orchestrator. */
155
+ hookTokens: HookTokenSecret[];
127
156
  /** One line per secret, then one per pod: where each goes, what each holds. */
128
157
  report: string[];
129
158
  warnings: string[];
@@ -184,7 +213,7 @@ export function secretPlacementsOf(
184
213
  walk: PlacementWalk,
185
214
  lookups: Pick<HeldLookups, "secretKeys" | "valueLength"> = {},
186
215
  ): SecretPlacements {
187
- const { entries, namedBy, instancePlaced } = namesOf(config, walk);
216
+ const { entries, namedBy, instancePlaced, hooks, tokenHooks } = namesOf(config, walk);
188
217
  const { pods, redundant } = heldPodsOf(config, walk, new Set(entries.map((e) => e.name)));
189
218
  const claimed = new Set(pods.flatMap((p) => p.names));
190
219
 
@@ -192,6 +221,7 @@ export function secretPlacementsOf(
192
221
  const warnings: string[] = [];
193
222
  const secrets: EffectSecret[] = [];
194
223
  const values: Record<string, string> = {};
224
+ const hookTokens: HookTokenSecret[] = [];
195
225
  for (const e of entries) {
196
226
  const named = namedBy.get(e.name) ?? [];
197
227
  if (named.length > 0) {
@@ -215,10 +245,21 @@ export function secretPlacementsOf(
215
245
  )})`,
216
246
  );
217
247
  const where = [...(heldIn.length ? [`Held in ${heldIn.join(", ")}`] : []), ...effectIn];
248
+ // A `token` Hook names its secret: the Orchestrator gets its digest, never its value (ADR-0078).
249
+ const byToken = tokenHooks.get(e.name);
250
+ if (byToken !== undefined) {
251
+ const source = sourceOf(e, lookups);
252
+ hookTokens.push(
253
+ source.kind === "literal"
254
+ ? { name: e.name, value: e.value as string }
255
+ : { name: e.name, secret: source.secret, key: source.key },
256
+ );
257
+ where.push(`a digest in the Orchestrator (${byToken.map((h) => `hook ${h}`).join(", ")})`);
258
+ }
218
259
  if (where.length === 0) {
219
260
  warnings.push(
220
261
  `${e.at} (${e.name}) is named by nothing; goes nowhere — no workspace() lists it, no Agent's provider ` +
221
- "reads it, and no Effect names it (ADR-0059, ADR-0068)",
262
+ "reads it, and no Effect or Hook names it (ADR-0059, ADR-0068)",
222
263
  );
223
264
  continue;
224
265
  }
@@ -231,21 +272,32 @@ export function secretPlacementsOf(
231
272
  );
232
273
  }
233
274
  for (const p of pods) {
234
- const holds = p.names.map((n) => (p.byProvider.includes(n) ? `${n} (provider)` : n));
275
+ const holds = p.names.map((n) =>
276
+ p.byProvider.includes(n)
277
+ ? `${n} (provider)`
278
+ : p.byServer?.[n] !== undefined
279
+ ? `${n} (mcp ${p.byServer[n]!.join(", ")})`
280
+ : n,
281
+ );
235
282
  report.push(`${p.label}: Custodian holds ${holds.length ? holds.join(", ") : "nothing"}`);
236
283
  }
237
284
  for (const { label, name } of redundant) {
238
285
  report.push(`${label}: secrets lists ${name}, which its Agents' provider claims already — redundant`);
239
286
  }
240
287
 
241
- const instanceSecrets = new Set(instancePlaced.flatMap((p) => p.effect.secrets));
288
+ const instanceSecrets = new Set([
289
+ ...instancePlaced.flatMap((p) => effectSecretsOf(config, p.effect)),
290
+ ...hooks.flatMap((h) => (h.secret === undefined ? [] : [h.secret])),
291
+ ]);
242
292
  const instance =
243
- instancePlaced.length === 0
293
+ instancePlaced.length === 0 && hooks.length === 0
244
294
  ? undefined
245
295
  : effectorPlan(
246
296
  instancePlaced,
297
+ hooks,
247
298
  secrets.filter((s) => instanceSecrets.has(s.name)),
248
299
  values,
300
+ config,
249
301
  );
250
302
  if (instance) {
251
303
  report.push(
@@ -275,7 +327,7 @@ export function secretPlacementsOf(
275
327
  `${pushes.length ? `; pushes ${pushes.join(", ")}` : ""}`,
276
328
  );
277
329
  }
278
- return { held: pods, claimed, ...(instance ? { instance } : {}), sandbox, report, warnings };
330
+ return { held: pods, claimed, ...(instance ? { instance } : {}), sandbox, hookTokens, report, warnings };
279
331
  }
280
332
 
281
333
  /** A Workspace's Sandbox Effector: the secrets its Effects name, the targets its file-body https call, its
@@ -354,34 +406,123 @@ function pushesOf(config: JR2Config, ws: PlacedWorkspace): SandboxPush[] {
354
406
  return out;
355
407
  }
356
408
 
357
- function effectorPlan(placed: InstancePlaced[], secrets: EffectSecret[], values: Record<string, string>): EffectorPlan {
409
+ function effectorPlan(
410
+ placed: InstancePlaced[],
411
+ hooks: InstanceHook[],
412
+ secrets: EffectSecret[],
413
+ values: Record<string, string>,
414
+ config: JR2Config,
415
+ ): EffectorPlan {
358
416
  const targets: EffectorTargetConfig[] = [];
417
+ const add = (t: EffectorTargetConfig): void => {
418
+ const same = (x: EffectorTargetConfig) =>
419
+ x.scheme === t.scheme && x.host === t.host && x.port === t.port && x.secrets.join() === t.secrets.join();
420
+ if (!targets.some(same)) targets.push(t);
421
+ };
422
+ const mcp: Record<string, EffectorMcpServer> = {};
423
+ const timeouts: number[] = [];
359
424
  for (const { effect } of placed) {
425
+ if (effect.kind === "mcp") {
426
+ // The server's url and secret are the config's (ADR-0079). An Open server, and one the block
427
+ // does not declare as a remote server, are refused before this (`mcpRefusals`).
428
+ const server = remoteServerOf(config, effect.server);
429
+ if (server === undefined) continue;
430
+ const timeoutMs = mcpTimeoutMs(server.timeout) ?? 0;
431
+ mcp[effect.server!] = { url: server.url, ...(server.secret ? { secret: server.secret } : {}), timeoutMs };
432
+ add({ ...originOf(server.url), secrets: server.secret ? [server.secret] : [] });
433
+ timeouts.push(effect.timeoutMs > 0 ? effect.timeoutMs : timeoutMs);
434
+ continue;
435
+ }
436
+ if (effect.kind === "http") timeouts.push(effect.timeoutMs);
360
437
  // An Open url or an Open dispatch target has no target, so it allows nothing: `jr2 up` refuses
361
438
  // it before this (ADR-0054). A dispatch's target is its origin, its one secret the token
362
439
  // (ADR-0071).
363
440
  if (effect.target === undefined) continue;
364
- const t = { ...effect.target, secrets: [...effect.secrets].sort() };
365
- const same = (x: EffectorTargetConfig) =>
366
- x.scheme === t.scheme && x.host === t.host && x.port === t.port && x.secrets.join() === t.secrets.join();
367
- if (!targets.some(same)) targets.push(t);
441
+ add({ ...effect.target, secrets: [...effect.secrets].sort() });
368
442
  }
443
+ // A Hook's follow-up is one `http` call with no secret, to a host its kind declares (ADR-0068).
444
+ for (const hook of hooks) for (const post of hook.posts) add({ ...post, secrets: [] });
369
445
  return {
370
446
  placement: "instance",
371
447
  secrets,
372
448
  values,
373
449
  targets,
374
- // The grace a rollout gives a replica covers the http Effects alone (ADR-0071): a dispatch is
375
- // idempotent by construction, so a `wait` a rollout cuts is sent again under its key and its
376
- // poll resumes, and a wait of hours never stretches the Effector's grace.
377
- longestTimeoutMs: Math.max(0, ...placed.flatMap((p) => (p.effect.kind === "http" ? [p.effect.timeoutMs] : []))),
450
+ mcp,
451
+ // The grace a rollout gives a replica covers the http and mcp Effects alone (ADR-0071): a
452
+ // dispatch is idempotent by construction, so a `wait` a rollout cuts is sent again under its
453
+ // key and its poll resumes, and a wait of hours never stretches the Effector's grace. An mcp
454
+ // Effect with no timeout of its own waits for its server's (ADR-0079).
455
+ longestTimeoutMs: Math.max(0, ...timeouts),
378
456
  effects: placed.map((p) => `${p.workflow}/${p.effect.name}`),
457
+ hooks: hooks.map((h) => h.name),
379
458
  };
380
459
  }
381
460
 
382
- /** An Effect the Instance Effector runs: an http one with no Worktree file, or a dispatch with a
383
- * target (ADR-0068, ADR-0071). */
384
- type InstancePlaced = { workflow: string; effect: Extract<CarriedEffect, { kind: "http" | "dispatch" }> };
461
+ /** A Hook the Instance Effector serves (ADR-0068): its `mac` secret's name, when its verifier is
462
+ * `mac`, and the targets of its kind's `posts`. */
463
+ type InstanceHook = { name: string; secret?: string; posts: Array<Omit<EffectorTargetConfig, "secrets">> };
464
+
465
+ /**
466
+ * The Hooks the Instance Effector serves (ADR-0068, ADR-0078): each one with a `mac` verifier, whose
467
+ * secret only that Effector holds, and each one whose kind declares `posts`, whose follow-up it
468
+ * sends. A `token` Hook's secret is a digest in the Orchestrator, and an `ed25519` Hook holds a
469
+ * public key: neither needs the Effector unless its kind posts.
470
+ */
471
+ function instanceHooksOf(config: JR2Config): InstanceHook[] {
472
+ const out: InstanceHook[] = [];
473
+ for (const [name, hook] of Object.entries(config.hooks ?? {})) {
474
+ const { kind, credential } = hookParts(hook);
475
+ const secret = "mac" in kind.verify && "secret" in credential ? credential.secret.secret : undefined;
476
+ const posts = (kind.posts ?? []).map((prefix) => {
477
+ const url = new URL(prefix);
478
+ return { scheme: "https" as const, host: url.hostname, port: url.port === "" ? 443 : Number(url.port) };
479
+ });
480
+ if (secret === undefined && posts.length === 0) continue;
481
+ out.push({ name, ...(secret !== undefined ? { secret } : {}), posts });
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /** The Hooks with a `token` verifier, by the name of the secret each names (ADR-0078). */
487
+ function tokenHooksOf(config: JR2Config): Map<string, string[]> {
488
+ const out = new Map<string, string[]>();
489
+ for (const [name, hook] of Object.entries(config.hooks ?? {})) {
490
+ const { kind, credential } = hookParts(hook);
491
+ if (!("token" in kind.verify) || !("secret" in credential)) continue;
492
+ const secret = credential.secret.secret;
493
+ out.set(secret, [...(out.get(secret) ?? []), name]);
494
+ }
495
+ return out;
496
+ }
497
+
498
+ /** An Effect the Instance Effector runs: an http one with no Worktree file, a dispatch with a
499
+ * target, or an mcp one (ADR-0068, ADR-0071, ADR-0079). */
500
+ type InstancePlaced = { workflow: string; effect: Extract<CarriedEffect, { kind: "http" | "dispatch" | "mcp" }> };
501
+
502
+ /** The remote server of the `mcp` block that an `mcp` Effect names, or undefined: an Open server,
503
+ * one the block does not declare, or a stdio one (ADR-0079). */
504
+ function remoteServerOf(config: JR2Config, name: string | undefined): McpRemoteServer | undefined {
505
+ if (name === undefined || !Object.hasOwn(config.mcp ?? {}, name)) return undefined;
506
+ const server = config.mcp![name]!;
507
+ return isStdioServer(server) ? undefined : server;
508
+ }
509
+
510
+ /** The secrets an Effect names, as the placement reads them: its own, or for an `mcp` Effect its
511
+ * server's, from the config (ADR-0079). */
512
+ function effectSecretsOf(config: JR2Config, effect: CarriedEffect): string[] {
513
+ if (effect.kind !== "mcp") return [...(effect.secrets ?? [])];
514
+ const secret = remoteServerOf(config, effect.server)?.secret;
515
+ return secret === undefined ? [] : [secret];
516
+ }
517
+
518
+ /** An MCP server url's origin as the Effector's fence reads it: scheme, host, port. `jr2 up` has
519
+ * checked the url (`mcpUrlRefusals`), so it parses. */
520
+ function originOf(url: string): { scheme: "http" | "https"; host: string; port: number } {
521
+ const u = new URL(url);
522
+ const scheme = u.protocol === "http:" ? "http" : "https";
523
+ const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
524
+ return { scheme, host, port: u.port ? Number(u.port) : scheme === "http" ? 80 : 443 };
525
+ }
385
526
 
386
527
  /** A Workspace as a human reads it: its workflow and the placement route to it, an inline hop as
387
528
  * `invoked inline in state <id>`. */
@@ -424,6 +565,19 @@ function heldPodsOf(
424
565
  };
425
566
  const pods: HeldPodPlan[] = [];
426
567
  const redundant: Array<{ label: string; name: string }> = [];
568
+ // The secret of each remote MCP server that the pod's Agents name (ADR-0079), with the servers:
569
+ // the Agent claims it by naming the server, as it claims a model key by naming a model. A name
570
+ // the `mcp` block does not declare is refused by the walk's own check (`mcpRefusals`), not here.
571
+ const serverClaims = (servers: Iterable<string>): Record<string, string[]> => {
572
+ const out: Record<string, string[]> = {};
573
+ for (const server of new Set(servers)) {
574
+ const declared = config.mcp?.[server];
575
+ const secret = declared && !isStdioServer(declared) ? declared.secret : undefined;
576
+ if (secret === undefined) continue;
577
+ out[secret] = [...(out[secret] ?? []), server].sort();
578
+ }
579
+ return out;
580
+ };
427
581
  for (const ws of walk.workspaces) {
428
582
  const label = workspaceLabel(ws);
429
583
  for (const name of ws.pod.secrets) {
@@ -435,18 +589,27 @@ function heldPodsOf(
435
589
  }
436
590
  const byProvider = providerClaims(config, ws.pod.models);
437
591
  for (const name of ws.pod.secrets) if (byProvider.includes(name)) redundant.push({ label, name });
592
+ const byServer = serverClaims((ws.pod.mcp ?? []).map((m) => m.entry.server));
438
593
  const plan: HeldPodPlan = {
439
594
  pod: workspaceHeldPod({ workflow: ws.workflow, route: ws.pod.route }),
440
595
  label,
441
- names: ordered([...ws.pod.secrets, ...byProvider]),
596
+ names: ordered([...ws.pod.secrets, ...byProvider, ...Object.keys(byServer)]),
442
597
  byProvider: ordered(byProvider),
598
+ ...(Object.keys(byServer).length ? { byServer } : {}),
443
599
  };
444
600
  if (pods.some((p) => p.pod === plan.pod)) throw sharedPodKey(plan.pod, label);
445
601
  pods.push(plan);
446
602
  }
447
603
  if (walk.instanceHarness) {
448
604
  const byProvider = ordered(providerClaims(config, walk.instanceHarness.models));
449
- pods.push({ pod: INSTANCE_HARNESS_HELD_POD, label: "the Instance Harness", names: byProvider, byProvider });
605
+ const byServer = serverClaims(walk.instanceHarness.servers ?? []);
606
+ pods.push({
607
+ pod: INSTANCE_HARNESS_HELD_POD,
608
+ label: "the Instance Harness",
609
+ names: ordered([...byProvider, ...Object.keys(byServer)]),
610
+ byProvider,
611
+ ...(Object.keys(byServer).length ? { byServer } : {}),
612
+ });
450
613
  }
451
614
  return { pods, redundant };
452
615
  }
@@ -469,7 +632,8 @@ function namesOf(config: JR2Config, walk: PlacementWalk) {
469
632
  const namedBy = new Map<string, Array<{ label: string; where: string }>>();
470
633
  const instancePlaced = walk.effects.filter(
471
634
  (p): p is InstancePlaced =>
472
- (p.effect.kind === "http" || p.effect.kind === "dispatch") && p.effect.placement === "instance",
635
+ (p.effect.kind === "http" || p.effect.kind === "dispatch" || p.effect.kind === "mcp") &&
636
+ p.effect.placement === "instance",
473
637
  );
474
638
  // Keyed by the Effect's placement route and slot name, as workspacePodsOf keys a pod's Effects:
475
639
  // two slots of one name in one workflow — one in a workspace(), one outside it — are two Effects,
@@ -489,23 +653,52 @@ function namesOf(config: JR2Config, walk: PlacementWalk) {
489
653
  "workspace()'s body, or send a json or text body (ADR-0068)",
490
654
  );
491
655
  }
656
+ // A Hook with a `mac` verifier names its secret as an Effect names one, and it is placed in the
657
+ // Instance Effector alone (ADR-0068).
658
+ const hooks = instanceHooksOf(config);
659
+ const tokenHooks = tokenHooksOf(config);
660
+ for (const [secret, names] of tokenHooks) {
661
+ if (byName.has(secret)) continue;
662
+ throw new Error(
663
+ `hooks.${names[0]} names secret("${secret}"), which the \`secrets\` list does not hold — add it: ` +
664
+ `secrets: [{ name: "${secret}", value: process.env.${envish(secret)} }] (ADR-0078)`,
665
+ );
666
+ }
667
+ for (const hook of hooks) {
668
+ if (hook.secret === undefined) continue;
669
+ if (!byName.has(hook.secret)) {
670
+ throw new Error(
671
+ `hooks.${hook.name} names secret("${hook.secret}"), which the \`secrets\` list does not hold — add it: ` +
672
+ `secrets: [{ name: "${hook.secret}", value: process.env.${envish(hook.secret)} }] (ADR-0068)`,
673
+ );
674
+ }
675
+ namedBy.set(hook.secret, [
676
+ ...(namedBy.get(hook.secret) ?? []),
677
+ { label: `hook ${hook.name}`, where: "the Instance Effector" },
678
+ ]);
679
+ }
492
680
  for (const { workflow, effect } of walk.effects) {
493
681
  const label = `${workflow}/${effect.name}`;
494
682
  const where =
495
683
  effect.placement === "instance"
496
684
  ? "the Instance Effector"
497
685
  : (sandboxOf.get(keyOf(workflow, effect)) ?? "the Sandbox Effector of the Workspace that invokes it");
498
- for (const name of effect.secrets ?? []) {
686
+ for (const name of effectSecretsOf(config, effect)) {
499
687
  if (!byName.has(name)) {
688
+ // An mcp Effect never writes secret(): the server it calls, in the `mcp` block, names it.
689
+ const names =
690
+ effect.kind === "mcp"
691
+ ? `calls the MCP server "${effect.server}", whose secret "${name}"`
692
+ : `names secret("${name}"), which`;
500
693
  throw new Error(
501
- `workflow "${workflow}": the ${effect.kind} Effect "${effect.name}" names secret("${name}"), which the ` +
694
+ `workflow "${workflow}": the ${effect.kind} Effect "${effect.name}" ${names} the ` +
502
695
  `\`secrets\` list does not hold — add it: secrets: [{ name: "${name}", value: process.env.${envish(name)} }] (ADR-0068)`,
503
696
  );
504
697
  }
505
698
  namedBy.set(name, [...(namedBy.get(name) ?? []), { label, where }]);
506
699
  }
507
700
  }
508
- return { entries, namedBy, instancePlaced };
701
+ return { entries, namedBy, instancePlaced, hooks, tokenHooks };
509
702
  }
510
703
 
511
704
  /** An Effect secret shorter than 8 characters is refused, and the refusal says why. */
@@ -515,10 +708,23 @@ function namesOf(config: JR2Config, walk: PlacementWalk) {
515
708
  * length leaves; `jr2 up` keeps no value of the user's Secret (ADR-0068).
516
709
  */
517
710
  export function storedValueLength(base64: string): number {
711
+ return storedBytes(base64).length;
712
+ }
713
+
714
+ /**
715
+ * The sha-256, in hex, of a value a Kubernetes Secret holds, trimmed as `storedValueLength` trims
716
+ * it: what `jr2-hook-digests` holds for a `token` Hook whose secret is a key of the user's Secret
717
+ * (ADR-0078). Only the digest leaves.
718
+ */
719
+ export function storedValueSha256(base64: string): string {
720
+ return createHash("sha256").update(storedBytes(base64)).digest("hex");
721
+ }
722
+
723
+ function storedBytes(base64: string): Buffer {
518
724
  let bytes = Buffer.from(base64, "base64");
519
725
  if (bytes.at(-1) === 0x0a) bytes = bytes.subarray(0, -1);
520
726
  if (bytes.at(-1) === 0x0d) bytes = bytes.subarray(0, -1);
521
- return bytes.length;
727
+ return bytes;
522
728
  }
523
729
 
524
730
  function shortRefusal(e: { at: string; name: string }, length: number): void {
package/src/server.ts CHANGED
@@ -16,6 +16,17 @@
16
16
  // a publish door anyone outside it can open.
17
17
  // JR2_SIGNING_KEY base64 key Sandbox tokens are signed with; from the Secret so live Sandboxes
18
18
  // survive a pod restart. Absent → minted into `<dir>/.jr2/secret` (dev-grade).
19
+ // JR2_CALLER_DIGESTS the file of the named Caller tokens' digests (ADR-0078). Default, deployed:
20
+ // the `jr2-caller-digests` mount; host-booted: none. Read again when it
21
+ // changes, so a rotation needs no restart.
22
+ // JR2_HOOK_DIGESTS the file of the `token` Hooks' secrets' digests (ADR-0078). Default,
23
+ // deployed: the `jr2-hook-digests` mount; host-booted: none. Read again when
24
+ // it changes.
25
+ // JR2_INSTANCE_EFFECTOR the Instance Effector's address, `host:port` (ADR-0068). Default,
26
+ // deployed: its Service; host-booted: none. A host-booted fixture names its
27
+ // own, which speaks the same signed wire (the e2e tier's `mac` Hook check).
28
+ // JR2_STATE_VOLUME the size of the state volume claim that `jr2 up` applied (ADR-0065): the
29
+ // space guard measures against it. Absent: `runs.volume`, or `5Gi`.
19
30
  // JR2_NAMESPACE the pod's own namespace (Deployment fieldRef) — presence = "deployed":
20
31
  // Sandboxes are driven in it, and the Custodians' route home is Service DNS.
21
32
  //
@@ -46,7 +57,7 @@ import { join } from "node:path";
46
57
  import { resolveAssembly, type ResolvedAssembly } from "./assembly.ts";
47
58
  import { KIT_VERSION, loadConfig, type JR2Config } from "./config.ts";
48
59
  import { readImageRefs } from "./images.ts";
49
- import { readMountDigests } from "./mount-digest.ts";
60
+ import { loadedDigests, readMountDigests } from "./mount-digest.ts";
50
61
  import { GIT_CLONE_MANIFEST_KEY, readGitCloneManifest } from "./git-clone.ts";
51
62
  import { loadWorkflows, startInstance, type RunningInstance } from "./instance.ts";
52
63
  import {
@@ -60,6 +71,12 @@ import {
60
71
  INSTANCE_HARNESS_SERVICE,
61
72
  INSTANCE_EFFECTOR,
62
73
  EFFECTOR_PORT,
74
+ CALLER_DIGESTS_KEY,
75
+ CALLER_DIGESTS_MOUNT,
76
+ CALLER_DIGESTS_SECRET,
77
+ HOOK_DIGESTS_KEY,
78
+ HOOK_DIGESTS_MOUNT,
79
+ HOOK_DIGESTS_SECRET,
63
80
  } from "./names.ts";
64
81
  import type { AnyStateMachine } from "xstate";
65
82
  import { partsOf, type CarriedRepo } from "./parts.ts";
@@ -69,7 +86,7 @@ import { kubeRepos, type RepoResources } from "./repos.ts";
69
86
  import { SANDBOX_EFFECTOR_PORT, kubeSandbox } from "./sandbox-kube.ts";
70
87
  import { sandboxEffectorService } from "./sandbox-effector.ts";
71
88
  import { watchSandboxes, type SandboxWatch } from "./sandbox-watch.ts";
72
- import { loadSigningKey, mintInstanceToken } from "./tokens.ts";
89
+ import { loadSigningKey, mintInstanceToken, watchCallerDigests } from "./tokens.ts";
73
90
  import type { SandboxPort } from "./workspace.ts";
74
91
 
75
92
  export type ServerMainOptions = {
@@ -100,6 +117,18 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
100
117
  // From the instance Secret when deployed; per-boot for a host-booted fixture.
101
118
  const instanceToken = env.JR2_INSTANCE_TOKEN ?? mintInstanceToken();
102
119
  const publishToken = env.JR2_PUBLISH_TOKEN ?? mintInstanceToken();
120
+ // The named Callers' digests (ADR-0078), from the mount `jr2 up` writes. A host-booted process
121
+ // has none unless the env names a file.
122
+ const digestsPath =
123
+ env.JR2_CALLER_DIGESTS ?? (namespace !== undefined ? join(CALLER_DIGESTS_MOUNT, CALLER_DIGESTS_KEY) : undefined);
124
+ const digests = digestsPath !== undefined ? watchCallerDigests(digestsPath) : undefined;
125
+ // The `token` Hooks' digests (ADR-0078): the same form, another mount.
126
+ const hookDigestsPath =
127
+ env.JR2_HOOK_DIGESTS ?? (namespace !== undefined ? join(HOOK_DIGESTS_MOUNT, HOOK_DIGESTS_KEY) : undefined);
128
+ const hookDigests = hookDigestsPath !== undefined ? watchCallerDigests(hookDigestsPath) : undefined;
129
+ const instanceEffector =
130
+ env.JR2_INSTANCE_EFFECTOR ??
131
+ (namespace !== undefined ? `${INSTANCE_EFFECTOR}.${namespace}.svc:${EFFECTOR_PORT}` : undefined);
103
132
 
104
133
  // The current Assembly (ADR-0069), and its config and Workflows read from its ROOT, never from
105
134
  // the folder directly. A published Assembly that does not load (a Kit bump that broke an import,
@@ -247,15 +276,27 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
247
276
  assembly: resolved,
248
277
  port,
249
278
  hostname: env.HOST ?? "0.0.0.0",
279
+ ...(env.JR2_STATE_VOLUME ? { stateVolume: env.JR2_STATE_VOLUME } : {}),
250
280
  instanceToken,
251
281
  publishToken,
282
+ ...(digests ? { callerDigests: digests.current } : {}),
283
+ ...(hookDigests ? { hookDigests: hookDigests.current } : {}),
252
284
  signingKey,
253
285
  sandbox,
254
286
  dataPlane,
255
287
  // The door reads the same image map a provision reads (ADR-0069): a `file:` context whose
256
288
  // digest it lacks is refused at the publish rather than at the first provision.
257
289
  ...(dataPlane
258
- ? { imageMap: () => readImageRefs(join(IMAGES_MOUNT, IMAGES_KEY)), mounts: () => readMountDigests() }
290
+ ? {
291
+ imageMap: () => readImageRefs(join(IMAGES_MOUNT, IMAGES_KEY)),
292
+ // The ConfigMaps as the mounts hold them, and the digests Secrets as this process LOADED
293
+ // them (ADR-0078): a token works when the authenticator holds its digest, not when the
294
+ // file has it.
295
+ mounts: async () => ({
296
+ ...(await readMountDigests()),
297
+ ...loadedDigests({ [CALLER_DIGESTS_SECRET]: digests, [HOOK_DIGESTS_SECRET]: hookDigests }),
298
+ }),
299
+ }
259
300
  : {}),
260
301
  // A publish landed (ADR-0069): its config is the one that applies from now on, and its
261
302
  // Machines may bind other Repos, so the Repo pass runs again. Synchronous: the walk is over
@@ -300,7 +341,7 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
300
341
  ...(namespace ? { orchestratorName: namespace } : {}),
301
342
  // Where an http Effect runs (ADR-0068): the Instance Effector's headless Service. `jr2 up`
302
343
  // converges it whenever a registered Machine carries an http Effect.
303
- ...(namespace ? { instanceEffector: `${INSTANCE_EFFECTOR}.${namespace}.svc:${EFFECTOR_PORT}` } : {}),
344
+ ...(instanceEffector !== undefined ? { instanceEffector } : {}),
304
345
  // Where a push, a file-body http or a credentialed command runs (ADR-0068): a Sandbox's Effector, by
305
346
  // the headless Service the provision writes beside it when `jr2 up` placed an Effect there.
306
347
  ...(namespace
@@ -311,6 +352,14 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
311
352
  : {}),
312
353
  });
313
354
  held = inst.heldMachines;
355
+ for (const watched of [digests, hookDigests]) {
356
+ if (!watched) continue;
357
+ const close = inst.close;
358
+ inst.close = async () => {
359
+ watched.stop();
360
+ await close();
361
+ };
362
+ }
314
363
  // The watch ends with the instance: its stream is the one thing here that never settles alone.
315
364
  if (watch) {
316
365
  const close = inst.close;