@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.
- 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 +86 -9
- package/docs/concepts/architecture.md +41 -28
- package/docs/concepts/callers-and-hooks.md +205 -0
- package/docs/concepts/charts.md +7 -5
- package/docs/concepts/composition.md +10 -1
- package/docs/concepts/converge.md +4 -2
- package/docs/concepts/durable-runs.md +43 -16
- package/docs/concepts/effects.md +50 -22
- package/docs/concepts/gates.md +95 -25
- package/docs/concepts/machines-and-workflows.md +5 -4
- package/docs/concepts/pools-and-sources.md +187 -11
- package/docs/concepts/secrets-and-trust.md +73 -33
- package/docs/concepts/the-menu.md +11 -0
- package/docs/concepts/workspaces.md +6 -5
- package/docs/get-started/a-model-decides.md +14 -13
- package/docs/get-started/an-agent-in-a-sandbox.md +43 -34
- package/docs/get-started/connect-a-model.md +1 -1
- package/docs/get-started/create-an-instance.md +17 -14
- package/docs/guides/add-tools-to-the-sandbox.md +55 -11
- 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 +24 -8
- 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/fix-a-failed-converge.md +18 -6
- 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 +9 -6
- package/docs/guides/look-inside-a-sandbox.md +101 -31
- package/docs/guides/open-the-console.md +63 -14
- package/docs/guides/read-a-conversation.md +225 -0
- package/docs/guides/run-a-chart-by-name.md +17 -16
- package/docs/guides/run-one-worker-per-item.md +15 -9
- package/docs/guides/set-an-agents-access-and-dials.md +2 -0
- 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 +1 -1
- package/docs/guides/watch-a-run.md +127 -121
- 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 +17 -7
- package/docs/llms.txt +81 -15
- package/docs/reference/authoring-surface/agent.md +1 -1
- package/docs/reference/authoring-surface/answer.md +1 -1
- package/docs/reference/authoring-surface/arg.md +1 -1
- 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 +1 -1
- 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 +1 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/file.md +1 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/isfailure.md +1 -1
- package/docs/reference/authoring-surface/isready.md +1 -1
- package/docs/reference/authoring-surface/jr2setup.md +13 -3
- package/docs/reference/authoring-surface/mcp.md +107 -0
- package/docs/reference/authoring-surface/open.md +5 -3
- package/docs/reference/authoring-surface/openbody.md +1 -1
- package/docs/reference/authoring-surface/paramsof.md +1 -1
- package/docs/reference/authoring-surface/pool.md +77 -9
- package/docs/reference/authoring-surface/push.md +1 -1
- package/docs/reference/authoring-surface/readyof.md +1 -1
- package/docs/reference/authoring-surface/reread.md +19 -0
- package/docs/reference/authoring-surface/secret.md +1 -1
- 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 +56 -1
- 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 +14 -1
- package/docs/reference/authoring-surface/type-agentmcpserver.md +54 -0
- package/docs/reference/authoring-surface/type-anyframepath.md +1 -1
- package/docs/reference/authoring-surface/type-anyworktreepath.md +1 -1
- package/docs/reference/authoring-surface/type-binding.md +1 -1
- 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-commandoutput.md +1 -1
- package/docs/reference/authoring-surface/type-customize.md +16 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/type-effectbinding.md +12 -2
- package/docs/reference/authoring-surface/type-framepath.md +1 -1
- 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 +1 -1
- package/docs/reference/authoring-surface/type-httpoutput.md +1 -1
- package/docs/reference/authoring-surface/type-jr2config.md +37 -2
- package/docs/reference/authoring-surface/type-lateroutcome.md +1 -1
- 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-openbodydeclaration.md +1 -1
- package/docs/reference/authoring-surface/type-openbodyoutput.md +1 -1
- package/docs/reference/authoring-surface/type-poolnextinput.md +1 -1
- package/docs/reference/authoring-surface/type-poolspec.md +11 -6
- package/docs/reference/authoring-surface/type-pushoutput.md +1 -1
- 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 +1 -1
- 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-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-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 +16 -7
- 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 +1 -1
- package/docs/reference/authoring-surface/type-worktreefound.md +3 -3
- package/docs/reference/authoring-surface/type-worktreeinput.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeoptions.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeoutput.md +1 -1
- package/docs/reference/authoring-surface/type-worktreepath.md +1 -1
- package/docs/reference/authoring-surface/type-worktreeready.md +1 -1
- package/docs/reference/authoring-surface/type-worktreerefused.md +4 -4
- package/docs/reference/authoring-surface/workspace.md +1 -1
- package/docs/reference/authoring-surface/worktree.md +1 -1
- package/docs/reference/authoring-surface.md +88 -167
- package/docs/reference/cli.md +413 -74
- package/docs/reference/config.md +235 -2
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +71 -11
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +14 -10
- package/docs/reference/decisions/0006-agent-control-surface.md +4 -4
- 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 +9 -2
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +20 -14
- 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 +18 -14
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +226 -12
- 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 +32 -18
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +14 -1
- 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 +116 -34
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +38 -37
- 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 +7 -6
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +23 -7
- 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 -18
- 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 +4 -3
- 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 +5 -4
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +19 -19
- package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +6 -1
- 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 +7 -0
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +13 -7
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +38 -30
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +64 -17
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +2 -1
- 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 +51 -23
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +94 -54
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +101 -47
- 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 +17 -13
- 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/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +4 -3
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +37 -10
- 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 +6 -4
- package/docs/reference/glossary.md +96 -42
- package/docs/reference/hooks.md +1077 -0
- package/docs/reference/http-api.md +976 -149
- package/docs/why.md +2 -1
- package/package.json +10 -3
- package/src/actor.ts +36 -10
- package/src/agent.ts +50 -0
- package/src/assembly-checks.ts +530 -4
- package/src/callers.ts +245 -0
- package/src/chart.ts +52 -7
- package/src/config.ts +550 -1
- package/src/conversations.ts +453 -0
- package/src/custodian.ts +8 -2
- package/src/customize.ts +143 -4
- package/src/dispatch-effect.ts +142 -26
- package/src/durability.ts +2 -0
- package/src/duration.ts +20 -0
- package/src/effect-checks.ts +2 -1
- package/src/effect.ts +60 -7
- package/src/effector-wire.ts +272 -27
- package/src/gate-brand.ts +16 -0
- package/src/gate.ts +634 -48
- package/src/harness-client.ts +10 -4
- package/src/held-secrets.ts +72 -23
- package/src/holds.ts +2 -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 +76 -38
- package/src/http.ts +889 -94
- package/src/index.ts +16 -5
- package/src/instance.ts +196 -26
- package/src/internal.ts +22 -3
- package/src/machine-doc.ts +26 -1
- package/src/mcp-effect.ts +424 -0
- package/src/mount-digest.ts +41 -7
- package/src/names.ts +64 -2
- package/src/notices.ts +138 -10
- package/src/open-body.ts +25 -11
- package/src/open-effect.ts +17 -4
- package/src/open.ts +4 -2
- package/src/parts.ts +87 -9
- package/src/party.ts +2 -1
- package/src/pick.ts +2 -2
- package/src/pool.ts +1142 -163
- package/src/registration.ts +203 -13
- package/src/run-host.ts +1509 -171
- package/src/run-list.ts +191 -0
- package/src/sandbox-kube.ts +53 -5
- package/src/secret-placement.ts +239 -33
- package/src/server.ts +53 -4
- package/src/setup.ts +72 -32
- package/src/snapshot-store.ts +565 -48
- package/src/steer.ts +111 -0
- package/src/stub-harness.ts +6 -2
- package/src/tokens.ts +263 -22
- package/src/wire.ts +17 -0
- package/src/workspace.ts +50 -6
- package/src/worktree.ts +3 -3
- 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/harness-client.ts
CHANGED
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
MAX_KILLS_BODY,
|
|
84
84
|
MAX_TOLD,
|
|
85
85
|
type Notice,
|
|
86
|
+
type SteeringMessage,
|
|
86
87
|
type Settlement,
|
|
87
88
|
type StreamEvent,
|
|
88
89
|
type SubmissionSettledEvent,
|
|
@@ -114,6 +115,8 @@ export type HarnessClient = {
|
|
|
114
115
|
thinkingLevel?: ThinkingLevel;
|
|
115
116
|
/** The pending notices in this admission's scope (ADR-0062). Omitted when there are none. */
|
|
116
117
|
notices?: Notice[];
|
|
118
|
+
/** The Steering messages held for this Agent slot (ADR-0002). Omitted when there are none. */
|
|
119
|
+
steering?: SteeringMessage[];
|
|
117
120
|
/** The admission is for a conversation this process was told the Harness keeps (ADR-0062).
|
|
118
121
|
* The Harness that keeps none answers 404 and seats none: this verb throws
|
|
119
122
|
* {@link ConversationNotKept}. */
|
|
@@ -393,6 +396,7 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
393
396
|
// The Briefing's news (ADR-0062): typed data, the Harness writes the words. Omitted
|
|
394
397
|
// when there is none, so an admission without notices is byte-identical to before.
|
|
395
398
|
...(sendOptions.notices?.length ? { notices: sendOptions.notices } : {}),
|
|
399
|
+
...(sendOptions.steering?.length ? { steering: sendOptions.steering } : {}),
|
|
396
400
|
// Omitted when unset, so an admission that is not for a kept conversation is
|
|
397
401
|
// byte-identical to before.
|
|
398
402
|
...(sendOptions.kept ? { kept: true } : {}),
|
|
@@ -613,10 +617,11 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
|
|
|
613
617
|
|
|
614
618
|
/** The definition as the ADMISSION carries it (ADR-0057): identity alone. `description` is
|
|
615
619
|
* Console material the Orchestrator strips before admission — the Harness has no use for it and
|
|
616
|
-
* no longer validates it.
|
|
617
|
-
* the
|
|
618
|
-
|
|
619
|
-
|
|
620
|
+
* no longer validates it. `steerable` is the Orchestrator's own (ADR-0002): it refuses Callers,
|
|
621
|
+
* and the model never reads it. Every other field travels, so a field added to the definition
|
|
622
|
+
* reaches the Harness without a change here. */
|
|
623
|
+
function admittedDefinition(definition: AgentDefinition): Omit<AgentDefinition, "description" | "steerable"> {
|
|
624
|
+
const { description: _description, steerable: _steerable, ...identity } = definition;
|
|
620
625
|
return identity;
|
|
621
626
|
}
|
|
622
627
|
|
|
@@ -638,6 +643,7 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
|
|
|
638
643
|
model: input.model,
|
|
639
644
|
thinkingLevel: input.thinkingLevel,
|
|
640
645
|
...(opts.notices?.length ? { notices: opts.notices } : {}),
|
|
646
|
+
...(opts.steering?.length ? { steering: opts.steering } : {}),
|
|
641
647
|
...(opts.kept ? { kept: true } : {}),
|
|
642
648
|
signal: opts.signal,
|
|
643
649
|
});
|
package/src/held-secrets.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
24
|
import { readFile } from "node:fs/promises";
|
|
25
25
|
import { isIP } from "node:net";
|
|
26
|
-
import type
|
|
26
|
+
import { isStdioServer, type JR2Config, type HarnessEnvVar } from "./config.ts";
|
|
27
27
|
import {
|
|
28
28
|
CREDENTIAL_FILES,
|
|
29
29
|
DOMAIN_WRITABLE_ROOTS,
|
|
@@ -280,6 +280,23 @@ export function heldSecretsOf(
|
|
|
280
280
|
catalogHosts.set(id, target.port === 443 ? target.host : `${target.host}:${target.port}`);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// R7 for an MCP server (ADR-0079): one with a `secret` needs an https url, because the Custodian
|
|
284
|
+
// carries TLS only. Its url's host is the default binding of the secret it names (`hostsOf`).
|
|
285
|
+
const serverHosts = new Map<string, Array<{ server: string; host: string; port: number; path: string }>>();
|
|
286
|
+
for (const [server, declared] of Object.entries(config.mcp ?? {})) {
|
|
287
|
+
if (isStdioServer(declared) || declared.secret === undefined || !claimed.has(declared.secret)) continue;
|
|
288
|
+
const target = httpsTarget(declared.url);
|
|
289
|
+
if (!target) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`mcp.${server}.url must be an https:// URL — the server has a secret, ${declared.secret}, and the Custodian ` +
|
|
292
|
+
"carries TLS only (ADR-0059, ADR-0079). Read it from .env: " +
|
|
293
|
+
`mcp: { "${server}": { url: process.env.${server.toUpperCase().replace(/-/g, "_")}_URL ?? "", secret: "${declared.secret}" } }`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const path = new URL(declared.url).pathname || "/";
|
|
297
|
+
serverHosts.set(declared.secret, [...(serverHosts.get(declared.secret) ?? []), { server, ...target, path }]);
|
|
298
|
+
}
|
|
299
|
+
|
|
283
300
|
// jr2's own names (ADR-0076): the Harness's start takes a `JR2_` name from jr2's composition alone,
|
|
284
301
|
// and refuses to start on an envFrom key that starts with `JR2_` (`effector nocore --harness-env`).
|
|
285
302
|
// A user's `JR2_MENU_ONLY` or `JR2_START_ENV` would turn the domain off or replace its env.
|
|
@@ -453,8 +470,10 @@ export function heldSecretsOf(
|
|
|
453
470
|
const source = sourceOf(e, lookups);
|
|
454
471
|
if (source.kind === "literal") values[e.name] = e.value as string;
|
|
455
472
|
|
|
456
|
-
// R6
|
|
457
|
-
|
|
473
|
+
// R6. An MCP server's secret with no binding goes in `authorization` alone (ADR-0079): the
|
|
474
|
+
// Harness sends `Authorization: Bearer <Stand-in>`.
|
|
475
|
+
const mcpDefault = e.hosts === undefined && e.headers === undefined && serverHosts.has(e.name);
|
|
476
|
+
const headers = [...(e.headers ?? (mcpDefault ? ["authorization"] : DEFAULT_CREDENTIAL_HEADERS))];
|
|
458
477
|
const seenHeader = new Set<string>();
|
|
459
478
|
for (const [j, header] of headers.entries()) {
|
|
460
479
|
if (!HEADER_TOKEN.test(header) || FRAMING_HEADERS.has(header) || header.startsWith("proxy-")) {
|
|
@@ -467,6 +486,16 @@ export function heldSecretsOf(
|
|
|
467
486
|
if (seenHeader.has(header)) throw new Error(`${e.at}.headers lists "${header}" twice (ADR-0059)`);
|
|
468
487
|
seenHeader.add(header);
|
|
469
488
|
}
|
|
489
|
+
// R6 for an MCP server's secret (ADR-0079): the Harness sends its Stand-in in `authorization`
|
|
490
|
+
// alone, so a binding without that header removes it on each call.
|
|
491
|
+
for (const s of serverHosts.get(e.name) ?? []) {
|
|
492
|
+
if (headers.includes("authorization")) break;
|
|
493
|
+
throw new Error(
|
|
494
|
+
`${e.at}.headers leaves out "authorization", where the Harness sends the Stand-in of ${e.name} to ` +
|
|
495
|
+
`mcp.${s.server}.url — each call of that server would arrive with no credential. Add it, or drop headers ` +
|
|
496
|
+
"to take that default (ADR-0059, ADR-0079)",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
470
499
|
|
|
471
500
|
// R12
|
|
472
501
|
const paths = e.paths === undefined ? undefined : [...e.paths];
|
|
@@ -478,9 +507,18 @@ export function heldSecretsOf(
|
|
|
478
507
|
);
|
|
479
508
|
}
|
|
480
509
|
}
|
|
510
|
+
// R12 for an MCP server's secret (ADR-0079): every call goes to the url's path, so `paths` must
|
|
511
|
+
// cover it, by the prefix rule the Custodian applies.
|
|
512
|
+
for (const s of serverHosts.get(e.name) ?? []) {
|
|
513
|
+
if (paths === undefined || paths.some((p) => pathCovers(p, s.path))) continue;
|
|
514
|
+
throw new Error(
|
|
515
|
+
`${e.at}.paths does not cover ${s.path}, the path of mcp.${s.server}.url, whose secret ${e.name} is — ` +
|
|
516
|
+
`each call of that server would be refused with 403. Add "${s.path}", or drop paths (ADR-0059, ADR-0079)`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
481
519
|
|
|
482
520
|
// R5 (+ the §3.4 default and R9)
|
|
483
|
-
const hosts = hostsOf(e, catalogHosts, lookups.models ?? []);
|
|
521
|
+
const hosts = hostsOf(e, catalogHosts, lookups.models ?? [], serverHosts.get(e.name) ?? []);
|
|
484
522
|
secrets.push({
|
|
485
523
|
name: e.name,
|
|
486
524
|
source,
|
|
@@ -538,24 +576,6 @@ export function heldSecretsOf(
|
|
|
538
576
|
}
|
|
539
577
|
}
|
|
540
578
|
|
|
541
|
-
// R5: one header on one host names one secret.
|
|
542
|
-
const byTarget = new Map<string, { name: string; headers: string[] }[]>();
|
|
543
|
-
for (const s of secrets) {
|
|
544
|
-
for (const h of s.hosts) {
|
|
545
|
-
const key = `${h.host}:${h.port}`;
|
|
546
|
-
for (const other of byTarget.get(key) ?? []) {
|
|
547
|
-
const shared = s.headers.filter((header) => other.headers.includes(header));
|
|
548
|
-
if (shared.length > 0) {
|
|
549
|
-
throw new Error(
|
|
550
|
-
`${s.name} and ${other.name} are both bound to ${key} in header ${shared.join(", ")} — one header on ` +
|
|
551
|
-
"one host carries one secret; give each its own `headers` (ADR-0059)",
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
byTarget.set(key, [...(byTarget.get(key) ?? []), { name: s.name, headers: s.headers }]);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
579
|
return { manifest: { version: 1, secrets }, values, warnings };
|
|
560
580
|
}
|
|
561
581
|
|
|
@@ -604,16 +624,31 @@ export function sourceOf(
|
|
|
604
624
|
|
|
605
625
|
/** R5 and R9: where one secret may go. A known model key with no `hosts` goes where its provider's
|
|
606
626
|
* calls go — the catalog override's host, else the table's — so the key and the calls read one
|
|
607
|
-
* config and cannot drift.
|
|
627
|
+
* config and cannot drift. An MCP server's secret with no `hosts` goes to the host of the url of
|
|
628
|
+
* each server that names it (ADR-0079), for the same reason. */
|
|
608
629
|
function hostsOf(
|
|
609
630
|
e: { at: string; name: string; hosts?: readonly string[] },
|
|
610
631
|
catalogHosts: ReadonlyMap<string, string>,
|
|
611
632
|
models: readonly string[],
|
|
633
|
+
servers: ReadonlyArray<{ server: string; host: string; port: number }>,
|
|
612
634
|
): Array<{ host: string; port: number }> {
|
|
613
635
|
const known = MODEL_KEYS[e.name];
|
|
614
636
|
const effective = new Map<string, string | undefined>();
|
|
615
637
|
for (const provider of known?.providers ?? []) effective.set(provider, catalogHosts.get(provider) ?? known?.host);
|
|
616
638
|
|
|
639
|
+
if (e.hosts === undefined && servers.length > 0) {
|
|
640
|
+
const out: Array<{ host: string; port: number }> = [];
|
|
641
|
+
const add = (t: { host: string; port: number }) => {
|
|
642
|
+
if (!out.some((o) => o.host === t.host && o.port === t.port)) out.push({ host: t.host, port: t.port });
|
|
643
|
+
};
|
|
644
|
+
for (const s of servers) add(s);
|
|
645
|
+
// A model key that an MCP server also names keeps its provider's host beside the servers'.
|
|
646
|
+
const defaults = new Set(effective.values());
|
|
647
|
+
const only = defaults.size === 1 ? [...defaults][0] : undefined;
|
|
648
|
+
if (only !== undefined) add(parseTarget(`${e.at}.hosts`, only));
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
617
652
|
if (e.hosts === undefined || e.hosts.length === 0) {
|
|
618
653
|
const defaults = new Set(effective.values());
|
|
619
654
|
const only = defaults.size === 1 ? [...defaults][0] : undefined;
|
|
@@ -636,9 +671,23 @@ function hostsOf(
|
|
|
636
671
|
`or drop hosts to take that default (ADR-0059)`,
|
|
637
672
|
);
|
|
638
673
|
}
|
|
674
|
+
for (const s of servers) {
|
|
675
|
+
if (targets.some((t) => t.host === s.host && t.port === s.port)) continue;
|
|
676
|
+
throw new Error(
|
|
677
|
+
`${e.at}.hosts leaves out ${s.host}${s.port === 443 ? "" : `:${s.port}`}, the host of mcp.${s.server}.url, ` +
|
|
678
|
+
`whose secret ${e.name} is — each call of that server would arrive with the Stand-in and fail with 401. ` +
|
|
679
|
+
"Add it, or drop hosts to take that default (ADR-0059, ADR-0079)",
|
|
680
|
+
);
|
|
681
|
+
}
|
|
639
682
|
return targets;
|
|
640
683
|
}
|
|
641
684
|
|
|
685
|
+
/** Whether the `paths` entry `prefix` lets a request to `path` through: the Custodian's rule — the
|
|
686
|
+
* same path, or `prefix` and then a `/` (or `prefix` ends in `/`). */
|
|
687
|
+
function pathCovers(prefix: string, path: string): boolean {
|
|
688
|
+
return path === prefix || (path.startsWith(prefix) && (prefix.endsWith("/") || path.charAt(prefix.length) === "/"));
|
|
689
|
+
}
|
|
690
|
+
|
|
642
691
|
/** An `https://` URL's host and port, or undefined for anything else. */
|
|
643
692
|
function httpsTarget(url: string): { host: string; port: number } | undefined {
|
|
644
693
|
let parsed: URL;
|
package/src/holds.ts
CHANGED
|
@@ -31,9 +31,9 @@ export type Hold = {
|
|
|
31
31
|
deliveryId: string;
|
|
32
32
|
/** The pick's event name. */
|
|
33
33
|
pick: string;
|
|
34
|
-
/** The Agent slot that picked — the Console's "
|
|
34
|
+
/** The Agent slot that picked — the Console's "outstanding since" names it. */
|
|
35
35
|
agent: string;
|
|
36
|
-
/** ISO time the pick was delivered: the Console's "
|
|
36
|
+
/** ISO time the pick was delivered: the Console's "outstanding since". */
|
|
37
37
|
since: string;
|
|
38
38
|
/** The Sandbox whose token may read the answer (ADR-0013) — the registration's, kept because the
|
|
39
39
|
* answer outlives a registration that a route out of the Agent's state ends. */
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// `@jr2/orchestrator/hooks/discord`: jr2's Discord kind, for an application's Interactions Endpoint URL.
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { at, defineHookKind, json } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
const user = z.looseObject({ id: z.string() });
|
|
7
|
+
|
|
8
|
+
/** The schema of each event key. A command's name is part of its key, so `.*` covers them all. */
|
|
9
|
+
export const discordEvents = {
|
|
10
|
+
"application_command.*": z.looseObject({
|
|
11
|
+
channel_id: z.string(),
|
|
12
|
+
/** In a guild. */
|
|
13
|
+
member: z.looseObject({ user }).optional(),
|
|
14
|
+
/** In a direct message. */
|
|
15
|
+
user: user.optional(),
|
|
16
|
+
data: z.looseObject({
|
|
17
|
+
name: z.string(),
|
|
18
|
+
options: z
|
|
19
|
+
.array(z.looseObject({ name: z.string(), value: z.union([z.string(), z.number(), z.boolean()]).optional() }))
|
|
20
|
+
.optional(),
|
|
21
|
+
}),
|
|
22
|
+
}),
|
|
23
|
+
message_component: z.looseObject({
|
|
24
|
+
member: z.looseObject({ user }).optional(),
|
|
25
|
+
user: user.optional(),
|
|
26
|
+
data: z.looseObject({ custom_id: z.string() }),
|
|
27
|
+
message: z.looseObject({ id: z.string() }),
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const text = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
|
|
32
|
+
|
|
33
|
+
/** The interaction types that this kind reads (Discord's numbers). */
|
|
34
|
+
const PING = 1;
|
|
35
|
+
const APPLICATION_COMMAND = 2;
|
|
36
|
+
const MESSAGE_COMPONENT = 3;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* - verifier: `ed25519` over `<X-Signature-Timestamp><raw body>`, `X-Signature-Ed25519` in hex,
|
|
40
|
+
* checked against the application's PUBLIC key in the Orchestrator. The signature covers the
|
|
41
|
+
* timestamp, so the kind gives it as `at`. Discord states no window; the door's 300 s window
|
|
42
|
+
* applies to each signed `at`, so it applies here too.
|
|
43
|
+
* - handshake: `PING` (type 1) gets `{ type: 1 }` (PONG).
|
|
44
|
+
* - event keys: `application_command.<data.name>` (type 2), `message_component` (type 3). Another
|
|
45
|
+
* type (an autocomplete, a modal) gets the key `interaction.<type>`, which has no schema, so it
|
|
46
|
+
* reaches nothing.
|
|
47
|
+
* - delivery id: the interaction's `id`. `onBehalfOf`: `member.user.id`, or `user.id` in a direct
|
|
48
|
+
* message. (Discord's docs do not say that Discord sends an interaction again.)
|
|
49
|
+
* - who reads the answer: a person, always.
|
|
50
|
+
* - answer: `{ type: 4, data: { content, flags: 64 } }`, an ephemeral message. Discord shows
|
|
51
|
+
* markdown, not HTML, so the text needs no escape. Text limit: 2000 characters.
|
|
52
|
+
*/
|
|
53
|
+
export const discord = defineHookKind({
|
|
54
|
+
api: 1,
|
|
55
|
+
id: "discord",
|
|
56
|
+
events: discordEvents,
|
|
57
|
+
verify: {
|
|
58
|
+
ed25519: {
|
|
59
|
+
signed: (req) => {
|
|
60
|
+
const ts = req.headers["x-signature-timestamp"];
|
|
61
|
+
const sig = req.headers["x-signature-ed25519"];
|
|
62
|
+
if (ts === undefined || !/^\d+$/.test(ts) || sig === undefined) {
|
|
63
|
+
return { unsigned: "no Discord signature or timestamp" };
|
|
64
|
+
}
|
|
65
|
+
return { bytes: [ts, req.body], signature: { hex: sig }, at: Number(ts) };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
read: (req) => {
|
|
70
|
+
const p = json(req);
|
|
71
|
+
const type = at(p, "type");
|
|
72
|
+
if (type === PING) return { handshake: { body: { type: 1 } } };
|
|
73
|
+
const id = text(at(p, "id"));
|
|
74
|
+
if (id === undefined) throw new Error("the interaction has no id");
|
|
75
|
+
const by = text(at(p, "member.user.id")) ?? text(at(p, "user.id"));
|
|
76
|
+
const key =
|
|
77
|
+
type === APPLICATION_COMMAND
|
|
78
|
+
? `application_command.${text(at(p, "data.name")) ?? ""}`
|
|
79
|
+
: type === MESSAGE_COMPONENT
|
|
80
|
+
? "message_component"
|
|
81
|
+
: `interaction.${String(type)}`;
|
|
82
|
+
return { key, id, payload: p, ...(by ? { onBehalfOf: by } : {}), readBy: "person" };
|
|
83
|
+
},
|
|
84
|
+
answer: (t) => ({ body: { type: 4, data: { content: t, flags: 64 } } }),
|
|
85
|
+
maxText: 2000,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
declare module "./index.ts" {
|
|
89
|
+
interface HookKinds {
|
|
90
|
+
discord: typeof discord;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `hook-kinds/door.ts`: what the Hook's door reads from a Hook and a kind. The package does not
|
|
2
|
+
// export it, because it is NOT part of a kind's interface: a kind imports `@jr2/orchestrator/hooks` only. It is here because
|
|
3
|
+
// `defineHookKind` decides the shape of a Hook, so the code that reads a Hook is beside it.
|
|
4
|
+
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
import { HOOK } from "./hook.ts";
|
|
7
|
+
import { HOOK_KIND_APIS, type Hook, type HookKind, type HookParts, type HookRequest, type Signed } from "./index.ts";
|
|
8
|
+
|
|
9
|
+
/** True when `value` is a Hook that a kind's constructor made. */
|
|
10
|
+
export function isHook(value: unknown): value is Hook {
|
|
11
|
+
return typeof value === "object" && value !== null && Object.hasOwn(value, HOOK);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The kind, the credential and `may` of a Hook. It throws when `hook` is not a Hook. */
|
|
15
|
+
export function hookParts<K extends HookKind>(hook: Hook<K>): HookParts<K> {
|
|
16
|
+
if (!isHook(hook)) throw new Error("not a Hook: make it with its kind, such as github({ secret, may })");
|
|
17
|
+
return hook[HOOK];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The request that the door gives a kind. Header names become lowercase. A header with more than
|
|
22
|
+
* one value keeps them joined by ", ", as `Headers` does. `strip` names a header that the door
|
|
23
|
+
* removes (the `token` verifier's header), so no code of the kind can read it.
|
|
24
|
+
*/
|
|
25
|
+
export function hookRequest(
|
|
26
|
+
headers: Headers | { readonly [name: string]: string | readonly string[] | undefined },
|
|
27
|
+
body: Uint8Array,
|
|
28
|
+
strip?: string,
|
|
29
|
+
): HookRequest {
|
|
30
|
+
const out: { [name: string]: string } = Object.create(null) as { [name: string]: string };
|
|
31
|
+
const entries: Iterable<[string, string | readonly string[] | undefined]> =
|
|
32
|
+
headers instanceof Headers ? headers.entries() : Object.entries(headers);
|
|
33
|
+
for (const [name, value] of entries) {
|
|
34
|
+
if (value === undefined) continue;
|
|
35
|
+
const lower = name.toLowerCase();
|
|
36
|
+
const text = typeof value === "string" ? value : value.join(", ");
|
|
37
|
+
out[lower] = Object.hasOwn(out, lower) ? `${out[lower]}, ${text}` : text;
|
|
38
|
+
}
|
|
39
|
+
if (strip !== undefined) delete out[strip.toLowerCase()];
|
|
40
|
+
const bytes = new Uint8Array(body);
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
headers: Object.freeze(out),
|
|
43
|
+
body: bytes,
|
|
44
|
+
text: new TextDecoder("utf-8").decode(bytes),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The schema of an event key: the exact key, else the longest `prefix.*` key that the event key
|
|
50
|
+
* starts with (`block_actions.*` for `block_actions.approve`). `undefined` when the kind names no
|
|
51
|
+
* schema for the key: the door does not parse such a delivery, and it reaches nothing.
|
|
52
|
+
*/
|
|
53
|
+
export function schemaFor(kind: HookKind, key: string): z.ZodType | undefined {
|
|
54
|
+
if (Object.hasOwn(kind.events, key)) return kind.events[key];
|
|
55
|
+
let best: { readonly length: number; readonly schema: z.ZodType } | undefined;
|
|
56
|
+
for (const [k, schema] of Object.entries(kind.events)) {
|
|
57
|
+
if (!k.endsWith(".*")) continue;
|
|
58
|
+
const prefix = k.slice(0, -1); // keeps the "."
|
|
59
|
+
if (key.length > prefix.length && key.startsWith(prefix) && (!best || prefix.length > best.length)) {
|
|
60
|
+
best = { length: prefix.length, schema };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return best?.schema;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The bytes that the service signed, joined. A string part is UTF-8. */
|
|
67
|
+
export function signedBytes(signed: Signed): Uint8Array {
|
|
68
|
+
const parts = signed.bytes.map((b) => (typeof b === "string" ? new TextEncoder().encode(b) : b));
|
|
69
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
70
|
+
let i = 0;
|
|
71
|
+
for (const p of parts) {
|
|
72
|
+
out.set(p, i);
|
|
73
|
+
i += p.length;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The signature as bytes, or `undefined` when its hex or base64 is not well formed. */
|
|
79
|
+
export function signatureBytes(signed: Signed): Uint8Array | undefined {
|
|
80
|
+
const s = signed.signature;
|
|
81
|
+
if ("hex" in s) {
|
|
82
|
+
if (!/^(?:[0-9a-fA-F]{2})+$/.test(s.hex)) return undefined;
|
|
83
|
+
return Uint8Array.from(Buffer.from(s.hex, "hex"));
|
|
84
|
+
}
|
|
85
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(s.base64) || s.base64.length % 4 !== 0) return undefined;
|
|
86
|
+
return Uint8Array.from(Buffer.from(s.base64, "base64"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The kinds of one Assembly, by id. It throws when two different kinds give one id, or when a kind
|
|
91
|
+
* states an `api` that this copy of `@jr2/orchestrator/hooks` does not know. One kind that two Hooks use is one
|
|
92
|
+
* entry.
|
|
93
|
+
*/
|
|
94
|
+
export function kindsById(kinds: Iterable<HookKind>): ReadonlyMap<string, HookKind> {
|
|
95
|
+
const out = new Map<string, HookKind>();
|
|
96
|
+
for (const kind of kinds) {
|
|
97
|
+
if (!HOOK_KIND_APIS.includes(kind.api)) {
|
|
98
|
+
throw new Error(`Hook kind ${kind.id} states api ${String(kind.api)}; this jr2 knows api 1 only`);
|
|
99
|
+
}
|
|
100
|
+
const had = out.get(kind.id);
|
|
101
|
+
if (had !== undefined && had !== kind) {
|
|
102
|
+
throw new Error(`two different Hook kinds have the id ${kind.id}: one id is one kind in an Assembly`);
|
|
103
|
+
}
|
|
104
|
+
out.set(kind.id, kind);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `@jr2/orchestrator/hooks/github`: jr2's GitHub kind. It imports `@jr2/orchestrator/hooks` and nothing more of jr2, as a
|
|
2
|
+
// kind from another package does.
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { defineHookKind, json, type HookRequest } from "./index.ts";
|
|
6
|
+
|
|
7
|
+
const user = z.looseObject({ login: z.string() });
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The schema of each event key. Each names only the fields that handlers read; a loose object keeps
|
|
11
|
+
* the rest. A value that GitHub can extend (a review's `state`) is a string, not an enum, so a new
|
|
12
|
+
* value fails no parse.
|
|
13
|
+
*/
|
|
14
|
+
export const githubEvents = {
|
|
15
|
+
"pull_request_review.submitted": z.looseObject({
|
|
16
|
+
review: z.looseObject({
|
|
17
|
+
/** Lowercase in a webhook: `approved`, `changes_requested`, `commented`. */
|
|
18
|
+
state: z.string(),
|
|
19
|
+
body: z.string().nullable(),
|
|
20
|
+
user,
|
|
21
|
+
commit_id: z.string(),
|
|
22
|
+
}),
|
|
23
|
+
pull_request: z.looseObject({ html_url: z.string() }),
|
|
24
|
+
}),
|
|
25
|
+
"issue_comment.created": z.looseObject({
|
|
26
|
+
comment: z.looseObject({ body: z.string(), user, author_association: z.string() }),
|
|
27
|
+
issue: z.looseObject({
|
|
28
|
+
/** Only when the issue is a pull request. */
|
|
29
|
+
pull_request: z.looseObject({ html_url: z.string() }).optional(),
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
"check_suite.completed": z.looseObject({
|
|
33
|
+
check_suite: z.looseObject({
|
|
34
|
+
head_sha: z.string(),
|
|
35
|
+
/** `success`, `failure`, `neutral`, `cancelled`, `timed_out`, …; `null` while it runs. */
|
|
36
|
+
conclusion: z.string().nullable(),
|
|
37
|
+
}),
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const header = (req: HookRequest, name: string): string => {
|
|
42
|
+
const v = req.headers[name];
|
|
43
|
+
if (v === undefined || v === "") throw new Error(`no ${name} header`);
|
|
44
|
+
return v;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A GitHub repository, organization or App webhook, with the content type `application/json`. (A
|
|
49
|
+
* webhook with the content type `application/x-www-form-urlencoded` gets 400.)
|
|
50
|
+
* - verifier: `mac`, HMAC-SHA256 of the raw body, `X-Hub-Signature-256: sha256=<hex>`.
|
|
51
|
+
* - handshake: the `ping` event (sent when the webhook is made) gets an empty 200.
|
|
52
|
+
* - event key: `<X-GitHub-Event>.<action>`, or `<X-GitHub-Event>` when the body has no action.
|
|
53
|
+
* - delivery id: `X-GitHub-Delivery`. `onBehalfOf`: `sender.login`.
|
|
54
|
+
* - answer: the outcome (the default). No person reads it.
|
|
55
|
+
*/
|
|
56
|
+
export const github = defineHookKind({
|
|
57
|
+
api: 1,
|
|
58
|
+
id: "github",
|
|
59
|
+
events: githubEvents,
|
|
60
|
+
verify: {
|
|
61
|
+
mac: {
|
|
62
|
+
alg: "sha256",
|
|
63
|
+
signed: (req) => {
|
|
64
|
+
const sig = req.headers["x-hub-signature-256"];
|
|
65
|
+
if (!sig?.startsWith("sha256=")) return { unsigned: "no X-Hub-Signature-256 header" };
|
|
66
|
+
return { bytes: [req.body], signature: { hex: sig.slice("sha256=".length) } };
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
read: (req) => {
|
|
71
|
+
const event = header(req, "x-github-event");
|
|
72
|
+
if (event === "ping") return { handshake: { body: null } };
|
|
73
|
+
const p = json(req) as { readonly action?: unknown; readonly sender?: { readonly login?: unknown } } | null;
|
|
74
|
+
const action = typeof p?.action === "string" && p.action !== "" ? p.action : undefined;
|
|
75
|
+
const by = p?.sender?.login;
|
|
76
|
+
return {
|
|
77
|
+
key: action ? `${event}.${action}` : event,
|
|
78
|
+
id: header(req, "x-github-delivery"),
|
|
79
|
+
payload: p,
|
|
80
|
+
...(typeof by === "string" && by !== "" ? { onBehalfOf: by } : {}),
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
declare module "./index.ts" {
|
|
86
|
+
interface HookKinds {
|
|
87
|
+
github: typeof github;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `@jr2/orchestrator/hooks/gitlab`: jr2's GitLab kind. The `token` verifier: the door compares the digest of the
|
|
2
|
+
// header and removes the header before `read` runs, so this code never sees the token.
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { at, defineHookKind, json } from "./index.ts";
|
|
6
|
+
|
|
7
|
+
const user = z.looseObject({ username: z.string() });
|
|
8
|
+
|
|
9
|
+
/** The schema of each event key: the fields that handlers read, in loose objects. */
|
|
10
|
+
export const gitlabEvents = {
|
|
11
|
+
"merge_request.approved": z.looseObject({
|
|
12
|
+
user,
|
|
13
|
+
object_attributes: z.looseObject({ url: z.string(), last_commit: z.looseObject({ id: z.string() }) }),
|
|
14
|
+
}),
|
|
15
|
+
"note.MergeRequest": z.looseObject({
|
|
16
|
+
user,
|
|
17
|
+
object_attributes: z.looseObject({ note: z.string() }),
|
|
18
|
+
merge_request: z.looseObject({ url: z.string() }),
|
|
19
|
+
}),
|
|
20
|
+
"pipeline.success": z.looseObject({
|
|
21
|
+
object_attributes: z.looseObject({ sha: z.string(), status: z.string() }),
|
|
22
|
+
}),
|
|
23
|
+
"pipeline.failed": z.looseObject({
|
|
24
|
+
object_attributes: z.looseObject({ sha: z.string(), status: z.string() }),
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const text = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A GitLab project or group webhook.
|
|
32
|
+
* - verifier: `token`, the secret token in `X-Gitlab-Token`.
|
|
33
|
+
* - handshake: none. A "Test" delivery from GitLab's settings is an ordinary delivery.
|
|
34
|
+
* - event key: `<object_kind>.<the second part>`, or `<object_kind>` when it has no second part.
|
|
35
|
+
* The second part is `object_attributes.noteable_type` for a `note`, and else
|
|
36
|
+
* `object_attributes.action`, then `object_attributes.status`: `merge_request.approved`,
|
|
37
|
+
* `note.MergeRequest`, `pipeline.success`.
|
|
38
|
+
* - delivery id: `Idempotency-Key` (the same on a retry), else `X-Gitlab-Event-UUID`. (From
|
|
39
|
+
* GitLab's docs; not checked against a real delivery.)
|
|
40
|
+
* - `onBehalfOf`: `user.username`.
|
|
41
|
+
* - answer: the outcome (the default). No person reads it.
|
|
42
|
+
*/
|
|
43
|
+
export const gitlab = defineHookKind({
|
|
44
|
+
api: 1,
|
|
45
|
+
id: "gitlab",
|
|
46
|
+
events: gitlabEvents,
|
|
47
|
+
verify: { token: { header: "x-gitlab-token" } },
|
|
48
|
+
read: (req) => {
|
|
49
|
+
const p = json(req);
|
|
50
|
+
const kind = text(at(p, "object_kind"));
|
|
51
|
+
if (kind === undefined) throw new Error("the body has no object_kind");
|
|
52
|
+
const second =
|
|
53
|
+
kind === "note"
|
|
54
|
+
? text(at(p, "object_attributes.noteable_type"))
|
|
55
|
+
: (text(at(p, "object_attributes.action")) ?? text(at(p, "object_attributes.status")));
|
|
56
|
+
const id = text(req.headers["idempotency-key"]) ?? text(req.headers["x-gitlab-event-uuid"]);
|
|
57
|
+
if (id === undefined) throw new Error("no Idempotency-Key or X-Gitlab-Event-UUID header");
|
|
58
|
+
const by = text(at(p, "user.username"));
|
|
59
|
+
return {
|
|
60
|
+
key: second ? `${kind}.${second}` : kind,
|
|
61
|
+
id,
|
|
62
|
+
payload: p,
|
|
63
|
+
...(by ? { onBehalfOf: by } : {}),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
declare module "./index.ts" {
|
|
69
|
+
interface HookKinds {
|
|
70
|
+
gitlab: typeof gitlab;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The key that a Hook value keeps its parts under. Internal: `defineHookKind` (./index.ts) writes
|
|
2
|
+
// it and the door (./door.ts) reads it. A kind never imports this module.
|
|
3
|
+
//
|
|
4
|
+
// `Symbol.for`, so that the key is the same in each copy of this module. The Kit image holds one
|
|
5
|
+
// copy of `@jr2/orchestrator` (ADR-0069), so a second copy is a fault, but the key does not depend on it.
|
|
6
|
+
|
|
7
|
+
export const HOOK: unique symbol = Symbol.for("@jr2/orchestrator/hooks.hook");
|