@fitchmultz/pi-subagents 0.45.0 → 0.46.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 +6 -0
- package/README.md +1 -1
- package/dist/extension/build-info.js +1 -1
- package/dist/pi-intercom/bridge-config.js +15 -5
- package/dist/pi-intercom/bridge-launch-policy.js +88 -0
- package/dist/pi-intercom/bridge-launch.js +217 -0
- package/dist/pi-intercom/bridge-routes.js +8 -0
- package/dist/pi-intercom/bridge-rpc-process.js +264 -0
- package/dist/pi-intercom/bridge-session.js +4 -0
- package/dist/pi-intercom/bridge.js +43 -7
- package/dist/shared/native-session-loader.js +17 -0
- package/dist/shared/native-session.js +2 -16
- package/docs/intercom-bridge.md +64 -14
- package/docs/reference.md +1 -1
- package/package.json +1 -1
- package/scripts/intercom-bridge-credentials.mjs +3 -1
- package/scripts/intercom-remote.mjs +40 -4
- package/src/pi-intercom/bridge-config.ts +24 -5
- package/src/pi-intercom/bridge-launch-policy.ts +109 -0
- package/src/pi-intercom/bridge-launch.ts +317 -0
- package/src/pi-intercom/bridge-routes.ts +15 -0
- package/src/pi-intercom/bridge-rpc-process.ts +293 -0
- package/src/pi-intercom/bridge-session.ts +5 -1
- package/src/pi-intercom/bridge.ts +53 -8
- package/src/shared/native-session-loader.ts +13 -0
- package/src/shared/native-session.ts +2 -9
|
@@ -8,12 +8,14 @@ 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
10
|
import { reply } from "./bridge-http.js";
|
|
11
|
+
import { Launcher, sameIdentity } from "./bridge-launch.js";
|
|
11
12
|
const MAX_CONNECTIONS = 128;
|
|
12
13
|
class Bridge {
|
|
13
14
|
config;
|
|
14
15
|
configPath;
|
|
15
16
|
sessions = new Map();
|
|
16
17
|
losses = new Map();
|
|
18
|
+
launcher = new Launcher();
|
|
17
19
|
sockets = new Set();
|
|
18
20
|
connections = new Map();
|
|
19
21
|
server;
|
|
@@ -72,7 +74,7 @@ class Bridge {
|
|
|
72
74
|
}
|
|
73
75
|
allowed(identity) {
|
|
74
76
|
const current = this.config.clients.get(identity.fingerprint256);
|
|
75
|
-
return !this.stopping &&
|
|
77
|
+
return !this.stopping && sameIdentity(identity, current);
|
|
76
78
|
}
|
|
77
79
|
lost(fp, count = 1) {
|
|
78
80
|
if (count > 0 && this.config.clients.has(fp)) {
|
|
@@ -160,8 +162,9 @@ class Bridge {
|
|
|
160
162
|
});
|
|
161
163
|
});
|
|
162
164
|
process.on("SIGHUP", () => this.reload());
|
|
163
|
-
|
|
164
|
-
process.
|
|
165
|
+
// Retain listeners during dispatch: selected SDK dependencies use signal-exit's listener check.
|
|
166
|
+
process.on("SIGINT", () => this.stop());
|
|
167
|
+
process.on("SIGTERM", () => this.stop());
|
|
165
168
|
}
|
|
166
169
|
reload() {
|
|
167
170
|
if (this.stopping) {
|
|
@@ -174,10 +177,14 @@ class Bridge {
|
|
|
174
177
|
}
|
|
175
178
|
this.config = next;
|
|
176
179
|
this.revokeRemoved();
|
|
180
|
+
this.launcher.revoke(next.clients).catch(() => log("launch_cleanup", { result: "failed" }));
|
|
177
181
|
log("allowlist_reloaded", { result: "accepted", clients: next.clients.size });
|
|
178
182
|
}
|
|
179
183
|
catch {
|
|
180
184
|
this.config = { ...this.config, clients: new Map() };
|
|
185
|
+
this.launcher
|
|
186
|
+
.revoke(this.config.clients)
|
|
187
|
+
.catch(() => log("launch_cleanup", { result: "failed" }));
|
|
181
188
|
for (const session of this.sessions.values()) {
|
|
182
189
|
session.close(new Fault(503, "configuration_invalid", "Configuration reload failed; all identities denied."));
|
|
183
190
|
}
|
|
@@ -224,7 +231,11 @@ class Bridge {
|
|
|
224
231
|
for (const socket of this.sockets) {
|
|
225
232
|
socket.destroy();
|
|
226
233
|
}
|
|
227
|
-
await Promise.all([
|
|
234
|
+
await Promise.all([
|
|
235
|
+
...this.handlers,
|
|
236
|
+
...sessions.map((session) => session.settled()),
|
|
237
|
+
this.launcher.shutdown(),
|
|
238
|
+
]);
|
|
228
239
|
log("shutdown", { result: "closed" });
|
|
229
240
|
}
|
|
230
241
|
admission(req) {
|
|
@@ -270,14 +281,16 @@ class Bridge {
|
|
|
270
281
|
const signal = controller.signal;
|
|
271
282
|
req.once("aborted", abort);
|
|
272
283
|
res.once("close", onClose);
|
|
273
|
-
|
|
284
|
+
const duration = route === "POST /v1/ask" ? ASK_TIMEOUT + 15000 : 15000;
|
|
285
|
+
setDeadline(route === "POST /v1/start" ? 75000 : duration);
|
|
274
286
|
const operation = await operationFor(req, route, signal);
|
|
275
287
|
if (operation.route === "POST /v1/ask") {
|
|
276
288
|
setDeadline(operation.timeoutMs + 15000);
|
|
277
289
|
}
|
|
278
|
-
const data = await
|
|
290
|
+
const data = await this.execute(operation, session, {
|
|
279
291
|
signal,
|
|
280
292
|
requestId,
|
|
293
|
+
expires,
|
|
281
294
|
onPeer: (id) => {
|
|
282
295
|
peer = id;
|
|
283
296
|
},
|
|
@@ -305,6 +318,29 @@ class Bridge {
|
|
|
305
318
|
}
|
|
306
319
|
}
|
|
307
320
|
}
|
|
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
|
+
}
|
|
308
344
|
}
|
|
309
345
|
function main() {
|
|
310
346
|
const args = process.argv.slice(2);
|
|
@@ -322,7 +358,7 @@ function main() {
|
|
|
322
358
|
new Bridge(loadConfig(path), path).start();
|
|
323
359
|
}
|
|
324
360
|
catch {
|
|
325
|
-
process.stderr.write("Bridge startup failed: check config schema, file ownership/mode 0600, absolute TLS/cwd paths, and
|
|
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");
|
|
326
362
|
process.exitCode = 1;
|
|
327
363
|
}
|
|
328
364
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { pathToFileURL } from "node:url";
|
|
11
|
+
export async function loadNativeSession(packageRoot) {
|
|
12
|
+
// The trusted selected Pi package supplies this exact SDK declaration contract.
|
|
13
|
+
// A computed URL is required: Jiti rewrites literal SDK imports to its own host graph.
|
|
14
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
15
|
+
const sessionModule = await import(__rewriteRelativeImportExtension(pathToFileURL(path.join(packageRoot, "dist/index.js")).href));
|
|
16
|
+
return sessionModule;
|
|
17
|
+
}
|
|
@@ -1,18 +1,4 @@
|
|
|
1
|
-
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
-
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
-
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
-
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
-
});
|
|
6
|
-
}
|
|
7
|
-
return path;
|
|
8
|
-
};
|
|
9
|
-
import * as path from "node:path";
|
|
10
|
-
import { pathToFileURL } from "node:url";
|
|
11
1
|
import { requirePiPackageRoot } from "../runs/shared/pi-spawn.js";
|
|
2
|
+
import { loadNativeSession } from "./native-session-loader.js";
|
|
12
3
|
// Detached Node runners do not get Pi's extension-loader package aliases.
|
|
13
|
-
const
|
|
14
|
-
// The trusted selected Pi package supplies this exact SDK declaration contract.
|
|
15
|
-
// A computed URL is required: Jiti rewrites literal SDK imports to its own host graph.
|
|
16
|
-
// oxlint-disable-next-line typescript/no-unsafe-assignment
|
|
17
|
-
const sessionModule = await import(__rewriteRelativeImportExtension(selectedSessionURL));
|
|
18
|
-
export const { buildSessionContext, parseSessionEntries, migrateSessionEntries, SessionManager } = sessionModule;
|
|
4
|
+
export const { buildSessionContext, parseSessionEntries, migrateSessionEntries, SessionManager } = await loadNativeSession(requirePiPackageRoot());
|
package/docs/intercom-bridge.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Secure remote Intercom bridge
|
|
2
2
|
|
|
3
|
-
Optional HTTPS messaging proxy for a remote agent such as **Grok Bot**. Pi-intercom's broker remains same-machine Unix IPC. Nothing is added to the Pi extension loader, and starting Pi alone never starts this proxy.
|
|
3
|
+
Optional HTTPS messaging proxy and locally controlled Pi-session launcher for a remote agent such as **Grok Bot**. Pi-intercom's broker remains same-machine Unix IPC. Nothing is added to the Pi extension loader, and starting Pi alone never starts this proxy.
|
|
4
4
|
|
|
5
5
|
## Design note
|
|
6
6
|
|
|
7
|
-
**Transport:** a Mac-local Node HTTPS server bound to **127.0.0.1**, carried by a Mac-initiated **SSH reverse TCP forward** to the Linux box's loopback interface. Grok calls HTTPS on its own loopback. Unlike a Unix-socket tunnel, only the
|
|
7
|
+
**Transport:** a Mac-local Node HTTPS server bound to **127.0.0.1**, carried by a Mac-initiated **SSH reverse TCP forward** to the Linux box's loopback interface. Grok calls HTTPS on its own loopback. Unlike a Unix-socket tunnel, only the local helper and normal local Pi peers use `broker.sock`; neither the remote nor SSH can issue raw broker frames. This choice needs no cloud account, public DNS, inbound Mac SSH, or per-call laptop approval. An existing SSH account on Linux is the only tunnel prerequisite. SSH authenticates the tunnel host; end-to-end mTLS authenticates both application endpoints even to other processes on Linux.
|
|
8
8
|
|
|
9
9
|
**Threat model:** deny unauthenticated local/network callers, stolen credentials after revocation/expiry, identity spoofing, arbitrary broker commands, forged replies, accidental LAN exposure, and unbounded waits/resource use. The Mac user, local broker, OS, private CA, and approved remote host are trusted. A compromised authorized Grok credential can read peer metadata and send agent-visible text to local peers (including instructions); mTLS does not sandbox those agents or make the content trustworthy. Listed peers can be in other projects. Approve only a host allowed to see that metadata and message those peers. Root/compromised endpoints and denial of service beyond fixed connection/request limits are outside this boundary.
|
|
10
10
|
|
|
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
|
|
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
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.
|
|
14
14
|
|
|
@@ -105,6 +105,53 @@ ssh "$GROK_SSH" 'chmod 600 ~/.config/pi-intercom/*'
|
|
|
105
105
|
|
|
106
106
|
For a least-privileged dedicated Linux SSH tunnel account, the administrator can restrict its key to remote forwarding with `restrict,port-forwarding,permitlisten="127.0.0.1:9443",command="/bin/false"` and `AllowTcpForwarding remote`, `GatewayPorts no`. `-N` requests no shell. Provision files separately; do not reuse Grok's client key as an SSH key. No SSH login credential for the Mac is given to Grok.
|
|
107
107
|
|
|
108
|
+
## Enable controlled destination-session launches
|
|
109
|
+
|
|
110
|
+
Launching is **disabled by default**, independently for each certificate identity. Messaging-only installs and helper `--help` do not require a Pi SDK. To enable launches, add `launch` to the selected client in the private Mac configuration and create an owned **0700** session directory first:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
mkdir -m 700 "$BRIDGE/sessions"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"packageRoot": "/absolute/immutable/node_modules/@earendil-works/pi-coding-agent",
|
|
119
|
+
"sessionDir": "/Users/mitch/.pi/intercom-bridge/sessions",
|
|
120
|
+
"provider": "openai-codex",
|
|
121
|
+
"model": "EXACT_MODEL_ID",
|
|
122
|
+
"context": "This is a locally authorized remote Intercom session. Stay idle until an Intercom task arrives and reply through Intercom.",
|
|
123
|
+
"extensions": [
|
|
124
|
+
"/absolute/pi-subagents/dist/pi-intercom/index.js",
|
|
125
|
+
"/absolute/pi-subagents/dist/extension/index.js"
|
|
126
|
+
],
|
|
127
|
+
"discoverResources": false,
|
|
128
|
+
"trustProject": false,
|
|
129
|
+
"offline": false,
|
|
130
|
+
"maxSessions": 4,
|
|
131
|
+
"startupTimeoutMs": 15000
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The helper runs its **physical Node executable**, resolves only the pinned package's manifest `bin.pi`, and uses the identity's fixed, canonical Mac `cwd`. Use the intended installed fork, not a development SDK with the same version. `provider` and `model` must be exact IDs, without fuzzy matching or a thinking suffix. Local settings still choose model reasoning. Native launch context is a genuine user instruction written using that package's exported `SessionManager`; it creates a **0600 native JSONL journal without submitting a prompt or running inference**. The session then starts idle in RPC mode. Success requires exact saved ID/name/file/model, idle RPC state, and actual publication of its named Intercom broker peer. This proves startup, **not provider credential usability**; verify the first real task/reply separately.
|
|
136
|
+
|
|
137
|
+
Resources are locally controlled. `extensions` requires 1–32 existing absolute paths including Intercom; include subagents and any other useful locally approved extensions there. By default (`discoverResources:false`), discovered/configured/built-in extensions, skills, templates, themes and context-file discovery are disabled; explicit extensions still load. This is an explicit resource selection, not full normal discovery. Set `discoverResources:true` to retain normal native discovery; use the **same physical Intercom extension path already selected by native settings**, avoiding duplicate owners loaded from separate installed copies. `trustProject` (default false) chooses native `--approve` versus `--no-approve` for the fixed local project; it is not a remote approval. `offline` (default false) disables automatic catalog/network activity, not requested model inference. The child retains local credentials and normal environment, but strips enclosing `PI_SUBAGENT_*` identity/control metadata so it is an independent session.
|
|
138
|
+
|
|
139
|
+
Send SIGHUP after editing policy. Removing/changing an identity or its launch policy stops its existing children; invalid reload denies everyone and stops all owned children. Credential `issue` preserves launch policy but replaces the fingerprint, so rotation also stops the old credential's children. Only this helper's own children may be stopped, never arbitrary Pi sessions or the broker.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
node "$REMOTE" start --name planner
|
|
143
|
+
node "$REMOTE" sessions
|
|
144
|
+
# Use peerId from start/sessions as the ordinary messaging target:
|
|
145
|
+
node "$REMOTE" ask --to PI_PEER_ID --message 'Review the current project and reply with your findings.'
|
|
146
|
+
node "$REMOTE" stop --session-id NATIVE_SESSION_ID
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Names are prefixed `remote:IDENTITY:NAME` to avoid impersonating local peers. An already-live name for the same identity is reused; a concurrent start/stop returns a conflict, not a duplicate launch. After a lost start response, inspect `sessions` or repeat the **same name**, not a fresh name. Name is the **only** remote launch input: no task, cwd, shell, environment, flags, model, resource paths or raw RPC commands. Submit tasks afterward through ordinary Intercom. Fixed cwd and launch settings are **not a sandbox**: the authorized agent can use its locally enabled tools and credentials. Grant launch permission only to a remote identity trusted with that authority and potential model costs.
|
|
150
|
+
|
|
151
|
+
Caps are 4 sessions per identity by default (local range 1–16), and 32 active launches helper-wide. Readiness deadline defaults to 15 seconds (local range 1–60000 ms). HTTP start has a 75-second outer deadline; CLI start has 90 seconds including cleanup. Startup failure, abort, deadline, explicit stop, identity revocation, certificate expiry and helper SIGINT/SIGTERM stop owned children. Each launch has a private, helper-owned process group. Cleanup closes stdin, escalates that group to TERM/KILL after 1/3 seconds if needed (including unresponsive native workers), and observes actual process closure within 8 seconds; it does not report a signal request as a completed exit. A cleanup deadline returns `stop_timeout`; inspect `sessions` and retry stop, even if the process has since exited. Unsupported RPC dialogs are cancelled and the session is stopped with `interaction_required`; they are never automatically approved. RPC events/diagnostics are continuously drained. Control records are bounded to 4 MiB; oversized unused native transcript/tool events are discarded through their LF boundary with metadata-only overflow reporting, without truncating the native session/tool result or killing a healthy peer. Raw stderr/model/tool content is never sent to the audit log.
|
|
152
|
+
|
|
153
|
+
Long-lived peers are **not tied to the remote presence peer's five-minute idle lease** or an HTTP connection. They remain available while the helper and certificate authorization remain valid. Journals survive stop/failure/restart and can be resumed locally with the pinned Pi's `--session PATH`; the helper does not silently restart or adopt old/native processes. `sessions` reports current helper ownership, state, PID, native journal path and metadata-only error codes; at most 64 recent records are retained. The PID identifies the owned manifest-CLI process; a supervised fork can run Pi in its native worker. It is not an all-machine session admin API. Planned helper shutdown stops its children; do not SIGKILL the helper to perform routine cleanup.
|
|
154
|
+
|
|
108
155
|
## Grok Bot: exact remote commands
|
|
109
156
|
|
|
110
157
|
On Linux, configure Grok's shell environment:
|
|
@@ -141,15 +188,18 @@ curl --fail-with-body --max-time 135 \
|
|
|
141
188
|
|
|
142
189
|
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.
|
|
143
190
|
|
|
144
|
-
| Route | Request | Success fields
|
|
145
|
-
| ------------------- | ------------------------- |
|
|
146
|
-
| `POST /v1/register` | `{}` | `sessionId`, `name` (also automatic on other operations)
|
|
147
|
-
| `GET /v1/list` | none | `sessionId`, `sessions` (metadata + unambiguous targets)
|
|
148
|
-
| `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered`
|
|
149
|
-
| `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}`
|
|
150
|
-
| `GET /v1/inbox` | none | `messages:[{from,message}]`, `overflow`, `lostMessages`
|
|
151
|
-
| `POST /v1/reply` | `{replyTo,message}` | send receipt; target derived from the received ask
|
|
152
|
-
| `POST /v1/ack` | `{ids:[...]}` | `acked`, `retained`
|
|
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 |
|
|
153
203
|
|
|
154
204
|
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.
|
|
155
205
|
|
|
@@ -180,10 +230,10 @@ Confirm the reload/revocation audit record, that Grok disappears from the local
|
|
|
180
230
|
|
|
181
231
|
## Verification / smoke transcript
|
|
182
232
|
|
|
183
|
-
The process-boundary
|
|
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:
|
|
184
234
|
|
|
185
235
|
```bash
|
|
186
|
-
node --test test/integration/intercom-bridge.test.ts
|
|
236
|
+
node --test test/integration/intercom-bridge.test.ts test/integration/intercom-bridge-launch.test.ts
|
|
187
237
|
# Do not inject host startup diagnostics into native fixture stderr:
|
|
188
238
|
env -u PI_TIMING -u PI_EXTENSION_PERFORMANCE npm run ci
|
|
189
239
|
```
|
package/docs/reference.md
CHANGED
|
@@ -375,7 +375,7 @@ The files in `prompts/` document common workflows without registering additional
|
|
|
375
375
|
|
|
376
376
|
## Bundled intercom
|
|
377
377
|
|
|
378
|
-
For a remote agent on Linux, the optional [secure remote bridge](intercom-bridge.md) provides a loopback-only mTLS HTTPS API over an SSH reverse forward. It registers a certificate-bound peer such as `grok-bot` and
|
|
378
|
+
For a remote agent on Linux, the optional [secure remote bridge](intercom-bridge.md) provides a loopback-only mTLS HTTPS API over an SSH reverse forward. It registers a certificate-bound peer such as `grok-bot` and supports messaging and optional locally pinned, name-only starts of persistent Pi sessions on the destination machine; the private Unix broker socket remains local. The guide includes setup, remote start/sessions/stop commands, lifecycle limits, credential rotation and revocation.
|
|
379
379
|
|
|
380
380
|
The same `pi-subagents` install registers the intercom extension and skill. `load_intercom({})` exposes the full peer tool explicitly; actionable peer inbound and recovered reply needs also expose it before use. Presence, passive breadcrumbs, routine subagent notices, and human messages answered in chat do not. Broker presence and delivery continue even with the schema hidden. Managed children get a private coordination channel back to the parent Pi session unless an explicit agent extension allowlist excludes it. See [the intercom guide](intercom.md) for direct peer messaging, keyboard UI, tool actions, configuration, and broker details.
|
|
381
381
|
|
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ function help() {
|
|
|
25
25
|
console.log(`Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke> DIR [NAME] [--cwd PATH]
|
|
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;
|
|
28
|
+
issue Issue a fresh 7-day client certificate; preserve local launch policy.
|
|
29
29
|
revoke Remove the named client from the allowlist. Does not delete saved credentials.
|
|
30
30
|
|
|
31
31
|
Options: --cwd PATH Local project identity for init/issue (default: current directory)
|
|
@@ -38,6 +38,7 @@ Examples:
|
|
|
38
38
|
node scripts/intercom-bridge-credentials.mjs revoke ~/.pi/intercom-bridge grok-bot
|
|
39
39
|
|
|
40
40
|
After issue/revoke, send SIGHUP to the helper PID to activate the allowlist.
|
|
41
|
+
Rotation/revocation stops that identity's helper-launched Pi children; journals remain.
|
|
41
42
|
Concurrent credential commands fail rather than overwrite each other's authorization.
|
|
42
43
|
After a crashed command, inspect DIR/.credentials.lock/owner and remove that lock
|
|
43
44
|
only after confirming its process has stopped.
|
|
@@ -247,6 +248,7 @@ try {
|
|
|
247
248
|
fingerprint256: credential.fingerprint256,
|
|
248
249
|
name,
|
|
249
250
|
cwd: resolve(args.values.cwd || previous?.cwd || process.cwd()),
|
|
251
|
+
...(previous?.launch === undefined ? {} : { launch: previous.launch }),
|
|
250
252
|
});
|
|
251
253
|
atomicConfig(configPath, config);
|
|
252
254
|
console.log(
|
|
@@ -4,7 +4,7 @@ import { lstatSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
|
|
6
6
|
function help() {
|
|
7
|
-
console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack> [options] [IDs...]
|
|
7
|
+
console.log(`Usage: node intercom-remote.mjs <register|list|inbox|send|ask|reply|ack|start|sessions|stop> [options] [IDs...]
|
|
8
8
|
|
|
9
9
|
Environment (required):
|
|
10
10
|
INTERCOM_BRIDGE_CA Path to trusted bridge CA certificate
|
|
@@ -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
|
+
--name NAME Start/reuse a named idle Pi session under local policy
|
|
21
|
+
--session-id ID Stop only a Pi session launched by this identity/helper
|
|
20
22
|
-h, --help Show help
|
|
21
23
|
|
|
22
24
|
Examples:
|
|
@@ -25,12 +27,21 @@ Examples:
|
|
|
25
27
|
node intercom-remote.mjs inbox
|
|
26
28
|
node intercom-remote.mjs reply --reply-to QUESTION_ID --message 'Proceed.'
|
|
27
29
|
node intercom-remote.mjs ack MESSAGE_ID
|
|
30
|
+
node intercom-remote.mjs start --name planner
|
|
31
|
+
node intercom-remote.mjs sessions
|
|
32
|
+
node intercom-remote.mjs stop --session-id NATIVE_SESSION_ID
|
|
28
33
|
|
|
29
34
|
Outputs JSON. Exit: 0 success, 1 API/TLS/transport failure, 2 invalid arguments.
|
|
30
35
|
No automatic retries: a lost response does not prove a message was unsent.`);
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
function allowedOptions(action) {
|
|
39
|
+
if (action === "start") {
|
|
40
|
+
return ["name"];
|
|
41
|
+
}
|
|
42
|
+
if (action === "stop") {
|
|
43
|
+
return ["session-id"];
|
|
44
|
+
}
|
|
34
45
|
if (action === "send") {
|
|
35
46
|
return ["to", "message"];
|
|
36
47
|
}
|
|
@@ -54,7 +65,17 @@ function requireMessage(recipient, message) {
|
|
|
54
65
|
}
|
|
55
66
|
}
|
|
56
67
|
|
|
68
|
+
function launchBody(action, options) {
|
|
69
|
+
const value = action === "start" ? options.name : options["session-id"];
|
|
70
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
71
|
+
throw new Error(action === "start" ? "--name is required." : "--session-id is required.");
|
|
72
|
+
}
|
|
73
|
+
return action === "start" ? { name: value } : { sessionId: value };
|
|
74
|
+
}
|
|
57
75
|
function requestBody(action, options, ids, timeout) {
|
|
76
|
+
if (action === "start" || action === "stop") {
|
|
77
|
+
return launchBody(action, options);
|
|
78
|
+
}
|
|
58
79
|
if (["send", "ask", "reply"].includes(action)) {
|
|
59
80
|
const recipient = action === "reply" ? options["reply-to"] : options.to;
|
|
60
81
|
requireMessage(recipient, options.message);
|
|
@@ -108,6 +129,8 @@ try {
|
|
|
108
129
|
message: { type: "string" },
|
|
109
130
|
"reply-to": { type: "string" },
|
|
110
131
|
"timeout-ms": { type: "string" },
|
|
132
|
+
name: { type: "string" },
|
|
133
|
+
"session-id": { type: "string" },
|
|
111
134
|
},
|
|
112
135
|
});
|
|
113
136
|
if (parsed.values.help) {
|
|
@@ -116,7 +139,20 @@ try {
|
|
|
116
139
|
}
|
|
117
140
|
const options = parsed.values;
|
|
118
141
|
[action] = parsed.positionals;
|
|
119
|
-
if (
|
|
142
|
+
if (
|
|
143
|
+
![
|
|
144
|
+
"register",
|
|
145
|
+
"list",
|
|
146
|
+
"inbox",
|
|
147
|
+
"send",
|
|
148
|
+
"ask",
|
|
149
|
+
"reply",
|
|
150
|
+
"ack",
|
|
151
|
+
"start",
|
|
152
|
+
"sessions",
|
|
153
|
+
"stop",
|
|
154
|
+
].includes(action)
|
|
155
|
+
) {
|
|
120
156
|
throw new Error("Choose an action; use --help.");
|
|
121
157
|
}
|
|
122
158
|
const allowed = allowedOptions(action);
|
|
@@ -159,7 +195,7 @@ try {
|
|
|
159
195
|
const files = tlsFiles();
|
|
160
196
|
origin.pathname = `/v1/${action}`;
|
|
161
197
|
const payload = JSON.stringify(body);
|
|
162
|
-
const get = ["list", "inbox"].includes(action);
|
|
198
|
+
const get = ["list", "inbox", "sessions"].includes(action);
|
|
163
199
|
const result = await new Promise((resolve, reject) => {
|
|
164
200
|
const req = https.request(
|
|
165
201
|
origin,
|
|
@@ -203,7 +239,7 @@ try {
|
|
|
203
239
|
() => {
|
|
204
240
|
req.destroy(new Error("Bridge request deadline exceeded; delivery may be unknown."));
|
|
205
241
|
},
|
|
206
|
-
(action === "ask" ? timeout : 0) + 15000,
|
|
242
|
+
action === "start" ? 90000 : (action === "ask" ? timeout : 0) + 15000,
|
|
207
243
|
);
|
|
208
244
|
req.once("close", () => clearTimeout(timer));
|
|
209
245
|
req.once("error", reject);
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
closeSync,
|
|
4
|
+
fstatSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from "node:fs";
|
|
2
10
|
import { isAbsolute } from "node:path";
|
|
3
11
|
import { createSecureContext } from "node:tls";
|
|
4
12
|
import { isUnknownArray } from "../shared/unknown.ts";
|
|
5
13
|
import { BODY_LIMIT, object, label, fingerprint } from "./bridge-protocol.ts";
|
|
14
|
+
import { launchPolicy, type LaunchPolicy } from "./bridge-launch-policy.ts";
|
|
6
15
|
|
|
7
16
|
const MAX_CLIENTS = 64;
|
|
8
17
|
|
|
@@ -10,6 +19,7 @@ export interface Identity {
|
|
|
10
19
|
readonly fingerprint256: string;
|
|
11
20
|
readonly name: string;
|
|
12
21
|
readonly cwd: string;
|
|
22
|
+
readonly launch?: LaunchPolicy;
|
|
13
23
|
}
|
|
14
24
|
export interface Config {
|
|
15
25
|
readonly port: number;
|
|
@@ -47,17 +57,26 @@ function identities(value: unknown): Map<string, Identity> {
|
|
|
47
57
|
const clients = new Map<string, Identity>();
|
|
48
58
|
const names = new Set<string>();
|
|
49
59
|
for (const raw of value) {
|
|
50
|
-
const entry = object(raw, ["fingerprint256", "name", "cwd"]);
|
|
60
|
+
const entry = object(raw, ["fingerprint256", "name", "cwd", "launch"]);
|
|
51
61
|
const fingerprint256 = fingerprint(entry.fingerprint256);
|
|
52
62
|
const name = label(entry.name, "name", 128).trim();
|
|
53
63
|
const cwd = label(entry.cwd, "cwd", 4096);
|
|
54
|
-
if (!isAbsolute(cwd)
|
|
55
|
-
throw new Error("Client cwd must be
|
|
64
|
+
if (!isAbsolute(cwd)) {
|
|
65
|
+
throw new Error("Client cwd must be absolute.");
|
|
66
|
+
}
|
|
67
|
+
const launch = launchPolicy(entry.launch);
|
|
68
|
+
if (launch && !statSync(cwd).isDirectory()) {
|
|
69
|
+
throw new Error("Launch cwd must be an existing directory.");
|
|
56
70
|
}
|
|
57
71
|
if (clients.has(fingerprint256) || names.has(name.toLowerCase())) {
|
|
58
72
|
throw new Error("Client fingerprints and names must be unique.");
|
|
59
73
|
}
|
|
60
|
-
clients.set(fingerprint256, {
|
|
74
|
+
clients.set(fingerprint256, {
|
|
75
|
+
fingerprint256,
|
|
76
|
+
name,
|
|
77
|
+
cwd: launch ? realpathSync(cwd) : cwd,
|
|
78
|
+
launch,
|
|
79
|
+
});
|
|
61
80
|
names.add(name.toLowerCase());
|
|
62
81
|
}
|
|
63
82
|
return clients;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import { isRecord, isUnknownArray } from "../shared/unknown.ts";
|
|
4
|
+
import { label, object, text } from "./bridge-protocol.ts";
|
|
5
|
+
|
|
6
|
+
export interface LaunchPolicy {
|
|
7
|
+
readonly packageRoot: string;
|
|
8
|
+
readonly cli: string;
|
|
9
|
+
readonly sessionDir: string;
|
|
10
|
+
readonly provider: string;
|
|
11
|
+
readonly model: string;
|
|
12
|
+
readonly context: string;
|
|
13
|
+
readonly extensions: readonly string[];
|
|
14
|
+
readonly discoverResources: boolean;
|
|
15
|
+
readonly trustProject: boolean;
|
|
16
|
+
readonly offline: boolean;
|
|
17
|
+
readonly maxSessions: number;
|
|
18
|
+
readonly startupTimeoutMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function absolute(value: unknown, field: string): string {
|
|
22
|
+
const path = label(value, field, 4096);
|
|
23
|
+
if (!isAbsolute(path)) {
|
|
24
|
+
throw new Error(`${field} must be absolute.`);
|
|
25
|
+
}
|
|
26
|
+
return realpathSync(path);
|
|
27
|
+
}
|
|
28
|
+
function bounded(value: unknown, fallback: number, max: number): number {
|
|
29
|
+
const number = value ?? fallback;
|
|
30
|
+
if (typeof number !== "number" || !Number.isInteger(number) || number < 1 || number > max) {
|
|
31
|
+
throw new Error("Invalid launch limit.");
|
|
32
|
+
}
|
|
33
|
+
return number;
|
|
34
|
+
}
|
|
35
|
+
function boolean(value: unknown, fallback: boolean): boolean {
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
if (typeof value !== "boolean") {
|
|
40
|
+
throw new Error("Launch switches must be boolean.");
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function executable(packageRoot: string): string {
|
|
45
|
+
const manifest: unknown = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
46
|
+
if (
|
|
47
|
+
!isRecord(manifest) ||
|
|
48
|
+
manifest.name !== "@earendil-works/pi-coding-agent" ||
|
|
49
|
+
!isRecord(manifest.bin)
|
|
50
|
+
) {
|
|
51
|
+
throw new Error("Launch package must be an installed Pi package.");
|
|
52
|
+
}
|
|
53
|
+
const bin = label(manifest.bin.pi, "Pi manifest bin", 4096);
|
|
54
|
+
const cli = realpathSync(join(packageRoot, bin));
|
|
55
|
+
const within = relative(packageRoot, cli);
|
|
56
|
+
if (within.startsWith("..") || isAbsolute(within) || !statSync(cli).isFile()) {
|
|
57
|
+
throw new Error("Pi bin must be a file inside the pinned package.");
|
|
58
|
+
}
|
|
59
|
+
return cli;
|
|
60
|
+
}
|
|
61
|
+
function extensions(value: unknown): readonly string[] {
|
|
62
|
+
if (!isUnknownArray(value) || value.length < 1 || value.length > 32) {
|
|
63
|
+
throw new Error("Launch extensions must contain 1..32 local paths.");
|
|
64
|
+
}
|
|
65
|
+
return value.map((entry) => absolute(entry, "extension path"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function launchPolicy(value: unknown): LaunchPolicy | undefined {
|
|
69
|
+
if (value === undefined) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const raw = object(value, [
|
|
73
|
+
"packageRoot",
|
|
74
|
+
"sessionDir",
|
|
75
|
+
"provider",
|
|
76
|
+
"model",
|
|
77
|
+
"context",
|
|
78
|
+
"extensions",
|
|
79
|
+
"discoverResources",
|
|
80
|
+
"trustProject",
|
|
81
|
+
"offline",
|
|
82
|
+
"maxSessions",
|
|
83
|
+
"startupTimeoutMs",
|
|
84
|
+
]);
|
|
85
|
+
const packageRoot = absolute(raw.packageRoot, "Pi package root");
|
|
86
|
+
const sessionDir = absolute(raw.sessionDir, "session directory");
|
|
87
|
+
const storage = lstatSync(sessionDir);
|
|
88
|
+
if (
|
|
89
|
+
!storage.isDirectory() ||
|
|
90
|
+
storage.uid !== process.getuid?.() ||
|
|
91
|
+
(storage.mode & 0o777) !== 0o700
|
|
92
|
+
) {
|
|
93
|
+
throw new Error("Launch storage must be an owned directory with mode 0700.");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
packageRoot,
|
|
97
|
+
cli: executable(packageRoot),
|
|
98
|
+
sessionDir,
|
|
99
|
+
provider: label(raw.provider, "provider", 128),
|
|
100
|
+
model: label(raw.model, "model", 256),
|
|
101
|
+
context: text(raw.context),
|
|
102
|
+
extensions: extensions(raw.extensions),
|
|
103
|
+
discoverResources: boolean(raw.discoverResources, false),
|
|
104
|
+
trustProject: boolean(raw.trustProject, false),
|
|
105
|
+
offline: boolean(raw.offline, false),
|
|
106
|
+
maxSessions: bounded(raw.maxSessions, 4, 16),
|
|
107
|
+
startupTimeoutMs: bounded(raw.startupTimeoutMs, 15000, 60000),
|
|
108
|
+
};
|
|
109
|
+
}
|