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