@aeondave/pi-persona 1.17.3 → 1.17.4
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/AGENTS.md +44 -36
- package/CHANGELOG.md +76 -1
- package/README.md +23 -13
- package/docs/ARCHITECTURE.md +97 -37
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +25 -8
- package/src/bus/broker/client.ts +119 -21
- package/src/bus/broker/host.ts +123 -8
- package/src/bus/broker/messages.ts +31 -4
- package/src/bus/contact.ts +113 -38
- package/src/bus/peers.ts +15 -0
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +83 -19
- package/src/engine/async.ts +49 -10
- package/src/engine/child.ts +10 -2
- package/src/engine/inproc.ts +87 -46
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/engine.ts +20 -21
- package/src/extension/hooks.ts +952 -938
- package/src/extension/identity.ts +41 -75
- package/src/extension/results.ts +136 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2963 -2873
- package/src/loader.ts +11 -3
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +122 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +44 -6
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +2 -0
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/agent-overlay.ts +11 -3
- package/src/ui/agent-tree.ts +43 -1
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
package/src/bus/broker/client.ts
CHANGED
|
@@ -15,18 +15,21 @@
|
|
|
15
15
|
* roster cache, and endless reconnect-after-disconnect "storm" are dropped — YAGNI here,
|
|
16
16
|
* see spec B6/Task 4 brief). An unexpected disconnect (host died
|
|
17
17
|
* mid-run) is NOT auto-reconnected: sends silently go nowhere, while in-flight asks/lists
|
|
18
|
-
* reject promptly and clean up their timers
|
|
18
|
+
* reject promptly and clean up their timers. Authenticated result readers are stricter:
|
|
19
|
+
* one connect attempt, bounded handshake/reads, closed failure replies, never mute success.
|
|
19
20
|
*
|
|
20
21
|
* Pure over an injected socket factory (`net`), mirroring `host.ts`'s `net` param — same
|
|
21
22
|
* transport-injection seam, opposite side of the same fake in tests.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
25
26
|
import nodeNet from "node:net";
|
|
26
27
|
import type net from "node:net";
|
|
27
28
|
|
|
28
29
|
import { createFrameReader, encodeFrame } from "./framing.ts";
|
|
29
|
-
import { type Frame, isFrame } from "./messages.ts";
|
|
30
|
+
import { type Frame, isFrame, isReaderToken } from "./messages.ts";
|
|
31
|
+
import { isReadRequest, isReadReplyFor, readFailure, RESULT_READ_MAX_PENDING, RESULT_READ_TIMEOUT_MS,
|
|
32
|
+
type ReadReply, type ResultReadRequest } from "../result-reader.ts";
|
|
30
33
|
import type { MsgKind } from "../inproc.ts";
|
|
31
34
|
|
|
32
35
|
export interface DeliverEvent {
|
|
@@ -39,8 +42,10 @@ export interface DeliverEvent {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export interface BrokerClient {
|
|
42
|
-
/**
|
|
45
|
+
/** Register once. Ordinary comms back off on initial connect; reader grants never retry. */
|
|
43
46
|
register(): Promise<void>;
|
|
47
|
+
/** Authenticated bounded read. Failures are fixed closed replies; never a mute success. */
|
|
48
|
+
read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
|
|
44
49
|
/** Fire-and-forget; a dead connection silently drops it (spec: "sends report undelivered"). */
|
|
45
50
|
send(to: string, kind: MsgKind, text: string): void;
|
|
46
51
|
/** Blocking request; resolves on a matching `replied`, rejects on `signal` abort or the
|
|
@@ -63,6 +68,8 @@ export interface MakeBrokerClientDeps {
|
|
|
63
68
|
label?: string;
|
|
64
69
|
group?: string;
|
|
65
70
|
peers?: boolean;
|
|
71
|
+
/** Private, one-shot parent grant. Never log this or pass it to a model. */
|
|
72
|
+
readerToken?: string;
|
|
66
73
|
/** Injected transport (tests use a paired in-memory fake); defaults to real `node:net`. */
|
|
67
74
|
net?: typeof import("node:net");
|
|
68
75
|
}
|
|
@@ -75,19 +82,17 @@ const ASK_TIMEOUT_MS = 600_000;
|
|
|
75
82
|
// let a half-open broker pin `contact_peer list/send` until the child idle watchdog fires.
|
|
76
83
|
const LIST_TIMEOUT_MS = 10_000;
|
|
77
84
|
|
|
78
|
-
function delay(ms: number): Promise<void> {
|
|
79
|
-
return new Promise((resolve) => {
|
|
80
|
-
const t = setTimeout(resolve, ms);
|
|
81
|
-
t.unref?.();
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
85
|
export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
86
86
|
const netImpl = deps.net ?? nodeNet;
|
|
87
87
|
|
|
88
88
|
let socket: net.Socket | undefined;
|
|
89
89
|
let closed = false;
|
|
90
90
|
let disconnected = false;
|
|
91
|
+
let registerStarted = false;
|
|
92
|
+
let registered = false;
|
|
93
|
+
let cancelConnect: (() => void) | undefined;
|
|
94
|
+
let cancelBackoff: (() => void) | undefined;
|
|
95
|
+
const pendingReads = new Map<string, { request: ResultReadRequest; resolve: (result: ReadReply) => void }>();
|
|
91
96
|
let registerSettle: { resolve: () => void; reject: (err: Error) => void } | undefined;
|
|
92
97
|
|
|
93
98
|
const deliverListeners = new Set<(evt: DeliverEvent) => void>();
|
|
@@ -122,9 +127,21 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
122
127
|
function handleFrame(frame: Frame): void {
|
|
123
128
|
switch (frame.t) {
|
|
124
129
|
case "registered":
|
|
125
|
-
registerSettle
|
|
130
|
+
if (!registerSettle) return;
|
|
131
|
+
if (frame.handle !== deps.handle || (deps.readerToken !== undefined && (!frame.readerToken
|
|
132
|
+
|| !timingSafeEqual(Buffer.from(deps.readerToken, "hex"), Buffer.from(frame.readerToken, "hex"))))) {
|
|
133
|
+
failReaderProtocol();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
registered = true;
|
|
137
|
+
registerSettle.resolve();
|
|
126
138
|
registerSettle = undefined;
|
|
127
139
|
return;
|
|
140
|
+
case "result_read_reply": {
|
|
141
|
+
const pending = pendingReads.get(frame.reqId);
|
|
142
|
+
if (pending) pending.resolve(isReadReplyFor(frame.result, pending.request) ? frame.result : readFailure("protocol"));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
128
145
|
case "error":
|
|
129
146
|
// A correlated error belongs to one request on this connection. Do not let an
|
|
130
147
|
// unknown/stale correlation id affect the register handshake or another ask.
|
|
@@ -132,9 +149,8 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
132
149
|
pendingAsks.get(frame.msgId)?.reject(new Error(frame.reason));
|
|
133
150
|
return;
|
|
134
151
|
}
|
|
135
|
-
// A bare error
|
|
136
|
-
|
|
137
|
-
registerSettle?.reject(new Error(frame.reason));
|
|
152
|
+
// A bare error belongs to the register handshake, not an outstanding read.
|
|
153
|
+
registerSettle?.reject(new Error(deps.readerToken !== undefined ? "Result reader authentication failed." : frame.reason));
|
|
138
154
|
registerSettle = undefined;
|
|
139
155
|
return;
|
|
140
156
|
case "deliver": {
|
|
@@ -167,25 +183,43 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
167
183
|
}
|
|
168
184
|
}
|
|
169
185
|
|
|
186
|
+
function failReaderProtocol(): void {
|
|
187
|
+
registerSettle?.reject(new Error("Result reader protocol failure."));
|
|
188
|
+
registerSettle = undefined;
|
|
189
|
+
for (const pending of pendingReads.values()) pending.resolve(readFailure("protocol"));
|
|
190
|
+
socket?.destroy();
|
|
191
|
+
}
|
|
192
|
+
|
|
170
193
|
function connectOnce(): Promise<net.Socket> {
|
|
171
194
|
return new Promise((resolve, reject) => {
|
|
172
195
|
const s = netImpl.connect(deps.endpoint);
|
|
173
196
|
let settled = false;
|
|
197
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
198
|
+
const finish = (): void => { clearTimeout(timer); cancelConnect = undefined; };
|
|
174
199
|
const onConnect = (): void => {
|
|
175
200
|
if (settled) return;
|
|
176
201
|
settled = true;
|
|
202
|
+
finish();
|
|
177
203
|
s.removeListener("error", onError);
|
|
178
204
|
resolve(s);
|
|
179
205
|
};
|
|
180
206
|
const onError = (err: Error): void => {
|
|
181
207
|
if (settled) return;
|
|
182
208
|
settled = true;
|
|
209
|
+
finish();
|
|
183
210
|
s.removeListener("connect", onConnect);
|
|
184
211
|
s.destroy();
|
|
185
212
|
reject(err);
|
|
186
213
|
};
|
|
187
214
|
s.once("connect", onConnect);
|
|
188
215
|
s.once("error", onError);
|
|
216
|
+
// Permanent sink also covers cancellation before the eventual connect event.
|
|
217
|
+
s.on("error", () => {});
|
|
218
|
+
cancelConnect = () => onError(new Error("broker client closed"));
|
|
219
|
+
if (deps.readerToken !== undefined) {
|
|
220
|
+
timer = setTimeout(() => onError(new Error("Result reader connection deadline exceeded.")), RESULT_READ_TIMEOUT_MS);
|
|
221
|
+
timer.unref?.();
|
|
222
|
+
}
|
|
189
223
|
});
|
|
190
224
|
}
|
|
191
225
|
|
|
@@ -198,9 +232,21 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
198
232
|
try {
|
|
199
233
|
return await connectOnce();
|
|
200
234
|
} catch (err) {
|
|
235
|
+
// Local cancellation is terminal. Waiting on an unref'ed retry timer here
|
|
236
|
+
// can leave register() pending after the process has no live handles.
|
|
237
|
+
if (closed) throw new Error("broker client closed");
|
|
201
238
|
lastErr = err;
|
|
202
239
|
if (attempt < CONNECT_MAX_ATTEMPTS - 1) {
|
|
203
|
-
await
|
|
240
|
+
await new Promise<void>((resolve) => {
|
|
241
|
+
const finish = (): void => {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
cancelBackoff = undefined;
|
|
244
|
+
resolve();
|
|
245
|
+
};
|
|
246
|
+
const timer = setTimeout(finish, Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS));
|
|
247
|
+
timer.unref?.();
|
|
248
|
+
cancelBackoff = finish;
|
|
249
|
+
});
|
|
204
250
|
}
|
|
205
251
|
}
|
|
206
252
|
}
|
|
@@ -208,6 +254,7 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
208
254
|
}
|
|
209
255
|
|
|
210
256
|
function rejectAllPending(reason: string): void {
|
|
257
|
+
for (const p of pendingReads.values()) p.resolve(readFailure("unavailable"));
|
|
211
258
|
for (const p of pendingAsks.values()) p.reject(new Error(reason));
|
|
212
259
|
pendingAsks.clear();
|
|
213
260
|
for (const p of pendingLists.values()) p.reject(new Error(reason));
|
|
@@ -215,7 +262,11 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
215
262
|
}
|
|
216
263
|
|
|
217
264
|
async function register(): Promise<void> {
|
|
218
|
-
|
|
265
|
+
if (registerStarted || closed) throw new Error("broker register is one-shot or closed");
|
|
266
|
+
registerStarted = true;
|
|
267
|
+
if (deps.readerToken !== undefined && !isReaderToken(deps.readerToken)) throw new Error("Result reader protocol failure.");
|
|
268
|
+
// Ordinary comms retain initial-connect backoff. A result grant never reconnects/replays.
|
|
269
|
+
const s = await (deps.readerToken !== undefined ? connectOnce() : connectWithBackoff());
|
|
219
270
|
if (closed) {
|
|
220
271
|
s.destroy();
|
|
221
272
|
throw new Error("broker client closed");
|
|
@@ -226,16 +277,19 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
226
277
|
createFrameReader(
|
|
227
278
|
(raw) => {
|
|
228
279
|
if (isFrame(raw)) handleFrame(raw);
|
|
280
|
+
else if (deps.readerToken !== undefined) failReaderProtocol();
|
|
229
281
|
},
|
|
230
|
-
() => s.destroy(),
|
|
282
|
+
() => { if (deps.readerToken !== undefined) failReaderProtocol(); else s.destroy(); },
|
|
231
283
|
),
|
|
232
284
|
);
|
|
233
285
|
s.on("error", () => {
|
|
286
|
+
if (deps.readerToken !== undefined) s.destroy();
|
|
234
287
|
/* The close event rejects in-flight requests; this listener only prevents an
|
|
235
288
|
* unhandled socket error from taking down the child. */
|
|
236
289
|
});
|
|
237
290
|
s.once("close", () => {
|
|
238
|
-
socket = undefined;
|
|
291
|
+
if (socket === s) socket = undefined;
|
|
292
|
+
registered = false;
|
|
239
293
|
// A peer-initiated drop (host teardown/crash) between connect and the handshake
|
|
240
294
|
// reply would otherwise leave register() pending for the child's whole life.
|
|
241
295
|
registerSettle?.reject(new Error("connection closed during register"));
|
|
@@ -248,10 +302,20 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
248
302
|
});
|
|
249
303
|
|
|
250
304
|
return new Promise<void>((resolve, reject) => {
|
|
251
|
-
|
|
305
|
+
const timer = deps.readerToken !== undefined ? setTimeout(() => {
|
|
306
|
+
registerSettle?.reject(new Error("Result reader handshake deadline exceeded."));
|
|
307
|
+
registerSettle = undefined;
|
|
308
|
+
s.destroy();
|
|
309
|
+
}, RESULT_READ_TIMEOUT_MS) : undefined;
|
|
310
|
+
timer?.unref?.();
|
|
311
|
+
registerSettle = {
|
|
312
|
+
resolve: () => { clearTimeout(timer); resolve(); },
|
|
313
|
+
reject: (error) => { clearTimeout(timer); reject(error); },
|
|
314
|
+
};
|
|
252
315
|
write({
|
|
253
316
|
t: "register",
|
|
254
317
|
handle: deps.handle,
|
|
318
|
+
...(deps.readerToken !== undefined ? { readerToken: deps.readerToken } : {}),
|
|
255
319
|
...(deps.label !== undefined ? { label: deps.label } : {}),
|
|
256
320
|
...(deps.group !== undefined ? { group: deps.group } : {}),
|
|
257
321
|
...(deps.peers !== undefined ? { peers: deps.peers } : {}),
|
|
@@ -259,6 +323,37 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
259
323
|
});
|
|
260
324
|
}
|
|
261
325
|
|
|
326
|
+
function read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply> {
|
|
327
|
+
if (signal?.aborted) return Promise.resolve(readFailure("cancelled"));
|
|
328
|
+
if (!isReadRequest(request)) return Promise.resolve(readFailure("invalid"));
|
|
329
|
+
if (deps.readerToken === undefined) return Promise.resolve(readFailure("denied"));
|
|
330
|
+
if (closed || disconnected || !registered || !socket) return Promise.resolve(readFailure("unavailable"));
|
|
331
|
+
if (pendingReads.size >= RESULT_READ_MAX_PENDING) return Promise.resolve(readFailure("busy"));
|
|
332
|
+
const reqId = randomUUID();
|
|
333
|
+
// Snapshot caller data so mutation after dispatch cannot alter correlation checks.
|
|
334
|
+
const snapshot = { ...request };
|
|
335
|
+
return new Promise((resolve) => {
|
|
336
|
+
const finish = (result: ReadReply): void => {
|
|
337
|
+
if (!pendingReads.delete(reqId)) return;
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
signal?.removeEventListener("abort", onAbort);
|
|
340
|
+
resolve(result);
|
|
341
|
+
};
|
|
342
|
+
const cancel = (result: ReadReply): void => {
|
|
343
|
+
finish(result);
|
|
344
|
+
write({ t: "result_read_cancel", reqId });
|
|
345
|
+
};
|
|
346
|
+
const onAbort = (): void => cancel(readFailure("cancelled"));
|
|
347
|
+
const timer = setTimeout(() => cancel(readFailure("timeout")), RESULT_READ_TIMEOUT_MS);
|
|
348
|
+
timer.unref?.();
|
|
349
|
+
pendingReads.set(reqId, { request: snapshot, resolve: finish });
|
|
350
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
351
|
+
try {
|
|
352
|
+
socket!.write(encodeFrame({ t: "result_read", reqId, ...snapshot }));
|
|
353
|
+
} catch { finish(readFailure("unavailable")); socket?.destroy(); }
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
262
357
|
function send(to: string, kind: MsgKind, text: string): void {
|
|
263
358
|
write({ t: "send", to, kind, text, msgId: randomUUID(), expectsReply: false });
|
|
264
359
|
}
|
|
@@ -357,6 +452,9 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
357
452
|
function close(): void {
|
|
358
453
|
if (closed) return;
|
|
359
454
|
closed = true;
|
|
455
|
+
registered = false;
|
|
456
|
+
cancelConnect?.();
|
|
457
|
+
cancelBackoff?.();
|
|
360
458
|
const s = socket;
|
|
361
459
|
if (s) {
|
|
362
460
|
write({ t: "bye" });
|
|
@@ -375,5 +473,5 @@ export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
|
375
473
|
rejectAllPending("broker client closed");
|
|
376
474
|
}
|
|
377
475
|
|
|
378
|
-
return { register, send, ask, reply, list, onDeliver, onSteer, onClose, close };
|
|
476
|
+
return { register, read, send, ask, reply, list, onDeliver, onSteer, onClose, close };
|
|
379
477
|
}
|
package/src/bus/broker/host.ts
CHANGED
|
@@ -29,17 +29,23 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { existsSync, unlinkSync } from "node:fs";
|
|
32
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
32
33
|
import nodeNet from "node:net";
|
|
33
34
|
import type net from "node:net";
|
|
34
35
|
|
|
35
36
|
import { createFrameReader, encodeFrame } from "./framing.ts";
|
|
36
37
|
import { type Frame, isFrame } from "./messages.ts";
|
|
37
38
|
import type { InProcessBus } from "../inproc.ts";
|
|
39
|
+
import { isReadReplyFor, readFailure, RESULT_READ_MAX_FRAME_BYTES, RESULT_READ_MAX_PENDING,
|
|
40
|
+
RESULT_READ_MAX_REQUESTS, RESULT_READ_TIMEOUT_MS, type PreparedResultReader, type ReadReply,
|
|
41
|
+
type ScopedReader } from "../result-reader.ts";
|
|
38
42
|
|
|
39
43
|
export interface BrokerHost {
|
|
40
44
|
endpoint: string;
|
|
41
45
|
close(): Promise<void>;
|
|
42
46
|
connectedHandles(): string[];
|
|
47
|
+
/** Parent-only preflight. Reserve before bus/adapter registration or spawning. */
|
|
48
|
+
prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader>;
|
|
43
49
|
/** Pushes a `steer` frame directly to a connected handle (spec B4) — the supervisor's own
|
|
44
50
|
* live voice, NOT a bus envelope (it bypasses `bus.send`/the outbound drain entirely, so
|
|
45
51
|
* it can't be mistaken for a peer/child message on the wire). Returns false (a harmless
|
|
@@ -158,6 +164,39 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
158
164
|
const sockets = new Set<net.Socket>();
|
|
159
165
|
const expected = new Set<string>();
|
|
160
166
|
const pendingSteer = new Map<string, string[]>();
|
|
167
|
+
type Grant = { token: Buffer; reader: ScopedReader; active: boolean; bound: boolean; stop?: () => void; revoke: () => void };
|
|
168
|
+
const grants = new Map<string, Grant>();
|
|
169
|
+
let closed = false;
|
|
170
|
+
|
|
171
|
+
function revokeReader(reader: ScopedReader): void {
|
|
172
|
+
try { reader.revoke(); } catch { /* cleanup cannot leak callback errors or strand siblings */ }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function prepareResultReader(handle: string, reader: ScopedReader, signal?: AbortSignal): Promise<PreparedResultReader> {
|
|
176
|
+
if (closed || signal?.aborted || !handle || handle.length > 256 || handle === deps.supervisorHandle
|
|
177
|
+
|| connections.has(handle) || grants.has(handle) || expected.has(handle) || bus.participants().includes(handle)) {
|
|
178
|
+
revokeReader(reader);
|
|
179
|
+
throw new Error("Result reader preparation refused.");
|
|
180
|
+
}
|
|
181
|
+
const token = randomBytes(32);
|
|
182
|
+
const grant: Grant = { token, reader, active: true, bound: false, revoke: () => {
|
|
183
|
+
if (!grant.active) return;
|
|
184
|
+
grant.active = false;
|
|
185
|
+
signal?.removeEventListener("abort", grant.revoke);
|
|
186
|
+
if (grants.get(handle) === grant) {
|
|
187
|
+
grants.delete(handle);
|
|
188
|
+
expected.delete(handle);
|
|
189
|
+
pendingSteer.delete(handle);
|
|
190
|
+
}
|
|
191
|
+
token.fill(0);
|
|
192
|
+
grant.stop?.();
|
|
193
|
+
revokeReader(reader);
|
|
194
|
+
} };
|
|
195
|
+
grants.set(handle, grant);
|
|
196
|
+
expected.add(handle);
|
|
197
|
+
signal?.addEventListener("abort", grant.revoke, { once: true });
|
|
198
|
+
return { token: token.toString("hex"), revoke: grant.revoke, wasBound: () => grant.bound };
|
|
199
|
+
}
|
|
161
200
|
|
|
162
201
|
function flushPendingSteer(handle: string): void {
|
|
163
202
|
const conn = connections.get(handle);
|
|
@@ -207,10 +246,19 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
207
246
|
let handle: string | undefined;
|
|
208
247
|
let group: string | undefined;
|
|
209
248
|
const pendingAsks = new Map<string, AbortController>();
|
|
249
|
+
let ended = false;
|
|
250
|
+
let boundGrant: Grant | undefined;
|
|
251
|
+
const seenReads = new Set<string>();
|
|
252
|
+
const pendingReads = new Map<string, { ac: AbortController; finish: (result?: ReadReply) => void }>();
|
|
210
253
|
|
|
211
254
|
const write = (frame: Frame): void => {
|
|
212
255
|
try {
|
|
213
|
-
|
|
256
|
+
const encoded = encodeFrame(frame);
|
|
257
|
+
if (frame.t === "result_read_reply" && encoded.length > RESULT_READ_MAX_FRAME_BYTES) {
|
|
258
|
+
socket.write(encodeFrame({ t: "result_read_reply", reqId: frame.reqId, result: readFailure("protocol") }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
socket.write(encoded);
|
|
214
262
|
} catch {
|
|
215
263
|
/* peer went away mid-write */
|
|
216
264
|
}
|
|
@@ -220,6 +268,11 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
220
268
|
const conn = { write };
|
|
221
269
|
|
|
222
270
|
const cleanup = (): void => {
|
|
271
|
+
if (ended) return;
|
|
272
|
+
ended = true;
|
|
273
|
+
for (const pending of pendingReads.values()) { pending.finish(); pending.ac.abort(); }
|
|
274
|
+
seenReads.clear();
|
|
275
|
+
boundGrant?.revoke();
|
|
223
276
|
for (const ac of pendingAsks.values()) ac.abort();
|
|
224
277
|
pendingAsks.clear();
|
|
225
278
|
// `bye` releases the handle while `handle` stays set (the socket's own `close`
|
|
@@ -233,10 +286,55 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
233
286
|
const errorReason = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
234
287
|
|
|
235
288
|
const dispatch = (frame: Frame): void => {
|
|
289
|
+
if (ended) return;
|
|
236
290
|
switch (frame.t) {
|
|
291
|
+
case "result_read": {
|
|
292
|
+
const grant = boundGrant;
|
|
293
|
+
const respond = (result: ReadReply): void => write({ t: "result_read_reply", reqId: frame.reqId, result });
|
|
294
|
+
if (!handle || connections.get(handle) !== conn || !grant?.active) { respond(readFailure("denied")); return; }
|
|
295
|
+
if (seenReads.has(frame.reqId)) {
|
|
296
|
+
const pending = pendingReads.get(frame.reqId);
|
|
297
|
+
pending?.finish();
|
|
298
|
+
pending?.ac.abort();
|
|
299
|
+
respond(readFailure("protocol"));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (seenReads.size >= RESULT_READ_MAX_REQUESTS) { grant.revoke(); return; }
|
|
303
|
+
seenReads.add(frame.reqId);
|
|
304
|
+
if (pendingReads.size >= RESULT_READ_MAX_PENDING) { respond(readFailure("busy")); return; }
|
|
305
|
+
const { t: _t, reqId, ...request } = frame;
|
|
306
|
+
const ac = new AbortController();
|
|
307
|
+
const finish = (result?: ReadReply): void => {
|
|
308
|
+
if (pendingReads.get(reqId)?.ac !== ac) return;
|
|
309
|
+
pendingReads.delete(reqId);
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
if (result && !ended && grant.active && handle && connections.get(handle) === conn) respond(result);
|
|
312
|
+
};
|
|
313
|
+
const timer = setTimeout(() => { finish(readFailure("timeout")); ac.abort(); }, RESULT_READ_TIMEOUT_MS);
|
|
314
|
+
timer.unref?.();
|
|
315
|
+
pendingReads.set(reqId, { ac, finish });
|
|
316
|
+
void Promise.resolve().then(() => {
|
|
317
|
+
if (ac.signal.aborted || !grant.active) return readFailure("revoked");
|
|
318
|
+
return grant.reader.read(request, ac.signal);
|
|
319
|
+
}).then((result) => {
|
|
320
|
+
try { finish(isReadReplyFor(result, request) ? result : readFailure("protocol")); }
|
|
321
|
+
catch { finish(readFailure("protocol")); }
|
|
322
|
+
}, () => finish(readFailure("unavailable")));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
case "result_read_cancel": {
|
|
326
|
+
const pending = pendingReads.get(frame.reqId);
|
|
327
|
+
pending?.finish(readFailure("cancelled"));
|
|
328
|
+
pending?.ac.abort();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
237
331
|
case "register": {
|
|
332
|
+
const reject = (reason: string): void => {
|
|
333
|
+
write({ t: "error", reason });
|
|
334
|
+
if (frame.readerToken !== undefined || grants.has(frame.handle)) socket.destroy();
|
|
335
|
+
};
|
|
238
336
|
if (frame.handle === deps.supervisorHandle) {
|
|
239
|
-
|
|
337
|
+
reject("reserved handle");
|
|
240
338
|
return;
|
|
241
339
|
}
|
|
242
340
|
// Registration is one-shot per socket and exclusive per handle: a
|
|
@@ -244,19 +342,31 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
244
342
|
// would then unregister a handle it no longer owns (or leak the one it
|
|
245
343
|
// silently abandoned).
|
|
246
344
|
if (handle) {
|
|
247
|
-
|
|
345
|
+
reject("already registered");
|
|
248
346
|
return;
|
|
249
347
|
}
|
|
250
348
|
if (connections.has(frame.handle)) {
|
|
251
|
-
|
|
349
|
+
reject("handle already connected");
|
|
252
350
|
return;
|
|
253
351
|
}
|
|
352
|
+
const grant = grants.get(frame.handle);
|
|
353
|
+
// A supplied unknown/expired token never falls through to ordinary registration.
|
|
354
|
+
if (grant || frame.readerToken !== undefined) {
|
|
355
|
+
if (!grant?.active || grant.bound || !frame.readerToken
|
|
356
|
+
|| !timingSafeEqual(grant.token, Buffer.from(frame.readerToken, "hex"))) {
|
|
357
|
+
reject("result reader authentication failed");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
grant.bound = true;
|
|
361
|
+
boundGrant = grant;
|
|
362
|
+
grant.stop = () => { if (!ended) { cleanup(); socket.destroy(); } };
|
|
363
|
+
}
|
|
254
364
|
handle = frame.handle;
|
|
255
365
|
group = frame.group;
|
|
256
366
|
bus.register(handle);
|
|
257
367
|
connections.set(handle, conn);
|
|
258
368
|
if (frame.peers) peerRegistry.set(handle, { handle, label: frame.label ?? handle, group: frame.group ?? "" });
|
|
259
|
-
write({ t: "registered", handle });
|
|
369
|
+
write({ t: "registered", handle, ...(frame.readerToken ? { readerToken: frame.readerToken } : {}) });
|
|
260
370
|
drain(handle);
|
|
261
371
|
flushPendingSteer(handle);
|
|
262
372
|
return;
|
|
@@ -324,6 +434,8 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
324
434
|
const handleFrame = (raw: unknown): void => {
|
|
325
435
|
if (!isFrame(raw)) {
|
|
326
436
|
write({ t: "error", reason: "invalid frame" });
|
|
437
|
+
if (boundGrant || (typeof raw === "object" && raw !== null
|
|
438
|
+
&& ("readerToken" in raw || ("t" in raw && String(raw.t).startsWith("result_read"))))) socket.destroy();
|
|
327
439
|
return;
|
|
328
440
|
}
|
|
329
441
|
try {
|
|
@@ -337,8 +449,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
337
449
|
}
|
|
338
450
|
};
|
|
339
451
|
|
|
340
|
-
socket.on("data", createFrameReader(handleFrame, (
|
|
341
|
-
|
|
452
|
+
socket.on("data", createFrameReader(handleFrame, () => {
|
|
453
|
+
// JSON parser diagnostics can contain the private registration token.
|
|
454
|
+
write({ t: "error", reason: "invalid frame" });
|
|
342
455
|
socket.destroy();
|
|
343
456
|
}));
|
|
344
457
|
socket.on("error", () => socket.destroy());
|
|
@@ -350,9 +463,9 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
350
463
|
|
|
351
464
|
const server = await bindServer(netImpl, deps.endpoint, handleConnection);
|
|
352
465
|
|
|
353
|
-
let closed = false;
|
|
354
466
|
return {
|
|
355
467
|
endpoint: deps.endpoint,
|
|
468
|
+
prepareResultReader,
|
|
356
469
|
connectedHandles: () => [...connections.keys()],
|
|
357
470
|
steer(handle: string, text: string): boolean {
|
|
358
471
|
const conn = connections.get(handle);
|
|
@@ -370,12 +483,14 @@ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<Broker
|
|
|
370
483
|
expected.add(handle);
|
|
371
484
|
},
|
|
372
485
|
forget(handle: string): void {
|
|
486
|
+
grants.get(handle)?.revoke();
|
|
373
487
|
expected.delete(handle);
|
|
374
488
|
pendingSteer.delete(handle);
|
|
375
489
|
},
|
|
376
490
|
async close(): Promise<void> {
|
|
377
491
|
if (closed) return;
|
|
378
492
|
closed = true;
|
|
493
|
+
for (const grant of grants.values()) grant.revoke();
|
|
379
494
|
unsubBus();
|
|
380
495
|
expected.clear();
|
|
381
496
|
pendingSteer.clear();
|
|
@@ -5,14 +5,28 @@
|
|
|
5
5
|
* `messages.ts` validation STYLE (structural per-field checks, one explicit assert per
|
|
6
6
|
* case) but drops presence/sessions/attachments/broadcasts entirely (YAGNI here).
|
|
7
7
|
*
|
|
8
|
+
* Authenticated result_read/result_read_reply/result_read_cancel frames extend the same
|
|
9
|
+
* connection with a closed bounded page contract. No archive summaries or client grants.
|
|
8
10
|
* Pure module: no Pi imports, unit-tested.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import type { MsgKind } from "../inproc.ts";
|
|
14
|
+
import { closedRecord, isReadReply, isReadRequest, type ReadReply, type ResultReadRequest } from "../result-reader.ts";
|
|
15
|
+
|
|
16
|
+
export function isReaderToken(value: unknown): value is string {
|
|
17
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRequestId(value: unknown): value is string {
|
|
21
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
|
|
22
|
+
}
|
|
12
23
|
|
|
13
24
|
export type Frame =
|
|
14
|
-
| { t: "register"; handle: string; label?: string; group?: string; peers?: boolean }
|
|
15
|
-
| { t: "registered"; handle: string }
|
|
25
|
+
| { t: "register"; handle: string; label?: string; group?: string; peers?: boolean; readerToken?: string }
|
|
26
|
+
| { t: "registered"; handle: string; readerToken?: string }
|
|
27
|
+
| ({ t: "result_read"; reqId: string } & ResultReadRequest)
|
|
28
|
+
| { t: "result_read_reply"; reqId: string; result: ReadReply }
|
|
29
|
+
| { t: "result_read_cancel"; reqId: string }
|
|
16
30
|
| { t: "send"; to: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
|
|
17
31
|
| { t: "cancel"; msgId: string }
|
|
18
32
|
| { t: "deliver"; from: string; fromLabel?: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
|
|
@@ -39,14 +53,27 @@ function isPeerEntry(value: unknown): value is { handle: string; label: string }
|
|
|
39
53
|
export function isFrame(value: unknown): value is Frame {
|
|
40
54
|
if (!isRecord(value)) return false;
|
|
41
55
|
switch (value.t) {
|
|
56
|
+
case "result_read": {
|
|
57
|
+
if (!closedRecord(value, ["t", "reqId", "ref", "offset", "limit"]) || !isRequestId(value.reqId)) return false;
|
|
58
|
+
const { t: _t, reqId: _id, ...request } = value;
|
|
59
|
+
return isReadRequest(request);
|
|
60
|
+
}
|
|
61
|
+
case "result_read_reply":
|
|
62
|
+
return closedRecord(value, ["t", "reqId", "result"]) && isRequestId(value.reqId) && isReadReply(value.result);
|
|
63
|
+
case "result_read_cancel":
|
|
64
|
+
return closedRecord(value, ["t", "reqId"]) && isRequestId(value.reqId);
|
|
42
65
|
case "register": {
|
|
43
|
-
if (
|
|
66
|
+
if (!closedRecord(value, ["t", "handle", "label", "group", "peers", "readerToken"])) return false;
|
|
67
|
+
if ("readerToken" in value && !isReaderToken(value.readerToken)) return false;
|
|
68
|
+
if (typeof value.handle !== "string" || value.handle.length === 0 || value.handle.length > 256) return false;
|
|
44
69
|
if (value.label !== undefined && typeof value.label !== "string") return false;
|
|
45
70
|
if (value.group !== undefined && typeof value.group !== "string") return false;
|
|
46
71
|
return value.peers === undefined || typeof value.peers === "boolean";
|
|
47
72
|
}
|
|
48
73
|
case "registered":
|
|
49
|
-
return typeof value.handle === "string"
|
|
74
|
+
return closedRecord(value, ["t", "handle", "readerToken"]) && typeof value.handle === "string"
|
|
75
|
+
&& value.handle.length > 0 && value.handle.length <= 256
|
|
76
|
+
&& (!("readerToken" in value) || isReaderToken(value.readerToken));
|
|
50
77
|
case "send":
|
|
51
78
|
return (
|
|
52
79
|
typeof value.to === "string" &&
|