@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
|
@@ -7,23 +7,65 @@ import { loadConfig, sameListener } from "./bridge-config.js";
|
|
|
7
7
|
import { ASK_TIMEOUT, Fault, fingerprint, log } from "./bridge-protocol.js";
|
|
8
8
|
import { Session } from "./bridge-session.js";
|
|
9
9
|
import { operationFor, routeFor } from "./bridge-routes.js";
|
|
10
|
-
import { reply } from "./bridge-http.js";
|
|
11
|
-
import {
|
|
10
|
+
import { reply, replyFault } from "./bridge-http.js";
|
|
11
|
+
import { sameIdentity } from "./bridge-launch.js";
|
|
12
|
+
import { BridgeOperations } from "./bridge-operations.js";
|
|
13
|
+
import { authenticatedCertificateChain } from "./native-control-auth.js";
|
|
14
|
+
import { PersistentPresence } from "./bridge-presence.js";
|
|
15
|
+
import { WakeDeliveries } from "./bridge-wake.js";
|
|
16
|
+
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
12
17
|
const MAX_CONNECTIONS = 128;
|
|
18
|
+
const LONG_ROUTES = new Set([
|
|
19
|
+
"POST /v1/start",
|
|
20
|
+
"POST /v1/spawn",
|
|
21
|
+
"POST /v1/resume",
|
|
22
|
+
"GET /v1/transcript",
|
|
23
|
+
"POST /v1/transcript",
|
|
24
|
+
]);
|
|
25
|
+
function validCertificateDates(starts, expires) {
|
|
26
|
+
return (Number.isFinite(starts) &&
|
|
27
|
+
Number.isFinite(expires) &&
|
|
28
|
+
starts <= Date.now() &&
|
|
29
|
+
expires > Date.now());
|
|
30
|
+
}
|
|
13
31
|
class Bridge {
|
|
14
32
|
config;
|
|
15
33
|
configPath;
|
|
16
34
|
sessions = new Map();
|
|
17
35
|
losses = new Map();
|
|
18
|
-
|
|
36
|
+
operations;
|
|
19
37
|
sockets = new Set();
|
|
20
38
|
connections = new Map();
|
|
21
39
|
server;
|
|
22
40
|
stopping = false;
|
|
23
41
|
handlers = new Set();
|
|
42
|
+
presence;
|
|
43
|
+
deliveries;
|
|
24
44
|
constructor(config, configPath) {
|
|
25
45
|
this.config = config;
|
|
26
46
|
this.configPath = configPath;
|
|
47
|
+
this.operations = new BridgeOperations(config.stateDir);
|
|
48
|
+
this.deliveries = new WakeDeliveries(config.stateDir, (identity) => this.allowed(identity));
|
|
49
|
+
if (config.stateDir !== undefined) {
|
|
50
|
+
this.presence = new PersistentPresence({
|
|
51
|
+
stateDir: config.stateDir,
|
|
52
|
+
clients: () => this.config.clients,
|
|
53
|
+
connect: async (identity, expires) => {
|
|
54
|
+
await spawnBrokerIfNeeded(process.execPath, []);
|
|
55
|
+
if (!this.allowed(identity)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const session = this.session(identity, expires);
|
|
59
|
+
const controller = session.begin();
|
|
60
|
+
try {
|
|
61
|
+
await session.connectedPeers(controller.signal);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
session.end(controller);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
27
69
|
this.server = createServer({
|
|
28
70
|
...config.tls,
|
|
29
71
|
minVersion: "TLSv1.2",
|
|
@@ -74,7 +116,9 @@ class Bridge {
|
|
|
74
116
|
}
|
|
75
117
|
allowed(identity) {
|
|
76
118
|
const current = this.config.clients.get(identity.fingerprint256);
|
|
77
|
-
return !this.stopping &&
|
|
119
|
+
return (!this.stopping &&
|
|
120
|
+
sameIdentity(identity, current) &&
|
|
121
|
+
JSON.stringify(identity.wake) === JSON.stringify(current?.wake));
|
|
78
122
|
}
|
|
79
123
|
lost(fp, count = 1) {
|
|
80
124
|
if (count > 0 && this.config.clients.has(fp)) {
|
|
@@ -82,9 +126,12 @@ class Bridge {
|
|
|
82
126
|
}
|
|
83
127
|
}
|
|
84
128
|
session(identity, expires) {
|
|
129
|
+
this.deliveries.session(identity, expires);
|
|
85
130
|
let session = this.sessions.get(identity.fingerprint256);
|
|
86
131
|
if (!session) {
|
|
87
132
|
session = new Session({
|
|
133
|
+
stateDir: this.config.stateDir,
|
|
134
|
+
notifications: identity.wake !== undefined,
|
|
88
135
|
allowed: (candidate) => this.allowed(candidate),
|
|
89
136
|
lost: (fp, count) => this.lost(fp, count),
|
|
90
137
|
losses: (fp) => this.losses.get(fp) ?? 0,
|
|
@@ -114,14 +161,19 @@ class Bridge {
|
|
|
114
161
|
if (!socket.authorized ||
|
|
115
162
|
!identity ||
|
|
116
163
|
this.stopping ||
|
|
117
|
-
!
|
|
118
|
-
!Number.isFinite(starts) ||
|
|
119
|
-
starts > Date.now() ||
|
|
120
|
-
expires <= Date.now()) {
|
|
164
|
+
!validCertificateDates(starts, expires)) {
|
|
121
165
|
log("connection_rejected", { fingerprint: fp, result: "unauthorized" });
|
|
122
166
|
socket.destroy();
|
|
123
167
|
return;
|
|
124
168
|
}
|
|
169
|
+
try {
|
|
170
|
+
this.presence?.authenticated(identity, expires);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
log("connection_rejected", { fingerprint: fp, result: "presence_storage_failed" });
|
|
174
|
+
socket.destroy();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
125
177
|
const expiry = () => {
|
|
126
178
|
if (expires <= Date.now()) {
|
|
127
179
|
this.drop(fp, new Fault(403, "certificate_expired", "Client certificate expired."));
|
|
@@ -143,6 +195,7 @@ class Bridge {
|
|
|
143
195
|
log("connected", { name: identity.name, fingerprint: fp, result: "authenticated" });
|
|
144
196
|
}
|
|
145
197
|
drop(fp, error) {
|
|
198
|
+
this.deliveries.drop(fp);
|
|
146
199
|
this.sessions.get(fp)?.close(error);
|
|
147
200
|
for (const [socket, connection] of this.connections) {
|
|
148
201
|
if (connection.fingerprint === fp) {
|
|
@@ -160,6 +213,7 @@ class Bridge {
|
|
|
160
213
|
port: typeof address === "object" && address ? address.port : this.config.port,
|
|
161
214
|
brokerSocket: getBrokerSocketPath(),
|
|
162
215
|
});
|
|
216
|
+
this.presence?.start();
|
|
163
217
|
});
|
|
164
218
|
process.on("SIGHUP", () => this.reload());
|
|
165
219
|
// Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
|
|
@@ -177,12 +231,13 @@ class Bridge {
|
|
|
177
231
|
}
|
|
178
232
|
this.config = next;
|
|
179
233
|
this.revokeRemoved();
|
|
180
|
-
this.
|
|
234
|
+
this.operations.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
|
|
181
235
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
182
236
|
}
|
|
183
237
|
catch {
|
|
184
238
|
this.config = { ...this.config, clients: new Map() };
|
|
185
|
-
this.
|
|
239
|
+
this.deliveries.reload();
|
|
240
|
+
this.operations
|
|
186
241
|
.revoke(this.config.clients)
|
|
187
242
|
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
188
243
|
for (const session of this.sessions.values()) {
|
|
@@ -196,6 +251,7 @@ class Bridge {
|
|
|
196
251
|
}
|
|
197
252
|
}
|
|
198
253
|
revokeRemoved() {
|
|
254
|
+
this.deliveries.reload();
|
|
199
255
|
for (const [fp, session] of this.sessions) {
|
|
200
256
|
if (!this.allowed(session.identity)) {
|
|
201
257
|
this.drop(fp, new Fault(403, "revoked", "Identity revoked or changed by configuration reload."));
|
|
@@ -234,7 +290,9 @@ class Bridge {
|
|
|
234
290
|
await Promise.all([
|
|
235
291
|
...this.handlers,
|
|
236
292
|
...sessions.map((session) => session.settled()),
|
|
237
|
-
this.
|
|
293
|
+
this.operations.shutdown(),
|
|
294
|
+
this.presence?.stop(),
|
|
295
|
+
this.deliveries.stop(),
|
|
238
296
|
]);
|
|
239
297
|
log("shutdown", { result: "closed" });
|
|
240
298
|
}
|
|
@@ -254,7 +312,11 @@ class Bridge {
|
|
|
254
312
|
this.drop(connection.fingerprint, fault);
|
|
255
313
|
throw fault;
|
|
256
314
|
}
|
|
257
|
-
return {
|
|
315
|
+
return {
|
|
316
|
+
identity,
|
|
317
|
+
expires: connection.expires,
|
|
318
|
+
certificate: authenticatedCertificateChain(socket),
|
|
319
|
+
};
|
|
258
320
|
}
|
|
259
321
|
async handle(req, res) {
|
|
260
322
|
const requestId = randomUUID();
|
|
@@ -274,7 +336,7 @@ class Bridge {
|
|
|
274
336
|
deadline = setTimeout(() => session?.close(new Fault(504, "request_timeout", "HTTP operation deadline exceeded; delivery may be unknown.")), Math.max(1, ms - (Date.now() - started)));
|
|
275
337
|
};
|
|
276
338
|
try {
|
|
277
|
-
const { identity, expires } = this.admission(req);
|
|
339
|
+
const { identity, expires, certificate } = this.admission(req);
|
|
278
340
|
const route = routeFor(req);
|
|
279
341
|
session = this.session(identity, expires);
|
|
280
342
|
controller = session.begin();
|
|
@@ -282,15 +344,19 @@ class Bridge {
|
|
|
282
344
|
req.once("aborted", abort);
|
|
283
345
|
res.once("close", onClose);
|
|
284
346
|
const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
|
|
285
|
-
setDeadline(route
|
|
347
|
+
setDeadline(LONG_ROUTES.has(route) ? 75000 : duration);
|
|
286
348
|
const operation = await operationFor(req, route, signal);
|
|
287
349
|
if (operation.route === "POST /v1/ask") {
|
|
288
350
|
setDeadline(operation.timeoutMs + 15000);
|
|
289
351
|
}
|
|
290
|
-
|
|
352
|
+
else if (operation.route === "GET /v1/transcript") {
|
|
353
|
+
setDeadline(75000);
|
|
354
|
+
}
|
|
355
|
+
const data = await this.operations.execute(operation, session, {
|
|
291
356
|
signal,
|
|
292
357
|
requestId,
|
|
293
358
|
expires,
|
|
359
|
+
certificate,
|
|
294
360
|
onPeer: (id) => {
|
|
295
361
|
peer = id;
|
|
296
362
|
},
|
|
@@ -304,10 +370,7 @@ class Bridge {
|
|
|
304
370
|
? error
|
|
305
371
|
: new Fault(503, "broker_unavailable", "Local broker operation failed.");
|
|
306
372
|
log("request", { ...session?.fields(), requestId, peer, result: fault.code });
|
|
307
|
-
|
|
308
|
-
ok: false,
|
|
309
|
-
error: { code: fault.code, message: fault.message, ...fault.details },
|
|
310
|
-
});
|
|
373
|
+
replyFault(res, requestId, fault);
|
|
311
374
|
}
|
|
312
375
|
finally {
|
|
313
376
|
clearTimeout(deadline);
|
|
@@ -318,34 +381,11 @@ class Bridge {
|
|
|
318
381
|
}
|
|
319
382
|
}
|
|
320
383
|
}
|
|
321
|
-
async execute(operation, session, context) {
|
|
322
|
-
switch (operation.route) {
|
|
323
|
-
case "POST /v1/start":
|
|
324
|
-
return this.launcher.start(operation.name, {
|
|
325
|
-
identity: session.identity,
|
|
326
|
-
expires: context.expires,
|
|
327
|
-
signal: context.signal,
|
|
328
|
-
peers: (signal) => session.connectedPeers(signal),
|
|
329
|
-
});
|
|
330
|
-
case "GET /v1/sessions":
|
|
331
|
-
return this.launcher.list(session.identity);
|
|
332
|
-
case "POST /v1/stop":
|
|
333
|
-
return this.launcher.stop(session.identity, operation.sessionId);
|
|
334
|
-
case "GET /v1/list":
|
|
335
|
-
case "GET /v1/inbox":
|
|
336
|
-
case "POST /v1/register":
|
|
337
|
-
case "POST /v1/ask":
|
|
338
|
-
case "POST /v1/send":
|
|
339
|
-
case "POST /v1/reply":
|
|
340
|
-
case "POST /v1/ack":
|
|
341
|
-
return session.execute(operation, context);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
384
|
}
|
|
345
385
|
function main() {
|
|
346
386
|
const args = process.argv.slice(2);
|
|
347
387
|
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
|
|
388
|
+
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
389
|
return;
|
|
350
390
|
}
|
|
351
391
|
if (args.length !== 2 || args[0] !== "--config" || args[1] === "") {
|
|
@@ -358,7 +398,7 @@ function main() {
|
|
|
358
398
|
new Bridge(loadConfig(path), path).start();
|
|
359
399
|
}
|
|
360
400
|
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");
|
|
401
|
+
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
402
|
process.exitCode = 1;
|
|
363
403
|
}
|
|
364
404
|
}
|
|
@@ -320,6 +320,7 @@ export class IntercomClient extends EventEmitter {
|
|
|
320
320
|
expectsReply: options.expectsReply,
|
|
321
321
|
...(options.human ? { human: options.human } : {}),
|
|
322
322
|
...(options.topic ? { topic: options.topic } : {}),
|
|
323
|
+
...(options.control ? { control: options.control } : {}),
|
|
323
324
|
delivery: messageDelivery(options),
|
|
324
325
|
queueMode: options.queueMode,
|
|
325
326
|
threadId: options.threadId,
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { IntercomClient } from "./broker/client.js";
|
|
3
2
|
import { spawnBrokerIfNeeded } from "./broker/spawn.js";
|
|
4
3
|
import { asError, errorMessage } from "./validation.js";
|
|
5
|
-
import { formatTargetOptions, resolveSessionTarget } from "./session-targets.js";
|
|
4
|
+
import { formatTargetOptions, resolveSessionTarget, nativePeerId } from "./session-targets.js";
|
|
6
5
|
/** Owns socket registration, coalesced connects and the reconnect/startup timers. */
|
|
7
6
|
export class IntercomConnection {
|
|
8
7
|
client = null;
|
|
@@ -195,7 +194,7 @@ export class IntercomConnection {
|
|
|
195
194
|
if (reason !== "peer-awareness") {
|
|
196
195
|
await spawnBrokerIfNeeded(this.config.brokerCommand, this.config.brokerArgs);
|
|
197
196
|
}
|
|
198
|
-
await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()),
|
|
197
|
+
await next.connect(await this.lifecycle.registration(this.hooks.pendingAsks()), nativePeerId(sessionId));
|
|
199
198
|
if (!this.lifecycle.live(ctx, generation)) {
|
|
200
199
|
await next.disconnect();
|
|
201
200
|
throw new Error("Intercom runtime no longer active");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { requestedDelivery, isBlockingSupervisorMessage } from "./inbound-record.js";
|
|
3
2
|
import { formatAttachments } from "./message-format.js";
|
|
4
3
|
import { readQuestionOwner } from "../runs/shared/supervisor-questions.js";
|
|
4
|
+
import { nativePeerId } from "./session-targets.js";
|
|
5
5
|
/** Validates origin and chooses admission; state/resources remain in their owners. */
|
|
6
6
|
export class IntercomInbound {
|
|
7
7
|
lifecycle;
|
|
@@ -19,9 +19,7 @@ export class IntercomInbound {
|
|
|
19
19
|
return false;
|
|
20
20
|
}
|
|
21
21
|
const owner = readQuestionOwner(origin.runId);
|
|
22
|
-
return (owner?.sessionId === origin.ownerSessionId &&
|
|
23
|
-
from.id ===
|
|
24
|
-
`pi-${createHash("sha256").update(origin.ownerSessionId).digest("hex").slice(0, 32)}`);
|
|
22
|
+
return (owner?.sessionId === origin.ownerSessionId && from.id === nativePeerId(origin.ownerSessionId));
|
|
25
23
|
}
|
|
26
24
|
normalize(from, input) {
|
|
27
25
|
const message = input.topic
|
|
@@ -8,6 +8,7 @@ import { InboundDeliveryOwner } from "./inbound-delivery.js";
|
|
|
8
8
|
import { ReplyWait } from "./reply-wait.js";
|
|
9
9
|
import { IntercomConnection } from "./connection.js";
|
|
10
10
|
import { IntercomInbound } from "./inbound.js";
|
|
11
|
+
import { NativeControls } from "./native-control.js";
|
|
11
12
|
import { IntercomOutbound } from "./outbound.js";
|
|
12
13
|
import { IntercomInspection } from "./inspection.js";
|
|
13
14
|
import { IntercomTopicActions } from "./topic-actions.js";
|
|
@@ -31,7 +32,11 @@ export default function piIntercomExtension(pi) {
|
|
|
31
32
|
const connection = new IntercomConnection(lifecycle, config, topics, {
|
|
32
33
|
restoring: () => delivery.restoring,
|
|
33
34
|
pendingAsks: () => replies.listPending().length,
|
|
34
|
-
incoming: (ctx, from, message) =>
|
|
35
|
+
incoming: async (ctx, from, message) => {
|
|
36
|
+
if (!(await controls.receive(ctx, from, message))) {
|
|
37
|
+
await inbound.receive(ctx, from, message);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
35
40
|
peerLeft: (id) => {
|
|
36
41
|
wait.peerDisconnected(id);
|
|
37
42
|
journal.peerLeft(id);
|
|
@@ -42,6 +47,7 @@ export default function piIntercomExtension(pi) {
|
|
|
42
47
|
}
|
|
43
48
|
},
|
|
44
49
|
});
|
|
50
|
+
const controls = new NativeControls(lifecycle, connection);
|
|
45
51
|
const delivery = new InboundDeliveryOwner(pi, lifecycle, journal, {
|
|
46
52
|
replies,
|
|
47
53
|
syncStatus: () => connection.syncStatus(),
|
|
@@ -0,0 +1,80 @@
|
|
|
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 { loadControlPolicy } from "./bridge-config.js";
|
|
6
|
+
import { getPiAgentDir } from "./agent-dir.js";
|
|
7
|
+
import { fingerprint } from "./bridge-protocol.js";
|
|
8
|
+
export function authenticatedCertificateChain(socket) {
|
|
9
|
+
const blocks = [];
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
let cert = socket.getPeerCertificate(true);
|
|
12
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
13
|
+
const parsed = new X509Certificate(cert.raw);
|
|
14
|
+
if (seen.has(parsed.fingerprint256)) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
seen.add(parsed.fingerprint256);
|
|
18
|
+
blocks.push(parsed.toString());
|
|
19
|
+
cert = cert.issuerCertificate;
|
|
20
|
+
}
|
|
21
|
+
return blocks.join("\n");
|
|
22
|
+
}
|
|
23
|
+
function certificates(pem) {
|
|
24
|
+
const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g);
|
|
25
|
+
if (!blocks || blocks.length > 8) {
|
|
26
|
+
throw new Error("Invalid bounded certificate chain.");
|
|
27
|
+
}
|
|
28
|
+
return blocks.map((block) => new X509Certificate(block));
|
|
29
|
+
}
|
|
30
|
+
function validNow(cert) {
|
|
31
|
+
return Date.parse(cert.validFrom) <= Date.now() && Date.parse(cert.validTo) > Date.now();
|
|
32
|
+
}
|
|
33
|
+
function trustedChain(chain, roots) {
|
|
34
|
+
let current = chain.at(0);
|
|
35
|
+
if (current === undefined || current.ca || !validNow(current)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
39
|
+
const subject = current;
|
|
40
|
+
const issuer = [...roots, ...chain.slice(1)].find((candidate) => candidate.ca &&
|
|
41
|
+
validNow(candidate) &&
|
|
42
|
+
subject.checkIssued(candidate) &&
|
|
43
|
+
subject.verify(candidate.publicKey));
|
|
44
|
+
if (!issuer) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (roots.some((root) => root.fingerprint256 === issuer.fingerprint256)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
current = issuer;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
function controlPolicyPath() {
|
|
55
|
+
const configured = process.env.PI_INTERCOM_BRIDGE_CONFIG;
|
|
56
|
+
if (configured !== undefined) {
|
|
57
|
+
return configured;
|
|
58
|
+
}
|
|
59
|
+
const standard = join(getPiAgentDir(), "intercom", "bridge.json");
|
|
60
|
+
return existsSync(standard) ? standard : join(homedir(), ".pi", "intercom-bridge", "config.json");
|
|
61
|
+
}
|
|
62
|
+
/** Unix broker peers are same-user trusted. Names/payload claims grant no authority. */
|
|
63
|
+
export function authorizedControlPeer(peerId, certificate) {
|
|
64
|
+
try {
|
|
65
|
+
const configPath = controlPolicyPath();
|
|
66
|
+
const config = loadControlPolicy(configPath);
|
|
67
|
+
const chain = certificates(certificate);
|
|
68
|
+
const client = chain.at(0);
|
|
69
|
+
if (client === undefined) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const fp = fingerprint(client.fingerprint256);
|
|
73
|
+
return (peerId === `remote-${fp.toLowerCase()}` &&
|
|
74
|
+
config.clients.has(fp) &&
|
|
75
|
+
trustedChain(chain, certificates(config.ca.toString("utf8"))));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { isRecord, isUnknownArray } from "../shared/unknown.js";
|
|
2
|
+
export const CONTROL_SCHEMA = "pi-intercom/control-v1";
|
|
3
|
+
export const CONTROL_FALLBACK = "Native Intercom metadata exchange (no conversation response needed).";
|
|
4
|
+
export function isControlVerb(value) {
|
|
5
|
+
return (value === "capabilities" || value === "cancel" || value === "stop" || value === "transcript");
|
|
6
|
+
}
|
|
7
|
+
export function isControlRequest(value) {
|
|
8
|
+
return (isRecord(value) &&
|
|
9
|
+
value.schema === CONTROL_SCHEMA &&
|
|
10
|
+
value.kind === "request" &&
|
|
11
|
+
isControlVerb(value.verb) &&
|
|
12
|
+
typeof value.certificate === "string" &&
|
|
13
|
+
value.certificate.length <= 16384 &&
|
|
14
|
+
Object.keys(value).every((key) => ["schema", "kind", "verb", "certificate", "options"].includes(key)));
|
|
15
|
+
}
|
|
16
|
+
export function isControlResult(value) {
|
|
17
|
+
if (!isRecord(value) ||
|
|
18
|
+
value.schema !== CONTROL_SCHEMA ||
|
|
19
|
+
value.kind !== "result" ||
|
|
20
|
+
!isControlVerb(value.verb)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return (validResultFields(value) &&
|
|
24
|
+
validResultPayload(value) &&
|
|
25
|
+
Buffer.byteLength(JSON.stringify(value)) <= 1024 * 1024);
|
|
26
|
+
}
|
|
27
|
+
function optionalText(value, max) {
|
|
28
|
+
return value === undefined || (typeof value === "string" && value.length <= max);
|
|
29
|
+
}
|
|
30
|
+
function validResultFields(value) {
|
|
31
|
+
const fields = new Set([
|
|
32
|
+
"schema",
|
|
33
|
+
"kind",
|
|
34
|
+
"verb",
|
|
35
|
+
"status",
|
|
36
|
+
"mode",
|
|
37
|
+
"idle",
|
|
38
|
+
"pendingInput",
|
|
39
|
+
"queueCleared",
|
|
40
|
+
"processExited",
|
|
41
|
+
"stopSupport",
|
|
42
|
+
"reason",
|
|
43
|
+
"transcript",
|
|
44
|
+
"verbs",
|
|
45
|
+
]);
|
|
46
|
+
return (Object.keys(value).every((key) => fields.has(key)) &&
|
|
47
|
+
typeof value.status === "string" &&
|
|
48
|
+
["ok", "denied", "cancel_requested", "shutdown_requested", "unsupported", "failed"].includes(value.status) &&
|
|
49
|
+
optionalText(value.reason, 2048) &&
|
|
50
|
+
optionalText(value.mode, 64) &&
|
|
51
|
+
[value.idle, value.pendingInput].every((flag) => flag === undefined || typeof flag === "boolean") &&
|
|
52
|
+
[value.queueCleared, value.processExited].every((flag) => flag === undefined || flag === false));
|
|
53
|
+
}
|
|
54
|
+
function validResultPayload(value) {
|
|
55
|
+
if (value.stopSupport !== undefined &&
|
|
56
|
+
(typeof value.stopSupport !== "string" ||
|
|
57
|
+
!["native_shutdown", "requires_activity", "host_dependent"].includes(value.stopSupport))) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (value.verbs !== undefined &&
|
|
61
|
+
(!isUnknownArray(value.verbs) || !value.verbs.every(isControlVerb))) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return value.transcript === undefined || validTranscript(value.transcript);
|
|
65
|
+
}
|
|
66
|
+
function validTranscript(value) {
|
|
67
|
+
if (!isRecord(value) || !isUnknownArray(value.records) || value.records.length > 100) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const fields = new Set([
|
|
71
|
+
"sessionId",
|
|
72
|
+
"leafId",
|
|
73
|
+
"records",
|
|
74
|
+
"bytes",
|
|
75
|
+
"truncated",
|
|
76
|
+
"cursor",
|
|
77
|
+
"scanLimitReached",
|
|
78
|
+
"finalizedOnly",
|
|
79
|
+
"streaming",
|
|
80
|
+
]);
|
|
81
|
+
return (Object.keys(value).every((key) => fields.has(key)) &&
|
|
82
|
+
typeof value.sessionId === "string" &&
|
|
83
|
+
value.sessionId.length <= 512 &&
|
|
84
|
+
(value.leafId === null || typeof value.leafId === "string") &&
|
|
85
|
+
value.records.every(validTranscriptRecord) &&
|
|
86
|
+
validTranscriptMetadata(value));
|
|
87
|
+
}
|
|
88
|
+
function validTranscriptMetadata(value) {
|
|
89
|
+
return (typeof value.bytes === "number" &&
|
|
90
|
+
Number.isInteger(value.bytes) &&
|
|
91
|
+
value.bytes >= 0 &&
|
|
92
|
+
value.bytes <= 131072 &&
|
|
93
|
+
typeof value.truncated === "boolean" &&
|
|
94
|
+
optionalText(value.cursor, 512) &&
|
|
95
|
+
typeof value.scanLimitReached === "boolean" &&
|
|
96
|
+
value.finalizedOnly === true &&
|
|
97
|
+
value.streaming === "unavailable");
|
|
98
|
+
}
|
|
99
|
+
function validTranscriptRecord(value) {
|
|
100
|
+
if (!isRecord(value)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const fields = new Set(["id", "timestamp", "role", "text", "truncated"]);
|
|
104
|
+
return (Object.keys(value).every((key) => fields.has(key)) &&
|
|
105
|
+
[value.id, value.timestamp].every((field) => typeof field === "string" && field.length <= 512) &&
|
|
106
|
+
typeof value.role === "string" &&
|
|
107
|
+
["user", "assistant", "toolResult", "custom"].includes(value.role) &&
|
|
108
|
+
typeof value.text === "string" &&
|
|
109
|
+
Buffer.byteLength(value.text) <= 131072 &&
|
|
110
|
+
typeof value.truncated === "boolean");
|
|
111
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { authorizedControlPeer } from "./native-control-auth.js";
|
|
2
|
+
import { CONTROL_FALLBACK, CONTROL_SCHEMA, isControlRequest, } from "./native-control-protocol.js";
|
|
3
|
+
import { readLiveTranscript, transcriptOptions } from "./transcript.js";
|
|
4
|
+
export class NativeControls {
|
|
5
|
+
lifecycle;
|
|
6
|
+
connection;
|
|
7
|
+
constructor(lifecycle, connection) {
|
|
8
|
+
this.lifecycle = lifecycle;
|
|
9
|
+
this.connection = connection;
|
|
10
|
+
}
|
|
11
|
+
result(ctx, request) {
|
|
12
|
+
const base = {
|
|
13
|
+
schema: CONTROL_SCHEMA,
|
|
14
|
+
kind: "result",
|
|
15
|
+
verb: request.verb,
|
|
16
|
+
mode: ctx.mode,
|
|
17
|
+
idle: ctx.isIdle(),
|
|
18
|
+
pendingInput: ctx.hasPendingMessages(),
|
|
19
|
+
queueCleared: false,
|
|
20
|
+
processExited: false,
|
|
21
|
+
};
|
|
22
|
+
if (request.verb !== "transcript" && request.options !== undefined) {
|
|
23
|
+
throw new Error("Only transcript accepts options.");
|
|
24
|
+
}
|
|
25
|
+
const stopSupport = stopCapability(ctx);
|
|
26
|
+
switch (request.verb) {
|
|
27
|
+
case "capabilities":
|
|
28
|
+
return {
|
|
29
|
+
...base,
|
|
30
|
+
status: "ok",
|
|
31
|
+
stopSupport,
|
|
32
|
+
verbs: ["capabilities", "cancel", "stop", "transcript"],
|
|
33
|
+
};
|
|
34
|
+
case "transcript":
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
status: "ok",
|
|
38
|
+
transcript: readLiveTranscript(ctx.sessionManager, transcriptOptions(request.options)),
|
|
39
|
+
};
|
|
40
|
+
case "cancel":
|
|
41
|
+
return {
|
|
42
|
+
...base,
|
|
43
|
+
status: "cancel_requested",
|
|
44
|
+
reason: "Native current-operation abort; accepted queued input is not cleared.",
|
|
45
|
+
};
|
|
46
|
+
case "stop":
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
status: ctx.mode === "print" || ctx.mode === "json" ? "unsupported" : "shutdown_requested",
|
|
50
|
+
stopSupport,
|
|
51
|
+
reason: "Native shutdown/current-operation abort requested, not process exit. Idle RPC is deferred until a native command/settlement; print/JSON/SDK have no guaranteed shutdown binding.",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async receive(ctx, from, message) {
|
|
56
|
+
if (!Object.hasOwn(message, "control")) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Even malformed/response envelopes never enter conversation journaling or inference.
|
|
60
|
+
const request = message.control;
|
|
61
|
+
if (!isControlRequest(request) ||
|
|
62
|
+
message.replyTo !== undefined ||
|
|
63
|
+
message.delivery !== "passive" ||
|
|
64
|
+
message.expectsReply === true) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
const generation = this.lifecycle.generation;
|
|
68
|
+
const active = this.connection.active;
|
|
69
|
+
if (!active || !this.lifecycle.live(ctx, generation)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const result = this.admit(ctx, from.id, request);
|
|
73
|
+
// Receipt is acknowledged before shutdown can retire this socket/context.
|
|
74
|
+
await active.send(from.id, {
|
|
75
|
+
text: CONTROL_FALLBACK,
|
|
76
|
+
delivery: "passive",
|
|
77
|
+
replyTo: message.id,
|
|
78
|
+
control: result,
|
|
79
|
+
});
|
|
80
|
+
this.apply(ctx, generation, { active, peer: from.id, request, result });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
apply(ctx, generation, state) {
|
|
84
|
+
const { active, peer, request, result } = state;
|
|
85
|
+
if (!this.lifecycle.live(ctx, generation) ||
|
|
86
|
+
this.connection.active !== active ||
|
|
87
|
+
!authorizedControlPeer(peer, request.certificate)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (result.status === "shutdown_requested" ||
|
|
91
|
+
(result.status === "unsupported" && request.verb === "stop")) {
|
|
92
|
+
ctx.shutdown();
|
|
93
|
+
if (this.lifecycle.live(ctx, generation)) {
|
|
94
|
+
ctx.abort();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (result.status === "cancel_requested") {
|
|
98
|
+
ctx.abort();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
admit(ctx, peer, request) {
|
|
102
|
+
if (!authorizedControlPeer(peer, request.certificate)) {
|
|
103
|
+
return {
|
|
104
|
+
schema: CONTROL_SCHEMA,
|
|
105
|
+
kind: "result",
|
|
106
|
+
verb: request.verb,
|
|
107
|
+
status: "denied",
|
|
108
|
+
reason: "Sender certificate is not currently authorized by private bridge configuration.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return this.result(ctx, request);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return {
|
|
116
|
+
schema: CONTROL_SCHEMA,
|
|
117
|
+
kind: "result",
|
|
118
|
+
verb: request.verb,
|
|
119
|
+
status: "failed",
|
|
120
|
+
reason: "Native control input/state is unavailable.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function stopCapability(ctx) {
|
|
126
|
+
if (ctx.mode === "tui") {
|
|
127
|
+
return "native_shutdown";
|
|
128
|
+
}
|
|
129
|
+
return ctx.mode === "rpc" ? "requires_activity" : "host_dependent";
|
|
130
|
+
}
|