@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
@@ -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 { Launcher, sameIdentity } from "./bridge-launch.js";
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
- launcher = new Launcher();
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 && sameIdentity(identity, current);
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
- !Number.isFinite(expires) ||
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.launcher.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
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.launcher
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.launcher.shutdown(),
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 { identity, expires: connection.expires };
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 === "POST /v1/start" ? 75000 : duration);
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
- const data = await this.execute(operation, session, {
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
- reply(res, requestId, fault.status, {
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. 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");
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()), `pi-${createHash("sha256").update(sessionId).digest("hex").slice(0, 32)}`);
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) => inbound.receive(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
+ }