@fitchmultz/pi-subagents 0.44.4 → 0.44.6

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.
@@ -1,22 +1,27 @@
1
1
  import * as path from "node:path";
2
- import { cancelSupervisorQuestion, claimQuestionRevival, formatSupervisorQuestions, listSupervisorQuestions, questionProcessAlive, questionRecoveryHint, readQuestionState, recordQuestionDelivery, releaseQuestionRevival, saveQuestionAnswer } from "../shared/supervisor-questions.js";
2
+ import { formatRunAction } from "../../shared/status-format.js";
3
+ import { cancelSupervisorQuestion, claimQuestionRevival, formatSupervisorQuestions, listSupervisorQuestions, questionProcessAlive, questionRecoveryHint, readQuestionState, recordQuestionDelivery, releaseQuestionRevival, saveQuestionAnswer, supervisorQuestionPage } from "../shared/supervisor-questions.js";
3
4
  import { liveLaunchOverrideNotice, nestedResolutionScopeForExecutor, reviveSavedSubagent } from "./foreground-control.js";
4
- function questionResult(questions, text = formatSupervisorQuestions(questions)) {
5
- return { content: [{ type: "text", text }], details: { mode: "management", results: [], questions } };
5
+ function questionResult(questions, text) {
6
+ return { content: [{ type: "text", text }], details: { mode: "management", results: [], ...supervisorQuestionPage(questions) } };
6
7
  }
7
8
  export function projectSupervisorQuestions(result, params, ownerSessionId, childSafe = false) {
8
9
  if (result.isError && !result.content.some((item) => item.type === "text" && /Async run not found|Status file not found/.test(item.text)))
9
10
  return result;
10
- const questions = listSupervisorQuestions(ownerSessionId, params.id ?? params.runId ?? (params.dir ? path.basename(params.dir) : undefined))
11
+ const id = params.id ?? params.runId ?? (params.dir ? path.basename(params.dir) : undefined);
12
+ const questions = listSupervisorQuestions(ownerSessionId, id)
11
13
  .filter((question) => question.state === "awaiting_input" || question.state === "answer_pending");
12
14
  if (!questions.length)
13
15
  return result;
16
+ const page = supervisorQuestionPage(questions);
14
17
  const questionOnly = result.isError && result.content.some((item) => item.type === "text" && /Async run not found|Status file not found/.test(item.text));
15
18
  return {
16
19
  ...result,
17
20
  ...(questionOnly ? { isError: false } : {}),
18
- content: [{ type: "text", text: [`Supervisor input (not execution completion):\n${formatSupervisorQuestions(questions, childSafe)}`, ...(questionOnly ? [] : result.content.map((item) => item.type === "text" ? item.text : ""))].join("\n\n") }],
19
- details: { ...result.details, questions },
21
+ content: [{ type: "text", text: [`Supervisor input (not execution completion):\n${formatSupervisorQuestions(page.questions, childSafe)}`,
22
+ ...(page.questionList.nextOffset !== undefined ? [`More questions: ${formatRunAction("questions", id, { offset: page.questionList.nextOffset, limit: page.questionList.limit }, childSafe)}`] : []),
23
+ ...(questionOnly ? [] : result.content.map((item) => item.type === "text" ? item.text : ""))].join("\n\n") }],
24
+ details: { ...result.details, ...page },
20
25
  };
21
26
  }
22
27
  export function cancelSupervisorInput(result, params, ownerSessionId, events) {
@@ -34,7 +39,7 @@ export function cancelSupervisorInput(result, params, ownerSessionId, events) {
34
39
  }
35
40
  const cancelled = questions.map((question) => readQuestionState(question));
36
41
  return result.isError ? questionResult(cancelled, `Cancelled ${questions.length} pending supervisor question(s). Any live waiter will abort its agent; cancellation is requested, not proof of process exit.`)
37
- : { ...result, details: { ...result.details, questions: cancelled } };
42
+ : { ...result, details: { ...result.details, ...supervisorQuestionPage(cancelled) } };
38
43
  }
39
44
  export function controlSupervisorQuestion(input) {
40
45
  try {
@@ -45,9 +50,15 @@ export function controlSupervisorQuestion(input) {
45
50
  if (params.index !== undefined && (!Number.isSafeInteger(params.index) || params.index < 0))
46
51
  throw new Error("index must be a non-negative integer.");
47
52
  const questions = listSupervisorQuestions(input.ctx.sessionManager.getSessionId(), id)
48
- .filter((question) => params.index === undefined || question.index === params.index);
49
- if (params.action === "questions")
50
- return questionResult(questions, formatSupervisorQuestions(questions, Boolean(nestedResolutionScopeForExecutor(input.deps))));
53
+ .filter((question) => (params.index === undefined || question.index === params.index) && (params.action !== "questions" || params.questionId === undefined || question.questionId === params.questionId));
54
+ if (params.action === "questions") {
55
+ const page = supervisorQuestionPage(questions, params), childSafe = Boolean(nestedResolutionScopeForExecutor(input.deps));
56
+ const text = [formatSupervisorQuestions(page.questions, childSafe),
57
+ `Questions: ${page.questions.length} shown, ${page.questionList.total} total (offset ${page.questionList.offset}).`,
58
+ ...(page.questionList.nextOffset !== undefined ? [`More questions: ${formatRunAction("questions", id, { offset: page.questionList.nextOffset, limit: page.questionList.limit,
59
+ ...(params.index !== undefined ? { index: params.index } : {}), ...(params.questionId ? { questionId: params.questionId } : {}) }, childSafe)}`] : [])].join("\n\n");
60
+ return { content: [{ type: "text", text }], details: { mode: "management", results: [], ...page } };
61
+ }
51
62
  if (!id || !params.questionId)
52
63
  throw new Error("action='answer' requires id and questionId.");
53
64
  const question = questions.find((entry) => entry.questionId === params.questionId);
@@ -66,10 +77,10 @@ export function controlSupervisorQuestion(input) {
66
77
  const result = reviveSavedSubagent({ ...input, params: { ...params, message: `${answer.origin === "human" ? "Direct user answer (human origin)" : "Supervisor answer"} to question ${question.questionId}:\n\n${answer.message}\n\nOriginal question:\n${question.message}` } }, { ...question, source: "question" }, claim.runId);
67
78
  if (result.isError) {
68
79
  releaseQuestionRevival(question);
69
- return { ...result, details: { ...result.details, questions: [readQuestionState(question)] } };
80
+ return { ...result, details: { ...result.details, ...supervisorQuestionPage([readQuestionState(question)]) } };
70
81
  }
71
82
  recordQuestionDelivery(question, { kind: "revive", runId: claim.runId, deliveredAt: Date.now() });
72
- return { ...result, details: { ...result.details, questions: [readQuestionState(question)] } };
83
+ return { ...result, details: { ...result.details, ...supervisorQuestionPage([readQuestionState(question)]) } };
73
84
  }
74
85
  catch (error) {
75
86
  return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true, details: { mode: "management", results: [] } };
@@ -1,7 +1,8 @@
1
1
  import * as path from "node:path";
2
2
  import { writeAsyncControlRequest } from "../background/async-control.js";
3
3
  import { ownedRunExecutionResult, ownedRunProgressResult, ownedRunStatusResult, ownedRunView, resolveOwnedRun } from "../shared/run-records.js";
4
- import { getRunMetadataDir, listOwnedRunQuestions, questionProcessAlive, readRunJson } from "../shared/supervisor-questions.js";
4
+ import { formatSupervisorQuestions, getRunMetadataDir, listOwnedRunQuestions, questionProcessAlive, readRunJson, supervisorQuestionPage } from "../shared/supervisor-questions.js";
5
+ import { formatRunAction } from "../../shared/status-format.js";
5
6
  import { getSingleResultOutput, readStatus } from "../../shared/utils.js";
6
7
  import { INTERCOM_DETACH_REQUEST_EVENT, INTERCOM_DETACH_RESPONSE_EVENT, POLL_INTERVAL_MS } from "../../shared/types.js";
7
8
  import { resolveSubagentRunId } from "../background/run-id-resolver.js";
@@ -87,8 +88,9 @@ export async function waitForOwnedRun(input) {
87
88
  const questions = runQuestions.filter((question) => question.ownerSessionId === owner && (index === undefined || question.index === index) && question.state === "awaiting_input");
88
89
  if (questions.length) {
89
90
  const result = ownedRunStatusResult(target, deps.state);
90
- result.details.questions = questions;
91
- finish("awaiting_input", `Run ${target.runId} needs input; waiting ended without stopping it.\n\n${questions.map((question) => `Question ${question.questionId}: ${question.message}`).join("\n\n")}`, result);
91
+ const page = supervisorQuestionPage(questions), childSafe = Boolean(nestedResolutionScopeForExecutor(deps));
92
+ Object.assign(result.details, page);
93
+ finish("awaiting_input", `Run ${target.runId} needs input; waiting ended without stopping it.\n\n${formatSupervisorQuestions(page.questions, childSafe)}\n\nAll questions: ${formatRunAction("questions", target.runId, index === undefined ? {} : { index }, childSafe)}`, result);
92
94
  return;
93
95
  }
94
96
  if ((index !== undefined || view.resultPath) && children.every((child) => child.result && child.state !== "live" && child.state !== "unknown") && (index !== undefined || view.state !== "live")) {
@@ -64,6 +64,12 @@ export function recordRun(agent, task, exitCode, durationMs) {
64
64
  fs.mkdirSync(agentDir, { recursive: true });
65
65
  db = openHistoryDatabase(path.join(historyDirectory(agentDir), TIMING_DATABASE));
66
66
  const connection = db;
67
+ const imported = connection.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name='timing_meta'").get()
68
+ && connection.prepare("SELECT key FROM timing_meta WHERE key='legacy-imported'").get();
69
+ // Filesystem reads and JSON parsing must not consume another writer's admission budget.
70
+ const legacy = imported ? [] : legacySamples(agentDir).reverse();
71
+ // Allow brief native writer overlap; three attempts still bound best-effort admission.
72
+ connection.exec("PRAGMA busy_timeout=1000");
67
73
  historyTransaction(connection, () => {
68
74
  connection.exec(`CREATE TABLE IF NOT EXISTS timing_meta(key TEXT PRIMARY KEY);
69
75
  CREATE TABLE IF NOT EXISTS samples(sequence INTEGER PRIMARY KEY,agent TEXT NOT NULL,task TEXT NOT NULL,ts INTEGER NOT NULL,status TEXT NOT NULL,duration REAL NOT NULL,exit INTEGER);
@@ -71,7 +77,8 @@ export function recordRun(agent, task, exitCode, durationMs) {
71
77
  const insert = connection.prepare("INSERT INTO samples(agent,task,ts,status,duration,exit) VALUES (?,?,?,?,?,?)");
72
78
  const save = (sample) => insert.run(sample.agent, sample.task, sample.ts, sample.status, sample.duration, sample.exit ?? null);
73
79
  if (!connection.prepare("SELECT key FROM timing_meta WHERE key='legacy-imported'").get()) {
74
- for (const sample of legacySamples(agentDir).reverse())
80
+ // Another writer may have imported while this snapshot was being read.
81
+ for (const sample of legacy)
75
82
  save(sample);
76
83
  connection.exec("INSERT INTO timing_meta VALUES ('legacy-imported')");
77
84
  }
@@ -9,6 +9,33 @@ import { runCooperatively, runSynchronously } from "../../shared/cooperative.js"
9
9
  import { ASYNC_DIR, TEMP_ROOT_DIR } from "../../shared/types.js";
10
10
  export const LEGACY_QUESTIONS_DIR = path.join(TEMP_ROOT_DIR, "supervisor-questions");
11
11
  export const QUESTIONS_DIR = path.join(getAgentDir(), "sessions", "subagent-runs");
12
+ export function summarizeSupervisorQuestion(question) {
13
+ const directory = questionDir(question);
14
+ return {
15
+ questionId: question.questionId, runId: question.runId, ownerSessionId: question.ownerSessionId, ownerTarget: question.ownerTarget,
16
+ agent: question.agent, index: question.index, childSessionId: question.childSessionId, childTarget: question.childTarget,
17
+ sessionFile: question.sessionFile, cwd: question.cwd, pid: question.pid, processIdentity: question.processIdentity,
18
+ createdAt: question.createdAt, reason: question.reason, state: question.state, message: question.message.slice(0, 2048),
19
+ questionPath: path.join(directory, "question.json"),
20
+ ...(question.message.length > 2048 ? { messageTruncated: true } : {}),
21
+ ...(question.interview ? { hasInterview: true } : {}),
22
+ ...(question.answer ? { answer: { message: question.answer.message.slice(0, 2048), answeredAt: question.answer.answeredAt, origin: question.answer.origin },
23
+ answerPath: path.join(directory, "answer.json"), ...(question.answer.message.length > 2048 ? { answerTruncated: true } : {}) } : {}),
24
+ ...(question.delivery ? { delivery: { kind: question.delivery.kind, runId: question.delivery.runId, deliveredAt: question.delivery.deliveredAt } } : {}),
25
+ ...(question.revival ? { revival: { runId: question.revival.runId, pid: question.revival.pid, startedAt: question.revival.startedAt } } : {}),
26
+ };
27
+ }
28
+ export function supervisorQuestionPage(questions, options = {}) {
29
+ const offset = options.offset ?? 0, limit = options.limit ?? 20;
30
+ if (!Number.isSafeInteger(offset) || offset < 0)
31
+ throw new Error("Question offset must be a non-negative integer.");
32
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
33
+ throw new Error("Question limit must be from 1 to 100.");
34
+ const pending = (question) => question.state === "awaiting_input" || question.state === "answer_pending" ? 0 : 1;
35
+ const sorted = questions.toSorted((a, b) => pending(a) - pending(b) || a.createdAt - b.createdAt || a.questionId.localeCompare(b.questionId));
36
+ return { questions: sorted.slice(offset, offset + limit).map((question) => summarizeSupervisorQuestion(question)),
37
+ questionList: { total: questions.length, offset, limit, ...(offset + limit < questions.length ? { nextOffset: offset + limit } : {}) } };
38
+ }
12
39
  function safeId(value) {
13
40
  if (typeof value !== "string" || !/^[a-zA-Z0-9_-]+$/.test(value))
14
41
  throw new Error("Invalid run or question ID.");
@@ -311,10 +338,13 @@ export function formatSupervisorQuestions(questions, childSafe = false) {
311
338
  `Run: ${question.runId} | Child: ${question.index} (${question.agent}) | Owner: ${question.ownerSessionId}`,
312
339
  `Session: ${question.sessionFile} | Child target: ${question.childTarget}`,
313
340
  question.message,
314
- ...(question.answer ? [`Saved answer: ${question.answer.message}`] : []),
341
+ ...(question.messageTruncated ? [`[Question preview truncated. Full question: ${question.questionPath}]`] : []),
342
+ ...(question.hasInterview ? [`Structured interview: ${question.questionPath}`] : []),
343
+ ...(question.answer ? [`Saved answer: ${question.answer.message}`, ...(question.answerTruncated ? [`[Answer preview truncated. Exact saved answer: ${question.answerPath}]`] : [])] : []),
315
344
  ...(question.revival && !question.delivery ? [`Continuation requested: ${question.revival.runId}; delivery unconfirmed. Answer retained.`, questionRecoveryHint(question, childSafe)] : []),
316
345
  question.delivery ? `Answer delivered via ${question.delivery.kind}; run: ${question.delivery.runId}`
317
346
  : question.state === "cancelled" ? "Question cancelled; use continue for a new follow-up."
318
- : `Answer: ${formatRunAction("answer", question.runId, { questionId: question.questionId, message: question.answer?.message ?? "..." }, childSafe)}`,
347
+ : question.answerTruncated ? `Retry answer with the exact saved message from ${question.answerPath}; a preview is not a valid replacement.`
348
+ : `Answer: ${formatRunAction("answer", question.runId, { questionId: question.questionId, message: question.answer?.message ?? "..." }, childSafe)}`,
319
349
  ].join("\n")).join("\n\n");
320
350
  }
@@ -7,7 +7,7 @@ export function formatRunAction(action, id, fields = {}, childSafe = false) {
7
7
  const legacyChild = childSafe && loadConfig().compactChildTools === false;
8
8
  const parentAction = { status: "inspect", resume: "continue", interrupt: "stop" };
9
9
  const name = legacyChild ? action : parentAction[action] ?? action;
10
- const args = Object.entries({ action: name, id, ...fields }).map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join(", ");
10
+ const args = Object.entries({ action: name, ...(id !== undefined ? { id } : {}), ...fields }).map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join(", ");
11
11
  const call = `${legacyChild || action === "extend" ? "subagent" : "agent_runs"}({ ${args} })`;
12
12
  return action === "extend" && !legacyChild ? `load_subagent({}), then ${call}` : call;
13
13
  }
@@ -0,0 +1,212 @@
1
+ # Secure remote Intercom bridge
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.
4
+
5
+ ## Design note
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 proxy opens `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
+
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
+
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 only register/list/send/ask/reply, plus inbox/ack receipt plumbing needed to receive messages and answer local asks. No broker admin, topic operations, file reads, or shell endpoint.
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.
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.
16
+
17
+ **Audit:** JSONL metadata on stdout: request/connection identity, certificate fingerprint, operation, peer/message IDs and outcome. 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
+
19
+ ## Mac setup
20
+
21
+ Requires Node **24.21.0+**, OpenSSL with `req -addext` (Homebrew OpenSSL works), and a connected Pi session with Intercom. Run commands from this checkout/package directory. A runtime-only installed checkout already has `dist/`; for development use `npm ci` first. See the [installation guide](../README.md#installation) before rebuilding an in-use extension. This standalone helper can run from its own installed copy without touching the live Pi runtime.
22
+
23
+ ```bash
24
+ # Use a NEW directory; init refuses to overwrite any existing CA.
25
+ umask 077
26
+ BRIDGE="$HOME/.pi/intercom-bridge"
27
+ node scripts/intercom-bridge-credentials.mjs init "$BRIDGE" --cwd "$PWD"
28
+ ```
29
+
30
+ The last JSON line contains paths to the issued client `cert`/`key`, trusted `ca`, and `config`; it does not print secret contents. Keep those paths for the copy step. Configuration is local-only, owned by the Mac user and private:
31
+
32
+ ```json
33
+ {
34
+ "port": 9443,
35
+ "ca": "/Users/mitch/.pi/intercom-bridge/ca.crt",
36
+ "cert": "/Users/mitch/.pi/intercom-bridge/server.crt",
37
+ "key": "/Users/mitch/.pi/intercom-bridge/server.key",
38
+ "clients": [{
39
+ "fingerprint256": "SHA256_FINGERPRINT_FROM_ISSUE",
40
+ "name": "grok-bot",
41
+ "cwd": "/Users/mitch/project"
42
+ }]
43
+ }
44
+ ```
45
+
46
+ `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.
47
+
48
+ Start the helper in a dedicated terminal (or supervise this exact command with launchd):
49
+
50
+ ```bash
51
+ # Match Pi's agent directory and temp environment, especially for launchd.
52
+ export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
53
+ export TMPDIR="$(getconf DARWIN_USER_TEMP_DIR)"
54
+ node dist/pi-intercom/bridge.js --config "$BRIDGE/config.json" >> "$BRIDGE/audit.jsonl"
55
+ ```
56
+
57
+ The listening record includes the helper PID and port. Record the PID as `HELPER_PID` in your administration terminal; **do not use the broker PID**. Wait for that readiness record before making a request: a running process is not proof its listener is ready. Verify `lsof -nP -iTCP:9443 -sTCP:LISTEN` shows only `127.0.0.1:9443`. Keep the helper running; no per-message approvals are needed.
58
+
59
+ For the installed Mac LaunchAgent (`com.fitchmultz.pi-intercom-bridge`), inspect and administer it with:
60
+
61
+ ```bash
62
+ launchctl print "gui/$(id -u)/com.fitchmultz.pi-intercom-bridge"
63
+ # Reload allowlist without restarting:
64
+ launchctl kill SIGHUP "gui/$(id -u)/com.fitchmultz.pi-intercom-bridge"
65
+ # Stop immediately; remove the plist too to disable future login starts:
66
+ launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.fitchmultz.pi-intercom-bridge.plist"
67
+ # Start again explicitly:
68
+ launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.fitchmultz.pi-intercom-bridge.plist"
69
+ ```
70
+
71
+ 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.
72
+
73
+ ### Tunnel and provision Grok
74
+
75
+ Use the **known, approved** Linux SSH host/account, not an invented example host. Preserve SSH host-key verification and do not forward your SSH agent:
76
+
77
+ ```bash
78
+ GROK_SSH='grok@YOUR_LINUX_HOST'
79
+ # On Linux, first inspect effective SSH config with the administrator:
80
+ # sudo sshd -T | grep gatewayports
81
+ # Require GatewayPorts=no (default) or clientspecified, NEVER yes.
82
+
83
+ # On the Mac, dedicated terminal/supervised process:
84
+ ssh -NT -a -o BatchMode=yes -o StrictHostKeyChecking=yes \
85
+ -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
86
+ -R 127.0.0.1:9443:127.0.0.1:9443 "$GROK_SSH"
87
+ ```
88
+
89
+ **On Linux, verify `ss -ltn '( sport = :9443 )'` shows only `127.0.0.1:9443` before use.** `GatewayPorts=yes` forces wildcard binding even when `-R` requests loopback. If it shows `0.0.0.0`, `*`, or `[::]`, stop the tunnel and correct that SSH policy. Do not open a firewall port. OpenSSH references: [ssh `-R`](https://man.openbsd.org/ssh.1) and [sshd `GatewayPorts`](https://man.openbsd.org/sshd_config.5#GatewayPorts).
90
+
91
+ Transfer **only** the client certificate/key, public `ca.crt`, and the dependency-free remote script through the existing approved SSH/SFTP channel:
92
+
93
+ ```bash
94
+ # Use cert/key paths printed by init/issue, not the CA or server keys.
95
+ CLIENT_CERT='/absolute/path/from/issue/grok-bot-ISSUED.crt'
96
+ CLIENT_KEY='/absolute/path/from/issue/grok-bot-ISSUED.key'
97
+ ssh "$GROK_SSH" 'umask 077; mkdir -p ~/.config/pi-intercom; chmod 700 ~/.config/pi-intercom'
98
+ scp "$CLIENT_CERT" "$GROK_SSH:.config/pi-intercom/client.crt"
99
+ scp "$CLIENT_KEY" "$GROK_SSH:.config/pi-intercom/client.key"
100
+ scp "$BRIDGE/ca.crt" scripts/intercom-remote.mjs "$GROK_SSH:.config/pi-intercom/"
101
+ ssh "$GROK_SSH" 'chmod 600 ~/.config/pi-intercom/*'
102
+ ```
103
+
104
+ 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.
105
+
106
+ ## Grok Bot: exact remote commands
107
+
108
+ On Linux, configure Grok's shell environment:
109
+
110
+ ```bash
111
+ export INTERCOM_BRIDGE_URL='https://127.0.0.1:9443'
112
+ export INTERCOM_BRIDGE_CA="$HOME/.config/pi-intercom/ca.crt"
113
+ export INTERCOM_BRIDGE_CERT="$HOME/.config/pi-intercom/client.crt"
114
+ export INTERCOM_BRIDGE_KEY="$HOME/.config/pi-intercom/client.key"
115
+ REMOTE="$HOME/.config/pi-intercom/intercom-remote.mjs"
116
+
117
+ node "$REMOTE" register
118
+ node "$REMOTE" list
119
+ node "$REMOTE" send --to planner --message 'Grok checking in. Please send your current status.'
120
+ node "$REMOTE" ask --to planner --message 'What is the current status?' --timeout-ms 120000
121
+ node "$REMOTE" inbox
122
+ node "$REMOTE" reply --reply-to QUESTION_ID_FROM_INBOX --message 'Proceed with the stable API.'
123
+ node "$REMOTE" ack ORDINARY_MESSAGE_ID_FROM_INBOX
124
+ ```
125
+
126
+ 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.
127
+
128
+ Curl works too, with bounded waiting and the same TLS files:
129
+
130
+ ```bash
131
+ curl --fail-with-body --max-time 135 \
132
+ --cacert "$INTERCOM_BRIDGE_CA" --cert "$INTERCOM_BRIDGE_CERT" --key "$INTERCOM_BRIDGE_KEY" \
133
+ -H 'Content-Type: application/json' \
134
+ -d '{"to":"planner","message":"Please reply with your status.","timeoutMs":120000}' \
135
+ "$INTERCOM_BRIDGE_URL/v1/ask"
136
+ ```
137
+
138
+ ### JSON API contract
139
+
140
+ 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.
141
+
142
+ | Route | Request | Success fields |
143
+ |---|---|---|
144
+ | `POST /v1/register` | `{}` | `sessionId`, `name` (also automatic on other operations) |
145
+ | `GET /v1/list` | none | `sessionId`, `sessions` (metadata + unambiguous targets) |
146
+ | `POST /v1/send` | `{to,message}` | `id`, `accepted`, `delivered` |
147
+ | `POST /v1/ask` | `{to,message,timeoutMs?}` | send receipt and `reply:{from,message}` |
148
+ | `GET /v1/inbox` | none | `messages:[{from,message}]`, `overflow`, `lostMessages` |
149
+ | `POST /v1/reply` | `{replyTo,message}` | send receipt; target derived from the received ask |
150
+ | `POST /v1/ack` | `{ids:[...]}` | `acked`, `retained` |
151
+
152
+ 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.
153
+
154
+ ## Rotate credentials
155
+
156
+ Client renewal replaces its old fingerprint rather than authorizing both indefinitely. Credential commands hold a private directory lock across the complete config read/modify/write and certificate issuance, so concurrent operations fail instead of restoring a revoked fingerprint. After a crashed command, inspect `DIR/.credentials.lock/owner`, confirm that process is gone, then remove that lock and retry; never clear a live command's lock.
157
+
158
+ Renew with:
159
+
160
+ ```bash
161
+ node scripts/intercom-bridge-credentials.mjs issue "$BRIDGE" grok-bot
162
+ # Transfer the NEW cert/key using the same private copy commands above.
163
+ kill -HUP "$HELPER_PID"
164
+ ```
165
+
166
+ SIGHUP rereads the private config, drops removed/changed peers and their TLS connections, and cancels pending waits. Confirm the reload audit record and verify the old certificate is denied. Invalid reload fails closed. Client expiry also applies to existing connections/peer leases, not just new TLS handshakes. Restart Grok's client process if it caches its files.
167
+
168
+ Server certificate expires after one year; CA after ten. To rotate server/CA, create a **new** credential directory with `init`, transfer its new public CA and new client credential, stop/restart **only this helper** with the new config, and retain the old directory briefly as a private rollback. Never restart the shared broker or change its socket permissions for rotation. If the CA key is compromised, replace the entire CA immediately. Old private key files retained by `issue` should be removed only when no rollback is needed.
169
+
170
+ ## How to revoke Grok
171
+
172
+ ```bash
173
+ node scripts/intercom-bridge-credentials.mjs revoke "$BRIDGE" grok-bot
174
+ kill -HUP "$HELPER_PID"
175
+ ```
176
+
177
+ Confirm the reload/revocation audit record, that Grok disappears from the local peer list, and that its old cert cannot call `/v1/list`. Stop the SSH forward too if no other client needs it. Remove Grok's saved client key on Linux and obsolete local client keys when no longer needed. Emergency immediate cut-off: stop **the helper process**, not Pi or the broker. Certificate renewal cannot bypass the removed fingerprint; reauthorization requires a new local `issue` + reload.
178
+
179
+ ## Verification / smoke transcript
180
+
181
+ The process-boundary integration test uses a private real broker, real TLS certificates, actual HTTPS requests and the remote CLI; it requires no model/provider credentials:
182
+
183
+ ```bash
184
+ node --test test/integration/intercom-bridge.test.ts
185
+ # Do not inject host startup diagnostics into native fixture stderr:
186
+ env -u PI_TIMING -u PI_EXTENSION_PERFORMANCE npm run ci
187
+ ```
188
+
189
+ For a live smoke, from a separate Mac process and then Linux use the remote CLI to list peers and ask an intentionally selected connected session:
190
+
191
+ ```bash
192
+ node "$REMOTE" list
193
+ node "$REMOTE" ask --to KNOWN_FULL_SESSION_ID --message 'Bridge smoke only: reply with bridge-smoke-ok.' --timeout-ms 120000
194
+ ```
195
+
196
+ Save only a redacted transcript: platform, operation, returned peer names relevant to the test, accepted/delivered, and reply text. Do not publish unrelated cwd/model/topic/message content.
197
+
198
+ Observed **2026-10-06**, from a separate process on the Mac through the installed runtime-only helper to the live broker and a real connected Pi session (not a mocked recipient):
199
+
200
+ ```json
201
+ {
202
+ "platform": "darwin",
203
+ "transport": "loopback HTTPS mTLS",
204
+ "list": {"ok": true, "peers": ["secure-intercom-bridge", "grok-bot"]},
205
+ "ask": {
206
+ "ok": true, "accepted": true, "delivered": true, "replied": true,
207
+ "reply": "bridge-smoke-ok"
208
+ }
209
+ }
210
+ ```
211
+
212
+ **Off-box deployment/smoke is not yet verified:** the actual Grok Linux SSH destination/access was unavailable. No credentials were transferred to an unverified host. Once that access is supplied, verify Linux's listener and repeat these exact list/ask commands there.
package/docs/intercom.md CHANGED
@@ -305,6 +305,8 @@ agent_runs({ action: "stop", id: "<run-id>" })
305
305
 
306
306
  The full `subagent` tool also accepts `questions` and `answer`; its stop action is `interrupt`. Questions expose `awaiting_input`, `answer_pending`, `answered`, or `cancelled` independently of run completion. Ownership follows the saved supervisor session, not its cwd or display name. The live intercom reply path saves the same answer. Saving an answer or cancelling through run controls also clears the matching live intercom pending ask; a nudge does not resolve a question.
307
307
 
308
+ Question lists page 20 summaries by default, unresolved first. Use `limit: 1–100`, then pass `details.questionList.nextOffset` as `offset`; `id`, `index` and `questionId` narrow the lookup. Summaries retain identity, state, delivery and revival evidence without copying the saved launch or attempt baseline. Previews stop at 2,048 characters. Read `questionPath` for the complete question/interview/contract and `answerPath` for the exact saved answer; never retry an answer using a truncated preview. Browsing does not modify these durable files.
309
+
308
310
  Needs-attention notices for a matching unresolved question say **Waiting for supervisor input** and include the question ID and answer call. `answer_pending` still means delivery is unconfirmed, not that the child resumed. A live waiter consumes the durable answer. If it exited, answering revives its saved Pi session with the original acceptance contract and a new run ID. Identical repeated answers do not launch duplicate work; conflicting answers leave the original intact. `stop` cancels the question and aborts any live waiter. An interrupted pre-launch revival can be recovered with the exact `continue` call shown in the answer receipt; ambiguous launch evidence is not silently retried.
309
311
 
310
312
  Questions, saved launch contracts, and canonical version-2 run status/results live under `${PI_CODING_AGENT_DIR:-~/.pi/agent}/sessions/subagent-runs`, separate from disposable temporary files. Pending records are not age-cleaned. Active legacy owners keep their original runtime and files until they finish; recovery preserves available evidence for the same saved owner without replaying work or deleting history. Ordinary peer asks still use `askTimeoutMs` and are not durable supervisor questions.
@@ -572,7 +574,7 @@ Assistant-error replies wait for broker acknowledgement during `message_end`, bo
572
574
 
573
575
  Historical official Pi 0.87.1 checks exposed queue visibility and prompt-preparation races. Current tests preserve exact-once receipts and input rejection/recovery on the selected host, including the genuine Pi 1.0 pre-admission window described above; they do not promise one combined provider turn. Historical results are not a current 1.0 pass. Use an explicit `async: false` wait when dependent work needs a finalized result. Native pending-call, idle-checkpoint and immediate-usage APIs are no longer supported. See [result delivery](../README.md#host-capabilities-and-result-delivery) and [local qualification](../README.md#local-validation).
574
576
 
575
- - **Same machine only** — Uses local Unix sockets, no network support
577
+ - **Same machine only** — The core broker uses local Unix sockets, no network support. The optional [secure remote bridge](intercom-bridge.md) authenticates remote messaging through a separate loopback mTLS proxy; it does not expose the broker socket.
576
578
  - **No dedicated intercom log** — Messages are kept in Pi session history, but there is no separate intercom transcript or inbox
577
579
  - **No attachments UI** — `file`, `snippet`, and `context` attachments are supported in the protocol, but not in the compose overlay
578
580
  - **Only connected sessions appear** — The list shows Pi sessions that have loaded `pi-intercom` and successfully registered with the broker, not every open Pi process on the machine
@@ -9,10 +9,10 @@ Startup recovers saved ownership, unanswered questions and Intercom receipts in
9
9
  ### Indexed browsing and lazy Agents work
10
10
 
11
11
  - A separate Node process incrementally indexes only the current saved parent's admitted runs and linked native sources. A fresh session with no owned work starts no history process or database until it browses. No directory-wide transcript backfill runs at startup.
12
- - List filters and attention/newest/oldest ordering apply globally before a bounded page. Ordinary tool lists return 20 runs by default, at most 100; the Agents picker loads 50 runs and searches full assignments, not just shortened labels. Latest-assignment queries retain unsuperseded siblings while hiding older continuation attempts.
12
+ - List filters and attention/newest/oldest ordering apply globally before a bounded page. Ordinary tool lists return 20 runs by default, at most 100; the Agents picker loads 50 runs and searches full assignments, not just shortened labels. Latest-assignment queries retain unsuperseded siblings while hiding older continuation attempts. Native branch configuration reuses at most 128 compact results across rows and queries, keyed by physical source generation, committed cursor, observed stamp and exact terminal/time/leaf boundary. Publication (including backdated records), replacement and reset invalidate reuse; null leaves and strict missing/cyclic-boundary errors retain their original meaning.
13
13
  - The parent UI does not scan child journals on startup, ticks, filtering or pagination. Only an opened conversation loads a 100-physical-record page; components are retained for that page. Full details and contextual Reply validate selected native byte references off-thread, including paired tool results outside the current page. Explicit navigation supersedes older requests; background refreshes are coalesced and retain the settled display rather than briefly adding loading rows. Reopening the picker restores its visible filter and page.
14
14
  - Catch-up, degraded sources, loading, no matches and unavailable history are distinct states. F5 explicitly retries. Drafts, reading anchors, unread boundaries and pins survive same-parent reopen; a changed owner or abandoned overlay cannot accept late results.
15
- - Search indexes saved visible text, not thinking, tool arguments, image payloads, hidden custom data or provider fields. Terminal sequences are removed incrementally before tokenization, including sequences split across read/parser chunks. Its grammar is lexical words or one quoted phrase. Unquoted words must all occur in the same native record, across any of its visible fields/windows; quoted phrases must remain contiguous in one field. Each record contributes one excerpt per matching child attempt. Filters and attempt terminal/time boundaries apply before ranking/paging. Common terms may still require broad matching and sorting: a small result page is not a constant-work guarantee. Deadlines and cancellation stop the owned history process, which can reopen and replay committed index progress.
15
+ - Search indexes saved visible text, not thinking, tool arguments, image payloads, hidden custom data or provider fields. Terminal sequences are removed incrementally before tokenization, including sequences split across read/parser chunks. Its grammar is lexical words or one quoted phrase. Unquoted words must all occur in the same native record, across any of its visible fields/windows; quoted phrases must remain contiguous in one field. Each record contributes one excerpt per matching child attempt. Filters and attempt terminal/time boundaries apply before ranking/paging. Run/child/agent scopes restrict linked physical sources before match aggregation without changing global relevance scores. Each scoped source is scanned once, even when many continuation attempts share it; terminal/time boundaries and attempt attribution still apply before ranking/paging in the final join. Common terms still scan global FTS postings, and global searches may require broad matching and sorting: a small result page is not a constant-work guarantee. Deadlines and cancellation stop the owned history process, which can reopen and replay committed index progress.
16
16
  - Browse freshness is never authoritative. Selected controls recheck canonical owner facts before acting. Human-message delivery and a later nonempty assistant reply are separate observations. Receipt integrity and native accounting safeguards remain unchanged.
17
17
 
18
18
  ### Storage, budgets and recovery
@@ -34,7 +34,7 @@ The changed boundaries are exercised through real extension functions and regist
34
34
  - Native Intercom reload restores all 1,024 historical receipts while servicing input. Cleared-queue recovery also services input before its 100 followers finish appending, retaining each message once and one final wake-up across an in-flight reload or a cleared busy handoff. New results and explicit steers are staged before acknowledgement and handed to native steering for the next tool boundary; ordinary queued and passive arrivals wait.
35
35
  - 125 owned assignments: global filtering finds an item beyond the first two picker pages; all later pages remain reachable without UI-thread source access.
36
36
  - 65 retained runs: off-page reads/stats and migration scans are zero while displayed controls, questions, review and continuation identities remain fresh.
37
- - Warm indexed list/search queries perform no source checks, opens or canonical projections. Cold ingestion and explicit refresh still do necessary verification off-thread.
37
+ - Warm indexed list/search queries perform no source checks, opens or canonical projections. Cold ingestion and explicit refresh still do necessary verification off-thread. Fifty genuinely admitted live rows sharing an 80,004-entry native journal retain correct state, model, thinking and bounded history through the real worker's unchanged three-second deadline under a 96 MiB JavaScript heap; completed terminal branches remain correct. An unchanged 1,000-run native census keeps projecting all handles but emits only settled freshness, while canonical changes and source failure/recovery still notify. This removes redundant notifications, not the census or UI polling.
38
38
  - A 512 MiB discarded native field preserves exact eligible usage/IDs/configuration/history, with unchanged source bytes, under a **96 MiB** heap limit. Structural over-budget image/deep/key records degrade explicitly while later visible text remains searchable.
39
39
  - Completed-attempt search cannot return a successor's later text from a shared physical source. Full selected details reject missing/replaced/truncated sources; canonical output reads reject concurrent growth beyond their snapshot.
40
40
  - Competing history processes preserve staged configuration/text on normal completion and recover after a writer is killed. Schema-4 indexes with intact cursors but missing entries rebuild from unchanged journals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.44.4",
3
+ "version": "0.44.6",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,6 +33,7 @@
33
33
  "skills/**/*",
34
34
  "prompts/**/*",
35
35
  "docs/intercom.md",
36
+ "docs/intercom-bridge.md",
36
37
  "docs/performance.md",
37
38
  "README.md",
38
39
  "CHANGELOG.md"
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { randomBytes, X509Certificate } from "node:crypto";
4
+ import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
5
+ import { resolve, join } from "node:path";
6
+ import { parseArgs } from "node:util";
7
+
8
+ function help() {
9
+ console.log(`Usage: node scripts/intercom-bridge-credentials.mjs <init|issue|revoke> DIR [NAME] [--cwd PATH]
10
+
11
+ init Create a new private CA, loopback server certificate and grok-bot credential.
12
+ issue Issue a fresh 7-day client certificate; replace that name's old authorization.
13
+ revoke Remove the named client from the allowlist. Does not delete saved credentials.
14
+
15
+ Options: --cwd PATH Local project identity for init/issue (default: current directory)
16
+ -h, --help Show help
17
+ Environment: OPENSSL OpenSSL executable (default: openssl; requires -addext support)
18
+
19
+ Examples:
20
+ node scripts/intercom-bridge-credentials.mjs init ~/.pi/intercom-bridge --cwd "$PWD"
21
+ node scripts/intercom-bridge-credentials.mjs issue ~/.pi/intercom-bridge grok-bot
22
+ node scripts/intercom-bridge-credentials.mjs revoke ~/.pi/intercom-bridge grok-bot
23
+
24
+ After issue/revoke, send SIGHUP to the helper PID to activate the allowlist.
25
+ Concurrent credential commands fail rather than overwrite each other's authorization.
26
+ After a crashed command, inspect DIR/.credentials.lock/owner and remove that lock
27
+ only after confirming its process has stopped.
28
+ Exit: 0 success, 1 filesystem/OpenSSL failure, 2 invalid arguments.
29
+ Never transfer ca.key or server.key to the remote host.`);
30
+ }
31
+
32
+ function privatePath(path, directory = false) {
33
+ const st = lstatSync(path);
34
+ if (st.isSymbolicLink() || !(directory ? st.isDirectory() : st.isFile()) ||
35
+ st.uid !== process.getuid() || (st.mode & 0o077)) {
36
+ throw new Error(`Expected an owned, private ${directory ? "directory" : "file"}: ${path}`);
37
+ }
38
+ }
39
+
40
+ function atomicConfig(path, config) {
41
+ const temp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
42
+ try {
43
+ writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
44
+ renameSync(temp, path);
45
+ } finally {
46
+ rmSync(temp, { force: true });
47
+ }
48
+ }
49
+
50
+ function openssl(...args) {
51
+ execFileSync(process.env.OPENSSL || "openssl", args, { stdio: ["ignore", "pipe", "pipe"], timeout: 30000 });
52
+ }
53
+
54
+ function key(path) {
55
+ openssl("ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", path);
56
+ privatePath(path);
57
+ }
58
+
59
+ function issueCertificate(dir, stem, name, server = false) {
60
+ const keyPath = join(dir, `${stem}.key`);
61
+ const certPath = join(dir, `${stem}.crt`);
62
+ const csrPath = join(dir, `${stem}.csr`);
63
+ const extPath = join(dir, `${stem}.ext`);
64
+ key(keyPath);
65
+ try {
66
+ openssl("req", "-new", "-key", keyPath, "-subj", `/CN=${name}`, "-out", csrPath);
67
+ writeFileSync(extPath, `basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=${server ? "serverAuth" : "clientAuth"}\n${server ? "subjectAltName=DNS:localhost,IP:127.0.0.1\n" : ""}`, { mode: 0o600, flag: "wx" });
68
+ openssl("x509", "-req", "-in", csrPath, "-CA", join(dir, "ca.crt"), "-CAkey", join(dir, "ca.key"),
69
+ "-set_serial", `0x${randomBytes(16).toString("hex")}`, "-days", server ? "365" : "7", "-sha256", "-extfile", extPath, "-out", certPath);
70
+ } finally {
71
+ rmSync(csrPath, { force: true });
72
+ rmSync(extPath, { force: true });
73
+ }
74
+ const cert = new X509Certificate(readFileSync(certPath));
75
+ return { cert: certPath, key: keyPath, fingerprint256: cert.fingerprint256, expires: cert.validTo };
76
+ }
77
+
78
+ let args;
79
+ try {
80
+ args = parseArgs({ allowPositionals: true, options: { help: { type: "boolean", short: "h" }, cwd: { type: "string" } } });
81
+ if (args.values.help) { help(); process.exit(0); }
82
+ const [action, dir, name] = args.positionals;
83
+ if (!["init", "issue", "revoke"].includes(action) || !dir || args.positionals.length > 3 ||
84
+ (action !== "init" && !name) || (name && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) ||
85
+ (action === "revoke" && args.values.cwd)) throw new Error("Invalid arguments; use --help.");
86
+ } catch (error) {
87
+ console.error(error.message);
88
+ process.exit(2);
89
+ }
90
+
91
+ let lock;
92
+ try {
93
+ process.umask(0o077);
94
+ const [action, rawDir, suppliedName] = args.positionals;
95
+ const dir = resolve(rawDir);
96
+ const name = suppliedName || "grok-bot";
97
+ const configPath = join(dir, "config.json");
98
+ let config;
99
+ if (action === "init") mkdirSync(dir, { mode: 0o700 }); // Never overwrite a CA.
100
+ privatePath(dir, true);
101
+ const lockPath = join(dir, ".credentials.lock");
102
+ try { mkdirSync(lockPath, { mode: 0o700 }); }
103
+ catch (error) {
104
+ if (error.code === "EEXIST") throw new Error(`Another credential command holds ${lockPath}; inspect its owner before retrying.`);
105
+ throw error;
106
+ }
107
+ lock = lockPath;
108
+ writeFileSync(join(lock, "owner"), `${process.pid}\n`, { mode: 0o600, flag: "wx" });
109
+ if (action === "init") {
110
+ key(join(dir, "ca.key"));
111
+ openssl("req", "-new", "-x509", "-key", join(dir, "ca.key"), "-days", "3650", "-sha256", "-subj", "/CN=Pi Intercom Bridge CA",
112
+ "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", "-addext", "keyUsage=critical,keyCertSign,cRLSign", "-out", join(dir, "ca.crt"));
113
+ const server = issueCertificate(dir, "server", "localhost", true);
114
+ config = { port: 9443, ca: join(dir, "ca.crt"), cert: server.cert, key: server.key, clients: [] };
115
+ } else {
116
+ privatePath(dir, true);
117
+ privatePath(configPath);
118
+ config = JSON.parse(readFileSync(configPath, "utf8"));
119
+ if (!Array.isArray(config.clients)) throw new Error("Invalid clients allowlist.");
120
+ }
121
+ if (action === "revoke") {
122
+ if (!config.clients.some((client) => client.name === name)) throw new Error(`No authorized client named ${name}.`);
123
+ config.clients = config.clients.filter((client) => client.name !== name);
124
+ atomicConfig(configPath, config);
125
+ console.log(JSON.stringify({ revoked: name, config: configPath, next: "Send SIGHUP to the helper PID." }));
126
+ } else {
127
+ privatePath(join(dir, "ca.key"));
128
+ const credential = issueCertificate(dir, `${name}-${randomBytes(8).toString("hex")}`, name);
129
+ const previous = config.clients.find((client) => client.name === name);
130
+ config.clients = config.clients.filter((client) => client.name !== name);
131
+ config.clients.push({ fingerprint256: credential.fingerprint256, name, cwd: resolve(args.values.cwd || previous?.cwd || process.cwd()) });
132
+ atomicConfig(configPath, config);
133
+ console.log(JSON.stringify({ name, ...credential, ca: config.ca, config: configPath, next: "Transfer only client cert/key and ca.crt; reload helper allowlist with SIGHUP." }));
134
+ }
135
+ } catch (error) {
136
+ console.error(error.message);
137
+ process.exitCode = 1;
138
+ } finally {
139
+ if (lock) {
140
+ rmSync(join(lock, "owner"), { force: true });
141
+ rmdirSync(lock);
142
+ }
143
+ }