@fitchmultz/pi-subagents 0.46.2 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,9 @@ Optional HTTPS messaging proxy and locally controlled Pi-session launcher for a
10
10
 
11
11
  **Auth/authority:** TLS 1.2+ with client certificates issued by a private CA **and** an explicit SHA-256 certificate fingerprint allowlist. Each allowlisted certificate fixes the peer name and Mac project directory; remote payloads cannot change identity, cwd, socket, shell commands, or presence. Certificates last seven days. TLS verifies the server CA and loopback SAN; never use `curl -k` or disable certificate checks. The proxy exposes register/list/send/ask/reply, inbox/ack receipt plumbing, and optional name-only start plus own-launched sessions/stop operations. No broker admin, topic operations, file reads, or shell endpoint.
12
12
 
13
- **Socket discovery:** reuse the actual `IntercomClient`, `getBrokerSocketPath()` and framing code. With the same Mac uid, `PI_CODING_AGENT_DIR` (default `~/.pi/agent`) and `TMPDIR` as Pi, the preferred path is `$TMPDIR/pi-intercom-<sha256(uid:agentDir)[0:16]>/broker.sock`; long paths fall back to `/tmp`. The client's existing owned-socket legacy fallback is retained. No hardcoded `/var/folders` path, chmod, broker spawn, PID-file operation, or broker restart. An unavailable broker returns an error; start a normal Pi session to bring it up.
13
+ **Socket discovery:** reuse the actual `IntercomClient`, `getBrokerSocketPath()` and framing code. With the same Mac uid, `PI_CODING_AGENT_DIR` (default `~/.pi/agent`) and `TMPDIR` as Pi, the preferred path is `$TMPDIR/pi-intercom-<sha256(uid:agentDir)[0:16]>/broker.sock`; long paths fall back to `/tmp`. The client's existing owned-socket legacy fallback is retained. No hardcoded `/var/folders` path, socket chmod, or broker restart. Without `stateDir`, an unavailable broker returns an error; start a normal Pi session to bring it up. Durable presence uses Intercom's safe acquisition to start a detached broker only when none is running, including before any Pi session starts after reboot. Acquisition maintains the normal PID-file/spawn-lock bookkeeping, reuses a healthy broker and refuses to replace a live unhealthy broker that may own accepted work.
14
14
 
15
- **Delivery/lifecycle:** one broker peer per authorized certificate, registered lazily on the first API call, with a five-minute inactivity lease. Poll inbox to maintain presence. Send returns broker acceptance, not proof a model consumed it. Ask steers the recipient and waits at most two minutes for both the exact recipient ID and exact `replyTo`. One outgoing ask per identity. Revocation/expiry/shutdown/client disconnect terminate waits. Inbox is bounded and volatile; overflow is reported, not silently described as successful receipt. Restarting the helper loses inbox contents and pending replies. Upgrade to a durable inbox journal if restart recovery is required; existing Pi recipients still save their own messages normally.
15
+ **Delivery/lifecycle:** one broker peer per authorized certificate. Without `stateDir`, registration is lazy and has a five-minute inactivity lease; poll inbox to maintain presence. With `stateDir`, an authenticated identity has durable presence restored after helper restart and refreshed without remote polling until expiry, revocation or helper shutdown. Send returns broker acceptance, not proof a model consumed it. Ask steers the recipient and waits at most two minutes for both the exact recipient ID and exact `replyTo`. One outgoing ask per identity. Revocation/expiry/shutdown/client disconnect terminate waits. With a local `stateDir`, inbox intake is committed to private SQLite storage before publication or satisfying an outgoing ask. Unacked messages and unanswered inbound asks survive helper restart, idle-lease retirement and broker outage. Inbox reads are non-destructive, bounded cursor pages; ack deletes only ordinary inbox payloads, and successful reply retires an inbound ask. Sender departure does not discard a reply obligation: reply still targets the saved exact sender ID and returns `peer_offline` without deletion while that ID is offline. Without `stateDir`, the legacy 256-message/4 MiB volatile inbox remains; overflow and lease/shutdown loss are reported. Outgoing HTTP ask waits themselves are not resumed after helper restart; persistent mode retains received answers in inbox for recovery and ack.
16
16
 
