@intx/workflow-host 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +287 -0
  3. package/dist/adapters/blob-substrate.d.ts +49 -0
  4. package/dist/adapters/blob-substrate.js +140 -0
  5. package/dist/adapters/repo-store.d.ts +39 -0
  6. package/dist/adapters/repo-store.js +344 -0
  7. package/dist/adapters/spawn-child.d.ts +74 -0
  8. package/dist/adapters/spawn-child.js +152 -0
  9. package/dist/adapters/step-invoker.d.ts +114 -0
  10. package/dist/adapters/step-invoker.js +360 -0
  11. package/dist/child/env-bootstrap.d.ts +56 -0
  12. package/dist/child/env-bootstrap.js +120 -0
  13. package/dist/child/from-process-env.d.ts +127 -0
  14. package/dist/child/from-process-env.js +183 -0
  15. package/dist/child/index.d.ts +9 -0
  16. package/dist/child/index.js +9 -0
  17. package/dist/child/outbound-mail-bridge.d.ts +36 -0
  18. package/dist/child/outbound-mail-bridge.js +143 -0
  19. package/dist/child/proxy-repo-store.d.ts +27 -0
  20. package/dist/child/proxy-repo-store.js +200 -0
  21. package/dist/child/run-child.d.ts +320 -0
  22. package/dist/child/run-child.js +900 -0
  23. package/dist/child/self-discovery.d.ts +29 -0
  24. package/dist/child/self-discovery.js +57 -0
  25. package/dist/child/substrate-write-bridge.d.ts +72 -0
  26. package/dist/child/substrate-write-bridge.js +188 -0
  27. package/dist/child/supervisor-backed-transport.d.ts +10 -0
  28. package/dist/child/supervisor-backed-transport.js +113 -0
  29. package/dist/child/warm-agent-cache.d.ts +78 -0
  30. package/dist/child/warm-agent-cache.js +112 -0
  31. package/dist/drain-controller.d.ts +37 -0
  32. package/dist/drain-controller.js +46 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/ipc/control-channel.d.ts +336 -0
  36. package/dist/ipc/control-channel.js +532 -0
  37. package/dist/ipc/crypto.d.ts +46 -0
  38. package/dist/ipc/crypto.js +126 -0
  39. package/dist/ipc/envelope.d.ts +53 -0
  40. package/dist/ipc/envelope.js +88 -0
  41. package/dist/ipc/event-channel.d.ts +677 -0
  42. package/dist/ipc/event-channel.js +278 -0
  43. package/dist/ipc/index.d.ts +4 -0
  44. package/dist/ipc/index.js +143 -0
  45. package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
  46. package/dist/mail-bus/hub-transport-adapter.js +76 -0
  47. package/dist/mail-bus/index.d.ts +1 -0
  48. package/dist/mail-bus/index.js +1 -0
  49. package/dist/seams/index.d.ts +3 -0
  50. package/dist/seams/index.js +3 -0
  51. package/dist/seams/scheduler-adapter.d.ts +3 -0
  52. package/dist/seams/scheduler-adapter.js +24 -0
  53. package/dist/seams/scheduler.d.ts +94 -0
  54. package/dist/seams/scheduler.js +397 -0
  55. package/dist/seams/signal-channel.d.ts +74 -0
  56. package/dist/seams/signal-channel.js +304 -0
  57. package/dist/supervisor/cancel-signing.d.ts +68 -0
  58. package/dist/supervisor/cancel-signing.js +144 -0
  59. package/dist/supervisor/child-termination.d.ts +51 -0
  60. package/dist/supervisor/child-termination.js +76 -0
  61. package/dist/supervisor/credentials.d.ts +101 -0
  62. package/dist/supervisor/credentials.js +153 -0
  63. package/dist/supervisor/dispatch-attribution.d.ts +37 -0
  64. package/dist/supervisor/dispatch-attribution.js +114 -0
  65. package/dist/supervisor/drain-timeout.d.ts +127 -0
  66. package/dist/supervisor/drain-timeout.js +231 -0
  67. package/dist/supervisor/index.d.ts +7 -0
  68. package/dist/supervisor/index.js +6 -0
  69. package/dist/supervisor/recycle.d.ts +212 -0
  70. package/dist/supervisor/recycle.js +440 -0
  71. package/dist/supervisor/run-event-compaction.d.ts +34 -0
  72. package/dist/supervisor/run-event-compaction.js +115 -0
  73. package/dist/supervisor/spawn-env.d.ts +39 -0
  74. package/dist/supervisor/spawn-env.js +36 -0
  75. package/dist/supervisor/supervisor.d.ts +202 -0
  76. package/dist/supervisor/supervisor.js +2244 -0
  77. package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
  78. package/dist/supervisor/terminal-broadcaster.js +184 -0
  79. package/dist/supervisor/types.d.ts +542 -0
  80. package/dist/supervisor/types.js +10 -0
  81. package/package.json +35 -0
