@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/dist/extension/build-info.js +1 -1
  4. package/dist/pi-intercom/bridge-broadcast.js +60 -0
  5. package/dist/pi-intercom/bridge-catalog.js +372 -0
  6. package/dist/pi-intercom/bridge-config.js +47 -26
  7. package/dist/pi-intercom/bridge-control.js +104 -0
  8. package/dist/pi-intercom/bridge-files.js +47 -0
  9. package/dist/pi-intercom/bridge-groups.js +135 -0
  10. package/dist/pi-intercom/bridge-http.js +8 -1
  11. package/dist/pi-intercom/bridge-launch-journal.js +84 -0
  12. package/dist/pi-intercom/bridge-launch-policy.js +71 -5
  13. package/dist/pi-intercom/bridge-launch.js +186 -76
  14. package/dist/pi-intercom/bridge-mailbox.js +324 -0
  15. package/dist/pi-intercom/bridge-operations.js +127 -0
  16. package/dist/pi-intercom/bridge-presence.js +100 -0
  17. package/dist/pi-intercom/bridge-protocol.js +7 -0
  18. package/dist/pi-intercom/bridge-routes.js +190 -17
  19. package/dist/pi-intercom/bridge-rpc-process.js +43 -15
  20. package/dist/pi-intercom/bridge-session.js +83 -85
  21. package/dist/pi-intercom/bridge-target.js +22 -0
  22. package/dist/pi-intercom/bridge-wake.js +158 -0
  23. package/dist/pi-intercom/bridge.js +84 -44
  24. package/dist/pi-intercom/broker/client.js +1 -0
  25. package/dist/pi-intercom/connection.js +2 -3
  26. package/dist/pi-intercom/inbound.js +2 -4
  27. package/dist/pi-intercom/index.js +7 -1
  28. package/dist/pi-intercom/native-control-auth.js +80 -0
  29. package/dist/pi-intercom/native-control-protocol.js +111 -0
  30. package/dist/pi-intercom/native-control.js +130 -0
  31. package/dist/pi-intercom/saved-transcript.js +135 -0
  32. package/dist/pi-intercom/session-targets.js +3 -0
  33. package/dist/pi-intercom/transcript.js +175 -0
  34. package/docs/intercom-bridge.md +293 -161
  35. package/docs/intercom.md +1 -1
  36. package/docs/reference.md +1 -1
  37. package/package.json +1 -1
  38. package/scripts/intercom-bridge-credentials.mjs +54 -12
  39. package/scripts/intercom-remote.mjs +196 -31
  40. package/scripts/package-smoke.mjs +1 -1
  41. package/src/pi-intercom/bridge-broadcast.ts +70 -0
  42. package/src/pi-intercom/bridge-catalog.ts +489 -0
  43. package/src/pi-intercom/bridge-config.ts +64 -37
  44. package/src/pi-intercom/bridge-control.ts +179 -0
  45. package/src/pi-intercom/bridge-files.ts +59 -0
  46. package/src/pi-intercom/bridge-groups.ts +169 -0
  47. package/src/pi-intercom/bridge-http.ts +15 -1
  48. package/src/pi-intercom/bridge-launch-journal.ts +122 -0
  49. package/src/pi-intercom/bridge-launch-policy.ts +88 -7
  50. package/src/pi-intercom/bridge-launch.ts +296 -107
  51. package/src/pi-intercom/bridge-mailbox.ts +395 -0
  52. package/src/pi-intercom/bridge-operations.ts +169 -0
  53. package/src/pi-intercom/bridge-presence.ts +128 -0
  54. package/src/pi-intercom/bridge-protocol.ts +8 -0
  55. package/src/pi-intercom/bridge-routes.ts +281 -32
  56. package/src/pi-intercom/bridge-rpc-process.ts +53 -16
  57. package/src/pi-intercom/bridge-session.ts +106 -105
  58. package/src/pi-intercom/bridge-target.ts +35 -0
  59. package/src/pi-intercom/bridge-wake.ts +188 -0
  60. package/src/pi-intercom/bridge.ts +88 -54
  61. package/src/pi-intercom/broker/client.ts +3 -0
  62. package/src/pi-intercom/connection.ts +2 -3
  63. package/src/pi-intercom/inbound.ts +2 -4
  64. package/src/pi-intercom/index.ts +7 -1
  65. package/src/pi-intercom/native-control-auth.ts +89 -0
  66. package/src/pi-intercom/native-control-protocol.ts +180 -0
  67. package/src/pi-intercom/native-control.ts +160 -0
  68. package/src/pi-intercom/saved-transcript.ts +175 -0
  69. package/src/pi-intercom/session-targets.ts +4 -0
  70. package/src/pi-intercom/transcript.ts +226 -0
  71. 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, type Operation } from "./bridge-routes.ts";
12
- import { reply } from "./bridge-http.ts";
13
- import { Launcher, sameIdentity } from "./bridge-launch.ts";
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 launcher = new Launcher();
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 !this.stopping && sameIdentity(identity, current);
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
- !Number.isFinite(expires) ||
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.launcher.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
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.launcher
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.launcher.shutdown(),
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 { identity, expires: connection.expires };
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 === "POST /v1/start" ? 75000 : duration);
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
- reply(res, requestId, fault.status, {
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. Never starts the 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",
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
- `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`,
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 {
@@ -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) => inbound.receive(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
+ }