17
17
  **Audit:** JSONL metadata on stdout: request/connection identity, certificate fingerprint, operation, peer/message IDs and outcome. `session_closed` confirms broker disconnection and removal of the retiring session; cancellation still rejects requests immediately. Message/attachment bodies and keys are not logged. Protect logs (0700 directory, 0600 files); rotate them with your normal log tooling. Never publish private config/keys or raw inbox/list output as smoke evidence.
18
18
 
@@ -45,6 +45,24 @@ The last JSON line contains paths to the issued client `cert`/`key`, trusted `ca
45
45
  }
46
46
  ```
47
47
 
48
+ ### Enable durable mailboxes
49
+
50
+ Create a dedicated owned **0700** directory and add its absolute path as top-level `stateDir` in the private configuration:
51
+
52
+ ```bash
53
+ mkdir -m 700 "$BRIDGE/mailboxes"
54
+ ```
55
+
56
+ ```json
57
+ { "stateDir": "/Users/mitch/.pi/intercom-bridge/mailboxes" }
58
+ ```
59
+
60
+ This opt-in does not enable Pi-session launches or require a Pi SDK. It does enable unattended broker acquisition for durable presence: after reboot or broker exit, the helper can start a broker without a running Pi session. It never restarts or replaces a live broker. Each certificate fingerprint owns a separate **0600** SQLite database. Directory symlinks, database/SQLite-sidecar symlinks, wrong ownership/modes and hard-linked files are rejected. SQLite uses full synchronous commits and a bounded cache; no mailbox-wide hydration is needed. Protect the directory and any backups: databases contain private message bodies, though audit logs do not. `stateDir` changes require helper restart, not SIGHUP.
61
+
62
+ There is no automatic retention limit on unacked messages or unanswered asks. Pages contain at most 256 messages and 4 MiB of serialized envelopes (plus small response metadata); read additional pages with `nextCursor` while `hasMore` is true. Message IDs remain as payload-free tombstones after completion to suppress duplicate/replayed IDs, including after restart. Provision and monitor disk space. Failed disk admission sets durable `overflow`, `lostMessages` and `storageError` when the loss ledger can still be committed; if that also fails, the helper reports `mailbox_storage_failed` (HTTP 507), closes the affected broker peer and logs metadata with `lossRecorded:false`. A failing disk cannot guarantee durable loss bookkeeping. Broker acceptance alone never proves mailbox persistence or remote consumption.
63
+
64
+ Persistent inbox/ack calls remain available while the broker is down; they do not require broker registration. Authenticate once with `register` or another successful connection to establish the private presence proof. Durable presence then reconnects independently, including after helper restart, without remote polling. Revoked/expired certificates still cannot read stored mail or restore presence. Mailboxes are fingerprint-scoped: rotating a certificate does not transfer old private mail to its replacement. Retain the old database for local recovery rather than automatically authorizing a new credential to read it.
65
+
48
66
  `cwd` controls presence/project association, **not** an authorization restriction. `list` intentionally returns all connected peer identities, omitting topics/subscriptions. Target full IDs or the returned `target` when names collide. Port `0` requests an ephemeral port, reported in the listening log; a fixed port is easier for SSH.
49
67
 
50
68
  Start the helper in a dedicated terminal (or supervise this exact command with launchd):
@@ -70,7 +88,7 @@ launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.fitchmultz.pi-i
70
88
  launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.fitchmultz.pi-intercom-bridge.plist"
71
89
  ```
72
90
 
73
- The installed agent has no `KeepAlive` auto-restart; stopping its helper really stops access. Remove its plist to disable future login starts permanently. Inspect/drain inbox before a planned helper restart because it is volatile.
91
+ The installed agent has no `KeepAlive` auto-restart; stopping its helper really stops access. Remove its plist to disable future login starts permanently. With `stateDir` enabled, unacked mail survives helper restart. Inspect/drain inbox before restarting a legacy volatile-mode helper.
74
92
 
75
93
  ### Tunnel and provision Grok
76
94
 
@@ -172,7 +190,7 @@ node "$REMOTE" reply --reply-to QUESTION_ID_FROM_INBOX --message 'Proceed with t
172
190
  node "$REMOTE" ack ORDINARY_MESSAGE_ID_FROM_INBOX
173
191
  ```
174
192
 
