@fitchmultz/pi-subagents 0.44.4 → 0.44.5
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 +9 -0
- package/README.md +4 -0
- package/dist/extension/build-info.js +1 -1
- package/dist/extension/compact-tools.js +1 -1
- package/dist/extension/schemas.js +9 -9
- package/dist/history/index.js +14 -9
- package/dist/history/ingest.js +26 -5
- package/dist/history/worker.js +13 -8
- package/dist/pi-intercom/bridge.js +750 -0
- package/dist/runs/foreground/question-control.js +23 -12
- package/dist/runs/foreground/wait-run.js +5 -3
- package/dist/runs/shared/run-history.js +8 -1
- package/dist/runs/shared/supervisor-questions.js +32 -2
- package/dist/shared/status-format.js +1 -1
- package/docs/intercom-bridge.md +212 -0
- package/docs/intercom.md +3 -1
- package/package.json +2 -1
- package/scripts/intercom-bridge-credentials.mjs +143 -0
- package/scripts/intercom-remote.mjs +103 -0
- package/scripts/package-smoke.mjs +4 -0
- package/src/extension/compact-tools.ts +1 -1
- package/src/extension/schemas.ts +9 -9
- package/src/history/index.ts +13 -9
- package/src/history/ingest.ts +22 -5
- package/src/history/worker.ts +11 -7
- package/src/pi-intercom/bridge.ts +668 -0
- package/src/runs/foreground/question-control.ts +23 -11
- package/src/runs/foreground/wait-run.ts +5 -2
- package/src/runs/shared/run-history.ts +8 -1
- package/src/runs/shared/supervisor-questions.ts +45 -2
- package/src/shared/status-format.ts +2 -2
- package/src/shared/types.ts +2 -1
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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)}`,
|
|
19
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
: `
|
|
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** —
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitchmultz/pi-subagents",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.5",
|
|
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
|
+
}
|