@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,278 @@
|
|
|
1
|
+
// Event channel: UNIX socketpair, HMAC-SHA256 authenticated.
|
|
2
|
+
//
|
|
3
|
+
// The workflow-process child sends InferenceEvents (high rate, plus
|
|
4
|
+
// the per-message `message.run.started` / `message.run.ended`
|
|
5
|
+
// brackets) to the supervisor. The supervisor verifies each frame's
|
|
6
|
+
// MAC and forwards into the hub via the existing `agent.event`
|
|
7
|
+
// session-channel path. The shared 32-byte HMAC key is minted by the
|
|
8
|
+
// supervisor at spawn time and passed to the child in spawn-time env.
|
|
9
|
+
// Both sides authenticate every frame: a malformed or tampered frame
|
|
10
|
+
// is a crash signal, not a recoverable error.
|
|
11
|
+
//
|
|
12
|
+
// Backpressure: the supervisor keeps a bounded userspace ring (default
|
|
13
|
+
// 1024 frames). On overrun the supervisor logs the saturation, fires
|
|
14
|
+
// the caller-supplied crash callback, and the workflow-process kills
|
|
15
|
+
// itself on the next signal cycle. Observability lost equals
|
|
16
|
+
// invariant violated equals crash; the audit chain cannot tolerate a
|
|
17
|
+
// silent drop, and a blocking writer would deadlock against the
|
|
18
|
+
// reactor's emit path.
|
|
19
|
+
//
|
|
20
|
+
// Mixing failure mode: the payload union here covers InferenceEvent
|
|
21
|
+
// shapes only. A "control message" structurally shaped as `drain` or
|
|
22
|
+
// `recycle` will not satisfy this union and the receiver will crash.
|
|
23
|
+
// The discriminated arktype validators in `control-channel.ts` and
|
|
24
|
+
// here are disjoint by construction.
|
|
25
|
+
import { type } from "arktype";
|
|
26
|
+
import { hexDecode, hexEncode } from "@intx/types";
|
|
27
|
+
import { InferenceEvent } from "@intx/types/runtime";
|
|
28
|
+
import { decodeEnvelope, encodeEnvelope, FrameEnvelope, MacedEnvelope, } from "./envelope.js";
|
|
29
|
+
import { signHmac, verifyHmac } from "./crypto.js";
|
|
30
|
+
/**
|
|
31
|
+
* The event channel additionally carries the two bracket events the
|
|
32
|
+
* reactor emits per message run. They are part of the InferenceEvent
|
|
33
|
+
* union upstream; re-exporting the union directly keeps this channel
|
|
34
|
+
* structurally identical to what the hub's `agent.event` frame
|
|
35
|
+
* carries today.
|
|
36
|
+
*/
|
|
37
|
+
export const EventPayload = InferenceEvent;
|
|
38
|
+
/**
|
|
39
|
+
* Construct the child-side event-channel sender. The shared HMAC
|
|
40
|
+
* key lives in closure. The child mints monotonic seq values per
|
|
41
|
+
* channelId; the receiver enforces strict monotonicity.
|
|
42
|
+
*/
|
|
43
|
+
export function createEventChannelSender(opts) {
|
|
44
|
+
let seq = 0;
|
|
45
|
+
// Serialize sends. `signHmac` is async, so without a lock two
|
|
46
|
+
// concurrent callers could each assign seq, suspend on the signer, and
|
|
47
|
+
// resume in HMAC-resolution order — writing frames out of seq order,
|
|
48
|
+
// which the receiver rejects as a gap and crashes the channel. The
|
|
49
|
+
// production caller fires events without awaiting (`void send(event)`),
|
|
50
|
+
// so this is the live case. The promise chain makes each send await the
|
|
51
|
+
// previous send's completion before it assigns seq, signs, and writes,
|
|
52
|
+
// keeping that critical section atomic. Mirrors the control channel's
|
|
53
|
+
// sender, whose Ed25519 signing has the same shape.
|
|
54
|
+
let tail = Promise.resolve();
|
|
55
|
+
return {
|
|
56
|
+
get seq() {
|
|
57
|
+
return seq;
|
|
58
|
+
},
|
|
59
|
+
send(payload) {
|
|
60
|
+
const previous = tail;
|
|
61
|
+
let release = () => undefined;
|
|
62
|
+
tail = new Promise((resolve) => {
|
|
63
|
+
release = resolve;
|
|
64
|
+
});
|
|
65
|
+
return (async () => {
|
|
66
|
+
await previous;
|
|
67
|
+
try {
|
|
68
|
+
seq += 1;
|
|
69
|
+
const envelope = {
|
|
70
|
+
seq,
|
|
71
|
+
channelId: opts.channelId,
|
|
72
|
+
payload,
|
|
73
|
+
};
|
|
74
|
+
const envelopeBytes = encodeEnvelope(envelope);
|
|
75
|
+
const tag = await signHmac(envelopeBytes, opts.hmacKey);
|
|
76
|
+
const maced = {
|
|
77
|
+
envelope,
|
|
78
|
+
mac: hexEncode(tag),
|
|
79
|
+
};
|
|
80
|
+
// Newline-delimit each frame. The channel rides a byte-stream
|
|
81
|
+
// pipe (fd3) where the kernel may coalesce successive writes into
|
|
82
|
+
// one read or split one write across reads -- the one-write-equals-
|
|
83
|
+
// one-frame assumption does not hold under the burst of events a
|
|
84
|
+
// real step emits. `JSON.stringify` never emits a literal newline
|
|
85
|
+
// (newlines inside strings are escaped as `\n`), so `\n` is an
|
|
86
|
+
// unambiguous frame terminator the receiver splits on, mirroring
|
|
87
|
+
// the control channel's NDJSON discipline.
|
|
88
|
+
await opts.writer.write(new TextEncoder().encode(`${JSON.stringify(maced)}\n`));
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
release();
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Construct the supervisor-side event-channel receiver. Yields one
|
|
99
|
+
* verified, in-order `EventPayload` per call. Any frame that fails
|
|
100
|
+
* HMAC verification, carries a non-current channelId, arrives out
|
|
101
|
+
* of order, or arrives faster than the consumer drains -- triggers
|
|
102
|
+
* `onCrash` and ends the iterator.
|
|
103
|
+
*/
|
|
104
|
+
export async function* receiveEventChannel(opts) {
|
|
105
|
+
const limit = opts.bufferLimit ?? DEFAULT_EVENT_BUFFER_LIMIT;
|
|
106
|
+
let highestSeq = 0;
|
|
107
|
+
const buffer = [];
|
|
108
|
+
let crashed = false;
|
|
109
|
+
let producerDone = false;
|
|
110
|
+
let waiter = null;
|
|
111
|
+
function wake() {
|
|
112
|
+
const w = waiter;
|
|
113
|
+
waiter = null;
|
|
114
|
+
if (w)
|
|
115
|
+
w();
|
|
116
|
+
}
|
|
117
|
+
const pump = (async () => {
|
|
118
|
+
// The channel rides a byte-stream pipe: the kernel can coalesce
|
|
119
|
+
// several sender writes into one read chunk or split one write
|
|
120
|
+
// across chunks, so a chunk boundary is not a frame boundary. The
|
|
121
|
+
// sender newline-delimits every frame; this decoder accumulates raw
|
|
122
|
+
// bytes and splits on `\n` so each complete line is exactly one
|
|
123
|
+
// envelope, mirroring the control channel's NDJSON reader. A partial
|
|
124
|
+
// trailing line stays buffered until its terminator arrives.
|
|
125
|
+
const decoder = new TextDecoder("utf-8");
|
|
126
|
+
let pending = "";
|
|
127
|
+
try {
|
|
128
|
+
for await (const chunk of opts.reader.read()) {
|
|
129
|
+
if (crashed)
|
|
130
|
+
return;
|
|
131
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
132
|
+
let nl = pending.indexOf("\n");
|
|
133
|
+
while (nl >= 0) {
|
|
134
|
+
const line = pending.slice(0, nl).replace(/\r$/, "");
|
|
135
|
+
pending = pending.slice(nl + 1);
|
|
136
|
+
nl = pending.indexOf("\n");
|
|
137
|
+
if (line.length === 0)
|
|
138
|
+
continue;
|
|
139
|
+
if (crashed)
|
|
140
|
+
return;
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = JSON.parse(line);
|
|
144
|
+
}
|
|
145
|
+
catch (cause) {
|
|
146
|
+
crashed = true;
|
|
147
|
+
opts.onCrash(`event channel received non-JSON frame: ${errorMessage(cause)}`);
|
|
148
|
+
wake();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const crashedOnLine = await processLine(raw);
|
|
152
|
+
if (crashedOnLine)
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// A non-empty trailing buffer at EOF is a truncated final frame:
|
|
157
|
+
// the sender always terminates a frame with `\n`, so unterminated
|
|
158
|
+
// bytes mean the writer died mid-frame. Surface it as a crash
|
|
159
|
+
// rather than silently dropping a partial envelope.
|
|
160
|
+
if (pending.length > 0) {
|
|
161
|
+
crashed = true;
|
|
162
|
+
opts.onCrash(`event channel received truncated frame at EOF (${String(pending.length)} bytes, no terminator)`);
|
|
163
|
+
wake();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
producerDone = true;
|
|
169
|
+
wake();
|
|
170
|
+
}
|
|
171
|
+
// Process one decoded frame through the verify/order/validate
|
|
172
|
+
// pipeline. Returns `true` when the frame tripped a crash (the
|
|
173
|
+
// caller must stop pumping), `false` on a clean buffered push.
|
|
174
|
+
async function processLine(raw) {
|
|
175
|
+
const maced = MacedEnvelope(raw);
|
|
176
|
+
if (maced instanceof type.errors) {
|
|
177
|
+
crashed = true;
|
|
178
|
+
opts.onCrash(`event channel envelope failed validation: ${maced.summary}`);
|
|
179
|
+
wake();
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
let envelopeBytes;
|
|
183
|
+
try {
|
|
184
|
+
envelopeBytes = encodeEnvelope(maced.envelope);
|
|
185
|
+
}
|
|
186
|
+
catch (cause) {
|
|
187
|
+
crashed = true;
|
|
188
|
+
opts.onCrash(`event channel envelope re-encode failed: ${errorMessage(cause)}`);
|
|
189
|
+
wake();
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
let macBytes;
|
|
193
|
+
try {
|
|
194
|
+
macBytes = hexDecode(maced.mac);
|
|
195
|
+
}
|
|
196
|
+
catch (cause) {
|
|
197
|
+
crashed = true;
|
|
198
|
+
opts.onCrash(`event channel MAC decode failed: ${errorMessage(cause)}`);
|
|
199
|
+
wake();
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
const ok = await verifyHmac(envelopeBytes, macBytes, opts.hmacKey);
|
|
203
|
+
if (!ok) {
|
|
204
|
+
crashed = true;
|
|
205
|
+
opts.onCrash(`event channel HMAC did not verify (seq=${String(maced.envelope.seq)}, channelId=${maced.envelope.channelId})`);
|
|
206
|
+
wake();
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
if (maced.envelope.channelId !== opts.channelId) {
|
|
210
|
+
crashed = true;
|
|
211
|
+
opts.onCrash(`event channel channelId mismatch: expected ${opts.channelId}, got ${maced.envelope.channelId} at seq=${String(maced.envelope.seq)}`);
|
|
212
|
+
wake();
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (maced.envelope.seq <= highestSeq) {
|
|
216
|
+
crashed = true;
|
|
217
|
+
opts.onCrash(`event channel out-of-order seq: expected > ${String(highestSeq)}, got ${String(maced.envelope.seq)}`);
|
|
218
|
+
wake();
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (maced.envelope.seq !== highestSeq + 1) {
|
|
222
|
+
crashed = true;
|
|
223
|
+
opts.onCrash(`event channel seq gap: expected ${String(highestSeq + 1)}, got ${String(maced.envelope.seq)}`);
|
|
224
|
+
wake();
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
highestSeq = maced.envelope.seq;
|
|
228
|
+
const payload = EventPayload(maced.envelope.payload);
|
|
229
|
+
if (payload instanceof type.errors) {
|
|
230
|
+
crashed = true;
|
|
231
|
+
opts.onCrash(`event channel payload failed validation: ${payload.summary}`);
|
|
232
|
+
wake();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
if (buffer.length >= limit) {
|
|
236
|
+
crashed = true;
|
|
237
|
+
opts.onCrash(`event channel buffer overrun: ${String(buffer.length)} frames pending, limit ${String(limit)}`);
|
|
238
|
+
wake();
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
buffer.push(payload);
|
|
242
|
+
wake();
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
})();
|
|
246
|
+
try {
|
|
247
|
+
while (true) {
|
|
248
|
+
if (buffer.length > 0) {
|
|
249
|
+
const next = buffer.shift();
|
|
250
|
+
if (next === undefined) {
|
|
251
|
+
throw new Error("event channel receiver invariant: shift returned undefined despite non-empty buffer");
|
|
252
|
+
}
|
|
253
|
+
yield next;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (crashed || producerDone) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
await new Promise((resolve) => {
|
|
260
|
+
waiter = resolve;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
await pump;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function errorMessage(cause) {
|
|
269
|
+
if (cause instanceof Error)
|
|
270
|
+
return cause.message;
|
|
271
|
+
return String(cause);
|
|
272
|
+
}
|
|
273
|
+
export const DEFAULT_EVENT_BUFFER_LIMIT = 1024;
|
|
274
|
+
/**
|
|
275
|
+
* Re-export the envelope decoder for callers that need raw access
|
|
276
|
+
* to a frame's envelope outside the receiver iterator.
|
|
277
|
+
*/
|
|
278
|
+
export { decodeEnvelope };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ControlPayload, OutboundAttachmentPayload, OutboundMessagePayload, SourcesUpdatedData, createControlChannelSender, receiveControlChannel, type ControlChannelSender, type ControlChannelSenderOpts, type ControlChannelReceiverOpts, type NdjsonReader, type NdjsonWriter, } from "./control-channel.js";
|
|
2
|
+
export { DEFAULT_EVENT_BUFFER_LIMIT, EventPayload, createEventChannelSender, receiveEventChannel, type EventChannelSender, type EventChannelSenderOpts, type EventChannelReceiverOpts, type FrameReader, type FrameWriter, } from "./event-channel.js";
|
|
3
|
+
export { FrameEnvelope, MacedEnvelope, SignedEnvelope, decodeEnvelope, encodeEnvelope, } from "./envelope.js";
|
|
4
|
+
export { IPC_CRYPTO, generateChannelId, generateHmacKey, signEd25519, signHmac, verifyEd25519, verifyHmac, } from "./crypto.js";
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// =============================================================
|
|
2
|
+
// THREAT MODEL -- workflow-process supervisor/child IPC
|
|
3
|
+
// =============================================================
|
|
4
|
+
//
|
|
5
|
+
// The workflow-process is a Bun child the supervisor spawns per
|
|
6
|
+
// active deployment. That child runs user-supplied workflow code:
|
|
7
|
+
// tools the operator deployed, director prompts the operator wrote,
|
|
8
|
+
// agent harnesses operating against external APIs. The supervisor
|
|
9
|
+
// lives in-sidecar, owns the mail-bus identity for the deployment's
|
|
10
|
+
// addresses, and holds the Ed25519 signing key used to commit
|
|
11
|
+
// authoritative records (`CancelRequested`, drain signatures, audit
|
|
12
|
+
// frames) on the deployment's behalf.
|
|
13
|
+
//
|
|
14
|
+
// The IPC channels between the supervisor and the child are the
|
|
15
|
+
// airlock between an authoritative-but-trusted process and a
|
|
16
|
+
// potentially-compromised one. Per-frame crypto is what makes the
|
|
17
|
+
// airlock seal: any frame the child receives whose signature does
|
|
18
|
+
// not verify under the supervisor's Ed25519 public key is dropped
|
|
19
|
+
// (and the receiver crashes); any frame the supervisor receives
|
|
20
|
+
// whose HMAC does not verify is dropped (and the receiver crashes).
|
|
21
|
+
//
|
|
22
|
+
// Design pieces, with the failure mode each one defends against:
|
|
23
|
+
//
|
|
24
|
+
// 1. Asymmetric crypto on the control channel, two keypairs per spawn.
|
|
25
|
+
// Both directions of the control channel carry Ed25519-signed
|
|
26
|
+
// frames; each direction uses its own keypair so neither end
|
|
27
|
+
// holds the private half of the keypair its peer signs with.
|
|
28
|
+
//
|
|
29
|
+
// Supervisor's keypair (downstream: supervisor -> child).
|
|
30
|
+
// The supervisor mints the keypair at spawn time, signs every
|
|
31
|
+
// downstream control frame (trigger.fire, signal.deliver, drain,
|
|
32
|
+
// recycle, shutdown, grants-updated, sources-updated) with the
|
|
33
|
+
// private half, and passes the corresponding 32-byte public half
|
|
34
|
+
// to the child in spawn-time env (`HOST_PUBKEY`). The
|
|
35
|
+
// SUPERVISOR'S PRIVATE KEY NEVER LEAVES THE SUPERVISOR'S ADDRESS
|
|
36
|
+
// SPACE. The spawn-time env carries pubkey + HMAC key + channelId
|
|
37
|
+
// and NEVER the supervisor's private key. A leak via fork() memory
|
|
38
|
+
// copy is impossible because the supervisor uses `spawn` with an
|
|
39
|
+
// explicitly constructed `env` object -- there is no inheritance
|
|
40
|
+
// of the supervisor's process env into the child by reference.
|
|
41
|
+
// A leak via accidental `process.env` propagation is prevented by
|
|
42
|
+
// constructing the child env as a fresh object containing only
|
|
43
|
+
// the documented variables (no `...process.env` spread). A leak
|
|
44
|
+
// via serialization is prevented by the supervisor never placing
|
|
45
|
+
// the private key in any IPC payload, log line, or audit-log
|
|
46
|
+
// frame -- it lives only as a 32-byte Uint8Array held in closure
|
|
47
|
+
// by the signing-key callback.
|
|
48
|
+
//
|
|
49
|
+
// Child's keypair (upstream: child -> supervisor).
|
|
50
|
+
// The child mints its own Ed25519 keypair at startup, signs every
|
|
51
|
+
// upstream control frame with the private half, and publishes the
|
|
52
|
+
// matching public half in the payload of the upstream `ready`
|
|
53
|
+
// frame as `childPublicKey` (hex-encoded). The supervisor's
|
|
54
|
+
// upstream receiver opens in bootstrap mode: it parses the first
|
|
55
|
+
// frame's envelope structurally, extracts `childPublicKey`,
|
|
56
|
+
// verifies the `ready` frame's signature against it, and uses the
|
|
57
|
+
// same key to verify every subsequent upstream frame. The CHILD'S
|
|
58
|
+
// PRIVATE KEY NEVER LEAVES THE CHILD'S ADDRESS SPACE. A
|
|
59
|
+
// compromised child cannot forge a frame the supervisor accepts
|
|
60
|
+
// under a different key: the bootstrap pins the verification key
|
|
61
|
+
// to the value the first `ready` frame published, and a frame
|
|
62
|
+
// that claims a different sender is one whose signature the
|
|
63
|
+
// receiver cannot verify.
|
|
64
|
+
//
|
|
65
|
+
// 2. HMAC-SHA256 on the event channel.
|
|
66
|
+
// Symmetric authentication is correct for the high-rate path
|
|
67
|
+
// (InferenceEvents stream at the reactor's emit cadence, including
|
|
68
|
+
// `message.run.started` / `message.run.ended` brackets). Both
|
|
69
|
+
// sides hold the same 32-byte key; both sides authenticate every
|
|
70
|
+
// frame. HMAC is roughly two orders of magnitude cheaper per byte
|
|
71
|
+
// than Ed25519, which is what makes per-frame authentication
|
|
72
|
+
// affordable at high rate. The cost saving holds across the full
|
|
73
|
+
// InferenceEvent volume profile (delta events at token rate).
|
|
74
|
+
//
|
|
75
|
+
// 3. ChannelId rotation on every spawn AND every recycle.
|
|
76
|
+
// The channelId is 16 bytes from `crypto.getRandomValues`, hex-encoded
|
|
77
|
+
// (interface-decisions Bonus 2). The supervisor mints it at every
|
|
78
|
+
// spawn, places it in spawn-time env (`IPC_CHANNEL_ID`), and mints
|
|
79
|
+
// a fresh one at every recycle. Receivers track the current
|
|
80
|
+
// channelId; any frame carrying a non-current channelId is a
|
|
81
|
+
// signal that the wire is still attached to a predecessor
|
|
82
|
+
// workflow-process (a recycled child's leftover state in some
|
|
83
|
+
// socket the supervisor failed to fully close, or an attacker who
|
|
84
|
+
// captured a frame from a previous spawn and is replaying it
|
|
85
|
+
// against the current one). The receiver crashes loudly rather
|
|
86
|
+
// than processing the stale frame. Crash-on-mismatch is the only
|
|
87
|
+
// honest response: a "stale frame" is by construction either a
|
|
88
|
+
// replay or a programming bug, and silently dropping it would
|
|
89
|
+
// paper over both.
|
|
90
|
+
//
|
|
91
|
+
// 4. Monotonic seq per channelId, crash on out-of-order.
|
|
92
|
+
// Every frame within a channelId's lifetime carries a strictly-
|
|
93
|
+
// increasing seq. The sender maintains a counter; the receiver
|
|
94
|
+
// tracks the highest seq seen and requires the next frame's seq
|
|
95
|
+
// to equal `highestSeq + 1` exactly (gap = drop, repeat = replay,
|
|
96
|
+
// decrease = replay). Any seq violation crashes the receiver.
|
|
97
|
+
// The combination of channelId rotation and monotonic seq
|
|
98
|
+
// prevents an attacker who captured a previous channelId's frame
|
|
99
|
+
// stream from replaying it against the current channel: the
|
|
100
|
+
// captured frames carry the wrong channelId. Within the current
|
|
101
|
+
// channelId, the monotonic-seq check prevents replay of an
|
|
102
|
+
// earlier frame in the same channel.
|
|
103
|
+
//
|
|
104
|
+
// 5. Crash-on-overrun on the event channel.
|
|
105
|
+
// The supervisor buffers in userspace with a bound (default 1024
|
|
106
|
+
// frames). On overrun, the supervisor logs the saturation and the
|
|
107
|
+
// workflow-process kills itself. The audit chain is built from
|
|
108
|
+
// forwarded InferenceEvents; a silent drop of a single event
|
|
109
|
+
// breaks the chain in a way no downstream consumer can detect.
|
|
110
|
+
// The choice is between two unrecoverable states: (a) corrupt
|
|
111
|
+
// audit chain that pretends to be correct, or (b) crash that
|
|
112
|
+
// advertises itself. Crash wins.
|
|
113
|
+
//
|
|
114
|
+
// 6. Clean control-vs-event boundary.
|
|
115
|
+
// The control channel carries `credentialsSnapshot` updates,
|
|
116
|
+
// drain, recycle, grants-updated, ready, shutdown -- shapes whose
|
|
117
|
+
// authority is the supervisor's identity and whose rate is low.
|
|
118
|
+
// The event channel carries InferenceEvents and the bracket
|
|
119
|
+
// events -- shapes whose authority is the deployment's identity
|
|
120
|
+
// (which the supervisor and child share via the HMAC key) and
|
|
121
|
+
// whose rate is high. The two channels' typed payload unions are
|
|
122
|
+
// DISJOINT BY CONSTRUCTION (the discriminated `ControlPayload`
|
|
123
|
+
// union in `control-channel.ts` does not overlap the
|
|
124
|
+
// `EventPayload` union in `event-channel.ts`). A control payload
|
|
125
|
+
// that included an inference-event shape would defeat the split;
|
|
126
|
+
// an inference-event payload that included a `drain` or `recycle`
|
|
127
|
+
// discriminator would let a compromised child issue control-plane
|
|
128
|
+
// commands the supervisor honors. Neither is possible at the
|
|
129
|
+
// type level.
|
|
130
|
+
//
|
|
131
|
+
// 7. Supervisor-minted channelId.
|
|
132
|
+
// The supervisor is the single source of truth for channelId.
|
|
133
|
+
// The child does NOT generate its own and never proposes a value.
|
|
134
|
+
// A protocol that let the child propose a channelId would let a
|
|
135
|
+
// compromised child negotiate a channelId an attacker had
|
|
136
|
+
// pre-captured frames for. The supervisor mints, the child reads
|
|
137
|
+
// from env, the supervisor enforces.
|
|
138
|
+
//
|
|
139
|
+
// =============================================================
|
|
140
|
+
export { ControlPayload, OutboundAttachmentPayload, OutboundMessagePayload, SourcesUpdatedData, createControlChannelSender, receiveControlChannel, } from "./control-channel.js";
|
|
141
|
+
export { DEFAULT_EVENT_BUFFER_LIMIT, EventPayload, createEventChannelSender, receiveEventChannel, } from "./event-channel.js";
|
|
142
|
+
export { FrameEnvelope, MacedEnvelope, SignedEnvelope, decodeEnvelope, encodeEnvelope, } from "./envelope.js";
|
|
143
|
+
export { IPC_CRYPTO, generateChannelId, generateHmacKey, signEd25519, signHmac, verifyEd25519, verifyHmac, } from "./crypto.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { HubTransport } from "@intx/mail-memory";
|
|
2
|
+
import type { MailBusBindings } from "../supervisor/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Returned shape: the `MailBusBindings` surface the supervisor
|
|
5
|
+
* consumes, plus a `routeInbound` method the host calls to deliver
|
|
6
|
+
* an inbound message to every handler subscribed at the named
|
|
7
|
+
* address. The split keeps the supervisor's subscription contract
|
|
8
|
+
* narrow while letting the host drive delivery through its existing
|
|
9
|
+
* transport plumbing.
|
|
10
|
+
*/
|
|
11
|
+
export interface HubTransportMailBusAdapter extends MailBusBindings {
|
|
12
|
+
/**
|
|
13
|
+
* Fan a delivered message out to every handler subscribed at
|
|
14
|
+
* `address`. Returns immediately if no handler is registered --
|
|
15
|
+
* the supervisor's lifecycle (`subscribeMailForAddress` returns a
|
|
16
|
+
* disposer the supervisor calls on teardown) is the authoritative
|
|
17
|
+
* source of which addresses are live; addresses without an active
|
|
18
|
+
* subscriber drop the message silently.
|
|
19
|
+
*/
|
|
20
|
+
routeInbound(address: string, message: Uint8Array): void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Wrap an existing `HubTransport` instance as the supervisor-facing
|
|
24
|
+
* `MailBusBindings` shape. The `transport` argument is held only as
|
|
25
|
+
* a sink-side reference the adapter does not actively reach into
|
|
26
|
+
* today -- the sidecar's deploy router registers the head's address
|
|
27
|
+
* on the transport, and the adapter delivers inbound bytes through
|
|
28
|
+
* `routeInbound` directly into the per-address subscriber map below.
|
|
29
|
+
*/
|
|
30
|
+
export declare function wrapHubTransportAsMailBus(transport: HubTransport): HubTransportMailBusAdapter;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Adapter that surfaces an existing `HubTransport` instance as the
|
|
2
|
+
// supervisor-facing `MailBusBindings` shape. Hosts that already own
|
|
3
|
+
// a hub-mail transport (the sidecar, the integration test harness,
|
|
4
|
+
// any future alternative-sidecar implementation) wire the supervisor
|
|
5
|
+
// through this adapter instead of standing up a second bus.
|
|
6
|
+
//
|
|
7
|
+
// Per-address subscriber maps live inside the adapter: the
|
|
8
|
+
// supervisor's `subscribeMailForAddress` registers a handler here;
|
|
9
|
+
// the host's transport delivers messages via `routeInbound` and the
|
|
10
|
+
// adapter fans the bytes out to every subscribed handler. The
|
|
11
|
+
// transport itself is treated as a sink the host already owns -- the
|
|
12
|
+
// adapter does not register addresses on the transport on the
|
|
13
|
+
// supervisor's behalf (production sidecars register the head's address
|
|
14
|
+
// through the deploy router before the supervisor spawns) and does not
|
|
15
|
+
// double-deliver messages the transport itself routes.
|
|
16
|
+
/**
|
|
17
|
+
* Wrap an existing `HubTransport` instance as the supervisor-facing
|
|
18
|
+
* `MailBusBindings` shape. The `transport` argument is held only as
|
|
19
|
+
* a sink-side reference the adapter does not actively reach into
|
|
20
|
+
* today -- the sidecar's deploy router registers the head's address
|
|
21
|
+
* on the transport, and the adapter delivers inbound bytes through
|
|
22
|
+
* `routeInbound` directly into the per-address subscriber map below.
|
|
23
|
+
*/
|
|
24
|
+
export function wrapHubTransportAsMailBus(transport) {
|
|
25
|
+
const subscribers = new Map();
|
|
26
|
+
return {
|
|
27
|
+
registerAddress(address) {
|
|
28
|
+
// The supervisor's address registration is the seam where the
|
|
29
|
+
// workflow-process child owns its own mailbox; production
|
|
30
|
+
// sidecars register the head's address through the deploy router
|
|
31
|
+
// before the supervisor spawns, so this method is intentionally
|
|
32
|
+
// inert. The `transport` reference is retained so a future
|
|
33
|
+
// routing change that wants the bus to own registration has
|
|
34
|
+
// the handle in scope.
|
|
35
|
+
void address;
|
|
36
|
+
void transport;
|
|
37
|
+
},
|
|
38
|
+
unregisterAddress(address) {
|
|
39
|
+
subscribers.delete(address);
|
|
40
|
+
},
|
|
41
|
+
subscribeMailForAddress(address, handler) {
|
|
42
|
+
let set = subscribers.get(address);
|
|
43
|
+
if (set === undefined) {
|
|
44
|
+
set = new Set();
|
|
45
|
+
subscribers.set(address, set);
|
|
46
|
+
}
|
|
47
|
+
set.add(handler);
|
|
48
|
+
return () => {
|
|
49
|
+
const current = subscribers.get(address);
|
|
50
|
+
current?.delete(handler);
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
routeInbound(address, message) {
|
|
54
|
+
const set = subscribers.get(address);
|
|
55
|
+
if (set === undefined)
|
|
56
|
+
return;
|
|
57
|
+
for (const handler of set)
|
|
58
|
+
handler(message);
|
|
59
|
+
},
|
|
60
|
+
async sendOutbound(senderAddress, message) {
|
|
61
|
+
// OUTBOUND half of mailbox ownership (§3a): route the agent's
|
|
62
|
+
// reply / mail-tool send through the host transport's signed-send
|
|
63
|
+
// path. `getTransportFor(senderAddress).send(message)` is the
|
|
64
|
+
// exact path the in-process agent uses today -- it signs with the
|
|
65
|
+
// `CryptoProvider` the host registered for `senderAddress` via
|
|
66
|
+
// `register(address, crypto)`, so the outbound mail carries the
|
|
67
|
+
// AGENT's signature and fires the same `addMessageSentHandler`
|
|
68
|
+
// audit hook the in-process path relies on. The supervisor never
|
|
69
|
+
// holds the agent's key; the host transport does. An unregistered
|
|
70
|
+
// sender throws inside `getTransportFor` rather than emitting
|
|
71
|
+
// unsigned mail.
|
|
72
|
+
const scoped = transport.getTransportFor(senderAddress);
|
|
73
|
+
return scoped.send(message);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { wrapHubTransportAsMailBus, type HubTransportMailBusAdapter, } from "./hub-transport-adapter.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { wrapHubTransportAsMailBus, } from "./hub-transport-adapter.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createWorkflowHostScheduler, type SchedulerHandle, type SchedulerOpts, } from "./scheduler.js";
|
|
2
|
+
export { adaptHostScheduler } from "./scheduler-adapter.js";
|
|
3
|
+
export { SignalReceivedEnvelope, createWorkflowHostSignalChannel, type SignalChannelHandle, type SignalChannelOpts, } from "./signal-channel.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Adapter from the host-singleton `SchedulerHandle` to the runtime's
|
|
2
|
+
// per-call `Scheduler` interface.
|
|
3
|
+
//
|
|
4
|
+
// The host's `SchedulerHandle` (from `seams/scheduler.ts`) tails
|
|
5
|
+
// `TimerSet` commits via `subscribeKind` and commits `TimerFired` at
|
|
6
|
+
// the wall-clock deadline. The runtime body (`@intx/workflow`'s
|
|
7
|
+
// `runtimeRun`) expects a `Scheduler.scheduleIn(runId, timerId,
|
|
8
|
+
// fireAt) => dispose` whose dispose cancels any pending `TimerFired`.
|
|
9
|
+
//
|
|
10
|
+
// The adapter routes `scheduleIn` through to the host scheduler's
|
|
11
|
+
// live ingest by relying on the supervisor's commit-then-subscribe
|
|
12
|
+
// ordering: by the time the runtime body has committed `TimerSet`
|
|
13
|
+
// and called `scheduleIn`, the host scheduler's `subscribeKind` tail
|
|
14
|
+
// will queue the timer. The returned disposer asks the host scheduler
|
|
15
|
+
// to drop the queued entry for `(runId, timerId)`.
|
|
16
|
+
export function adaptHostScheduler(handle) {
|
|
17
|
+
return {
|
|
18
|
+
scheduleIn(runId, timerId, _fireAt) {
|
|
19
|
+
return () => {
|
|
20
|
+
handle.cancelQueued(runId, timerId);
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|