175
- Poll inbox at a reasonable cadence (for example every 5–15 seconds) while Grok is available. Fetch is non-destructive, so retrying a fetch cannot lose messages; persist/consume then ack ordinary messages. Inbound asks remain in the inbox until `reply` succeeds; ack does not discard a reply obligation. Use `send` for most coordination and `ask` only when Grok really needs a blocking answer. The CLI has a hard request deadline (ask timeout + 15 seconds), validates TLS, emits JSON, and exits 1 on errors / 2 on invalid arguments. It never retries mutations automatically.
193
+ Poll inbox at a reasonable cadence (for example every 5–15 seconds) while Grok is available. Fetch is non-destructive, so retrying a fetch cannot lose messages; persist/consume then ack ordinary messages. In persistent mode, follow `hasMore` using `inbox --after NEXT_CURSOR --limit 256`; do not treat the first page as the entire inbox. Restart a drain from cursor `0` to revisit unacked messages. Inbound asks remain in the inbox until `reply` succeeds; ack does not discard a reply obligation. Use `send` for most coordination and `ask` only when Grok really needs a blocking answer. The CLI has a hard request deadline (ask timeout + 15 seconds), validates TLS, emits JSON, and exits 1 on errors / 2 on invalid arguments. It never retries mutations automatically.
176
194
 
177
195
  Curl works too, with bounded waiting and the same TLS files:
178
196
 
@@ -188,18 +206,22 @@ curl --fail-with-body --max-time 135 \
188
206
 
189
207
  All routes require mTLS and the certificate allowlist. Unknown routes/fields, wrong methods, non-JSON bodies, unsafe control characters, and requests larger than 64 KiB are rejected. No CORS; browser-origin requests are rejected. Success is `{ "ok": true, ... }`; application failures are `{ "ok": false, "error": { "code": "...", "message": "..." } }` with a non-2xx status. Certificate/allowlist admission can terminate TLS before HTTP; the CLI reports that as a TLS/transport error. No remote-controlled broker frame or command is passed through.
190
208
 
191
- | Route | Request | Success fields |
192
- | ------------------- | ------------------------- | ------------------------------------------------------------------------- |
193
- | `POST /v1/register` | `{}` | `sessionId`, `name` (also automatic on other operations) |
194
- | `GET /v1/list` | none | `sessionId`, `sessions` (metadata + unambiguous targets) |
195
- | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
196
- | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
197
- | `GET /v1/inbox` | none | `messages:[{from,message}]`, `overflow`, `lostMessages` |
198
- | `POST /v1/reply` | `{replyTo,message}` | send receipt; target derived from the received ask |
199
- | `POST /v1/ack` | `{ids:[...]}` | `acked`, `retained` |
200
- | `POST /v1/start` | `{name}` | `session:{sessionId,peerId,name,status,pid,sessionFile,error?}`, `reused` |
201
- | `GET /v1/sessions` | none | `sessions` (only current helper/identity-owned launch records) |
202
- | `POST /v1/stop` | `{sessionId}` | `session` with observed `exited` status; journal retained |
209
+ | Route | Request | Success fields |
210
+ | ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------ |
211
+ | `POST /v1/register` | `{}` | `sessionId`, `name` (also automatic on other operations) |
212
+ | `GET /v1/list` | none | `sessionId`, `sessions` (metadata + unambiguous targets) |
213
+ | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
214
+ | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
215
+ | `GET /v1/inbox` | `?after=CURSOR&limit=N` (optional) | `messages:[{from,message}]`, `nextCursor`, `hasMore`, `overflow`, `lostMessages`, `storageError` |
216
+ | `POST /v1/reply` | `{replyTo,message}` | send receipt; target derived from the received ask |
217
+ | `POST /v1/ack` | `{ids:[...]}` | `acked`, `retained` |
218
+ | `POST /v1/start` | `{name}` | `session:{sessionId,peerId,name,status,pid,sessionFile,error?}`, `reused` |
219
+ | `GET /v1/sessions` | none | `sessions` (only current helper/identity-owned launch records) |
220
+ | `POST /v1/stop` | `{sessionId}` | `session` with observed `exited` status; journal retained |
221
+
222
+ Inbox `after` is an opaque decimal sequence cursor (`0` starts a drain), not a message ID or offset. `limit` is 1–256, default 256; unknown/repeated query keys and invalid cursors are rejected. Ack accepts at most 256 IDs per request and is idempotent; it returns asks in `retained`. Persistent duplicate IDs never increment loss counts or replace the original envelope. In persistent mode an answer returned by `ask` is also retained in inbox; ack its `reply.message.id` after consuming it.
223
+
224
+ The storage API also supports opt-in durable notification intents for a local delivery worker: `Mailbox(stateDir, fingerprint, true)`, `pendingNotifications(after, limit)` and `notificationAccepted(messageId)`. Pending envelopes expose local `receivedAt` and `acknowledged` metadata. Intake and notification intent commit in one SQLite insert. Remote ack/reply hides the inbox row but preserves the notification envelope until the worker marks it accepted; notification acceptance never acknowledges unread mail. This API does not post webhooks or enable any wake service on its own.
203
225
 
