@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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { IntercomClient } from "./broker/client.js";
|
|
3
3
|
import { formatSessionTarget, resolveSessionProjectId, resolveSessionTarget, } from "./session-targets.js";
|
|
4
|
-
import { Fault,
|
|
4
|
+
import { Fault, cancelled, log, wait } from "./bridge-protocol.js";
|
|
5
|
+
import { Mailbox } from "./bridge-mailbox.js";
|
|
5
6
|
import { WaitingAsk } from "./bridge-ask.js";
|
|
6
7
|
const MAX_REQUESTS = 8;
|
|
7
8
|
const IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
@@ -17,8 +18,8 @@ export class Session {
|
|
|
17
18
|
listTimeoutMs: LIST_TIMEOUT,
|
|
18
19
|
});
|
|
19
20
|
requests = new Set();
|
|
20
|
-
inbox
|
|
21
|
-
|
|
21
|
+
inbox;
|
|
22
|
+
replying = new Set();
|
|
22
23
|
pending;
|
|
23
24
|
closed = false;
|
|
24
25
|
connecting;
|
|
@@ -30,6 +31,7 @@ export class Session {
|
|
|
30
31
|
this.owner = owner;
|
|
31
32
|
this.identity = identity;
|
|
32
33
|
this.expires = expires;
|
|
34
|
+
this.inbox = new Mailbox(owner.stateDir, identity.fingerprint256, owner.notifications);
|
|
33
35
|
this.client.on("error", () => this.close(new Fault(503, "broker_unavailable", "Broker connection failed.")));
|
|
34
36
|
this.client.on("disconnected", () => this.close(new Fault(503, "broker_disconnected", "Broker disconnected.")));
|
|
35
37
|
this.client.on("_registered", () => {
|
|
@@ -102,6 +104,7 @@ export class Session {
|
|
|
102
104
|
this.close(fault);
|
|
103
105
|
throw fault;
|
|
104
106
|
}
|
|
107
|
+
this.presence();
|
|
105
108
|
log("registered", this.fields());
|
|
106
109
|
}
|
|
107
110
|
async connect(signal) {
|
|
@@ -136,6 +139,7 @@ export class Session {
|
|
|
136
139
|
// The original request receives the connection failure; teardown must still close a late socket.
|
|
137
140
|
}
|
|
138
141
|
await this.disconnect();
|
|
142
|
+
this.owner.lost(this.identity.fingerprint256, this.inbox.close());
|
|
139
143
|
this.owner.retired(this.identity.fingerprint256, this.generation);
|
|
140
144
|
log("session_closed", { ...this.fields(), result: this.closeReason.code });
|
|
141
145
|
}
|
|
@@ -150,9 +154,6 @@ export class Session {
|
|
|
150
154
|
for (const controller of this.requests) {
|
|
151
155
|
controller.abort(error);
|
|
152
156
|
}
|
|
153
|
-
this.owner.lost(this.identity.fingerprint256, this.inbox.size);
|
|
154
|
-
this.inbox.clear();
|
|
155
|
-
this.inboxBytes = 0;
|
|
156
157
|
// Retain the generation until acquisition/registration AND final disconnect settle.
|
|
157
158
|
this.startRetirement();
|
|
158
159
|
}
|
|
@@ -166,12 +167,6 @@ export class Session {
|
|
|
166
167
|
if (this.pending?.peer === id && !this.pending.settled) {
|
|
167
168
|
this.pending.reject(new Fault(409, "peer_offline", "Ask recipient disconnected.", { id: this.pending.id }));
|
|
168
169
|
}
|
|
169
|
-
for (const [messageId, entry] of this.inbox) {
|
|
170
|
-
if (entry.from.id === id && entry.message.expectsReply === true) {
|
|
171
|
-
this.remove(messageId);
|
|
172
|
-
this.owner.lost(this.identity.fingerprint256);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
170
|
this.presence();
|
|
176
171
|
}
|
|
177
172
|
receive(from, message) {
|
|
@@ -187,48 +182,57 @@ export class Session {
|
|
|
187
182
|
: new Fault(403, "revoked", "Identity is no longer authorized."));
|
|
188
183
|
return;
|
|
189
184
|
}
|
|
190
|
-
|
|
191
|
-
const pending = this.pending;
|
|
192
|
-
if (pending?.matches(from.id, message.replyTo) === true) {
|
|
193
|
-
pending.resolve({ from, message });
|
|
185
|
+
if (!this.inbox.persistent && this.resolveReply(from, message)) {
|
|
194
186
|
return;
|
|
195
187
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
188
|
+
this.storeReceive(from, message);
|
|
189
|
+
}
|
|
190
|
+
storeReceive(from, message) {
|
|
191
|
+
let result;
|
|
192
|
+
try {
|
|
193
|
+
result = this.inbox.add({ from, message });
|
|
199
194
|
}
|
|
200
|
-
|
|
201
|
-
if (this.inbox.size >= INBOX_COUNT || this.inboxBytes + bytes > INBOX_BYTES) {
|
|
202
|
-
this.owner.lost(this.identity.fingerprint256);
|
|
195
|
+
catch {
|
|
203
196
|
log("inbox_overflow", {
|
|
204
197
|
...this.fields(),
|
|
205
198
|
peer: from.id,
|
|
206
199
|
messageId: message.id,
|
|
207
|
-
result: "
|
|
200
|
+
result: "storage_failed",
|
|
201
|
+
lossRecorded: false,
|
|
208
202
|
});
|
|
203
|
+
this.close(new Fault(507, "mailbox_storage_failed", "Mailbox storage failed; inspect the metadata audit for unrecorded losses."));
|
|
209
204
|
return;
|
|
210
205
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
206
|
+
if (result === "lost" || (result === "duplicate" && !this.inbox.persistent)) {
|
|
207
|
+
this.owner.lost(this.identity.fingerprint256);
|
|
208
|
+
}
|
|
209
|
+
this.recordReceive(from.id, message.id, result);
|
|
210
|
+
if (result !== "lost") {
|
|
211
|
+
this.resolveReply(from, message);
|
|
212
|
+
}
|
|
214
213
|
this.presence();
|
|
215
214
|
}
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
this.inbox.delete(id);
|
|
215
|
+
resolveReply(from, message) {
|
|
216
|
+
const pending = this.pending;
|
|
217
|
+
if (pending?.matches(from.id, message.replyTo) !== true) {
|
|
218
|
+
return false;
|
|
221
219
|
}
|
|
220
|
+
pending.resolve({ from, message });
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
recordReceive(peer, messageId, result) {
|
|
224
|
+
log(result === "lost" ? "inbox_overflow" : "received", {
|
|
225
|
+
...this.fields(),
|
|
226
|
+
peer,
|
|
227
|
+
messageId,
|
|
228
|
+
result: result === "lost" && this.inbox.persistent ? "storage_failed" : result,
|
|
229
|
+
});
|
|
222
230
|
}
|
|
223
231
|
presence() {
|
|
224
232
|
if (this.closed) {
|
|
225
233
|
return;
|
|
226
234
|
}
|
|
227
|
-
|
|
228
|
-
this.client.updatePresence({
|
|
229
|
-
pendingAsks,
|
|
230
|
-
acceptsAsks: this.inbox.size < INBOX_COUNT && this.inboxBytes < INBOX_BYTES,
|
|
231
|
-
});
|
|
235
|
+
this.client.updatePresence(this.inbox.presence());
|
|
232
236
|
}
|
|
233
237
|
async peers(signal) {
|
|
234
238
|
this.guard(signal);
|
|
@@ -337,30 +341,19 @@ export class Session {
|
|
|
337
341
|
}
|
|
338
342
|
}
|
|
339
343
|
ack(ids) {
|
|
340
|
-
const
|
|
341
|
-
const retained = [];
|
|
342
|
-
for (const id of ids) {
|
|
343
|
-
const entry = this.inbox.get(id);
|
|
344
|
-
if (entry?.message.expectsReply === true) {
|
|
345
|
-
retained.push(id);
|
|
346
|
-
}
|
|
347
|
-
else if (entry) {
|
|
348
|
-
this.remove(id);
|
|
349
|
-
acked.push(id);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
344
|
+
const result = this.inbox.ack(ids);
|
|
352
345
|
this.presence();
|
|
353
|
-
return
|
|
346
|
+
return result;
|
|
354
347
|
}
|
|
355
348
|
async answer(replyTo, message, context) {
|
|
356
349
|
const entry = this.inbox.get(replyTo);
|
|
357
350
|
if (entry?.message.expectsReply !== true) {
|
|
358
351
|
throw new Fault(404, "unknown_ask", "replyTo must identify an unanswered inbound ask.");
|
|
359
352
|
}
|
|
360
|
-
if (
|
|
353
|
+
if (this.replying.has(replyTo)) {
|
|
361
354
|
throw new Fault(409, "reply_in_progress", "This ask already has a reply in flight.");
|
|
362
355
|
}
|
|
363
|
-
|
|
356
|
+
this.replying.add(replyTo);
|
|
364
357
|
const peer = entry.from.id;
|
|
365
358
|
context.onPeer(peer);
|
|
366
359
|
try {
|
|
@@ -369,16 +362,16 @@ export class Session {
|
|
|
369
362
|
throw new Fault(409, "peer_offline", "Original ask sender is disconnected.");
|
|
370
363
|
}
|
|
371
364
|
this.guard(context.signal);
|
|
372
|
-
if (this.inbox.get(replyTo)
|
|
365
|
+
if (!this.inbox.get(replyTo)) {
|
|
373
366
|
throw new Fault(409, "ask_retired", "Inbound ask is no longer replyable.");
|
|
374
367
|
}
|
|
375
368
|
const data = { ...(await this.send(peer, message, context, { replyTo })) };
|
|
376
|
-
this.remove(replyTo);
|
|
369
|
+
this.inbox.remove(replyTo);
|
|
377
370
|
this.presence();
|
|
378
371
|
return data;
|
|
379
372
|
}
|
|
380
373
|
finally {
|
|
381
|
-
|
|
374
|
+
this.replying.delete(replyTo);
|
|
382
375
|
}
|
|
383
376
|
}
|
|
384
377
|
async execute(operation, context) {
|
|
@@ -386,6 +379,19 @@ export class Session {
|
|
|
386
379
|
if (operation.route === "POST /v1/ask") {
|
|
387
380
|
return this.ask(operation, context);
|
|
388
381
|
}
|
|
382
|
+
if (operation.route === "GET /v1/inbox" || operation.route === "POST /v1/ack") {
|
|
383
|
+
// Durable reads/acks remain available while the local broker is down.
|
|
384
|
+
if (!this.inbox.persistent) {
|
|
385
|
+
await this.connect(context.signal);
|
|
386
|
+
}
|
|
387
|
+
if (operation.route === "POST /v1/ack") {
|
|
388
|
+
return this.ack(operation.ids);
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
...this.inbox.page(operation.after, operation.limit),
|
|
392
|
+
...this.inbox.status(this.owner.losses(this.identity.fingerprint256)),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
389
395
|
await this.connect(context.signal);
|
|
390
396
|
switch (operation.route) {
|
|
391
397
|
case "POST /v1/register":
|
|
@@ -399,16 +405,6 @@ export class Session {
|
|
|
399
405
|
}),
|
|
400
406
|
};
|
|
401
407
|
}
|
|
402
|
-
case "GET /v1/inbox": {
|
|
403
|
-
const lostMessages = this.owner.losses(this.identity.fingerprint256);
|
|
404
|
-
return {
|
|
405
|
-
messages: [...this.inbox.values()].map(({ from, message }) => ({ from, message })),
|
|
406
|
-
overflow: lostMessages > 0,
|
|
407
|
-
lostMessages,
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
case "POST /v1/ack":
|
|
411
|
-
return this.ack(operation.ids);
|
|
412
408
|
case "POST /v1/send":
|
|
413
409
|
return {
|
|
414
410
|
...(await this.send((await this.target(operation.to, context.signal)).id, operation.message, context)),
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { log } from "./bridge-protocol.js";
|
|
2
|
+
import { Mailbox } from "./bridge-mailbox.js";
|
|
3
|
+
const POLL_MS = 15000;
|
|
4
|
+
const FALLBACK_AFTER_MS = 120000;
|
|
5
|
+
const MAX_BACKOFF_MS = 300000;
|
|
6
|
+
export class WakeDelivery {
|
|
7
|
+
controller = new AbortController();
|
|
8
|
+
timer;
|
|
9
|
+
running;
|
|
10
|
+
backoff = POLL_MS;
|
|
11
|
+
name;
|
|
12
|
+
mailbox;
|
|
13
|
+
policy;
|
|
14
|
+
authorized;
|
|
15
|
+
constructor(name, mailbox, policy, authorized) {
|
|
16
|
+
this.name = name;
|
|
17
|
+
this.mailbox = mailbox;
|
|
18
|
+
this.policy = policy;
|
|
19
|
+
this.authorized = authorized;
|
|
20
|
+
}
|
|
21
|
+
start() {
|
|
22
|
+
if (!this.running && !this.controller.signal.aborted) {
|
|
23
|
+
this.schedule(0);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
schedule(delay) {
|
|
27
|
+
clearTimeout(this.timer);
|
|
28
|
+
this.timer = setTimeout(() => {
|
|
29
|
+
this.running = this.deliver()
|
|
30
|
+
.catch(() => {
|
|
31
|
+
log("wake_delivery", { name: this.name, result: "storage_unavailable" });
|
|
32
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
33
|
+
})
|
|
34
|
+
.finally(() => {
|
|
35
|
+
this.running = undefined;
|
|
36
|
+
if (!this.controller.signal.aborted) {
|
|
37
|
+
this.schedule(this.backoff);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}, delay);
|
|
41
|
+
}
|
|
42
|
+
async deliver() {
|
|
43
|
+
if (!this.authorized()) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const entries = this.mailbox.pendingNotifications().messages;
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.acknowledged) {
|
|
49
|
+
// The box's durable worker owns primary wake/replay after acknowledgement.
|
|
50
|
+
this.mailbox.notificationAccepted(entry.message.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const next = entries.find((entry) => !entry.acknowledged && Date.now() - entry.receivedAt >= FALLBACK_AFTER_MS);
|
|
54
|
+
if (next) {
|
|
55
|
+
// One fallback wakes the box to drain the remaining mailbox; never flood a reset VM.
|
|
56
|
+
await this.post(next);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.backoff = POLL_MS;
|
|
60
|
+
}
|
|
61
|
+
async post(entry) {
|
|
62
|
+
if (!this.authorized()) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const fields = { name: this.name, messageId: entry.message.id };
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(this.policy.url, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
redirect: "error",
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${this.policy.key}`,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
from: entry.from.name ?? entry.from.id,
|
|
76
|
+
from_id: entry.from.id,
|
|
77
|
+
to: this.name,
|
|
78
|
+
message_id: entry.message.id,
|
|
79
|
+
ask_id: entry.message.expectsReply === true ? entry.message.id : null,
|
|
80
|
+
text: entry.message.content.text,
|
|
81
|
+
}),
|
|
82
|
+
signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(10000)]),
|
|
83
|
+
});
|
|
84
|
+
await response.body?.cancel();
|
|
85
|
+
if (response.ok && !this.controller.signal.aborted && this.authorized()) {
|
|
86
|
+
// HTTP acceptance cannot prove durable intake; the same ID remains pending until inbox ACK.
|
|
87
|
+
this.backoff = MAX_BACKOFF_MS;
|
|
88
|
+
log("wake_delivery", { ...fields, result: "accepted" });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
92
|
+
log("wake_delivery", { ...fields, result: "http_rejected", status: response.status });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
|
97
|
+
if (!this.controller.signal.aborted) {
|
|
98
|
+
log("wake_delivery", { ...fields, result: "transport_unconfirmed" });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async stop() {
|
|
103
|
+
this.controller.abort();
|
|
104
|
+
clearTimeout(this.timer);
|
|
105
|
+
await this.running;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export class WakeDeliveries {
|
|
109
|
+
stateDir;
|
|
110
|
+
allowed;
|
|
111
|
+
deliveries = new Map();
|
|
112
|
+
cleanup = new Set();
|
|
113
|
+
constructor(stateDir, allowed) {
|
|
114
|
+
this.stateDir = stateDir;
|
|
115
|
+
this.allowed = allowed;
|
|
116
|
+
}
|
|
117
|
+
session(identity, expires) {
|
|
118
|
+
if (!identity.wake ||
|
|
119
|
+
this.stateDir === undefined ||
|
|
120
|
+
this.deliveries.has(identity.fingerprint256)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const mailbox = new Mailbox(this.stateDir, identity.fingerprint256, true);
|
|
124
|
+
const worker = new WakeDelivery(identity.name, mailbox, identity.wake, () => this.allowed(identity) && expires > Date.now());
|
|
125
|
+
this.deliveries.set(identity.fingerprint256, { identity, mailbox, worker });
|
|
126
|
+
worker.start();
|
|
127
|
+
}
|
|
128
|
+
drop(fp) {
|
|
129
|
+
const delivery = this.deliveries.get(fp);
|
|
130
|
+
if (!delivery) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this.deliveries.delete(fp);
|
|
134
|
+
const cleanup = delivery.worker
|
|
135
|
+
.stop()
|
|
136
|
+
.then(() => delivery.mailbox.close())
|
|
137
|
+
.catch(() => {
|
|
138
|
+
log("wake_delivery", { name: delivery.identity.name, result: "cleanup_failed" });
|
|
139
|
+
})
|
|
140
|
+
.finally(() => {
|
|
141
|
+
this.cleanup.delete(cleanup);
|
|
142
|
+
});
|
|
143
|
+
this.cleanup.add(cleanup);
|
|
144
|
+
}
|
|
145
|
+
reload() {
|
|
146
|
+
for (const [fp, delivery] of this.deliveries) {
|
|
147
|
+
if (!this.allowed(delivery.identity)) {
|
|
148
|
+
this.drop(fp);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async stop() {
|
|
153
|
+
for (const fp of this.deliveries.keys()) {
|
|
154
|
+
this.drop(fp);
|
|
155
|
+
}
|
|
156
|
+
await Promise.all(this.cleanup);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -9,7 +9,16 @@ import { Session } from "./bridge-session.js";
|
|
|
9
9
|
import { operationFor, routeFor } from "./bridge-routes.js";
|
|
10
10
|
import { reply } from "./bridge-http.js";
|
|
11
11
|
import { Launcher, sameIdentity } from "./bridge-launch.js";
|
|
12
|
+
import { PersistentPresence } from "./bridge-presence.js";
|
|
13
|
+
import { WakeDeliveries } from "./bridge-wake.js";
|
|
14
|
+
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
12
15
|
const MAX_CONNECTIONS = 128;
|
|
16
|
+
function validCertificateDates(starts, expires) {
|
|
17
|
+
return (Number.isFinite(starts) &&
|
|
18
|
+
Number.isFinite(expires) &&
|
|
19
|
+
starts <= Date.now() &&
|
|
20
|
+
expires > Date.now());
|
|
21
|
+
}
|
|
13
22
|
class Bridge {
|
|
14
23
|
config;
|
|
15
24
|
configPath;
|
|
@@ -21,9 +30,32 @@ class Bridge {
|
|
|
21
30
|
server;
|
|
22
31
|
stopping = false;
|
|
23
32
|
handlers = new Set();
|
|
33
|
+
presence;
|
|
34
|
+
deliveries;
|
|
24
35
|
constructor(config, configPath) {
|
|
25
36
|
this.config = config;
|
|
26
37
|
this.configPath = configPath;
|
|
38
|
+
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
39
|
+
if (config.stateDir !== undefined) {
|
|
40
|
+
this.presence = new PersistentPresence({
|
|
41
|
+
stateDir: config.stateDir,
|
|
42
|
+
clients: () => this.config.clients,
|
|
43
|
+
connect: async (identity, expires) => {
|
|
44
|
+
await spawnBrokerIfNeeded(process.execPath, []);
|
|
45
|
+
if (!this.allowed(identity)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const session = this.session(identity, expires);
|
|
49
|
+
const controller = session.begin();
|
|
50
|
+
try {
|
|
51
|
+
await session.connectedPeers(controller.signal);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
session.end(controller);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
27
59
|
this.server = createServer({
|
|
28
60
|
...config.tls,
|
|
29
61
|
minVersion: "TLSv1.2",
|
|
@@ -74,7 +106,9 @@ class Bridge {
|
|
|
74
106
|
}
|
|
75
107
|
allowed(identity) {
|
|
76
108
|
const current = this.config.clients.get(identity.fingerprint256);
|
|
77
|
-
return !this.stopping &&
|
|
109
|
+
return (!this.stopping &&
|
|
110
|
+
sameIdentity(identity, current) &&
|
|
111
|
+
JSON.stringify(identity.wake) === JSON.stringify(current?.wake));
|
|
78
112
|
}
|
|
79
113
|
lost(fp, count = 1) {
|
|
80
114
|
if (count > 0 && this.config.clients.has(fp)) {
|
|
@@ -82,9 +116,12 @@ class Bridge {
|
|
|
82
116
|
}
|
|
83
117
|
}
|
|
84
118
|
session(identity, expires) {
|
|
119
|
+
this.deliveries.session(identity, expires);
|
|
85
120
|
let session = this.sessions.get(identity.fingerprint256);
|
|
86
121
|
if (!session) {
|
|
87
122
|
session = new Session({
|
|
123
|
+
stateDir: this.config.stateDir,
|
|
124
|
+
notifications: identity.wake !== undefined,
|
|
88
125
|
allowed: (candidate) => this.allowed(candidate),
|
|
89
126
|
lost: (fp, count) => this.lost(fp, count),
|
|
90
127
|
losses: (fp) => this.losses.get(fp) ?? 0,
|
|
@@ -114,14 +151,19 @@ class Bridge {
|
|
|
114
151
|
if (!socket.authorized ||
|
|
115
152
|
!identity ||
|
|
116
153
|
this.stopping ||
|
|
117
|
-
!
|
|
118
|
-
!Number.isFinite(starts) ||
|
|
119
|
-
starts > Date.now() ||
|
|
120
|
-
expires <= Date.now()) {
|
|
154
|
+
!validCertificateDates(starts, expires)) {
|
|
121
155
|
log("connection_rejected", { fingerprint: fp, result: "unauthorized" });
|
|
122
156
|
socket.destroy();
|
|
123
157
|
return;
|
|
124
158
|
}
|
|
159
|
+
try {
|
|
160
|
+
this.presence?.authenticated(identity, expires);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
log("connection_rejected", { fingerprint: fp, result: "presence_storage_failed" });
|
|
164
|
+
socket.destroy();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
125
167
|
const expiry = () => {
|
|
126
168
|
if (expires <= Date.now()) {
|
|
127
169
|
this.drop(fp, new Fault(403, "certificate_expired", "Client certificate expired."));
|
|
@@ -143,6 +185,7 @@ class Bridge {
|
|
|
143
185
|
log("connected", { name: identity.name, fingerprint: fp, result: "authenticated" });
|
|
144
186
|
}
|
|
145
187
|
drop(fp, error) {
|
|
188
|
+
this.deliveries.drop(fp);
|
|
146
189
|
this.sessions.get(fp)?.close(error);
|
|
147
190
|
for (const [socket, connection] of this.connections) {
|
|
148
191
|
if (connection.fingerprint === fp) {
|
|
@@ -160,6 +203,7 @@ class Bridge {
|
|
|
160
203
|
port: typeof address === "object" && address ? address.port : this.config.port,
|
|
161
204
|
brokerSocket: getBrokerSocketPath(),
|
|
162
205
|
});
|
|
206
|
+
this.presence?.start();
|
|
163
207
|
});
|
|
164
208
|
process.on("SIGHUP", () => this.reload());
|
|
165
209
|
// Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
|
|
@@ -182,6 +226,7 @@ class Bridge {
|
|
|
182
226
|
}
|
|
183
227
|
catch {
|
|
184
228
|
this.config = { ...this.config, clients: new Map() };
|
|
229
|
+
this.deliveries.reload();
|
|
185
230
|
this.launcher
|
|
186
231
|
.revoke(this.config.clients)
|
|
187
232
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
@@ -196,6 +241,7 @@ class Bridge {
|
|
|
196
241
|
}
|
|
197
242
|
}
|
|
198
243
|
revokeRemoved() {
|
|
244
|
+
this.deliveries.reload();
|
|
199
245
|
for (const [fp, session] of this.sessions) {
|
|
200
246
|
if (!this.allowed(session.identity)) {
|
|
201
247
|
this.drop(fp, new Fault(403, "revoked", "Identity revoked or changed by configuration reload."));
|
|
@@ -235,6 +281,8 @@ class Bridge {
|
|
|
235
281
|
...this.handlers,
|
|
236
282
|
...sessions.map((session) => session.settled()),
|
|
237
283
|
this.launcher.shutdown(),
|
|
284
|
+
this.presence?.stop(),
|
|
285
|
+
this.deliveries.stop(),
|
|
238
286
|
]);
|
|
239
287
|
log("shutdown", { result: "closed" });
|
|
240
288
|
}
|
|
@@ -345,7 +393,7 @@ class Bridge {
|
|
|
345
393
|
function main() {
|
|
346
394
|
const args = process.argv.slice(2);
|
|
347
395
|
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
348
|
-
process.stdout.write("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
|
|
396
|
+
process.stdout.write("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");
|
|
349
397
|
return;
|
|
350
398
|
}
|
|
351
399
|
if (args.length !== 2 || args[0] !== "--config" || args[1] === "") {
|
|
@@ -358,7 +406,7 @@ function main() {
|
|
|
358
406
|
new Bridge(loadConfig(path), path).start();
|
|
359
407
|
}
|
|
360
408
|
catch {
|
|
361
|
-
process.stderr.write("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");
|
|
409
|
+
process.stderr.write("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");
|
|
362
410
|
process.exitCode = 1;
|
|
363
411
|
}
|
|
364
412
|
}
|