@@ -0,0 +1,532 @@
1
+ // Control channel: NDJSON over stdio, Ed25519-signed per direction.
2
+ //
3
+ // Two Ed25519 keypairs flow per spawn:
4
+ // - Supervisor's keypair. The supervisor holds the private half and
5
+ // signs every downstream (supervisor->child) frame. The matching
6
+ // public half is passed to the child in spawn-time env
7
+ // (`HOST_PUBKEY`) and the child verifies downstream frames
8
+ // against it. The supervisor's PRIVATE KEY NEVER LEAVES THE
9
+ // SUPERVISOR'S ADDRESS SPACE.
10
+ // - Child's keypair. The child mints it at startup, holds the
11
+ // private half in its own address space, and signs every upstream
12
+ // (child->supervisor) frame. The matching public half rides as
13
+ // `childPublicKey` on the upstream `ready` frame's payload; the
14
+ // supervisor extracts it on receive and uses it to verify
15
+ // subsequent upstream frames. The CHILD'S PRIVATE KEY NEVER
16
+ // LEAVES THE CHILD'S ADDRESS SPACE.
17
+ //
18
+ // Upstream `ready` bootstraps the supervisor's view of the child's
19
+ // public key. The supervisor's receiver opens in bootstrap mode:
20
+ // the first frame's envelope is parsed structurally so the
21
+ // supervisor can extract `childPublicKey` from the payload, then the
22
+ // signature is verified against that key. Subsequent upstream frames
23
+ // verify against the same key. A child-signed frame whose claimed
24
+ // `childPublicKey` does not match the bootstrap value (or any
25
+ // non-`ready` first frame) crashes the receiver.
26
+ //
27
+ // Wire format: one signed envelope per line. Each line is the JSON
28
+ // serialization of `{ envelope: { seq, channelId, payload }, sig:
29
+ // <hex Ed25519> }`. The signature covers the canonical bytes of the
30
+ // envelope sub-object (see `envelope.ts`).
31
+ //
32
+ // Payload union: every legal payload's discriminator lives in this
33
+ // module. The mixing-the-two-channels failure mode -- a "control"
34
+ // frame whose payload is structurally an InferenceEvent -- is
35
+ // prevented at the type level by the disjoint discriminated union.
36
+ import { type } from "arktype";
37
+ import { hexDecode, hexEncode } from "@intx/types";
38
+ import { InferenceSource, InterchangeType } from "@intx/types/runtime";
39
+ import { decodeEnvelope, encodeEnvelope, FrameEnvelope, SignedEnvelope, } from "./envelope.js";
40
+ import { signEd25519, verifyEd25519 } from "./crypto.js";
41
+ /**
42
+ * Wire-shape of one per-step credentials entry the supervisor pushes
43
+ * inside a `grants-updated` frame. Mirrors `CredentialsSnapshotStep`
44
+ * in `supervisor/credentials.ts` -- duplicated here as an arktype
45
+ * validator so the control-channel module stays free of a
46
+ * compile-time import on the supervisor module (the IPC module sits
47
+ * underneath the supervisor and child modules in the dependency
48
+ * graph). The contentHash pins the per-step grants so the child can
49
+ * detect a stale push and ignore an out-of-order one.
50
+ */
51
+ export const CredentialsSnapshotStepPayload = type({
52
+ stepId: "string",
53
+ address: "string",
54
+ grants: "unknown[]",
55
+ contentHash: "string",
56
+ });
57
+ export const CredentialsSnapshotPayload = type({
58
+ steps: CredentialsSnapshotStepPayload.array(),
59
+ });
60
+ /**
61
+ * Wire shape of a `sources-updated` frame's `data`: the full ordered
62
+ * inference-source failover chain plus the default source id. Carried
63
+ * inline like the grants snapshot -- a single-producer, single-consumer
64
+ * supervisor->child push, so a substrate round-trip would only add
65
+ * latency. No per-source hash rides along; a source list is flat, with no
66
+ * per-item pin for a receiver to cross-check.
67
+ *
68
+ * The `narrow` pins two frame-structural invariants at this boundary so
69
+ * every consumer can trust them without re-checking: source ids are
70
+ * unique, and the first element is the default source. The head-is-default
71
+ * rule is what keeps the two rotation paths in agreement -- a warm agent's
72
+ * `setSources` activates the matched default index, while a cold rebuild
73
+ * pins element 0 -- so they pick the same active source only when the
74
+ * default is the head.
75
+ */
76
+ export const SourcesUpdatedData = type({
77
+ sources: InferenceSource.array().atLeastLength(1),
78
+ defaultSource: "string > 0",
79
+ }).narrow((data, ctx) => {
80
+ const seen = new Set();
81
+ for (const source of data.sources) {
82
+ if (seen.has(source.id)) {
83
+ return ctx.mustBe(`a source list with unique ids; "${source.id}" appears more than once`);
84
+ }
85
+ seen.add(source.id);
86
+ }
87
+ const head = data.sources[0];
88
+ if (head === undefined || head.id !== data.defaultSource) {
89
+ return ctx.mustBe("a source list whose first element is the default source");
90
+ }
91
+ return true;
92
+ });
93
+ /**
94
+ * Wire projection of an attachment on an outbound mail message. The
95
+ * runtime `MessageAttachment.data` is raw bytes; the NDJSON control
96
+ * channel is text, so the bytes ride base64-encoded under `dataBase64`.
97
+ * The child encodes on send; the supervisor decodes before handing the
98
+ * `OutboundMessage` to the host transport.
99
+ */
100
+ export const OutboundAttachmentPayload = type({
101
+ name: "string",
102
+ contentType: "string",
103
+ dataBase64: "string",
104
+ });
105
+ /**
106
+ * Wire projection of `@intx/types/runtime`'s `OutboundMessage`. Mirrors
107
+ * that type field-for-field with two adjustments for the NDJSON wire:
108
+ * attachment bytes are base64 strings (see `OutboundAttachmentPayload`),
109
+ * and every optional field is spelled with the `"?"` suffix so an
110
+ * absent field round-trips as absent rather than `null`. The supervisor
111
+ * reconstructs the runtime `OutboundMessage` from this shape before
112
+ * invoking `MailBusBindings.sendOutbound`.
113
+ *
114
+ * Duplicated here as an arktype validator (rather than importing the
115
+ * TypeScript `OutboundMessage` type) so the IPC module validates the
116
+ * child-supplied payload at the wire boundary -- the child is a separate
117
+ * process and its frames are untrusted input the receiver must parse.
118
+ */
119
+ export const OutboundMessagePayload = type({
120
+ to: "string | string[]",
121
+ "cc?": "string | string[]",
122
+ "subject?": "string",
123
+ type: InterchangeType,
124
+ "content?": "string",
125
+ "payload?": "Record<string, unknown>",
126
+ "summary?": "string",
127
+ "attachments?": OutboundAttachmentPayload.array(),
128
+ "inReplyTo?": "string",
129
+ "correlationId?": "string",
130
+ "sessionId?": "string",
131
+ "tenantId?": "string",
132
+ });
133
+ /**
134
+ * Discriminated union of every control-channel payload kind. The
135
+ * `type` discriminator namespaces the control-plane vocabulary so a
136
+ * future addition (e.g. `connector-bind`) lands by extending this
137
+ * union and not by widening the envelope shape. Inference events
138
+ * NEVER appear here; they ride the event channel.
139
+ */
140
+ export const ControlPayload = type({
141
+ type: "'trigger.fire'",
142
+ data: {
143
+ runId: "string",
144
+ messageId: "string",
145
+ receivedAt: "number",
146
+ },
147
+ }, "|", {
148
+ type: "'signal.deliver'",
149
+ data: {
150
+ runId: "string",
151
+ signalName: "string",
152
+ signalId: "string",
153
+ payload: "unknown",
154
+ },
155
+ })
156
+ .or({
157
+ type: "'drain'",
158
+ data: {
159
+ deadlineMs: "number",
160
+ },
161
+ })
162
+ .or({
163
+ type: "'shutdown'",
164
+ data: {
165
+ reason: "string",
166
+ },
167
+ })
168
+ .or({
169
+ type: "'grants-updated'",
170
+ data: {
171
+ /**
172
+ * Full credentialsSnapshot the supervisor assembled. The child
173
+ * replaces its in-memory snapshot wholesale on receive so the
174
+ * authorize closure binds to the new per-step grants on the
175
+ * next step invocation. Carried inline rather than by reference
176
+ * because the snapshot is per-step grants payload -- the
177
+ * supervisor is the only producer and the child is the only
178
+ * consumer, so the substrate round-trip would just add latency.
179
+ */
180
+ snapshot: CredentialsSnapshotPayload,
181
+ /**
182
+ * Per-step content hashes the supervisor expects the snapshot
183
+ * to pin to. Surfaced separately so receivers can cheap-compare
184
+ * a push against the snapshot they already have without rehashing
185
+ * each step's grants. Optional; the receiver does not require it
186
+ * but uses it for the staleness cross-check when present.
187
+ */
188
+ "stepHashes?": "Record<string, string>",
189
+ },
190
+ })
191
+ .or({
192
+ type: "'sources-updated'",
193
+ data: SourcesUpdatedData,
194
+ })
195
+ .or({
196
+ type: "'ready'",
197
+ data: {
198
+ childPid: "number",
199
+ /**
200
+ * Hex-encoded Ed25519 public key the child minted at startup.
201
+ * The supervisor extracts this on receive and uses it to verify
202
+ * every subsequent upstream control frame's signature. The
203
+ * child's private key never leaves the child's address space.
204
+ */
205
+ childPublicKey: "string",
206
+ },
207
+ })
208
+ .or({
209
+ // Child-initiated request to recycle the workflow-process. The
210
+ // child emits this when its own self-check decides it needs to be
211
+ // recycled (an internal consistency error it can't recover from,
212
+ // a watchdog tripping); the supervisor receives it on its
213
+ // upstream control-channel reader and funnels it into the same
214
+ // `triggerRecycle` code path the operator and policy origins use.
215
+ // The `reason` rides verbatim into the supervisor's reason field;
216
+ // the supervisor does not interpret it beyond logging and
217
+ // attaching it to the recycle attempt.
218
+ type: "'recycle.request'",
219
+ data: {
220
+ reason: "string",
221
+ },
222
+ })
223
+ .or({
224
+ // Child-initiated `writeTreePreservingPrefix` request. The child
225
+ // does not hold a substrate write authority for the workflow-run
226
+ // repo (single-writer at the ref tip belongs to the supervisor);
227
+ // its workflow-run substrate proxy forwards every write through
228
+ // this frame. The supervisor receives the request, runs its own
229
+ // wrapped `writeTreePreservingPrefix`, and reaches back to the
230
+ // child for the merge bytes via `substrate.merge.request` so the
231
+ // child's merge closure (which knows about seq computation,
232
+ // duplicate detection, etc.) keeps producing the prospective tree.
233
+ // The supervisor resolves the child's awaiter with
234
+ // `substrate.write.response`.
235
+ type: "'substrate.write.request'",
236
+ data: {
237
+ requestId: "string > 0",
238
+ repoId: {
239
+ kind: "string",
240
+ id: "string > 0",
241
+ },
242
+ ref: "string > 0",
243
+ preservePrefix: "string > 0",
244
+ message: "string > 0",
245
+ },
246
+ })
247
+ .or({
248
+ // Supervisor-initiated request for the child's merge bytes. Fired
249
+ // from inside the supervisor's `writeTreePreservingPrefix` merge
250
+ // callback while the per-repo lock is held; the child receives the
251
+ // existing prefix entries (base64-encoded bytes), invokes its merge
252
+ // closure, and replies with the prospective tree on
253
+ // `substrate.merge.response`. Carrying the entries inline preserves
254
+ // the lock window: the supervisor blocks inside the merge callback
255
+ // until the response lands.
256
+ type: "'substrate.merge.request'",
257
+ data: {
258
+ requestId: "string > 0",
259
+ existing: type({
260
+ path: "string > 0",
261
+ contentBase64: "string",
262
+ }).array(),
263
+ },
264
+ })
265
+ .or({
266
+ // Child's merge result. `requestId` correlates with the
267
+ // `substrate.write.request` that started the write; the supervisor
268
+ // resumes its merge callback with the supplied entries (or
269
+ // propagates the structured failure).
270
+ type: "'substrate.merge.response'",
271
+ data: {
272
+ requestId: "string > 0",
273
+ result: type({
274
+ ok: "true",
275
+ files: type({
276
+ path: "string > 0",
277
+ contentBase64: "string",
278
+ }).array(),
279
+ }, "|", {
280
+ ok: "false",
281
+ reason: "string > 0",
282
+ }),
283
+ },
284
+ })
285
+ .or({
286
+ // Supervisor's terminal reply to a child's `substrate.write.request`.
287
+ // The `requestId` echoes the child's allocated correlation id so
288
+ // the child's pending-id map resolves the awaiter. A successful
289
+ // write surfaces `commitSha`; the child's proxy returns that to its
290
+ // caller. A failed write (substrate rejection, validatePush
291
+ // violation, the supervisor's pack-push wrap's downstream
292
+ // `HubLink.pushWorkflowRunPack` rejection) surfaces a structured
293
+ // `{ ok: false, reason }` the child's proxy rethrows.
294
+ type: "'substrate.write.response'",
295
+ data: {
296
+ requestId: "string > 0",
297
+ result: type({
298
+ ok: "true",
299
+ commitSha: "string > 0",
300
+ }, "|", {
301
+ ok: "false",
302
+ reason: "string > 0",
303
+ }),
304
+ },
305
+ })
306
+ .or({
307
+ // Child-initiated outbound-mail request (OUTBOUND half of mailbox
308
+ // ownership, §3a). The workflow-process child never holds the
309
+ // agent's signing key and never calls `transport.send` itself. When
310
+ // a step agent produces a reply or invokes a mail-send tool, the
311
+ // child forwards the structured outbound message plus the sender
312
+ // (agent) address up over the control channel; the supervisor
313
+ // performs the actual signed send through the host's real transport
314
+ // (`MailBusBindings.sendOutbound`), which signs with the sender's
315
+ // `CryptoProvider` exactly as the in-process path does. The
316
+ // supervisor is the sole mail owner and the only process that can
317
+ // emit signed mail on the agent's behalf.
318
+ //
319
+ // `requestId` correlates the supervisor's `outbound.result` reply so
320
+ // the child's mail-tool `send()` resolves with the real
321
+ // `SendReceipt` (or rejects with the supervisor's structured
322
+ // failure). The message is carried as a JSON-projected
323
+ // `OutboundMessage`; attachment bytes ride base64-encoded so the
324
+ // NDJSON wire stays text-safe.
325
+ type: "'outbound.message'",
326
+ data: {
327
+ requestId: "string > 0",
328
+ senderAddress: "string > 0",
329
+ "mailbox?": "string",
330
+ message: OutboundMessagePayload,
331
+ },
332
+ })
333
+ .or({
334
+ // Supervisor's terminal reply to a child's `outbound.message`. The
335
+ // `requestId` echoes the child's correlation id so the child's
336
+ // pending mail-tool awaiter resolves. A successful send surfaces the
337
+ // `SendReceipt` (messageId + status); a failed send (unregistered
338
+ // sender, signing failure, transport rejection) surfaces a
339
+ // structured `{ ok: false, reason }` the child's transport rethrows
340
+ // so the mail-tool call fails loudly rather than dropping the send.
341
+ type: "'outbound.result'",
342
+ data: {
343
+ requestId: "string > 0",
344
+ result: type({
345
+ ok: "true",
346
+ messageId: "string > 0",
347
+ status: "'delivered' | 'queued'",
348
+ }, "|", {
349
+ ok: "false",
350
+ reason: "string > 0",
351
+ }),
352
+ },
353
+ })
354
+ .or({
355
+ // Child-initiated terminal-run notification. The workflow-process
356
+ // child emits this when one of its runs reaches a terminal phase
357
+ // (`RunCompleted`, `RunFailed`, `RunCancelled`) so the supervisor's
358
+ // dispatch loop and drain accumulators can settle without re-reading
359
+ // the workflow-run substrate from the supervisor process. The child
360
+ // commits the terminal event to its own substrate through the
361
+ // workflow-run pack-push pipeline; this frame is the peer-channel
362
+ // notification that mirrors the commit so the supervisor's
363
+ // in-process consumers do not have to round-trip the substrate.
364
+ //
365
+ // The `seq` mirrors the on-disk EventBase.seq the child assigned at
366
+ // commit time. The supervisor does not authoritatively verify the
367
+ // commit landed -- the pack-push response covers that contract --
368
+ // but the field is carried so a downstream consumer can correlate
369
+ // the notification with the substrate blob.
370
+ type: "'terminal.event'",
371
+ data: {
372
+ runId: "string > 0",
373
+ seq: "number >= 0",
374
+ kind: "'RunCompleted' | 'RunFailed' | 'RunCancelled'",
375
+ at: "string > 0",
376
+ "error?": {
377
+ message: "string",
378
+ },
379
+ },
380
+ });
381
+ /**
382
+ * Construct the supervisor-side control-channel sender. The
383
+ * supervisor's Ed25519 seed lives in closure. The matching public
384
+ * key flows to the child through spawn-time env -- never the seed.
385
+ */
386
+ export function createControlChannelSender(opts) {
387
+ let seq = 0;
388
+ // Serialize sends. Signing is async, so without a lock two concurrent
389
+ // callers could each assign seq, suspend on `signEd25519`, and resume in
390
+ // signature-resolution order — writing frames out of seq order, which the
391
+ // receiver rejects as a gap and crashes the channel. The promise chain
392
+ // makes each send await the previous send's completion before it assigns
393
+ // seq, signs, and writes, keeping that critical section atomic.
394
+ let tail = Promise.resolve();
395
+ return {
396
+ get seq() {
397
+ return seq;
398
+ },
399
+ send(payload) {
400
+ const previous = tail;
401
+ let release = () => undefined;
402
+ tail = new Promise((resolve) => {
403
+ release = resolve;
404
+ });
405
+ return (async () => {
406
+ await previous;
407
+ try {
408
+ seq += 1;
409
+ const envelope = {
410
+ seq,
411
+ channelId: opts.channelId,
412
+ payload,
413
+ };
414
+ const envelopeBytes = encodeEnvelope(envelope);
415
+ const sig = await signEd25519(envelopeBytes, opts.privateKeySeed);
416
+ const signed = {
417
+ envelope,
418
+ sig: hexEncode(sig),
419
+ };
420
+ await opts.writer.write(JSON.stringify(signed) + "\n");
421
+ }
422
+ finally {
423
+ release();
424
+ }
425
+ })();
426
+ },
427
+ };
428
+ }
429
+ /**
430
+ * Construct the child-side control-channel receiver. Yields one
431
+ * verified, in-order `ControlPayload` per call. Any frame that
432
+ * fails verification, carries a non-current channelId, or arrives
433
+ * out of order calls `onCrash` and ends the iterator.
434
+ */
435
+ export async function* receiveControlChannel(opts) {
436
+ let highestSeq = 0;
437
+ let activePublicKey = opts.publicKey instanceof Uint8Array ? opts.publicKey : null;
438
+ const bootstrapping = activePublicKey === null;
439
+ for await (const line of opts.reader.read()) {
440
+ if (line.length === 0)
441
+ continue;
442
+ let raw;
443
+ try {
444
+ raw = JSON.parse(line);
445
+ }
446
+ catch (cause) {
447
+ opts.onCrash(`control channel received non-JSON line: ${errorMessage(cause)}`);
448
+ return;
449
+ }
450
+ const signed = SignedEnvelope(raw);
451
+ if (signed instanceof type.errors) {
452
+ opts.onCrash(`control channel envelope failed validation: ${signed.summary}`);
453
+ return;
454
+ }
455
+ let envelopeBytes;
456
+ try {
457
+ envelopeBytes = encodeEnvelope(signed.envelope);
458
+ }
459
+ catch (cause) {
460
+ opts.onCrash(`control channel envelope re-encode failed: ${errorMessage(cause)}`);
461
+ return;
462
+ }
463
+ let sigBytes;
464
+ try {
465
+ sigBytes = hexDecode(signed.sig);
466
+ }
467
+ catch (cause) {
468
+ opts.onCrash(`control channel signature decode failed: ${errorMessage(cause)}`);
469
+ return;
470
+ }
471
+ if (activePublicKey === null) {
472
+ // Bootstrap mode: the first frame must be `ready`. Extract the
473
+ // child's public key from the payload, then verify the
474
+ // first frame's signature against it. The receiver crashes if
475
+ // the payload is not a `ready` frame or carries a malformed
476
+ // `childPublicKey`.
477
+ const candidate = ControlPayload(signed.envelope.payload);
478
+ if (candidate instanceof type.errors) {
479
+ opts.onCrash(`control channel bootstrap payload failed validation: ${candidate.summary}`);
480
+ return;
481
+ }
482
+ if (candidate.type !== "ready") {
483
+ opts.onCrash(`control channel bootstrap expected a ready frame, got ${candidate.type}`);
484
+ return;
485
+ }
486
+ let bootstrapKey;
487
+ try {
488
+ bootstrapKey = hexDecode(candidate.data.childPublicKey);
489
+ }
490
+ catch (cause) {
491
+ opts.onCrash(`control channel bootstrap childPublicKey decode failed: ${errorMessage(cause)}`);
492
+ return;
493
+ }
494
+ activePublicKey = bootstrapKey;
495
+ }
496
+ const ok = await verifyEd25519(envelopeBytes, sigBytes, activePublicKey);
497
+ if (!ok) {
498
+ opts.onCrash(`control channel signature did not verify (seq=${String(signed.envelope.seq)}, channelId=${signed.envelope.channelId}${bootstrapping ? "; bootstrap" : ""})`);
499
+ return;
500
+ }
501
+ if (signed.envelope.channelId !== opts.channelId) {
502
+ opts.onCrash(`control channel channelId mismatch: expected ${opts.channelId}, got ${signed.envelope.channelId} at seq=${String(signed.envelope.seq)}`);
503
+ return;
504
+ }
505
+ if (signed.envelope.seq <= highestSeq) {
506
+ opts.onCrash(`control channel out-of-order seq: expected > ${String(highestSeq)}, got ${String(signed.envelope.seq)}`);
507
+ return;
508
+ }
509
+ if (signed.envelope.seq !== highestSeq + 1) {
510
+ opts.onCrash(`control channel seq gap: expected ${String(highestSeq + 1)}, got ${String(signed.envelope.seq)}`);
511
+ return;
512
+ }
513
+ highestSeq = signed.envelope.seq;
514
+ const payload = ControlPayload(signed.envelope.payload);
515
+ if (payload instanceof type.errors) {
516
+ opts.onCrash(`control channel payload failed validation: ${payload.summary}`);
517
+ return;
518
+ }
519
+ yield payload;
520
+ }
521
+ }
522
+ function errorMessage(cause) {
523
+ if (cause instanceof Error)
524
+ return cause.message;
525
+ return String(cause);
526
+ }
527
+ /**
528
+ * Re-export the envelope decoder for callers that need to inspect
529
+ * a control frame's envelope without going through the receiver
530
+ * iterator (testing harnesses that fuzz the wire format).
531
+ */
532
+ export { decodeEnvelope };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Mint a fresh control-channel HMAC key. Used by the supervisor at
3
+ * spawn time. The child never derives its own key; it receives the
4
+ * 32-byte secret in spawn-time env and never sees the Ed25519 private
5
+ * key the supervisor uses on the control channel.
6
+ */
7
+ export declare function generateHmacKey(): Uint8Array;
8
+ /**
9
+ * Mint a fresh channelId per the channel-identity contract: 16 bytes
10
+ * from `crypto.getRandomValues`, hex-encoded. The supervisor mints one at
11
+ * every spawn and every recycle, passes it to the child in spawn-time
12
+ * env, and rotates it on the next respawn. The hex encoding keeps the
13
+ * value safe to log and round-trips cleanly through JSON.
14
+ */
15
+ export declare function generateChannelId(): string;
16
+ /**
17
+ * Sign the canonicalized envelope bytes with the supervisor's
18
+ * Ed25519 private key. Caller is responsible for canonicalization;
19
+ * this primitive does not see the structured envelope.
20
+ *
21
+ * The private-key bytes are the 32-byte Ed25519 seed. The raw signing
22
+ * primitive lives in `@intx/crypto`; this module wraps it with the
23
+ * channel's fixed-length validation.
24
+ */
25
+ export declare function signEd25519(bytes: Uint8Array, privateKeySeed: Uint8Array): Promise<Uint8Array>;
26
+ export declare function verifyEd25519(bytes: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): Promise<boolean>;
27
+ /**
28
+ * Produce the 32-byte HMAC-SHA256 tag for the given canonicalized
29
+ * envelope bytes under the shared key. Same primitive on both sides
30
+ * of the event channel.
31
+ */
32
+ export declare function signHmac(bytes: Uint8Array, key: Uint8Array): Promise<Uint8Array>;
33
+ /**
34
+ * Verify an HMAC tag by recomputing it and comparing in constant time.
35
+ * Deliberately avoids `subtle.verify`, whose constant-time behavior the
36
+ * Web Crypto spec does not guarantee; this channel owns that property
37
+ * via `constantTimeEqual`.
38
+ */
39
+ export declare function verifyHmac(bytes: Uint8Array, tag: Uint8Array, key: Uint8Array): Promise<boolean>;
40
+ export declare const IPC_CRYPTO: Readonly<{
41
+ ED25519_SIGNATURE_BYTES: 64;
42
+ ED25519_KEY_BYTES: 32;
43
+ HMAC_KEY_BYTES: 32;
44
+ HMAC_TAG_BYTES: 32;
45
+ CHANNEL_ID_BYTES: 16;
46
+ }>;