204
226
  Timeout does **not** retract a delivered ask; a late answer can arrive in inbox. Offline/missing peers, broker outages, ambiguous/self targets, a second concurrent ask, invalid/revoked credentials, timeout, and resource limits have explicit errors. HTTP send/list deadlines are bounded by the real client (8/5 seconds); broker registration by 10 seconds. A lost HTTP response or acknowledgement timeout means **delivery unknown**—inspect inbox/local peer before retrying to avoid duplicate instructions.
205
227
 
@@ -230,7 +252,7 @@ Confirm the reload/revocation audit record, that Grok disappears from the local
230
252
 
231
253
  ## Verification / smoke transcript
232
254
 
233
- The process-boundary tests use a private real broker, real TLS certificates, actual HTTPS requests and the remote CLI. Launch tests use the actually installed Pi dependency graph with a selected-host native faux provider (no paid inference or external credentials); they prove native persistence, zero pre-startup inference, real Intercom wake/tool reply, ownership/caps, startup failure/abort/deadline, safe dialog cancellation, presence-lease independence, short-lived certificate expiry, rotation/revocation and child exit before helper shutdown completes:
255
+ The process-boundary tests use a private real broker, real TLS certificates, actual HTTPS requests and the remote CLI. Mailbox coverage includes cold SIGKILL restart, sender reconnection, idle-lease retirement, broker-outage reads/acks, more than both former count/byte caps, bounded cursor drains, deduplication, failed storage admission, private-file enforcement and notification/ack races across process restarts. Launch tests use the actually installed Pi dependency graph with a selected-host native faux provider (no paid inference or external credentials); they prove native persistence, zero pre-startup inference, real Intercom wake/tool reply, ownership/caps, startup failure/abort/deadline, safe dialog cancellation, presence-lease independence, short-lived certificate expiry, rotation/revocation and child exit before helper shutdown completes:
234
256
 
