@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.
- package/LICENSE +176 -0
- package/README.md +287 -0
- package/dist/adapters/blob-substrate.d.ts +49 -0
- package/dist/adapters/blob-substrate.js +140 -0
- package/dist/adapters/repo-store.d.ts +39 -0
- package/dist/adapters/repo-store.js +344 -0
- package/dist/adapters/spawn-child.d.ts +74 -0
- package/dist/adapters/spawn-child.js +152 -0
- package/dist/adapters/step-invoker.d.ts +114 -0
- package/dist/adapters/step-invoker.js +360 -0
- package/dist/child/env-bootstrap.d.ts +56 -0
- package/dist/child/env-bootstrap.js +120 -0
- package/dist/child/from-process-env.d.ts +127 -0
- package/dist/child/from-process-env.js +183 -0
- package/dist/child/index.d.ts +9 -0
- package/dist/child/index.js +9 -0
- package/dist/child/outbound-mail-bridge.d.ts +36 -0
- package/dist/child/outbound-mail-bridge.js +143 -0
- package/dist/child/proxy-repo-store.d.ts +27 -0
- package/dist/child/proxy-repo-store.js +200 -0
- package/dist/child/run-child.d.ts +320 -0
- package/dist/child/run-child.js +900 -0
- package/dist/child/self-discovery.d.ts +29 -0
- package/dist/child/self-discovery.js +57 -0
- package/dist/child/substrate-write-bridge.d.ts +72 -0
- package/dist/child/substrate-write-bridge.js +188 -0
- package/dist/child/supervisor-backed-transport.d.ts +10 -0
- package/dist/child/supervisor-backed-transport.js +113 -0
- package/dist/child/warm-agent-cache.d.ts +78 -0
- package/dist/child/warm-agent-cache.js +112 -0
- package/dist/drain-controller.d.ts +37 -0
- package/dist/drain-controller.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ipc/control-channel.d.ts +336 -0
- package/dist/ipc/control-channel.js +532 -0
- package/dist/ipc/crypto.d.ts +46 -0
- package/dist/ipc/crypto.js +126 -0
- package/dist/ipc/envelope.d.ts +53 -0
- package/dist/ipc/envelope.js +88 -0
- package/dist/ipc/event-channel.d.ts +677 -0
- package/dist/ipc/event-channel.js +278 -0
- package/dist/ipc/index.d.ts +4 -0
- package/dist/ipc/index.js +143 -0
- package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
- package/dist/mail-bus/hub-transport-adapter.js +76 -0
- package/dist/mail-bus/index.d.ts +1 -0
- package/dist/mail-bus/index.js +1 -0
- package/dist/seams/index.d.ts +3 -0
- package/dist/seams/index.js +3 -0
- package/dist/seams/scheduler-adapter.d.ts +3 -0
- package/dist/seams/scheduler-adapter.js +24 -0
- package/dist/seams/scheduler.d.ts +94 -0
- package/dist/seams/scheduler.js +397 -0
- package/dist/seams/signal-channel.d.ts +74 -0
- package/dist/seams/signal-channel.js +304 -0
- package/dist/supervisor/cancel-signing.d.ts +68 -0
- package/dist/supervisor/cancel-signing.js +144 -0
- package/dist/supervisor/child-termination.d.ts +51 -0
- package/dist/supervisor/child-termination.js +76 -0
- package/dist/supervisor/credentials.d.ts +101 -0
- package/dist/supervisor/credentials.js +153 -0
- package/dist/supervisor/dispatch-attribution.d.ts +37 -0
- package/dist/supervisor/dispatch-attribution.js +114 -0
- package/dist/supervisor/drain-timeout.d.ts +127 -0
- package/dist/supervisor/drain-timeout.js +231 -0
- package/dist/supervisor/index.d.ts +7 -0
- package/dist/supervisor/index.js +6 -0
- package/dist/supervisor/recycle.d.ts +212 -0
- package/dist/supervisor/recycle.js +440 -0
- package/dist/supervisor/run-event-compaction.d.ts +34 -0
- package/dist/supervisor/run-event-compaction.js +115 -0
- package/dist/supervisor/spawn-env.d.ts +39 -0
- package/dist/supervisor/spawn-env.js +36 -0
- package/dist/supervisor/supervisor.d.ts +202 -0
- package/dist/supervisor/supervisor.js +2244 -0
- package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
- package/dist/supervisor/terminal-broadcaster.js +184 -0
- package/dist/supervisor/types.d.ts +542 -0
- package/dist/supervisor/types.js +10 -0
- 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
|
+
}>;
|