@fitchmultz/pi-subagents 0.46.2 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +35 -26
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-routes.js +27 -3
- package/dist/pi-intercom/bridge-session.js +60 -64
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +55 -7
- package/docs/intercom-bridge.md +39 -17
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +25 -1
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-config.ts +46 -36
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-routes.ts +41 -4
- package/src/pi-intercom/bridge-session.ts +73 -71
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +58 -7
|
@@ -7,20 +7,17 @@ import {
|
|
|
7
7
|
} from "./session-targets.ts";
|
|
8
8
|
import type { Message, SendResult, SessionInfo } from "./types.ts";
|
|
9
9
|
import type { Identity } from "./bridge-config.ts";
|
|
10
|
-
import { Fault,
|
|
10
|
+
import { Fault, cancelled, log, wait } from "./bridge-protocol.ts";
|
|
11
|
+
import { Mailbox } from "./bridge-mailbox.ts";
|
|
11
12
|
import type { UnknownRecord } from "../shared/unknown.ts";
|
|
12
13
|
import type { MessagingOperation as Operation } from "./bridge-routes.ts";
|
|
13
14
|
|
|
14
|
-
import { WaitingAsk
|
|
15
|
+
import { WaitingAsk } from "./bridge-ask.ts";
|
|
15
16
|
const MAX_REQUESTS = 8;
|
|
16
17
|
const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
17
18
|
const SEND_TIMEOUT = 8000;
|
|
18
19
|
const LIST_TIMEOUT = 5000;
|
|
19
20
|
|
|
20
|
-
interface InboxEntry extends Envelope {
|
|
21
|
-
readonly bytes: number;
|
|
22
|
-
replying: boolean;
|
|
23
|
-
}
|
|
24
21
|
interface RequestContext {
|
|
25
22
|
readonly signal: AbortSignal;
|
|
26
23
|
readonly requestId: string;
|
|
@@ -32,6 +29,8 @@ interface SendOptions {
|
|
|
32
29
|
readonly expectsReply?: boolean;
|
|
33
30
|
}
|
|
34
31
|
export interface SessionOwner {
|
|
32
|
+
readonly stateDir?: string;
|
|
33
|
+
readonly notifications?: boolean;
|
|
35
34
|
readonly allowed: (identity: Identity) => boolean;
|
|
36
35
|
readonly lost: (fp: string, count?: number) => void;
|
|
37
36
|
readonly retired: (fp: string, generation: string) => void;
|
|
@@ -48,8 +47,8 @@ export class Session {
|
|
|
48
47
|
listTimeoutMs: LIST_TIMEOUT,
|
|
49
48
|
});
|
|
50
49
|
private readonly requests = new Set<AbortController>();
|
|
51
|
-
private readonly inbox
|
|
52
|
-
private
|
|
50
|
+
private readonly inbox: Mailbox;
|
|
51
|
+
private readonly replying = new Set<string>();
|
|
53
52
|
private pending?: WaitingAsk;
|
|
54
53
|
private closed = false;
|
|
55
54
|
private connecting?: Promise<void>;
|
|
@@ -62,6 +61,7 @@ export class Session {
|
|
|
62
61
|
this.owner = owner;
|
|
63
62
|
this.identity = identity;
|
|
64
63
|
this.expires = expires;
|
|
64
|
+
this.inbox = new Mailbox(owner.stateDir, identity.fingerprint256, owner.notifications);
|
|
65
65
|
this.client.on("error", () =>
|
|
66
66
|
this.close(new Fault(503, "broker_unavailable", "Broker connection failed.")),
|
|
67
67
|
);
|
|
@@ -153,6 +153,7 @@ export class Session {
|
|
|
153
153
|
this.close(fault);
|
|
154
154
|
throw fault;
|
|
155
155
|
}
|
|
156
|
+
this.presence();
|
|
156
157
|
log("registered", this.fields());
|
|
157
158
|
}
|
|
158
159
|
private async connect(signal: AbortSignal): Promise<void> {
|
|
@@ -192,6 +193,7 @@ export class Session {
|
|
|
192
193
|
// The original request receives the connection failure; teardown must still close a late socket.
|
|
193
194
|
}
|
|
194
195
|
await this.disconnect();
|
|
196
|
+
this.owner.lost(this.identity.fingerprint256, this.inbox.close());
|
|
195
197
|
this.owner.retired(this.identity.fingerprint256, this.generation);
|
|
196
198
|
log("session_closed", { ...this.fields(), result: this.closeReason.code });
|
|
197
199
|
}
|
|
@@ -206,9 +208,6 @@ export class Session {
|
|
|
206
208
|
for (const controller of this.requests) {
|
|
207
209
|
controller.abort(error);
|
|
208
210
|
}
|
|
209
|
-
this.owner.lost(this.identity.fingerprint256, this.inbox.size);
|
|
210
|
-
this.inbox.clear();
|
|
211
|
-
this.inboxBytes = 0;
|
|
212
211
|
// Retain the generation until acquisition/registration AND final disconnect settle.
|
|
213
212
|
this.startRetirement();
|
|
214
213
|
}
|
|
@@ -225,12 +224,6 @@ export class Session {
|
|
|
225
224
|
new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }),
|
|
226
225
|
);
|
|
227
226
|
}
|
|
228
|
-
for (const [messageId, entry] of this.inbox) {
|
|
229
|
-
if (entry.from.id === id && entry.message.expectsReply === true) {
|
|
230
|
-
this.remove(messageId);
|
|
231
|
-
this.owner.lost(this.identity.fingerprint256);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
227
|
this.presence();
|
|
235
228
|
}
|
|
236
229
|
private receive(from: SessionInfo, message: Message): void {
|
|
@@ -247,50 +240,66 @@ export class Session {
|
|
|
247
240
|
);
|
|
248
241
|
return;
|
|
249
242
|
}
|
|
250
|
-
|
|
251
|
-
const pending = this.pending;
|
|
252
|
-
if (pending?.matches(from.id, message.replyTo) === true) {
|
|
253
|
-
pending.resolve({ from, message });
|
|
243
|
+
if (!this.inbox.persistent && this.resolveReply(from, message)) {
|
|
254
244
|
return;
|
|
255
245
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
246
|
+
this.storeReceive(from, message);
|
|
247
|
+
}
|
|
248
|
+
private storeReceive(from: SessionInfo, message: Message): void {
|
|
249
|
+
let result: "stored" | "duplicate" | "lost";
|
|
250
|
+
try {
|
|
251
|
+
result = this.inbox.add({ from, message });
|
|
252
|
+
} catch {
|
|
263
253
|
log("inbox_overflow", {
|
|
264
254
|
...this.fields(),
|
|
265
255
|
peer: from.id,
|
|
266
256
|
messageId: message.id,
|
|
267
|
-
result: "
|
|
257
|
+
result: "storage_failed",
|
|
258
|
+
lossRecorded: false,
|
|
268
259
|
});
|
|
260
|
+
this.close(
|
|
261
|
+
new Fault(
|
|
262
|
+
507,
|
|
263
|
+
"mailbox_storage_failed",
|
|
264
|
+
"Mailbox storage failed; inspect the metadata audit for unrecorded losses.",
|
|
265
|
+
),
|
|
266
|
+
);
|
|
269
267
|
return;
|
|
270
268
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
269
|
+
if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
|
|
270
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
271
|
+
}
|
|
272
|
+
this.recordReceive(from.id, message.id, result);
|
|
273
|
+
if (result !== "lost") {
|
|
274
|
+
this.resolveReply(from, message);
|
|
275
|
+
}
|
|
274
276
|
this.presence();
|
|
275
277
|
}
|
|
276
|
-
private
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
this.inbox.delete(id);
|
|
278
|
+
private resolveReply(from: SessionInfo, message: Message): boolean {
|
|
279
|
+
const pending = this.pending;
|
|
280
|
+
if (pending?.matches(from.id, message.replyTo) !== true) {
|
|
281
|
+
return false;
|
|
281
282
|
}
|
|
283
|
+
pending.resolve({ from, message });
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
private recordReceive(
|
|
287
|
+
peer: string,
|
|
288
|
+
messageId: string,
|
|
289
|
+
result: "stored" | "duplicate" | "lost",
|
|
290
|
+
): void {
|
|
291
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
292
|
+
...this.fields(),
|
|
293
|
+
peer,
|
|
294
|
+
messageId,
|
|
295
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
296
|
+
});
|
|
282
297
|
}
|
|
283
298
|
private presence(): void {
|
|
284
299
|
if (this.closed) {
|
|
285
300
|
return;
|
|
286
301
|
}
|
|
287
|
-
|
|
288
|
-
(entry) => entry.message.expectsReply === true,
|
|
289
|
-
).length;
|
|
290
|
-
this.client.updatePresence({
|
|
291
|
-
pendingAsks,
|
|
292
|
-
acceptsAsks: this.inbox.size < INBOX_COUNT && this.inboxBytes < INBOX_BYTES,
|
|
293
|
-
});
|
|
302
|
+
this.client.updatePresence(this.inbox.presence());
|
|
294
303
|
}
|
|
295
304
|
private async peers(signal: AbortSignal): Promise<SessionInfo[]> {
|
|
296
305
|
this.guard(signal);
|
|
@@ -421,19 +430,9 @@ export class Session {
|
|
|
421
430
|
}
|
|
422
431
|
}
|
|
423
432
|
private ack(ids: readonly string[]): UnknownRecord {
|
|
424
|
-
const
|
|
425
|
-
const retained: string[] = [];
|
|
426
|
-
for (const id of ids) {
|
|
427
|
-
const entry = this.inbox.get(id);
|
|
428
|
-
if (entry?.message.expectsReply === true) {
|
|
429
|
-
retained.push(id);
|
|
430
|
-
} else if (entry) {
|
|
431
|
-
this.remove(id);
|
|
432
|
-
acked.push(id);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
433
|
+
const result = this.inbox.ack(ids);
|
|
435
434
|
this.presence();
|
|
436
|
-
return
|
|
435
|
+
return result;
|
|
437
436
|
}
|
|
438
437
|
private async answer(
|
|
439
438
|
replyTo: string,
|
|
@@ -444,10 +443,10 @@ export class Session {
|
|
|
444
443
|
if (entry?.message.expectsReply !== true) {
|
|
445
444
|
throw new Fault(404, "unknown_ask", "replyTo must identify an unanswered inbound ask.");
|
|
446
445
|
}
|
|
447
|
-
if (
|
|
446
|
+
if (this.replying.has(replyTo)) {
|
|
448
447
|
throw new Fault(409, "reply_in_progress", "This ask already has a reply in flight.");
|
|
449
448
|
}
|
|
450
|
-
|
|
449
|
+
this.replying.add(replyTo);
|
|
451
450
|
const peer = entry.from.id;
|
|
452
451
|
context.onPeer(peer);
|
|
453
452
|
try {
|
|
@@ -456,15 +455,15 @@ export class Session {
|
|
|
456
455
|
throw new Fault(409, "peer_offline", "Original ask sender is disconnected.");
|
|
457
456
|
}
|
|
458
457
|
this.guard(context.signal);
|
|
459
|
-
if (this.inbox.get(replyTo)
|
|
458
|
+
if (!this.inbox.get(replyTo)) {
|
|
460
459
|
throw new Fault(409, "ask_retired", "Inbound ask is no longer replyable.");
|
|
461
460
|
}
|
|
462
461
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
463
|
-
this.remove(replyTo);
|
|
462
|
+
this.inbox.remove(replyTo);
|
|
464
463
|
this.presence();
|
|
465
464
|
return data;
|
|
466
465
|
} finally {
|
|
467
|
-
|
|
466
|
+
this.replying.delete(replyTo);
|
|
468
467
|
}
|
|
469
468
|
}
|
|
470
469
|
async execute(operation: Operation, context: RequestContext): Promise<UnknownRecord> {
|
|
@@ -472,6 +471,19 @@ export class Session {
|
|
|
472
471
|
if (operation.route === "POST /v1/ask") {
|
|
473
472
|
return this.ask(operation, context);
|
|
474
473
|
}
|
|
474
|
+
if (operation.route === "GET /v1/inbox" || operation.route === "POST /v1/ack") {
|
|
475
|
+
// Durable reads/acks remain available while the local broker is down.
|
|
476
|
+
if (!this.inbox.persistent) {
|
|
477
|
+
await this.connect(context.signal);
|
|
478
|
+
}
|
|
479
|
+
if (operation.route === "POST /v1/ack") {
|
|
480
|
+
return this.ack(operation.ids);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
484
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
475
487
|
await this.connect(context.signal);
|
|
476
488
|
switch (operation.route) {
|
|
477
489
|
case "POST /v1/register":
|
|
@@ -485,16 +497,6 @@ export class Session {
|
|
|
485
497
|
}),
|
|
486
498
|
};
|
|
487
499
|
}
|
|
488
|
-
case "GET /v1/inbox": {
|
|
489
|
-
const lostMessages = this.owner.losses(this.identity.fingerprint256);
|
|
490
|
-
return {
|
|
491
|
-
messages: [...this.inbox.values()].map(({ from, message }) => ({ from, message })),
|
|
492
|
-
overflow: lostMessages > 0,
|
|
493
|
-
lostMessages,
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
case "POST /v1/ack":
|
|
497
|
-
return this.ack(operation.ids);
|
|
498
500
|
case "POST /v1/send":
|
|
499
501
|
return {
|
|
500
502
|
...(await this.send(
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { log } from "./bridge-protocol.ts";
|
|
2
|
+
import { Mailbox, type PendingNotification } from "./bridge-mailbox.ts";
|
|
3
|
+
import type { Identity, WakePolicy } from "./bridge-config.ts";
|
|
4
|
+
|
|
5
|
+
const POLL_MS = 15000;
|
|
6
|
+
const FALLBACK_AFTER_MS = 120000;
|
|
7
|
+
const MAX_BACKOFF_MS = 300000;
|
|
8
|
+
|
|
9
|
+
export class WakeDelivery {
|
|
10
|
+
private readonly controller = new AbortController();
|
|
11
|
+
private timer?: NodeJS.Timeout;
|
|
12
|
+
private running?: Promise<void>;
|
|
13
|
+
private backoff = POLL_MS;
|
|
14
|
+
private readonly name: string;
|
|
15
|
+
private readonly mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">;
|
|
16
|
+
private readonly policy: WakePolicy;
|
|
17
|
+
private readonly authorized: () => boolean;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
name: string,
|
|
21
|
+
mailbox: Pick<Mailbox, "pendingNotifications" | "notificationAccepted">,
|
|
22
|
+
policy: WakePolicy,
|
|
23
|
+
authorized: () => boolean,
|
|
24
|
+
) {
|
|
25
|
+
this.name = name;
|
|
26
|
+
this.mailbox = mailbox;
|
|
27
|
+
this.policy = policy;
|
|
28
|
+
this.authorized = authorized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start(): void {
|
|
32
|
+
if (!this.running && !this.controller.signal.aborted) {
|
|
33
|
+
this.schedule(0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private schedule(delay: number): void {
|
|
38
|
+
clearTimeout(this.timer);
|
|
39
|
+
this.timer = setTimeout(() => {
|
|
40
|
+
this.running = this.deliver()
|
|
41
|
+
.catch(() => {
|
|
42
|
+
log("wake_delivery", { name: this.name, result: "storage_unavailable" });
|
|
43
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
44
|
+
})
|
|
45
|
+
.finally(() => {
|
|
46
|
+
this.running = undefined;
|
|
47
|
+
if (!this.controller.signal.aborted) {
|
|
48
|
+
this.schedule(this.backoff);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}, delay);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async deliver(): Promise<void> {
|
|
55
|
+
if (!this.authorized()) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const entries = this.mailbox.pendingNotifications().messages;
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (entry.acknowledged) {
|
|
61
|
+
// The box's durable worker owns primary wake/replay after acknowledgement.
|
|
62
|
+
this.mailbox.notificationAccepted(entry.message.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const next = entries.find(
|
|
66
|
+
(entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS,
|
|
67
|
+
);
|
|
68
|
+
if (next) {
|
|
69
|
+
// One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
|
|
70
|
+
await this.post(next);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.backoff = POLL_MS;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async post(entry: PendingNotification): Promise<void> {
|
|
77
|
+
if (!this.authorized()) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const fields = { name: this.name, messageId: entry.message.id };
|
|
81
|
+
try {
|
|
82
|
+
const response = await fetch(this.policy.url, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
redirect: "error",
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${this.policy.key}`,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
from: entry.from.name ?? entry.from.id,
|
|
91
|
+
from_id: entry.from.id,
|
|
92
|
+
to: this.name,
|
|
93
|
+
message_id: entry.message.id,
|
|
94
|
+
ask_id: entry.message.expectsReply === true ? entry.message.id : null,
|
|
95
|
+
text: entry.message.content.text,
|
|
96
|
+
}),
|
|
97
|
+
signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
|
|
98
|
+
});
|
|
99
|
+
await response.body?.cancel();
|
|
100
|
+
if (response.ok && !this.controller.signal.aborted && this.authorized()) {
|
|
101
|
+
// HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
|
|
102
|
+
this.backoff = MAX_BACKOFF_MS;
|
|
103
|
+
log("wake_delivery", { ...fields, result: "accepted" });
|
|
104
|
+
} else {
|
|
105
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
106
|
+
log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
110
|
+
if (!this.controller.signal.aborted) {
|
|
111
|
+
log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async stop(): Promise<void> {
|
|
117
|
+
this.controller.abort();
|
|
118
|
+
clearTimeout(this.timer);
|
|
119
|
+
await this.running;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class WakeDeliveries {
|
|
124
|
+
private readonly stateDir: string | undefined;
|
|
125
|
+
private readonly allowed: (identity: Identity) => boolean;
|
|
126
|
+
private readonly deliveries = new Map<
|
|
127
|
+
string,
|
|
128
|
+
{ readonly identity: Identity; readonly mailbox: Mailbox; readonly worker: WakeDelivery }
|
|
129
|
+
>();
|
|
130
|
+
private readonly cleanup = new Set<Promise<unknown>>();
|
|
131
|
+
|
|
132
|
+
constructor(stateDir: string | undefined, allowed: (identity: Identity) => boolean) {
|
|
133
|
+
this.stateDir = stateDir;
|
|
134
|
+
this.allowed = allowed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
session(identity: Identity, expires: number): void {
|
|
138
|
+
if (
|
|
139
|
+
!identity.wake ||
|
|
140
|
+
this.stateDir === undefined ||
|
|
141
|
+
this.deliveries.has(identity.fingerprint256)
|
|
142
|
+
) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
|
|
146
|
+
const worker = new WakeDelivery(
|
|
147
|
+
identity.name,
|
|
148
|
+
mailbox,
|
|
149
|
+
identity.wake,
|
|
150
|
+
() => this.allowed(identity) && expires > Date.now(),
|
|
151
|
+
);
|
|
152
|
+
this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
|
|
153
|
+
worker.start();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
drop(fp: string): void {
|
|
157
|
+
const delivery = this.deliveries.get(fp);
|
|
158
|
+
if (!delivery) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.deliveries.delete(fp);
|
|
162
|
+
const cleanup = delivery.worker
|
|
163
|
+
.stop()
|
|
164
|
+
.then(() => delivery.mailbox.close())
|
|
165
|
+
.catch(() => {
|
|
166
|
+
log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
|
|
167
|
+
})
|
|
168
|
+
.finally(() => {
|
|
169
|
+
this.cleanup.delete(cleanup);
|
|
170
|
+
});
|
|
171
|
+
this.cleanup.add(cleanup);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
reload(): void {
|
|
175
|
+
for (const [fp, delivery] of this.deliveries) {
|
|
176
|
+
if (!this.allowed(delivery.identity)) {
|
|
177
|
+
this.drop(fp);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async stop(): Promise<void> {
|
|
183
|
+
for (const fp of this.deliveries.keys()) {
|
|
184
|
+
this.drop(fp);
|
|
185
|
+
}
|
|
186
|
+
await Promise.all(this.cleanup);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -11,9 +11,21 @@ import { Session } from "./bridge-session.ts";
|
|
|
11
11
|
import { operationFor, routeFor, type Operation } from "./bridge-routes.ts";
|
|
12
12
|
import { reply } from "./bridge-http.ts";
|
|
13
13
|
import { Launcher, sameIdentity } from "./bridge-launch.ts";
|
|
14
|
+
import { PersistentPresence } from "./bridge-presence.ts";
|
|
15
|
+
import { WakeDeliveries } from "./bridge-wake.ts";
|
|
16
|
+
import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
|
|
14
17
|
|
|
15
18
|
const MAX_CONNECTIONS = 128;
|
|
16
19
|
|
|
20
|
+
function validCertificateDates(starts: number, expires: number): boolean {
|
|
21
|
+
return (
|
|
22
|
+
Number.isFinite(starts) &&
|
|
23
|
+
Number.isFinite(expires) &&
|
|
24
|
+
starts <= Date.now() &&
|
|
25
|
+
expires > Date.now()
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
interface Connection {
|
|
18
30
|
readonly fingerprint: string;
|
|
19
31
|
readonly expires: number;
|
|
@@ -31,10 +43,32 @@ class Bridge {
|
|
|
31
43
|
private readonly server: Server;
|
|
32
44
|
private stopping = false;
|
|
33
45
|
private readonly handlers = new Set<Promise<void>>();
|
|
46
|
+
private readonly presence?: PersistentPresence;
|
|
47
|
+
private readonly deliveries: WakeDeliveries;
|
|
34
48
|
|
|
35
49
|
constructor(config: Config, configPath: string) {
|
|
36
50
|
this.config = config;
|
|
37
51
|
this.configPath = configPath;
|
|
52
|
+
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
53
|
+
if (config.stateDir !== undefined) {
|
|
54
|
+
this.presence = new PersistentPresence({
|
|
55
|
+
stateDir: config.stateDir,
|
|
56
|
+
clients: () => this.config.clients,
|
|
57
|
+
connect: async (identity, expires) => {
|
|
58
|
+
await spawnBrokerIfNeeded(process.execPath, []);
|
|
59
|
+
if (!this.allowed(identity)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const session = this.session(identity, expires);
|
|
63
|
+
const controller = session.begin();
|
|
64
|
+
try {
|
|
65
|
+
await session.connectedPeers(controller.signal);
|
|
66
|
+
} finally {
|
|
67
|
+
session.end(controller);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
38
72
|
this.server = createServer(
|
|
39
73
|
{
|
|
40
74
|
...config.tls,
|
|
@@ -90,7 +124,11 @@ class Bridge {
|
|
|
90
124
|
}
|
|
91
125
|
private allowed(identity: Identity): boolean {
|
|
92
126
|
const current = this.config.clients.get(identity.fingerprint256);
|
|
93
|
-
return
|
|
127
|
+
return (
|
|
128
|
+
!this.stopping &&
|
|
129
|
+
sameIdentity(identity, current) &&
|
|
130
|
+
JSON.stringify(identity.wake) === JSON.stringify(current?.wake)
|
|
131
|
+
);
|
|
94
132
|
}
|
|
95
133
|
private lost(fp: string, count = 1): void {
|
|
96
134
|
if (count > 0 && this.config.clients.has(fp)) {
|
|
@@ -98,10 +136,13 @@ class Bridge {
|
|
|
98
136
|
}
|
|
99
137
|
}
|
|
100
138
|
private session(identity: Identity, expires: number): Session {
|
|
139
|
+
this.deliveries.session(identity, expires);
|
|
101
140
|
let session = this.sessions.get(identity.fingerprint256);
|
|
102
141
|
if (!session) {
|
|
103
142
|
session = new Session(
|
|
104
143
|
{
|
|
144
|
+
stateDir: this.config.stateDir,
|
|
145
|
+
notifications: identity.wake !== undefined,
|
|
105
146
|
allowed: (candidate) => this.allowed(candidate),
|
|
106
147
|
lost: (fp, count) => this.lost(fp, count),
|
|
107
148
|
losses: (fp) => this.losses.get(fp) ?? 0,
|
|
@@ -134,15 +175,19 @@ class Bridge {
|
|
|
134
175
|
!socket.authorized ||
|
|
135
176
|
!identity ||
|
|
136
177
|
this.stopping ||
|
|
137
|
-
!
|
|
138
|
-
!Number.isFinite(starts) ||
|
|
139
|
-
starts > Date.now() ||
|
|
140
|
-
expires <= Date.now()
|
|
178
|
+
!validCertificateDates(starts, expires)
|
|
141
179
|
) {
|
|
142
180
|
log("connection_rejected", { fingerprint: fp, result: "unauthorized" });
|
|
143
181
|
socket.destroy();
|
|
144
182
|
return;
|
|
145
183
|
}
|
|
184
|
+
try {
|
|
185
|
+
this.presence?.authenticated(identity, expires);
|
|
186
|
+
} catch {
|
|
187
|
+
log("connection_rejected", { fingerprint: fp, result: "presence_storage_failed" });
|
|
188
|
+
socket.destroy();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
146
191
|
const expiry = () => {
|
|
147
192
|
if (expires <= Date.now()) {
|
|
148
193
|
this.drop(fp, new Fault(403, "certificate_expired", "Client certificate expired."));
|
|
@@ -164,6 +209,7 @@ class Bridge {
|
|
|
164
209
|
log("connected", { name: identity.name, fingerprint: fp, result: "authenticated" });
|
|
165
210
|
}
|
|
166
211
|
private drop(fp: string, error: Readonly<Fault>): void {
|
|
212
|
+
this.deliveries.drop(fp);
|
|
167
213
|
this.sessions.get(fp)?.close(error);
|
|
168
214
|
for (const [socket, connection] of this.connections) {
|
|
169
215
|
if (connection.fingerprint === fp) {
|
|
@@ -181,6 +227,7 @@ class Bridge {
|
|
|
181
227
|
port: typeof address === "object" && address ? address.port : this.config.port,
|
|
182
228
|
brokerSocket: getBrokerSocketPath(),
|
|
183
229
|
});
|
|
230
|
+
this.presence?.start();
|
|
184
231
|
});
|
|
185
232
|
process.on("SIGHUP", () => this.reload());
|
|
186
233
|
// Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
|
|
@@ -202,6 +249,7 @@ class Bridge {
|
|
|
202
249
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
203
250
|
} catch {
|
|
204
251
|
this.config = { ...this.config, clients: new Map() };
|
|
252
|
+
this.deliveries.reload();
|
|
205
253
|
this.launcher
|
|
206
254
|
.revoke(this.config.clients)
|
|
207
255
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
@@ -222,6 +270,7 @@ class Bridge {
|
|
|
222
270
|
}
|
|
223
271
|
}
|
|
224
272
|
private revokeRemoved(): void {
|
|
273
|
+
this.deliveries.reload();
|
|
225
274
|
for (const [fp, session] of this.sessions) {
|
|
226
275
|
if (!this.allowed(session.identity)) {
|
|
227
276
|
this.drop(
|
|
@@ -264,6 +313,8 @@ class Bridge {
|
|
|
264
313
|
...this.handlers,
|
|
265
314
|
...sessions.map((session) => session.settled()),
|
|
266
315
|
this.launcher.shutdown(),
|
|
316
|
+
this.presence?.stop(),
|
|
317
|
+
this.deliveries.stop(),
|
|
267
318
|
]);
|
|
268
319
|
log("shutdown", { result: "closed" });
|
|
269
320
|
}
|
|
@@ -402,7 +453,7 @@ function main(): void {
|
|
|
402
453
|
const args = process.argv.slice(2);
|
|
403
454
|
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
404
455
|
process.stdout.write(
|
|
405
|
-
"Usage: node dist/pi-intercom/bridge.js --config PATH\n\nOptional loopback HTTPS/mTLS bridge for the local pi-intercom broker.\nConfig and TLS key must be owned by you with mode 0600
|
|
456
|
+
"Usage: node dist/pi-intercom/bridge.js --config PATH\n\nOptional loopback HTTPS/mTLS bridge for the local pi-intercom broker.\nConfig and TLS key must be owned by you with mode 0600.\nWithout stateDir, messaging never starts the broker. Durable presence safely starts\none only when none is running; it never restarts or replaces a live broker.\nSIGHUP reloads the client allowlist (invalid reload denies all clients).\nRestart this helper for TLS/listener rotation. SIGINT/SIGTERM disconnect peers.\n\nExample: node dist/pi-intercom/bridge.js --config ~/.pi/agent/intercom/bridge.json\n",
|
|
406
457
|
);
|
|
407
458
|
return;
|
|
408
459
|
}
|
|
@@ -416,7 +467,7 @@ function main(): void {
|
|
|
416
467
|
new Bridge(loadConfig(path), path).start();
|
|
417
468
|
} catch {
|
|
418
469
|
process.stderr.write(
|
|
419
|
-
"Bridge startup failed: check config schema, file ownership/mode 0600, absolute TLS/cwd paths, TLS credentials, and launch policy (0700 sessionDir, Pi packageRoot, extension paths).\n",
|
|
470
|
+
"Bridge startup failed: check config schema, file ownership/mode 0600, absolute TLS/cwd paths, TLS credentials, stateDir (owned 0700 directory), and launch policy (0700 sessionDir, Pi packageRoot, extension paths).\n",
|
|
420
471
|
);
|
|
421
472
|
process.exitCode = 1;
|
|
422
473
|
}
|