235
257
  ```bash
236
258
  node --test test/integration/intercom-bridge.test.ts test/integration/intercom-bridge-launch.test.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.46.2",
3
+ "version": "0.47.0",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,13 +22,16 @@ function errorMessage(error) {
22
22
  }
23
23
 
24
24
  function help() {
25
- console.log(`Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke> DIR [NAME] [--cwd PATH]
25
+ console.log(`Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke|renew-server> DIR [NAME] [--cwd PATH] [--port N] [--days N]
26
26
 
27
27
  init Create a new private CA, loopback server certificate and grok-bot credential.
28
- issue Issue a fresh 7-day client certificate; preserve local launch policy.
28
+ issue Issue a fresh client certificate; preserve local identity policies.
29
29
  revoke Remove the named client from the allowlist. Does not delete saved credentials.
30
+ renew-server Renew the loopback server key/certificate using the same CA; preserve clients.
30
31
 
31
32
  Options: --cwd PATH Local project identity for init/issue (default: current directory)
33
+ --port N Loopback bridge port for init (default: 9443)
34
+ --days N Certificate validity (1–3650; client default: 7, renew-server: 365)
32
35
  -h, --help Show help
33
36
  Environment: OPENSSL OpenSSL executable (default: openssl; requires -addext support)
34
37
 
@@ -38,6 +41,7 @@ Examples:
38
41
  node scripts/intercom-bridge-credentials.mjs revoke ~/.pi/intercom-bridge grok-bot
39
42
 
40
43
  After issue/revoke, send SIGHUP to the helper PID to activate the allowlist.
44
+ After renew-server, restart only the helper; existing client credentials remain valid.
41
45
  Rotation/revocation stops that identity's helper-launched Pi children; journals remain.
42
46
  Concurrent credential commands fail rather than overwrite each other's authorization.
43
47
  After a crashed command, inspect DIR/.credentials.lock/owner and remove that lock
@@ -80,7 +84,7 @@ function key(path) {
80
84
  privatePath(path);
81
85
  }
82
86
 
83
- function issueCertificate(dir, stem, name, server = false) {
87
+ function issueCertificate(dir, stem, name, { server = false, days = 7 } = {}) {
84
88
  const keyPath = join(dir, `${stem}.key`);
85
89
  const certPath = join(dir, `${stem}.crt`);
86
90
  const csrPath = join(dir, `${stem}.csr`);
@@ -105,7 +109,7 @@ function issueCertificate(dir, stem, name, server = false) {
105
109
  "-set_serial",
106
110
  `0x${randomBytes(16).toString("hex")}`,
107
111
  "-days",
108
- server ? "365" : "7",
112
+ String(days),
109
113
  "-sha256",
110
114
  "-extfile",
111
115
  extPath,
@@ -145,23 +149,41 @@ function readConfig(path) {
145
149
  }
146
150
 
147
151
  let args;
152
+ let port;
153
+ let certificateDays;
148
154
  try {
149
155
  args = parseArgs({
150
156
  allowPositionals: true,
151
- options: { help: { type: "boolean", short: "h" }, cwd: { type: "string" } },
157
+ options: {
158
+ help: { type: "boolean", short: "h" },
159
+ cwd: { type: "string" },
160
+ port: { type: "string" },
161
+ days: { type: "string" },
162
+ },
152
163
  });
153
164
  if (args.values.help) {
154
165
  help();
155
166
  process.exit(0);
156
167
  }
157
168
  const [action, dir, name] = args.positionals;
169
+ port = args.values.port === undefined ? 9443 : Number(args.values.port);
170
+ const defaultDays = action === "renew-server" ? 365 : 7;
171
+ certificateDays = args.values.days === undefined ? defaultDays : Number(args.values.days);
158
172
  if (
159
- !["init", "issue", "revoke"].includes(action) ||
173
+ !["init", "issue", "revoke", "renew-server"].includes(action) ||
160
174
  !dir ||
161
175
  args.positionals.length > 3 ||
162
- (action !== "init" && !name) ||
176
+ (["issue", "revoke"].includes(action) && !name) ||
177
+ (action === "renew-server" && (name || args.values.cwd)) ||
163
178
  (name && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) ||
164
- (action === "revoke" && args.values.cwd)
179
+ (action === "revoke" && (args.values.cwd || args.values.days)) ||
180
+ (action !== "init" && args.values.port !== undefined) ||
181
+ !Number.isInteger(port) ||
182
+ port < 0 ||
183
+ port > 65535 ||
184
+ !Number.isInteger(certificateDays) ||
185
+ certificateDays < 1 ||
186
+ certificateDays > 3650
165
187
  ) {
166
188
  throw new Error("Invalid arguments; use --help.");
167
189
  }
@@ -217,9 +239,9 @@ try {
217
239
  "-out",
218
240
  join(dir, "ca.crt"),
219
241
  );
220
- const server = issueCertificate(dir, "server", "localhost", true);
242
+ const server = issueCertificate(dir, "server", "localhost", { server: true, days: 365 });
221
243
  config = {
222
- port: 9443,
244
+ port,
223
245
  ca: join(dir, "ca.crt"),
224
246
  cert: server.cert,
225
247
  key: server.key,
@@ -239,16 +261,36 @@ try {
239
261
  console.log(
240
262
  JSON.stringify({ revoked: name, config: configPath, next: "Send SIGHUP to the helper PID." }),
241
263
  );
264
+ } else if (action === "renew-server") {
265
+ privatePath(join(dir, "ca.key"));
266
+ const server = issueCertificate(dir, `server-${randomBytes(8).toString("hex")}`, "localhost", {
267
+ server: true,
268
+ days: certificateDays,
269
+ });
270
+ config.cert = server.cert;
271
+ config.key = server.key;
272
+ atomicConfig(configPath, config);
273
+ console.log(
274
+ JSON.stringify({
275
+ renewed: "server",
276
+ ...server,
277
+ ca: config.ca,
278
+ config: configPath,
279
+ next: "Restart only this helper; client keys and CA are unchanged.",
280
+ }),
281
+ );
242
282
  } else {
243
283
  privatePath(join(dir, "ca.key"));
244
- const credential = issueCertificate(dir, `${name}-${randomBytes(8).toString("hex")}`, name);
284
+ const credential = issueCertificate(dir, `${name}-${randomBytes(8).toString("hex")}`, name, {
285
+ days: certificateDays,
286
+ });
245
287
  const previous = config.clients.find((client) => client.name === name);
246
288
  config.clients = config.clients.filter((client) => client.name !== name);
247
289
  config.clients.push({
290
+ ...previous,
248
291
  fingerprint256: credential.fingerprint256,
249
292
  name,
250
293
  cwd: resolve(args.values.cwd || previous?.cwd || process.cwd()),
251
- ...(previous?.launch === undefined ? {} : { launch: previous.launch }),
252
294
  });
253
295
  atomicConfig(configPath, config);
254
296
  console.log(
@@ -17,6 +17,8 @@ Options:
17
17
  --message TEXT Message for send/ask/reply
18
18
  --reply-to ID Inbound ask ID from inbox, for reply
19
19
  --timeout-ms MS Ask timeout (100..120000, default: 120000)
20
+ --after CURSOR Inbox cursor from nextCursor (default: 0)
21
+ --limit COUNT Inbox page size (1..256, default: 256)
20
22
  --name NAME Start/reuse a named idle Pi session under local policy
21
23
  --session-id ID Stop only a Pi session launched by this identity/helper
22
24
  -h, --help Show help
@@ -25,6 +27,7 @@ Examples:
25
27
  node intercom-remote.mjs list
26
28
  node intercom-remote.mjs ask --to planner --message 'What is the current status?'
27
29
  node intercom-remote.mjs inbox
30
+ node intercom-remote.mjs inbox --after NEXT_CURSOR --limit 64
28
31
  node intercom-remote.mjs reply --reply-to QUESTION_ID --message 'Proceed.'
29
32
  node intercom-remote.mjs ack MESSAGE_ID
30
33
  node intercom-remote.mjs start --name planner
@@ -36,6 +39,9 @@ No automatic retries: a lost response does not prove a message was unsent.`);
36
39
  }
