@fitchmultz/pi-subagents 0.46.2 → 0.48.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 +25 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-broadcast.js +60 -0
- package/dist/pi-intercom/bridge-catalog.js +372 -0
- package/dist/pi-intercom/bridge-config.js +47 -26
- package/dist/pi-intercom/bridge-control.js +104 -0
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-groups.js +135 -0
- package/dist/pi-intercom/bridge-http.js +8 -1
- package/dist/pi-intercom/bridge-launch-journal.js +84 -0
- package/dist/pi-intercom/bridge-launch-policy.js +71 -5
- package/dist/pi-intercom/bridge-launch.js +186 -76
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-operations.js +127 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-protocol.js +7 -0
- package/dist/pi-intercom/bridge-routes.js +190 -17
- package/dist/pi-intercom/bridge-rpc-process.js +43 -15
- package/dist/pi-intercom/bridge-session.js +83 -85
- package/dist/pi-intercom/bridge-target.js +22 -0
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +84 -44
- package/dist/pi-intercom/broker/client.js +1 -0
- package/dist/pi-intercom/connection.js +2 -3
- package/dist/pi-intercom/inbound.js +2 -4
- package/dist/pi-intercom/index.js +7 -1
- package/dist/pi-intercom/native-control-auth.js +80 -0
- package/dist/pi-intercom/native-control-protocol.js +111 -0
- package/dist/pi-intercom/native-control.js +130 -0
- package/dist/pi-intercom/saved-transcript.js +135 -0
- package/dist/pi-intercom/session-targets.js +3 -0
- package/dist/pi-intercom/transcript.js +175 -0
- package/docs/intercom-bridge.md +293 -161
- package/docs/intercom.md +1 -1
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +196 -31
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-broadcast.ts +70 -0
- package/src/pi-intercom/bridge-catalog.ts +489 -0
- package/src/pi-intercom/bridge-config.ts +64 -37
- package/src/pi-intercom/bridge-control.ts +179 -0
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-groups.ts +169 -0
- package/src/pi-intercom/bridge-http.ts +15 -1
- package/src/pi-intercom/bridge-launch-journal.ts +122 -0
- package/src/pi-intercom/bridge-launch-policy.ts +88 -7
- package/src/pi-intercom/bridge-launch.ts +296 -107
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-operations.ts +169 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-protocol.ts +8 -0
- package/src/pi-intercom/bridge-routes.ts +281 -32
- package/src/pi-intercom/bridge-rpc-process.ts +53 -16
- package/src/pi-intercom/bridge-session.ts +106 -105
- package/src/pi-intercom/bridge-target.ts +35 -0
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +88 -54
- package/src/pi-intercom/broker/client.ts +3 -0
- package/src/pi-intercom/connection.ts +2 -3
- package/src/pi-intercom/inbound.ts +2 -4
- package/src/pi-intercom/index.ts +7 -1
- package/src/pi-intercom/native-control-auth.ts +89 -0
- package/src/pi-intercom/native-control-protocol.ts +180 -0
- package/src/pi-intercom/native-control.ts +160 -0
- package/src/pi-intercom/saved-transcript.ts +175 -0
- package/src/pi-intercom/session-targets.ts +4 -0
- package/src/pi-intercom/transcript.ts +226 -0
- package/src/pi-intercom/types.ts +2 -0
|
@@ -8,11 +8,32 @@ import { getBrokerSocketPath } from "./broker/paths.ts";
|
|
|
8
8
|
import { loadConfig, sameListener, type Config, type Identity } from "./bridge-config.ts";
|
|
9
9
|
import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.ts";
|
|
10
10
|
import { Session } from "./bridge-session.ts";
|
|
11
|
-
import { operationFor, routeFor
|
|
12
|
-
import { reply } from "./bridge-http.ts";
|
|
13
|
-
import {
|
|
11
|
+
import { operationFor, routeFor } from "./bridge-routes.ts";
|
|
12
|
+
import { reply, replyFault } from "./bridge-http.ts";
|
|
13
|
+
import { sameIdentity } from "./bridge-launch.ts";
|
|
14
|
+
import { BridgeOperations } from "./bridge-operations.ts";
|
|
15
|
+
import { authenticatedCertificateChain } from "./native-control-auth.ts";
|
|
16
|
+
import { PersistentPresence } from "./bridge-presence.ts";
|
|
17
|
+
import { WakeDeliveries } from "./bridge-wake.ts";
|
|
18
|
+
import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
|
|
14
19
|
|
|
15
20
|
const MAX_CONNECTIONS = 128;
|
|
21
|
+
const LONG_ROUTES = new Set([
|
|
22
|
+
"POST /v1/start",
|
|
23
|
+
"POST /v1/spawn",
|
|
24
|
+
"POST /v1/resume",
|
|
25
|
+
"GET /v1/transcript",
|
|
26
|
+
"POST /v1/transcript",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function validCertificateDates(starts: number, expires: number): boolean {
|
|
30
|
+
return (
|
|
31
|
+
Number.isFinite(starts) &&
|
|
32
|
+
Number.isFinite(expires) &&
|
|
33
|
+
starts <= Date.now() &&
|
|
34
|
+
expires > Date.now()
|
|
35
|
+
);
|
|
36
|
+
}
|
|
16
37
|
|
|
17
38
|
interface Connection {
|
|
18
39
|
readonly fingerprint: string;
|
|
@@ -25,16 +46,39 @@ class Bridge {
|
|
|
25
46
|
private readonly configPath: string;
|
|
26
47
|
private readonly sessions = new Map<string, Session>();
|
|
27
48
|
private readonly losses = new Map<string, number>();
|
|
28
|
-
private readonly
|
|
49
|
+
private readonly operations: BridgeOperations;
|
|
29
50
|
private readonly sockets = new Set<Socket>();
|
|
30
51
|
private readonly connections = new Map<TLSSocket, Connection>();
|
|
31
52
|
private readonly server: Server;
|
|
32
53
|
private stopping = false;
|
|
33
54
|
private readonly handlers = new Set<Promise<void>>();
|
|
55
|
+
private readonly presence?: PersistentPresence;
|
|
56
|
+
private readonly deliveries: WakeDeliveries;
|
|
34
57
|
|
|
35
58
|
constructor(config: Config, configPath: string) {
|
|
36
59
|
this.config = config;
|
|
37
60
|
this.configPath = configPath;
|
|
61
|
+
this.operations = new BridgeOperations(config.stateDir);
|
|
62
|
+
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
63
|
+
if (config.stateDir !== undefined) {
|
|
64
|
+
this.presence = new PersistentPresence({
|
|
65
|
+
stateDir: config.stateDir,
|
|
66
|
+
clients: () => this.config.clients,
|
|
67
|
+
connect: async (identity, expires) => {
|
|
68
|
+
await spawnBrokerIfNeeded(process.execPath, []);
|
|
69
|
+
if (!this.allowed(identity)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const session = this.session(identity, expires);
|
|
73
|
+
const controller = session.begin();
|
|
74
|
+
try {
|
|
75
|
+
await session.connectedPeers(controller.signal);
|
|
76
|
+
} finally {
|
|
77
|
+
session.end(controller);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
38
82
|
this.server = createServer(
|
|
39
83
|
{
|
|
40
84
|
...config.tls,
|
|
@@ -90,7 +134,11 @@ class Bridge {
|
|
|
90
134
|
}
|
|
91
135
|
private allowed(identity: Identity): boolean {
|
|
92
136
|
const current = this.config.clients.get(identity.fingerprint256);
|
|
93
|
-
return
|
|
137
|
+
return (
|
|
138
|
+
!this.stopping &&
|
|
139
|
+
sameIdentity(identity, current) &&
|
|
140
|
+
JSON.stringify(identity.wake) === JSON.stringify(current?.wake)
|
|
141
|
+
);
|
|
94
142
|
}
|
|
95
143
|
private lost(fp: string, count = 1): void {
|
|
96
144
|
if (count > 0 && this.config.clients.has(fp)) {
|
|
@@ -98,10 +146,13 @@ class Bridge {
|
|
|
98
146
|
}
|
|
99
147
|
}
|
|
100
148
|
private session(identity: Identity, expires: number): Session {
|
|
149
|
+
this.deliveries.session(identity, expires);
|
|
101
150
|
let session = this.sessions.get(identity.fingerprint256);
|
|
102
151
|
if (!session) {
|
|
103
152
|
session = new Session(
|
|
104
153
|
{
|
|
154
|
+
stateDir: this.config.stateDir,
|
|
155
|
+
notifications: identity.wake !== undefined,
|
|
105
156
|
allowed: (candidate) => this.allowed(candidate),
|
|
106
157
|
lost: (fp, count) => this.lost(fp, count),
|
|
107
158
|
losses: (fp) => this.losses.get(fp) ?? 0,
|
|
@@ -134,15 +185,19 @@ class Bridge {
|
|
|
134
185
|
!socket.authorized ||
|
|
135
186
|
!identity ||
|
|
136
187
|
this.stopping ||
|
|
137
|
-
!
|
|
138
|
-
!Number.isFinite(starts) ||
|
|
139
|
-
starts > Date.now() ||
|
|
140
|
-
expires <= Date.now()
|
|
188
|
+
!validCertificateDates(starts, expires)
|
|
141
189
|
) {
|
|
142
190
|
log("connection_rejected", { fingerprint: fp, result: "unauthorized" });
|
|
143
191
|
socket.destroy();
|
|
144
192
|
return;
|
|
145
193
|
}
|
|
194
|
+
try {
|
|
195
|
+
this.presence?.authenticated(identity, expires);
|
|
196
|
+
} catch {
|
|
197
|
+
log("connection_rejected", { fingerprint: fp, result: "presence_storage_failed" });
|
|
198
|
+
socket.destroy();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
146
201
|
const expiry = () => {
|
|
147
202
|
if (expires <= Date.now()) {
|
|
148
203
|
this.drop(fp, new Fault(403, "certificate_expired", "Client certificate expired."));
|
|
@@ -164,6 +219,7 @@ class Bridge {
|
|
|
164
219
|
log("connected", { name: identity.name, fingerprint: fp, result: "authenticated" });
|
|
165
220
|
}
|
|
166
221
|
private drop(fp: string, error: Readonly<Fault>): void {
|
|
222
|
+
this.deliveries.drop(fp);
|
|
167
223
|
this.sessions.get(fp)?.close(error);
|
|
168
224
|
for (const [socket, connection] of this.connections) {
|
|
169
225
|
if (connection.fingerprint === fp) {
|
|
@@ -181,6 +237,7 @@ class Bridge {
|
|
|
181
237
|
port: typeof address === "object" && address ? address.port : this.config.port,
|
|
182
238
|
brokerSocket: getBrokerSocketPath(),
|
|
183
239
|
});
|
|
240
|
+
this.presence?.start();
|
|
184
241
|
});
|
|
185
242
|
process.on("SIGHUP", () => this.reload());
|
|
186
243
|
// Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
|
|
@@ -198,11 +255,12 @@ class Bridge {
|
|
|
198
255
|
}
|
|
199
256
|
this.config = next;
|
|
200
257
|
this.revokeRemoved();
|
|
201
|
-
this.
|
|
258
|
+
this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
|
|
202
259
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
203
260
|
} catch {
|
|
204
261
|
this.config = { ...this.config, clients: new Map() };
|
|
205
|
-
this.
|
|
262
|
+
this.deliveries.reload();
|
|
263
|
+
this.operations
|
|
206
264
|
.revoke(this.config.clients)
|
|
207
265
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
208
266
|
for (const session of this.sessions.values()) {
|
|
@@ -222,6 +280,7 @@ class Bridge {
|
|
|
222
280
|
}
|
|
223
281
|
}
|
|
224
282
|
private revokeRemoved(): void {
|
|
283
|
+
this.deliveries.reload();
|
|
225
284
|
for (const [fp, session] of this.sessions) {
|
|
226
285
|
if (!this.allowed(session.identity)) {
|
|
227
286
|
this.drop(
|
|
@@ -263,13 +322,16 @@ class Bridge {
|
|
|
263
322
|
await Promise.all([
|
|
264
323
|
...this.handlers,
|
|
265
324
|
...sessions.map((session) => session.settled()),
|
|
266
|
-
this.
|
|
325
|
+
this.operations.shutdown(),
|
|
326
|
+
this.presence?.stop(),
|
|
327
|
+
this.deliveries.stop(),
|
|
267
328
|
]);
|
|
268
329
|
log("shutdown", { result: "closed" });
|
|
269
330
|
}
|
|
270
331
|
private admission(req: IncomingMessage): {
|
|
271
332
|
readonly identity: Identity;
|
|
272
333
|
readonly expires: number;
|
|
334
|
+
readonly certificate: string;
|
|
273
335
|
} {
|
|
274
336
|
const socket = req.socket;
|
|
275
337
|
const connection = socket instanceof TLSSocket ? this.connections.get(socket) : undefined;
|
|
@@ -288,7 +350,11 @@ class Bridge {
|
|
|
288
350
|
this.drop(connection.fingerprint, fault);
|
|
289
351
|
throw fault;
|
|
290
352
|
}
|
|
291
|
-
return {
|
|
353
|
+
return {
|
|
354
|
+
identity,
|
|
355
|
+
expires: connection.expires,
|
|
356
|
+
certificate: authenticatedCertificateChain(socket),
|
|
357
|
+
};
|
|
292
358
|
}
|
|
293
359
|
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
294
360
|
const requestId = randomUUID();
|
|
@@ -321,7 +387,7 @@ class Bridge {
|
|
|
321
387
|
);
|
|
322
388
|
};
|
|
323
389
|
try {
|
|
324
|
-
const { identity, expires } = this.admission(req);
|
|
390
|
+
const { identity, expires, certificate } = this.admission(req);
|
|
325
391
|
const route = routeFor(req);
|
|
326
392
|
session = this.session(identity, expires);
|
|
327
393
|
controller = session.begin();
|
|
@@ -329,15 +395,18 @@ class Bridge {
|
|
|
329
395
|
req.once("aborted", abort);
|
|
330
396
|
res.once("close", onClose);
|
|
331
397
|
const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
|
|
332
|
-
setDeadline(route
|
|
398
|
+
setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
|
|
333
399
|
const operation = await operationFor(req, route, signal);
|
|
334
400
|
if (operation.route === "POST /v1/ask") {
|
|
335
401
|
setDeadline(operation.timeoutMs + 15000);
|
|
402
|
+
} else if (operation.route === "GET /v1/transcript") {
|
|
403
|
+
setDeadline(75000);
|
|
336
404
|
}
|
|
337
|
-
const data = await this.execute(operation, session, {
|
|
405
|
+
const data = await this.operations.execute(operation, session, {
|
|
338
406
|
signal,
|
|
339
407
|
requestId,
|
|
340
408
|
expires,
|
|
409
|
+
certificate,
|
|
341
410
|
onPeer: (id) => {
|
|
342
411
|
peer = id;
|
|
343
412
|
},
|
|
@@ -351,10 +420,7 @@ class Bridge {
|
|
|
351
420
|
? error
|
|
352
421
|
: new Fault(503, "broker_unavailable", "Local broker operation failed.");
|
|
353
422
|
log("request", { ...session?.fields(), requestId, peer, result: fault.code });
|
|
354
|
-
|
|
355
|
-
ok: false,
|
|
356
|
-
error: { code: fault.code, message: fault.message, ...fault.details },
|
|
357
|
-
});
|
|
423
|
+
replyFault(res, requestId, fault);
|
|
358
424
|
} finally {
|
|
359
425
|
clearTimeout(deadline);
|
|
360
426
|
req.off("aborted", abort);
|
|
@@ -364,45 +430,13 @@ class Bridge {
|
|
|
364
430
|
}
|
|
365
431
|
}
|
|
366
432
|
}
|
|
367
|
-
private async execute(
|
|
368
|
-
operation: Operation,
|
|
369
|
-
session: Readonly<Session>,
|
|
370
|
-
context: {
|
|
371
|
-
readonly signal: AbortSignal;
|
|
372
|
-
readonly requestId: string;
|
|
373
|
-
readonly expires: number;
|
|
374
|
-
readonly onPeer: (id: string) => void;
|
|
375
|
-
},
|
|
376
|
-
) {
|
|
377
|
-
switch (operation.route) {
|
|
378
|
-
case "POST /v1/start":
|
|
379
|
-
return this.launcher.start(operation.name, {
|
|
380
|
-
identity: session.identity,
|
|
381
|
-
expires: context.expires,
|
|
382
|
-
signal: context.signal,
|
|
383
|
-
peers: (signal) => session.connectedPeers(signal),
|
|
384
|
-
});
|
|
385
|
-
case "GET /v1/sessions":
|
|
386
|
-
return this.launcher.list(session.identity);
|
|
387
|
-
case "POST /v1/stop":
|
|
388
|
-
return this.launcher.stop(session.identity, operation.sessionId);
|
|
389
|
-
case "GET /v1/list":
|
|
390
|
-
case "GET /v1/inbox":
|
|
391
|
-
case "POST /v1/register":
|
|
392
|
-
case "POST /v1/ask":
|
|
393
|
-
case "POST /v1/send":
|
|
394
|
-
case "POST /v1/reply":
|
|
395
|
-
case "POST /v1/ack":
|
|
396
|
-
return session.execute(operation, context);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
433
|
}
|
|
400
434
|
|
|
401
435
|
function main(): void {
|
|
402
436
|
const args = process.argv.slice(2);
|
|
403
437
|
if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
|
|
404
438
|
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
|
|
439
|
+
"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
440
|
);
|
|
407
441
|
return;
|
|
408
442
|
}
|
|
@@ -416,7 +450,7 @@ function main(): void {
|
|
|
416
450
|
new Bridge(loadConfig(path), path).start();
|
|
417
451
|
} catch {
|
|
418
452
|
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",
|
|
453
|
+
"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
454
|
);
|
|
421
455
|
process.exitCode = 1;
|
|
422
456
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from "events";
|
|
2
2
|
import type net from "net";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
+
import type { NativeControlEnvelope } from "../native-control-protocol.ts";
|
|
4
5
|
import { writeMessage } from "./framing.ts";
|
|
5
6
|
import { BrokerConnection, connectBrokerSocket } from "./connection.ts";
|
|
6
7
|
import { SnapshotReader } from "./snapshot-reader.ts";
|
|
@@ -51,6 +52,7 @@ interface SendOptions {
|
|
|
51
52
|
readonly messageId?: string;
|
|
52
53
|
readonly human?: HumanMessageOrigin;
|
|
53
54
|
readonly topic?: TopicUpdate;
|
|
55
|
+
readonly control?: NativeControlEnvelope;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function toError(error: unknown): Error {
|
|
@@ -405,6 +407,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
405
407
|
expectsReply: options.expectsReply,
|
|
406
408
|
...(options.human ? { human: options.human } : {}),
|
|
407
409
|
...(options.topic ? { topic: options.topic } : {}),
|
|
410
|
+
...(options.control ? { control: options.control } : {}),
|
|
408
411
|
delivery: messageDelivery(options),
|
|
409
412
|
queueMode: options.queueMode,
|
|
410
413
|
threadId: options.threadId,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import { IntercomClient } from "./broker/client.ts";
|
|
4
3
|
import type { IntercomTransport } from "./transport.ts";
|
|
@@ -8,7 +7,7 @@ import type { IntercomLifecycle } from "./lifecycle.ts";
|
|
|
8
7
|
import type { IntercomTopics } from "./topics.ts";
|
|
9
8
|
import type { Message, SessionInfo, TopicChange } from "./types.ts";
|
|
10
9
|
import { asError, errorMessage } from "./validation.ts";
|
|
11
|
-
import { formatTargetOptions, resolveSessionTarget } from "./session-targets.ts";
|
|
10
|
+
import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.ts";
|
|
12
11
|
|
|
13
12
|
export type ConnectionReason = "startup" | "background" | "tool" | "overlay" | "peer-awareness";
|
|
14
13
|
type Lifecycle = Readonly<
|
|
@@ -233,7 +232,7 @@ export class IntercomConnection {
|
|
|
233
232
|
}
|
|
234
233
|
await next.connect(
|
|
235
234
|
await this.lifecycle.registration(this.hooks.pendingAsks()),
|
|
236
|
-
|
|
235
|
+
nativePeerId(sessionId),
|
|
237
236
|
);
|
|
238
237
|
if (!this.lifecycle.live(ctx, generation)) {
|
|
239
238
|
await next.disconnect();
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
import type { IntercomConfig } from "./config.ts";
|
|
4
3
|
import type { IntercomLifecycle } from "./lifecycle.ts";
|
|
@@ -13,6 +12,7 @@ import type { Message, SessionInfo } from "./types.ts";
|
|
|
13
12
|
import { requestedDelivery, isBlockingSupervisorMessage } from "./inbound-record.ts";
|
|
14
13
|
import { formatAttachments } from "./message-format.ts";
|
|
15
14
|
import { readQuestionOwner } from "../runs/shared/supervisor-questions.ts";
|
|
15
|
+
import { nativePeerId } from "./session-targets.ts";
|
|
16
16
|
|
|
17
17
|
type Lifecycle = Readonly<
|
|
18
18
|
Pick<IntercomLifecycle, "generation" | "isIdle" | "live" | "markActivity">
|
|
@@ -53,9 +53,7 @@ export class IntercomInbound {
|
|
|
53
53
|
}
|
|
54
54
|
const owner = readQuestionOwner(origin.runId);
|
|
55
55
|
return (
|
|
56
|
-
owner?.sessionId === origin.ownerSessionId &&
|
|
57
|
-
from.id ===
|
|
58
|
-
`pi-${createHash("sha256").update(origin.ownerSessionId).digest("hex").slice(0, 32)}`
|
|
56
|
+
owner?.sessionId === origin.ownerSessionId && from.id === nativePeerId(origin.ownerSessionId)
|
|
59
57
|
);
|
|
60
58
|
}
|
|
61
59
|
private normalize(from: SessionInfo, input: Message): Message {
|
package/src/pi-intercom/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { InboundDeliveryOwner } from "./inbound-delivery.ts";
|
|
|
9
9
|
import { ReplyWait } from "./reply-wait.ts";
|
|
10
10
|
import { IntercomConnection } from "./connection.ts";
|
|
11
11
|
import { IntercomInbound } from "./inbound.ts";
|
|
12
|
+
import { NativeControls } from "./native-control.ts";
|
|
12
13
|
import { IntercomOutbound } from "./outbound.ts";
|
|
13
14
|
import { IntercomInspection } from "./inspection.ts";
|
|
14
15
|
import { IntercomTopicActions } from "./topic-actions.ts";
|
|
@@ -33,7 +34,11 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
|
|
|
33
34
|
const connection = new IntercomConnection(lifecycle, config, topics, {
|
|
34
35
|
restoring: () => delivery.restoring,
|
|
35
36
|
pendingAsks: () => replies.listPending().length,
|
|
36
|
-
incoming: (ctx, from, message) =>
|
|
37
|
+
incoming: async (ctx, from, message) => {
|
|
38
|
+
if (!(await controls.receive(ctx, from, message))) {
|
|
39
|
+
await inbound.receive(ctx, from, message);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
37
42
|
peerLeft: (id) => {
|
|
38
43
|
wait.peerDisconnected(id);
|
|
39
44
|
journal.peerLeft(id);
|
|
@@ -46,6 +51,7 @@ export default function piIntercomExtension(pi: ExtensionAPI): void {
|
|
|
46
51
|
}
|
|
47
52
|
},
|
|
48
53
|
});
|
|
54
|
+
const controls = new NativeControls(lifecycle, connection);
|
|
49
55
|
const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
|
|
50
56
|
replies,
|
|
51
57
|
syncStatus: () => connection.syncStatus(),
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { X509Certificate } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import type { TLSSocket } from "node:tls";
|
|
6
|
+
import { loadControlPolicy } from "./bridge-config.ts";
|
|
7
|
+
import { getPiAgentDir } from "./agent-dir.ts";
|
|
8
|
+
import { fingerprint } from "./bridge-protocol.ts";
|
|
9
|
+
|
|
10
|
+
export function authenticatedCertificateChain(socket: Readonly<TLSSocket>): string {
|
|
11
|
+
const blocks: string[] = [];
|
|
12
|
+
const seen = new Set<string>();
|
|
13
|
+
let cert = socket.getPeerCertificate(true);
|
|
14
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
15
|
+
const parsed = new X509Certificate(cert.raw);
|
|
16
|
+
if (seen.has(parsed.fingerprint256)) {
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
seen.add(parsed.fingerprint256);
|
|
20
|
+
blocks.push(parsed.toString());
|
|
21
|
+
cert = cert.issuerCertificate;
|
|
22
|
+
}
|
|
23
|
+
return blocks.join("\n");
|
|
24
|
+
}
|
|
25
|
+
function certificates(pem: string): X509Certificate[] {
|
|
26
|
+
const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g);
|
|
27
|
+
if (!blocks || blocks.length > 8) {
|
|
28
|
+
throw new Error("Invalid bounded certificate chain.");
|
|
29
|
+
}
|
|
30
|
+
return blocks.map((block) => new X509Certificate(block));
|
|
31
|
+
}
|
|
32
|
+
function validNow(cert: X509Certificate): boolean {
|
|
33
|
+
return Date.parse(cert.validFrom) <= Date.now() && Date.parse(cert.validTo) > Date.now();
|
|
34
|
+
}
|
|
35
|
+
function trustedChain(
|
|
36
|
+
chain: readonly X509Certificate[],
|
|
37
|
+
roots: readonly X509Certificate[],
|
|
38
|
+
): boolean {
|
|
39
|
+
let current = chain.at(0);
|
|
40
|
+
if (current === undefined || current.ca || !validNow(current)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
44
|
+
const subject: X509Certificate = current;
|
|
45
|
+
const issuer: X509Certificate | undefined = [...roots, ...chain.slice(1)].find(
|
|
46
|
+
(candidate) =>
|
|
47
|
+
candidate.ca &&
|
|
48
|
+
validNow(candidate) &&
|
|
49
|
+
subject.checkIssued(candidate) &&
|
|
50
|
+
subject.verify(candidate.publicKey),
|
|
51
|
+
);
|
|
52
|
+
if (!issuer) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (roots.some((root) => root.fingerprint256 === issuer.fingerprint256)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
current = issuer;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
function controlPolicyPath(): string {
|
|
63
|
+
const configured = process.env.PI_INTERCOM_BRIDGE_CONFIG;
|
|
64
|
+
if (configured !== undefined) {
|
|
65
|
+
return configured;
|
|
66
|
+
}
|
|
67
|
+
const standard = join(getPiAgentDir(), "intercom", "bridge.json");
|
|
68
|
+
return existsSync(standard) ? standard : join(homedir(), ".pi", "intercom-bridge", "config.json");
|
|
69
|
+
}
|
|
70
|
+
/** Unix broker peers are same-user trusted. Names/payload claims grant no authority. */
|
|
71
|
+
export function authorizedControlPeer(peerId: string, certificate: string): boolean {
|
|
72
|
+
try {
|
|
73
|
+
const configPath = controlPolicyPath();
|
|
74
|
+
const config = loadControlPolicy(configPath);
|
|
75
|
+
const chain = certificates(certificate);
|
|
76
|
+
const client = chain.at(0);
|
|
77
|
+
if (client === undefined) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const fp = fingerprint(client.fingerprint256);
|
|
81
|
+
return (
|
|
82
|
+
peerId === `remote-${fp.toLowerCase()}` &&
|
|
83
|
+
config.clients.has(fp) &&
|
|
84
|
+
trustedChain(chain, certificates(config.ca.toString("utf8")))
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { isRecord, isUnknownArray } from "../shared/unknown.ts";
|
|
2
|
+
import type { TranscriptOptions, TranscriptPage } from "./transcript.ts";
|
|
3
|
+
|
|
4
|
+
export const CONTROL_SCHEMA = "pi-intercom/control-v1";
|
|
5
|
+
export const CONTROL_FALLBACK =
|
|
6
|
+
"Native Intercom metadata exchange (no conversation response needed).";
|
|
7
|
+
export type NativeControlVerb = "capabilities" | "cancel" | "stop" | "transcript";
|
|
8
|
+
export interface NativeControlOperation {
|
|
9
|
+
readonly to: string;
|
|
10
|
+
readonly verb: NativeControlVerb;
|
|
11
|
+
readonly options?: TranscriptOptions;
|
|
12
|
+
readonly timeoutMs: number;
|
|
13
|
+
}
|
|
14
|
+
export interface NativeControlRequest {
|
|
15
|
+
readonly schema: typeof CONTROL_SCHEMA;
|
|
16
|
+
readonly kind: "request";
|
|
17
|
+
readonly verb: NativeControlVerb;
|
|
18
|
+
/** Public TLS certificate, supplied by the bridge, never a caller-selected identity. */
|
|
19
|
+
readonly certificate: string;
|
|
20
|
+
readonly options?: TranscriptOptions;
|
|
21
|
+
}
|
|
22
|
+
export interface NativeControlResult {
|
|
23
|
+
readonly schema: typeof CONTROL_SCHEMA;
|
|
24
|
+
readonly kind: "result";
|
|
25
|
+
readonly verb: NativeControlVerb;
|
|
26
|
+
readonly status:
|
|
27
|
+
| "ok"
|
|
28
|
+
| "denied"
|
|
29
|
+
| "cancel_requested"
|
|
30
|
+
| "shutdown_requested"
|
|
31
|
+
| "unsupported"
|
|
32
|
+
| "failed";
|
|
33
|
+
readonly verbs?: readonly NativeControlVerb[];
|
|
34
|
+
readonly mode?: string;
|
|
35
|
+
readonly idle?: boolean;
|
|
36
|
+
readonly pendingInput?: boolean;
|
|
37
|
+
readonly queueCleared?: false;
|
|
38
|
+
readonly processExited?: false;
|
|
39
|
+
readonly stopSupport?: "native_shutdown" | "requires_activity" | "host_dependent";
|
|
40
|
+
readonly reason?: string;
|
|
41
|
+
readonly transcript?: TranscriptPage;
|
|
42
|
+
}
|
|
43
|
+
export type NativeControlEnvelope = NativeControlRequest | NativeControlResult;
|
|
44
|
+
|
|
45
|
+
export function isControlVerb(value: unknown): value is NativeControlVerb {
|
|
46
|
+
return (
|
|
47
|
+
value === "capabilities" || value === "cancel" || value === "stop" || value === "transcript"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
export function isControlRequest(value: unknown): value is NativeControlRequest {
|
|
51
|
+
return (
|
|
52
|
+
isRecord(value) &&
|
|
53
|
+
value.schema === CONTROL_SCHEMA &&
|
|
54
|
+
value.kind === "request" &&
|
|
55
|
+
isControlVerb(value.verb) &&
|
|
56
|
+
typeof value.certificate === "string" &&
|
|
57
|
+
value.certificate.length <= 16384 &&
|
|
58
|
+
Object.keys(value).every((key) =>
|
|
59
|
+
["schema", "kind", "verb", "certificate", "options"].includes(key),
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
export function isControlResult(value: unknown): value is NativeControlResult {
|
|
64
|
+
if (
|
|
65
|
+
!isRecord(value) ||
|
|
66
|
+
value.schema !== CONTROL_SCHEMA ||
|
|
67
|
+
value.kind !== "result" ||
|
|
68
|
+
!isControlVerb(value.verb)
|
|
69
|
+
) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return (
|
|
73
|
+
validResultFields(value) &&
|
|
74
|
+
validResultPayload(value) &&
|
|
75
|
+
Buffer.byteLength(JSON.stringify(value)) <= 1024 * 1024
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
function optionalText(value: unknown, max: number): boolean {
|
|
79
|
+
return value === undefined || (typeof value === "string" && value.length <= max);
|
|
80
|
+
}
|
|
81
|
+
function validResultFields(value: Readonly<Record<string, unknown>>): boolean {
|
|
82
|
+
const fields = new Set([
|
|
83
|
+
"schema",
|
|
84
|
+
"kind",
|
|
85
|
+
"verb",
|
|
86
|
+
"status",
|
|
87
|
+
"mode",
|
|
88
|
+
"idle",
|
|
89
|
+
"pendingInput",
|
|
90
|
+
"queueCleared",
|
|
91
|
+
"processExited",
|
|
92
|
+
"stopSupport",
|
|
93
|
+
"reason",
|
|
94
|
+
"transcript",
|
|
95
|
+
"verbs",
|
|
96
|
+
]);
|
|
97
|
+
return (
|
|
98
|
+
Object.keys(value).every((key) => fields.has(key)) &&
|
|
99
|
+
typeof value.status === "string" &&
|
|
100
|
+
["ok", "denied", "cancel_requested", "shutdown_requested", "unsupported", "failed"].includes(
|
|
101
|
+
value.status,
|
|
102
|
+
) &&
|
|
103
|
+
optionalText(value.reason, 2048) &&
|
|
104
|
+
optionalText(value.mode, 64) &&
|
|
105
|
+
[value.idle, value.pendingInput].every(
|
|
106
|
+
(flag) => flag === undefined || typeof flag === "boolean",
|
|
107
|
+
) &&
|
|
108
|
+
[value.queueCleared, value.processExited].every((flag) => flag === undefined || flag === false)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
function validResultPayload(value: Readonly<Record<string, unknown>>): boolean {
|
|
112
|
+
if (
|
|
113
|
+
value.stopSupport !== undefined &&
|
|
114
|
+
(typeof value.stopSupport !== "string" ||
|
|
115
|
+
!["native_shutdown", "requires_activity", "host_dependent"].includes(value.stopSupport))
|
|
116
|
+
) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
value.verbs !== undefined &&
|
|
121
|
+
(!isUnknownArray(value.verbs) || !value.verbs.every(isControlVerb))
|
|
122
|
+
) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return value.transcript === undefined || validTranscript(value.transcript);
|
|
126
|
+
}
|
|
127
|
+
function validTranscript(value: unknown): boolean {
|
|
128
|
+
if (!isRecord(value) || !isUnknownArray(value.records) || value.records.length > 100) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const fields = new Set([
|
|
132
|
+
"sessionId",
|
|
133
|
+
"leafId",
|
|
134
|
+
"records",
|
|
135
|
+
"bytes",
|
|
136
|
+
"truncated",
|
|
137
|
+
"cursor",
|
|
138
|
+
"scanLimitReached",
|
|
139
|
+
"finalizedOnly",
|
|
140
|
+
"streaming",
|
|
141
|
+
]);
|
|
142
|
+
return (
|
|
143
|
+
Object.keys(value).every((key) => fields.has(key)) &&
|
|
144
|
+
typeof value.sessionId === "string" &&
|
|
145
|
+
value.sessionId.length <= 512 &&
|
|
146
|
+
(value.leafId === null || typeof value.leafId === "string") &&
|
|
147
|
+
value.records.every(validTranscriptRecord) &&
|
|
148
|
+
validTranscriptMetadata(value)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
function validTranscriptMetadata(value: Readonly<Record<string, unknown>>): boolean {
|
|
152
|
+
return (
|
|
153
|
+
typeof value.bytes === "number" &&
|
|
154
|
+
Number.isInteger(value.bytes) &&
|
|
155
|
+
value.bytes >= 0 &&
|
|
156
|
+
value.bytes <= 131072 &&
|
|
157
|
+
typeof value.truncated === "boolean" &&
|
|
158
|
+
optionalText(value.cursor, 512) &&
|
|
159
|
+
typeof value.scanLimitReached === "boolean" &&
|
|
160
|
+
value.finalizedOnly === true &&
|
|
161
|
+
value.streaming === "unavailable"
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
function validTranscriptRecord(value: unknown): boolean {
|
|
165
|
+
if (!isRecord(value)) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const fields = new Set(["id", "timestamp", "role", "text", "truncated"]);
|
|
169
|
+
return (
|
|
170
|
+
Object.keys(value).every((key) => fields.has(key)) &&
|
|
171
|
+
[value.id, value.timestamp].every(
|
|
172
|
+
(field) => typeof field === "string" && field.length <= 512,
|
|
173
|
+
) &&
|
|
174
|
+
typeof value.role === "string" &&
|
|
175
|
+
["user", "assistant", "toolResult", "custom"].includes(value.role) &&
|
|
176
|
+
typeof value.text === "string" &&
|
|
177
|
+
Buffer.byteLength(value.text) <= 131072 &&
|
|
178
|
+
typeof value.truncated === "boolean"
|
|
179
|
+
);
|
|
180
|
+
}
|