@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.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +35 -26
- package/dist/pi-intercom/bridge-files.js +47 -0
- package/dist/pi-intercom/bridge-mailbox.js +324 -0
- package/dist/pi-intercom/bridge-presence.js +100 -0
- package/dist/pi-intercom/bridge-routes.js +27 -3
- package/dist/pi-intercom/bridge-session.js +60 -64
- package/dist/pi-intercom/bridge-wake.js +158 -0
- package/dist/pi-intercom/bridge.js +55 -7
- package/docs/intercom-bridge.md +39 -17
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +54 -12
- package/scripts/intercom-remote.mjs +25 -1
- package/scripts/package-smoke.mjs +1 -1
- package/src/pi-intercom/bridge-config.ts +46 -36
- package/src/pi-intercom/bridge-files.ts +59 -0
- package/src/pi-intercom/bridge-mailbox.ts +395 -0
- package/src/pi-intercom/bridge-presence.ts +128 -0
- package/src/pi-intercom/bridge-routes.ts +41 -4
- package/src/pi-intercom/bridge-session.ts +73 -71
- package/src/pi-intercom/bridge-wake.ts +188 -0
- package/src/pi-intercom/bridge.ts +58 -7
package/docs/intercom-bridge.md
CHANGED
|
@@ -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,
|
|
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
|
|
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.
|
|
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
|
|
192
|
-
| ------------------- |
|
|
193
|
-
| `POST /v1/register` | `{}`
|
|
194
|
-
| `GET /v1/list` | none
|
|
195
|
-
| `POST /v1/send` | `{to,message}`
|
|
196
|
-
| `POST /v1/ask` | `{to,message,timeoutMs?}`
|
|
197
|
-
| `GET /v1/inbox` |
|
|
198
|
-
| `POST /v1/reply` | `{replyTo,message}`
|
|
199
|
-
| `POST /v1/ack` | `{ids:[...]}`
|
|
200
|
-
| `POST /v1/start` | `{name}`
|
|
201
|
-
| `GET /v1/sessions` | none
|
|
202
|
-
| `POST /v1/stop` | `{sessionId}`
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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: {
|
|
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
|
-
(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
103
|
-
cert:
|
|
104
|
-
key:
|
|
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
|
-
|
|
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
|
+
}
|