37
40
 
38
41
  function allowedOptions(action) {
42
+ if (action === "inbox") {
43
+ return ["after", "limit"];
44
+ }
39
45
  if (action === "start") {
40
46
  return ["name"];
41
47
  }
@@ -119,7 +125,21 @@ function privateKey(path) {
119
125
  }
120
126
  }
121
127
 
122
- let body, action, origin, timeout;
128
+ function inboxQuery(options) {
129
+ const after = options.after ?? "0";
130
+ const limit = options.limit ?? "256";
131
+ if (
132
+ !/^(0|[1-9]\d{0,15})$/.test(after) ||
133
+ !Number.isSafeInteger(Number(after)) ||
134
+ !/^[1-9]\d{0,2}$/.test(limit) ||
135
+ Number(limit) > 256
136
+ ) {
137
+ throw new Error("--after must be a nonnegative safe integer cursor; --limit must be 1..256.");
138
+ }
139
+ return new URLSearchParams({ after, limit }).toString();
140
+ }
141
+
142
+ let body, action, origin, timeout, query;
123
143
  try {
124
144
  const parsed = parseArgs({
125
145
  allowPositionals: true,
@@ -129,6 +149,8 @@ try {
129
149
  message: { type: "string" },
130
150
  "reply-to": { type: "string" },
131
151
  "timeout-ms": { type: "string" },
152
+ after: { type: "string" },
153
+ limit: { type: "string" },
132
154
  name: { type: "string" },
133
155
  "session-id": { type: "string" },
134
156
  },
@@ -167,6 +189,7 @@ try {
167
189
  throw new Error("--timeout-ms must be 100..120000.");
168
190
  }
169
191
  body = requestBody(action, options, parsed.positionals.slice(1), timeout);
192
+ query = action === "inbox" ? inboxQuery(options) : "";
170
193
  origin = new URL(process.env.INTERCOM_BRIDGE_URL || "https://127.0.0.1:9443");
171
194
  if (
172
195
  origin.protocol !== "https:" ||
@@ -194,6 +217,7 @@ try {
194
217
  try {
195
218
  const files = tlsFiles();
196
219
  origin.pathname = `/v1/${action}`;
220
+ origin.search = query;
197
221
  const payload = JSON.stringify(body);
198
222
  const get = ["list", "inbox", "sessions"].includes(action);
199
223
  const result = await new Promise((resolve, reject) => {
@@ -200,7 +200,7 @@ function installProduction(packed) {
200
200
  );
201
201
  if (
202
202
  !credentialHelp.includes(
203
- "Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke>",
203
+ "Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke|renew-server>",
204
204
  )
205
205
  ) {
206
206
  throw new Error("installed credential CLI did not provide usage help");
@@ -1,27 +1,51 @@
1
- import {
2
- constants,
3
- closeSync,
4
- fstatSync,
5
- openSync,
6
- readFileSync,
7
- realpathSync,
8
- statSync,
9
- } from "node:fs";
1
+ import { realpathSync, statSync } from "node:fs";
10
2
  import { isAbsolute } from "node:path";
11
3
  import { createSecureContext } from "node:tls";
4
+ import { parseEnv } from "node:util";
12
5
  import { isUnknownArray } from "../shared/unknown.ts";
13
6
  import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.ts";
14
7
  import { launchPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
8
+ import { stateDirectory } from "./bridge-mailbox.ts";
9
+ import { readBridgeFile } from "./bridge-files.ts";
15
10
 
16
11
  const MAX_CLIENTS = 64;
17
12
 
13
+ export interface WakePolicy {
14
+ readonly envFile: string;
15
+ readonly url: string;
16
+ readonly key: string;
17
+ }
18
+
19
+ function wakePolicy(value: unknown): WakePolicy | undefined {
20
+ if (value === undefined) {
21
+ return;
22
+ }
23
+ const input = object(value, ["envFile"]);
24
+ const envFile = label(input.envFile, "wake credential file", 4096);
25
+ if (!isAbsolute(envFile)) {
26
+ throw new Error("Wake credential file must be absolute.");
27
+ }
28
+ const env = parseEnv(readBridgeFile(envFile, true, 16384).toString("utf8"));
29
+ const url = new URL(label(env.GROK_WAKE_WEBHOOK_URL, "wake webhook URL", 8192));
30
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") {
31
+ throw new Error("Wake webhook requires HTTPS without URL credentials or a fragment.");
32
+ }
33
+ return {
34
+ envFile,
35
+ url: url.href,
36
+ key: label(env.GROK_WAKE_WEBHOOK_KEY, "wake webhook key", 4096),
37
+ };
38
+ }
39
+
18
40
  export interface Identity {
19
41
  readonly fingerprint256: string;
20
42
  readonly name: string;
21
43
  readonly cwd: string;
22
44
  readonly launch?: LaunchPolicy;
45
+ readonly wake?: WakePolicy;
23
46
  }
24
47
  export interface Config {
48
+ readonly stateDir?: string;
25
49
  readonly port: number;
26
50
  readonly ca: string;
27
51
  readonly cert: string;
@@ -30,26 +54,6 @@ export interface Config {
30
54
  readonly tls: { readonly ca: Buffer; readonly cert: Buffer; readonly key: Buffer };
31
55
  }
32
56
 
33
- function readFile(path: string, privateFile: boolean, limit: number): Buffer {
34
- const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
35
- try {
36
- const stat = fstatSync(fd);
37
- if (!stat.isFile() || stat.size > limit) {
38
- throw new Error("Configuration/TLS file must be a bounded regular file.");
39
- }
40
- if (privateFile && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600)) {
41
- throw new Error("Config and TLS key must be owned by the current user with mode 0600.");
42
- }
43
- const data = readFileSync(fd);
44
- if (data.length > limit) {
45
- throw new Error("Configuration/TLS file exceeds its size limit.");
46
- }
47
- return data;
48
- } finally {
49
- closeSync(fd);
50
- }
51
- }
52
-
53
57
  function identities(value: unknown): Map<string, Identity> {
54
58
  if (!isUnknownArray(value) || value.length > MAX_CLIENTS) {
55
59
  throw new Error(`clients must be an array of at most ${MAX_CLIENTS} identities.`);
@@ -57,7 +61,7 @@ function identities(value: unknown): Map<string, Identity> {
57
61
  const clients = new Map<string, Identity>();
58
62
  const names = new Set<string>();
59
63
  for (const raw of value) {
60
- const entry = object(raw, ["fingerprint256", "name", "cwd", "launch"]);
64
+ const entry = object(raw, ["fingerprint256", "name", "cwd", "launch", "wake"]);
61
65
  const fingerprint256 = fingerprint(entry.fingerprint256);
62
66
  const name = label(entry.name, "name", 128).trim();
63
67
  const cwd = label(entry.cwd, "cwd", 4096);
@@ -76,6 +80,7 @@ function identities(value: unknown): Map<string, Identity> {
76
80
  name,
77
81
  cwd: launch ? realpathSync(cwd) : cwd,
78
82
  launch,
83
+ wake: wakePolicy(entry.wake),
79
84
  });
80
85
  names.add(name.toLowerCase());
81
86
  }
@@ -84,9 +89,9 @@ function identities(value: unknown): Map<string, Identity> {
84
89
 
85
90
  export function loadConfig(path: string): Config {
86
91
  const raw: unknown = JSON.parse(
87
- new TextDecoder("utf-8", { fatal: true }).decode(readFile(path, true, BODY_LIMIT)),
92
+ new TextDecoder("utf-8", { fatal: true }).decode(readBridgeFile(path, true, BODY_LIMIT)),
88
93
  );
89
- const input = object(raw, ["port", "ca", "cert", "key", "clients"]);
94
+ const input = object(raw, ["port", "ca", "cert", "key", "clients", "stateDir"]);
90
95
  const port = input.port === undefined ? 9443 : input.port;
91
96
  if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535) {
92
97
  throw new Error("Invalid HTTPS port.");
@@ -99,16 +104,21 @@ export function loadConfig(path: string): Config {
99
104
  }
100
105
  const clients = identities(input.clients);
101
106
  const tls = {
102
- ca: readFile(ca, false, 1024 * 1024),
103
- cert: readFile(cert, false, 1024 * 1024),
104
- key: readFile(key, true, 1024 * 1024),
107
+ ca: readBridgeFile(ca, false, 1024 * 1024),
108
+ cert: readBridgeFile(cert, false, 1024 * 1024),
109
+ key: readBridgeFile(key, true, 1024 * 1024),
105
110
  };
106
111
  createSecureContext({ ...tls, minVersion: "TLSv1.2" });
107
- return { port, ca, cert, key, clients, tls };
112
+ const stateDir = stateDirectory(input.stateDir);
113
+ if (stateDir === undefined && [...clients.values()].some((identity) => identity.wake)) {
114
+ throw new Error("Wake delivery requires a durable stateDir.");
115
+ }
116
+ return { port, ca, cert, key, clients, tls, stateDir };
108
117
  }
109
118
 
110
119
  export function sameListener(next: Config, current: Config): boolean {
111
120
  return (
121
+ next.stateDir === current.stateDir &&
112
122
  next.port === current.port &&
113
123
  next.ca === current.ca &&
114
124
  next.cert === current.cert &&
@@ -0,0 +1,59 @@
1
+ import {
2
+ constants,
3
+ closeSync,
4
+ fstatSync,
5
+ fsyncSync,
6
+ openSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ export function readBridgeFile(path: string, privateFile: boolean, limit: number): Buffer {
16
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
17
+ try {
18
+ const stat = fstatSync(fd);
19
+ if (!stat.isFile() || stat.size > limit) {
20
+ throw new Error("Configuration/TLS file must be a bounded regular file.");
21
+ }
22
+ if (privateFile && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600)) {
23
+ throw new Error("Config and TLS key must be owned by the current user with mode 0600.");
24
+ }
25
+ const data = readFileSync(fd);
26
+ if (data.length > limit) {
27
+ throw new Error("Configuration/TLS file exceeds its size limit.");
28
+ }
29
+ return data;
30
+ } finally {
31
+ closeSync(fd);
32
+ }
33
+ }
34
+
35
+ export function writeBridgeJson(path: string, value: object): void {
36
+ const temporary = `${path}.${randomUUID()}.tmp`;
37
+ const fd = openSync(
38
+ temporary,
39
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
40
+ 0o600,
41
+ );
42
+ try {
43
+ try {
44
+ writeFileSync(fd, JSON.stringify(value));
45
+ fsyncSync(fd);
46
+ } finally {
47
+ closeSync(fd);
48
+ }
49
+ renameSync(temporary, path);
50
+ const directory = openSync(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
51
+ try {
52
+ fsyncSync(directory);
53
+ } finally {
54
+ closeSync(directory);
55
+ }
56
+ } finally {
57
+ rmSync(temporary, { force: true });
58
+ }
59
+ }