@uuaid/pillar 0.2.0-alpha.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.
- package/INTERACTION-CONVENTION.md +44 -0
- package/LICENSE +201 -0
- package/README.md +81 -0
- package/bin/carrier.mjs +51 -0
- package/bin/pillar-ask.mjs +128 -0
- package/bin/pillar-responder.mjs +261 -0
- package/bin/pillar-seed.mjs +82 -0
- package/bin/pillar.mjs +325 -0
- package/package.json +59 -0
- package/src/address-book.mjs +42 -0
- package/src/bridges/zestro.mjs +83 -0
- package/src/crypto/e2e.mjs +97 -0
- package/src/identity/jcs.mjs +35 -0
- package/src/identity/keychain.mjs +140 -0
- package/src/index.mjs +18 -0
- package/src/mailbox/mailbox.mjs +184 -0
- package/src/net/carrier-client.mjs +118 -0
- package/src/net/carrier-server.mjs +301 -0
- package/src/net/envelope.mjs +175 -0
- package/src/net/transport.mjs +109 -0
- package/src/pillar.mjs +457 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pillar-responder — always-on auto-attendant for the Pillar Interaction
|
|
4
|
+
* Convention. Runs as a standalone process (with its own identity/home, so
|
|
5
|
+
* it never races a live agent daemon on the same mailbox) and answers
|
|
6
|
+
* kind=request envelopes IMMEDIATELY — no waiting for a live agent turn:
|
|
7
|
+
*
|
|
8
|
+
* request → journal → ack (inReplyTo = request id) → reply (thread + inReplyTo + text)
|
|
9
|
+
*
|
|
10
|
+
* Reply text comes from, in order:
|
|
11
|
+
* 1. --reply-text / config responder.replyText (static text)
|
|
12
|
+
* 2. --command / config responder.command (spawn hook: JSON request on
|
|
13
|
+
* stdin, stdout = reply text, non-zero exit or timeout → status:"error"
|
|
14
|
+
* with the stderr tail as reason) — LLM-agnostic, any program works
|
|
15
|
+
* 3. builtin default text
|
|
16
|
+
*
|
|
17
|
+
* Optional --forward-to <uuaid> --forward-pubkey <hex> re-sends the request
|
|
18
|
+
* payload as kind=message to an owner identity so a live agent picks it up.
|
|
19
|
+
*
|
|
20
|
+
* At-least-once delivery: every request is journaled to
|
|
21
|
+
* <home>/responder-journal.jsonl BEFORE the ack; on startup, journaled
|
|
22
|
+
* requests without a done record from the last 24h get ack+reply re-sent
|
|
23
|
+
* (default text — the hook is not re-run, so a restart never double-executes
|
|
24
|
+
* a side-effecting command). Duplicate acks/replies are harmless: the
|
|
25
|
+
* convention is idempotent by envelope id.
|
|
26
|
+
*
|
|
27
|
+
* Carrier-only by default (outbound HTTPS only — runs on any machine or
|
|
28
|
+
* webserver; no inbound ports, no libp2p). Pass --libp2p to also listen.
|
|
29
|
+
*
|
|
30
|
+
* pillar-responder --home ~/.pillar-fleet/zz-master-responder \
|
|
31
|
+
* --reply-text "zz-master is away — left a message" \
|
|
32
|
+
* --forward-to uuaid:foundation:agent:… --forward-pubkey <hex>
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, readFileSync, appendFileSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
import { argv, exit } from "node:process";
|
|
38
|
+
import { spawn } from "node:child_process";
|
|
39
|
+
import { Keychain } from "../src/identity/keychain.mjs";
|
|
40
|
+
import { Pillar } from "../src/pillar.mjs";
|
|
41
|
+
|
|
42
|
+
const DEFAULT_REPLY = "pillar-responder: request acked — owner will respond through the normal channel";
|
|
43
|
+
const MAX_REPLY_BYTES = 8192;
|
|
44
|
+
const HOOK_TIMEOUT_MS = 60_000;
|
|
45
|
+
const REPLAY_WINDOW_MS = 24 * 3600_000;
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const args = { _: [] };
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const a = argv[i];
|
|
51
|
+
if (a.startsWith("--")) {
|
|
52
|
+
const [k, ...rest] = a.slice(2).split("=");
|
|
53
|
+
args[k] = rest.length ? rest.join("=") : (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true);
|
|
54
|
+
} else {
|
|
55
|
+
args._.push(a);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function homePath(args) {
|
|
62
|
+
return args.home || process.env.PILLAR_HOME || join(homedir(), ".pillar");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- journal (at-least-once) -------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function journalPath(home) {
|
|
68
|
+
return join(home, "responder-journal.jsonl");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function journalRequest(home, env) {
|
|
72
|
+
appendFileSync(journalPath(home), JSON.stringify({
|
|
73
|
+
ts: new Date().toISOString(),
|
|
74
|
+
event: "request",
|
|
75
|
+
id: env.id,
|
|
76
|
+
sender: env.sender,
|
|
77
|
+
senderPublicKey: env.transportSignature.publicKey,
|
|
78
|
+
payload: env.payload,
|
|
79
|
+
}) + "\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function journalDone(home, id, reply) {
|
|
83
|
+
appendFileSync(journalPath(home), JSON.stringify({
|
|
84
|
+
ts: new Date().toISOString(),
|
|
85
|
+
event: "done",
|
|
86
|
+
id,
|
|
87
|
+
replyText: reply.text,
|
|
88
|
+
replyStatus: reply.status,
|
|
89
|
+
}) + "\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Requests journaled but never completed, within the replay window.
|
|
93
|
+
function loadPending(home) {
|
|
94
|
+
const p = journalPath(home);
|
|
95
|
+
if (!existsSync(p)) return [];
|
|
96
|
+
const done = new Set();
|
|
97
|
+
const requests = new Map();
|
|
98
|
+
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
99
|
+
if (!line.trim()) continue;
|
|
100
|
+
try {
|
|
101
|
+
const e = JSON.parse(line);
|
|
102
|
+
if (e.event === "done") done.add(e.id);
|
|
103
|
+
else if (e.event === "request") requests.set(e.id, e);
|
|
104
|
+
} catch (_e) { /* skip torn line */ }
|
|
105
|
+
}
|
|
106
|
+
const cutoff = Date.now() - REPLAY_WINDOW_MS;
|
|
107
|
+
return [...requests.values()].filter((r) => !done.has(r.id) && Date.parse(r.ts) > cutoff);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- command hook -------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function runHook(cmd, req, { timeoutMs, maxBytes }) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const child = spawn(cmd, { shell: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
115
|
+
let out = Buffer.alloc(0);
|
|
116
|
+
let err = Buffer.alloc(0);
|
|
117
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
118
|
+
child.stdout.on("data", (d) => { out = Buffer.concat([out, d]); });
|
|
119
|
+
child.stderr.on("data", (d) => { err = Buffer.concat([err, d]); });
|
|
120
|
+
child.on("error", (e) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
resolve({ ok: false, reason: `hook spawn failed: ${e.message}` });
|
|
123
|
+
});
|
|
124
|
+
child.on("close", (code) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
if (code === 0) {
|
|
127
|
+
const text = out.toString("utf-8").trim();
|
|
128
|
+
resolve({ ok: true, text: text ? text.slice(0, maxBytes) : null });
|
|
129
|
+
} else if (code === null) {
|
|
130
|
+
resolve({ ok: false, reason: "hook timed out" });
|
|
131
|
+
} else {
|
|
132
|
+
resolve({ ok: false, reason: (err.toString("utf-8").trim() || `hook exited ${code}`).slice(0, 300) });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
child.stdin.write(JSON.stringify(req));
|
|
136
|
+
child.stdin.end();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- main ----------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const args = parseArgs(process.argv.slice(2));
|
|
143
|
+
const home = homePath(args);
|
|
144
|
+
const kcPath = join(home, "keychain.json");
|
|
145
|
+
const cfgPath = join(home, "config.json");
|
|
146
|
+
|
|
147
|
+
if (!existsSync(kcPath)) {
|
|
148
|
+
console.error(`no pillar identity at ${home}. run: pillar init --home ${home}`);
|
|
149
|
+
exit(2);
|
|
150
|
+
}
|
|
151
|
+
const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, "utf-8")) : {};
|
|
152
|
+
const rcfg = cfg.responder ?? {};
|
|
153
|
+
|
|
154
|
+
const carriers = args.carrier
|
|
155
|
+
? [args.carrier]
|
|
156
|
+
: (process.env.PILLAR_CARRIERS ? process.env.PILLAR_CARRIERS.split(",") : (cfg.carriers ?? []));
|
|
157
|
+
const enableLibp2p = args["libp2p"] === true;
|
|
158
|
+
if (!carriers.length && !enableLibp2p) {
|
|
159
|
+
console.error("responder needs at least one carrier (--carrier URL or carriers[] in config.json) or --libp2p");
|
|
160
|
+
exit(2);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const replyText = args["reply-text"] ?? rcfg.replyText ?? DEFAULT_REPLY;
|
|
164
|
+
const hookCmd = args.command ?? rcfg.command ?? null;
|
|
165
|
+
const hookTimeoutMs = Number(args["timeout-ms"] ?? rcfg.timeoutMs ?? HOOK_TIMEOUT_MS);
|
|
166
|
+
const hookMaxBytes = Number(args["max-reply-bytes"] ?? MAX_REPLY_BYTES);
|
|
167
|
+
const fwd = args["forward-to"] || rcfg.forwardTo?.uuaid
|
|
168
|
+
? { uuaid: args["forward-to"] ?? rcfg.forwardTo?.uuaid, publicKey: args["forward-pubkey"] ?? rcfg.forwardTo?.publicKey }
|
|
169
|
+
: null;
|
|
170
|
+
if (fwd && (!fwd.uuaid || !fwd.publicKey)) {
|
|
171
|
+
console.error("--forward-to needs both a UUAID and --forward-pubkey (encryption is mandatory)");
|
|
172
|
+
exit(2);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const kc = new Keychain(kcPath);
|
|
176
|
+
kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
177
|
+
const p = new Pillar({
|
|
178
|
+
keychain: kc,
|
|
179
|
+
mailboxPath: join(home, "mailbox.db"),
|
|
180
|
+
listenAddrs: cfg.listenAddrs ?? ["/ip4/127.0.0.1/tcp/0"],
|
|
181
|
+
bootstrapPeers: cfg.bootstrapPeers ?? [],
|
|
182
|
+
enableRelay: cfg.enableRelay ?? false,
|
|
183
|
+
dhtClientMode: cfg.dhtClientMode ?? true,
|
|
184
|
+
carriers,
|
|
185
|
+
enableLibp2p,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await p.start();
|
|
189
|
+
|
|
190
|
+
console.log(`pillar-responder up — ${p.uuaid}`);
|
|
191
|
+
console.log(` pubkey: ${p.publicKeyHex}`);
|
|
192
|
+
console.log(` carriers: ${carriers.length ? carriers.join(", ") : "(none — libp2p only)"}`);
|
|
193
|
+
console.log(` reply: ${hookCmd ? `hook ${hookCmd}` : "builtin text"}`);
|
|
194
|
+
if (fwd) console.log(` forwardTo: ${fwd.uuaid}`);
|
|
195
|
+
|
|
196
|
+
// Replay journaled-but-unfinished requests first (at-least-once).
|
|
197
|
+
for (const r of loadPending(home)) {
|
|
198
|
+
try {
|
|
199
|
+
await p.send({ recipient: r.sender, recipientPublicKey: r.senderPublicKey, kind: "ack", payload: { inReplyTo: r.id } });
|
|
200
|
+
const replyPayload = { thread: r.payload?.thread ?? r.id, inReplyTo: r.id, status: "ok", text: replyText };
|
|
201
|
+
await p.send({ recipient: r.sender, recipientPublicKey: r.senderPublicKey, kind: "reply", payload: replyPayload });
|
|
202
|
+
journalDone(home, r.id, replyPayload);
|
|
203
|
+
console.log(` replay: ack+reply re-sent for ${r.id.slice(0, 16)}…`);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.error(` replay failed for ${r.id.slice(0, 16)}…: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Register the handler BEFORE polling starts so fast deliveries are caught.
|
|
210
|
+
p.onEnvelope(async (env) => {
|
|
211
|
+
if (env.kind !== "request") return; // ack/reply/message stay passive (owner daemon handles them)
|
|
212
|
+
const senderKey = env.transportSignature.publicKey; // open() F1: sender derives from this key
|
|
213
|
+
try {
|
|
214
|
+
journalRequest(home, env);
|
|
215
|
+
const thread = env.payload?.thread ?? env.id;
|
|
216
|
+
let status = "ok";
|
|
217
|
+
let text = replyText;
|
|
218
|
+
if (hookCmd) {
|
|
219
|
+
const r = await runHook(hookCmd, {
|
|
220
|
+
id: env.id,
|
|
221
|
+
sender: env.sender,
|
|
222
|
+
senderPublicKey: senderKey,
|
|
223
|
+
payload: env.payload,
|
|
224
|
+
thread,
|
|
225
|
+
}, { timeoutMs: hookTimeoutMs, maxBytes: hookMaxBytes });
|
|
226
|
+
if (r.ok) {
|
|
227
|
+
if (r.text) text = r.text;
|
|
228
|
+
} else {
|
|
229
|
+
status = "error";
|
|
230
|
+
text = r.reason;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
|
|
234
|
+
const replyPayload = { thread, inReplyTo: env.id, status, text };
|
|
235
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: replyPayload });
|
|
236
|
+
journalDone(home, env.id, replyPayload);
|
|
237
|
+
console.log(` ${env.sender.slice(-8)} request ${env.id.slice(0, 16)}… → ack + reply (${status})`);
|
|
238
|
+
if (fwd) {
|
|
239
|
+
await p.send({
|
|
240
|
+
recipient: fwd.uuaid,
|
|
241
|
+
recipientPublicKey: fwd.publicKey,
|
|
242
|
+
kind: "message",
|
|
243
|
+
payload: { ...env.payload, _forwarded: { from: env.sender, requestId: env.id, thread } },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
} catch (e) {
|
|
247
|
+
console.error(` request ${env.id.slice(0, 16)}… failed: ${e.message} (journaled — replayed on restart)`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// p.start() does NOT poll on its own — the pillar-ask trap. waitS=20 keeps us
|
|
252
|
+
// inside the carrier's MAX_WAIT_S=30 long-poll cap + the 30s ack window.
|
|
253
|
+
if (p.carrierClient) p.startCarrierPolling({ waitS: 20 });
|
|
254
|
+
console.log(" polling started — Ctrl-C to stop");
|
|
255
|
+
|
|
256
|
+
const shutdown = async () => {
|
|
257
|
+
if (p.carrierClient) p.stopCarrierPolling();
|
|
258
|
+
process.exit(0);
|
|
259
|
+
};
|
|
260
|
+
process.on("SIGINT", shutdown);
|
|
261
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pillar-seed — run a pillar seed: a carrier server that makes you part of
|
|
4
|
+
* the p2p store-and-forward mesh, like a LimeWire/BitTorrent seed peer.
|
|
5
|
+
*
|
|
6
|
+
* pillar-seed --port 8799 --db /opt/pillar-seed/seed.db
|
|
7
|
+
*
|
|
8
|
+
* A seed IS a carrier server with a public URL — nothing else. Agents
|
|
9
|
+
* anywhere list your URL in their carriers[] and then:
|
|
10
|
+
* - deliver() replicates every envelope to you (so messages survive any
|
|
11
|
+
* single seed dying — the BitTorrent property), and
|
|
12
|
+
* - poll you for their inbox in parallel with every other seed.
|
|
13
|
+
* Minimal ongoing requirements: one process, one SQLite file, outbound-free
|
|
14
|
+
* (all traffic comes TO you). That is the whole contract.
|
|
15
|
+
*
|
|
16
|
+
* Defaults to loopback (RedTeam F5). To become a real seed, put it behind a
|
|
17
|
+
* TLS-terminating proxy (nginx/Caddy) and advertise the PUBLIC https:// URL —
|
|
18
|
+
* never the loopback one — as a seeds[] entry in a pillar directory.json:
|
|
19
|
+
*
|
|
20
|
+
* { "url": "https://<your-host>", "operator": "<you>", "region": "<region>", "since": "<ISO date>" }
|
|
21
|
+
*
|
|
22
|
+
* Anyone running `pillar init --directory <directory-url>` then picks you up
|
|
23
|
+
* automatically as one of their carriers. You can also be someone's ENTIRE
|
|
24
|
+
* carrier set (`--carriers https://<your-host>`) — two agents can talk with
|
|
25
|
+
* only your seed between them, no official carrier involved.
|
|
26
|
+
*/
|
|
27
|
+
import { CarrierServer } from "../src/net/carrier-server.mjs";
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const args = {};
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const a = argv[i];
|
|
33
|
+
if (a.startsWith("--")) {
|
|
34
|
+
const k = a.slice(2);
|
|
35
|
+
args[k] = (argv[i + 1] && !argv[i + 1].startsWith("--")) ? argv[++i] : true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const args = parseArgs(process.argv.slice(2));
|
|
42
|
+
const port = Number(args.port ?? process.env.SEED_PORT ?? 8799);
|
|
43
|
+
const host = args.host ?? process.env.SEED_HOST ?? "127.0.0.1";
|
|
44
|
+
const dbPath = args.db ?? process.env.SEED_DB ?? "pillar-seed.db";
|
|
45
|
+
const ttlDays = Number(args.ttl ?? process.env.SEED_TTL_DAYS ?? 14);
|
|
46
|
+
|
|
47
|
+
const server = new CarrierServer({ dbPath, ttlDays });
|
|
48
|
+
const addr = await server.listen(port, host);
|
|
49
|
+
console.log(`pillar-seed listening on http://${addr.address}:${addr.port}`);
|
|
50
|
+
console.log(` db: ${dbPath}`);
|
|
51
|
+
console.log(` ttl: ${ttlDays} days`);
|
|
52
|
+
|
|
53
|
+
// Boot self-check on the address we actually serve — verify before claim.
|
|
54
|
+
try {
|
|
55
|
+
const probeHost = addr.address === "0.0.0.0" || addr.address === "::" ? "127.0.0.1" : addr.address;
|
|
56
|
+
const res = await fetch(`http://${probeHost}:${addr.port}/v1/health`);
|
|
57
|
+
if (res.ok) console.log(` health: ok (${res.status})`);
|
|
58
|
+
else console.log(` health: WARN ${res.status}`);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.log(` health: unreachable — ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (host === "127.0.0.1") {
|
|
64
|
+
console.log(`\n loopback seed — reachable only from this machine.`);
|
|
65
|
+
console.log(` To join the mesh as a real seed: TLS-terminate a public URL and list it:`);
|
|
66
|
+
console.log(` { "url": "https://<your-host>", "operator": "<you>", "region": "<region>", "since": "${new Date().toISOString().slice(0, 10)}" }`);
|
|
67
|
+
console.log(` in the seeds[] array of your pillar directory.json.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Vacuum expired envelopes hourly.
|
|
71
|
+
const vacuumTimer = setInterval(() => {
|
|
72
|
+
const n = server.vacuum();
|
|
73
|
+
if (n > 0) console.log(`vacuumed ${n} expired envelopes`);
|
|
74
|
+
}, 3600_000);
|
|
75
|
+
|
|
76
|
+
const shutdown = async () => {
|
|
77
|
+
clearInterval(vacuumTimer);
|
|
78
|
+
await server.close();
|
|
79
|
+
process.exit(0);
|
|
80
|
+
};
|
|
81
|
+
process.on("SIGINT", shutdown);
|
|
82
|
+
process.on("SIGTERM", shutdown);
|
package/bin/pillar.mjs
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pillar — one-command CLI.
|
|
4
|
+
*
|
|
5
|
+
* pillar init [--home ~/.pillar] first-time setup: mint UUAID + keys
|
|
6
|
+
* pillar id [--home ~/.pillar] print our UUAID + PeerId + addrs
|
|
7
|
+
* pillar send <recipient> <kind> <json> send a signed envelope
|
|
8
|
+
* pillar recv [--home ~/.pillar] [--follow] listen for envelopes and print them
|
|
9
|
+
* pillar peers list currently connected peers
|
|
10
|
+
*
|
|
11
|
+
* All commands respect --home to point at an alternate pillar directory,
|
|
12
|
+
* useful for running multiple pillars on one machine (Zestro dogfood etc.).
|
|
13
|
+
*/
|
|
14
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { argv, exit } from "node:process";
|
|
18
|
+
import { Keychain } from "../src/identity/keychain.mjs";
|
|
19
|
+
import { Pillar } from "../src/pillar.mjs";
|
|
20
|
+
import { loadFleetAddressBook } from "../src/address-book.mjs";
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = { _: [] };
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
if (a.startsWith("--")) {
|
|
27
|
+
const [k, ...rest] = a.slice(2).split("=");
|
|
28
|
+
args[k] = rest.length ? rest.join("=") : (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true);
|
|
29
|
+
} else {
|
|
30
|
+
args._.push(a);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return args;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function homePath(args) {
|
|
37
|
+
return args.home || process.env.PILLAR_HOME || join(homedir(), ".pillar");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Address book default: <home>/fleet.json unless --fleet points elsewhere.
|
|
41
|
+
function fleetPath(args) {
|
|
42
|
+
return args.fleet || join(homePath(args), "fleet.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadPillar(args) {
|
|
46
|
+
const home = homePath(args);
|
|
47
|
+
const kcPath = join(home, "keychain.json");
|
|
48
|
+
const mbPath = join(home, "mailbox.db");
|
|
49
|
+
const cfgPath = join(home, "config.json");
|
|
50
|
+
if (!existsSync(kcPath)) {
|
|
51
|
+
console.error(`no pillar found at ${home}. run: pillar init`);
|
|
52
|
+
exit(2);
|
|
53
|
+
}
|
|
54
|
+
const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, "utf-8")) : {};
|
|
55
|
+
const kc = new Keychain(kcPath);
|
|
56
|
+
kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
57
|
+
const carriers = args.carrier
|
|
58
|
+
? [args.carrier]
|
|
59
|
+
: (process.env.PILLAR_CARRIERS ? process.env.PILLAR_CARRIERS.split(",") : (cfg.carriers ?? []));
|
|
60
|
+
return new Pillar({
|
|
61
|
+
keychain: kc,
|
|
62
|
+
mailboxPath: mbPath,
|
|
63
|
+
listenAddrs: cfg.listenAddrs ?? ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
|
|
64
|
+
bootstrapPeers: cfg.bootstrapPeers ?? [],
|
|
65
|
+
enableRelay: cfg.enableRelay ?? false,
|
|
66
|
+
dhtClientMode: cfg.dhtClientMode ?? true,
|
|
67
|
+
carriers,
|
|
68
|
+
enableLibp2p: !(args["carrier-only"] || cfg.carrierOnly === true),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadGroups(args) {
|
|
73
|
+
// groups.json lives next to fleet.json by default:
|
|
74
|
+
// { "groups": { "<name>": { "members": ["uuaid:...", ...] } } }
|
|
75
|
+
const p = args.groups || (args.fleet ? join(dirname(args.fleet), "groups.json") : null);
|
|
76
|
+
if (!p || !existsSync(p)) return { path: p, groups: {} };
|
|
77
|
+
return { path: p, groups: JSON.parse(readFileSync(p, "utf-8")).groups ?? {} };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeRuntimeFile(pillar, home) {
|
|
81
|
+
if (!pillar.node) return;
|
|
82
|
+
const rt = {
|
|
83
|
+
uuaid: pillar.uuaid,
|
|
84
|
+
peerId: pillar.node.peerId.toString(),
|
|
85
|
+
addrs: pillar.node.getMultiaddrs().map(a => a.toString()),
|
|
86
|
+
pid: process.pid,
|
|
87
|
+
startedAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
writeFileSync(join(home, "runtime.json"), JSON.stringify(rt, null, 2));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function cmdInit(args) {
|
|
93
|
+
const home = homePath(args);
|
|
94
|
+
mkdirSync(home, { recursive: true });
|
|
95
|
+
const kcPath = join(home, "keychain.json");
|
|
96
|
+
if (existsSync(kcPath) && !args.force) {
|
|
97
|
+
console.error(`keychain already exists at ${kcPath}. use --force to overwrite.`);
|
|
98
|
+
exit(1);
|
|
99
|
+
}
|
|
100
|
+
const kc = new Keychain(kcPath);
|
|
101
|
+
const id = Keychain.generate();
|
|
102
|
+
kc.save(id, { passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
103
|
+
const cfg = {
|
|
104
|
+
listenAddrs: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
|
|
105
|
+
bootstrapPeers: [],
|
|
106
|
+
enableRelay: false,
|
|
107
|
+
dhtClientMode: true,
|
|
108
|
+
};
|
|
109
|
+
// P2P seed discovery: --directory fetches a pillar directory.json and wires
|
|
110
|
+
// the primary carrier plus every seeds[] entry as carriers (the LimeWire
|
|
111
|
+
// seed model — poll all, replicate to all); --carriers sets them directly.
|
|
112
|
+
if (typeof args.directory === "string") {
|
|
113
|
+
const res = await fetch(args.directory);
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
console.error(`init: directory fetch failed: ${args.directory} -> ${res.status}`);
|
|
116
|
+
exit(1);
|
|
117
|
+
}
|
|
118
|
+
const d = await res.json();
|
|
119
|
+
const list = [d.carrier, ...(Array.isArray(d.seeds) ? d.seeds.map(s => s.url) : [])].filter(Boolean);
|
|
120
|
+
if (!list.length) {
|
|
121
|
+
console.error(`init: directory ${args.directory} lists no carrier and no seeds`);
|
|
122
|
+
exit(1);
|
|
123
|
+
}
|
|
124
|
+
cfg.carriers = list;
|
|
125
|
+
console.log(`carriers from directory (${args.directory}): ${list.join(", ")}`);
|
|
126
|
+
// Seed the address book from the directory's agents[] so a fresh agent can
|
|
127
|
+
// `pillar ask`/`pillar send` with zero key plumbing (keys required for E2E).
|
|
128
|
+
// Merge — never clobber a hand-curated fleet.json.
|
|
129
|
+
if (Array.isArray(d.agents)) {
|
|
130
|
+
const fleetPath = join(home, "fleet.json");
|
|
131
|
+
const prev = existsSync(fleetPath) ? JSON.parse(readFileSync(fleetPath, "utf-8")).fleet ?? {} : {};
|
|
132
|
+
const fleet = { ...prev };
|
|
133
|
+
let added = 0;
|
|
134
|
+
for (const a of d.agents) {
|
|
135
|
+
if (!a.uuaid || !a.publicKey) continue;
|
|
136
|
+
const key = typeof a.role === "string" && a.role ? a.role : a.uuaid;
|
|
137
|
+
fleet[key] = {
|
|
138
|
+
uuaid: a.uuaid,
|
|
139
|
+
publicKey: a.publicKey,
|
|
140
|
+
...(prev[key]?.addrs ? { addrs: prev[key].addrs } : {}),
|
|
141
|
+
...(prev[key]?.peerId ? { peerId: prev[key].peerId } : {}),
|
|
142
|
+
};
|
|
143
|
+
if (!prev[key]) added++;
|
|
144
|
+
}
|
|
145
|
+
writeFileSync(fleetPath, JSON.stringify({ fleet }, null, 2));
|
|
146
|
+
console.log(`address book: ${Object.keys(fleet).length} peers in ${fleetPath} (${added} new from directory)`);
|
|
147
|
+
}
|
|
148
|
+
} else if (args.carriers) {
|
|
149
|
+
cfg.carriers = args.carriers.split(",").map(s => s.trim()).filter(Boolean);
|
|
150
|
+
}
|
|
151
|
+
writeFileSync(join(home, "config.json"), JSON.stringify(cfg, null, 2));
|
|
152
|
+
console.log(`initialized pillar at ${home}`);
|
|
153
|
+
console.log(`uuaid: ${id.uuaid}`);
|
|
154
|
+
console.log(`publicKey: ${id.publicKeyHex}`);
|
|
155
|
+
console.log(`\nconfig: ${join(home, "config.json")}`);
|
|
156
|
+
console.log(`edit bootstrapPeers to join a network, or run again with --directory <url> / --carriers a,b to wire seeds.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function cmdId(args) {
|
|
160
|
+
const p = loadPillar(args);
|
|
161
|
+
await p.start();
|
|
162
|
+
console.log(`uuaid: ${p.uuaid}`);
|
|
163
|
+
console.log(`publicKey: ${p.publicKeyHex}`);
|
|
164
|
+
if (p.node) {
|
|
165
|
+
console.log(`peerId: ${p.node.peerId.toString()}`);
|
|
166
|
+
console.log(`addrs:`);
|
|
167
|
+
for (const a of p.node.getMultiaddrs()) console.log(` ${a.toString()}`);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`mode: carrier-only (no libp2p)`);
|
|
170
|
+
}
|
|
171
|
+
if (p.carriers?.length) console.log(`carriers: ${p.carriers.join(", ")}`);
|
|
172
|
+
await p.stop();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function cmdSend(args) {
|
|
176
|
+
const [recipient, kind, jsonStr] = args._.slice(1);
|
|
177
|
+
if (!recipient || !kind) {
|
|
178
|
+
console.error("usage: pillar send <recipient-uuaid> <kind> [json-payload] [--fleet FLEET.json]");
|
|
179
|
+
exit(2);
|
|
180
|
+
}
|
|
181
|
+
const payload = jsonStr ? JSON.parse(jsonStr) : {};
|
|
182
|
+
const p = loadPillar(args);
|
|
183
|
+
await p.start();
|
|
184
|
+
const n = loadFleetAddressBook(fleetPath(args), p);
|
|
185
|
+
if (n > 0) console.log(`address book: ${n} peers`);
|
|
186
|
+
try {
|
|
187
|
+
const { envelope, delivery } = await p.send({
|
|
188
|
+
recipient, kind, payload,
|
|
189
|
+
recipientPublicKey: args["to-pubkey"], // explicit key for peers outside fleet.json
|
|
190
|
+
resolveTimeoutMs: 5000,
|
|
191
|
+
});
|
|
192
|
+
console.log(`sent envelope ${envelope.id}`);
|
|
193
|
+
console.log(` to: ${recipient}`);
|
|
194
|
+
console.log(` kind: ${kind}`);
|
|
195
|
+
if (delivery?.peerId) console.log(` via peerId: ${delivery.peerId}`);
|
|
196
|
+
} finally {
|
|
197
|
+
await p.stop();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function cmdSendGroup(args) {
|
|
202
|
+
const [groupName, kind, jsonStr] = args._.slice(1);
|
|
203
|
+
if (!groupName || !kind) {
|
|
204
|
+
console.error("usage: pillar send-group <group> <kind> [json-payload] --fleet FLEET.json [--groups GROUPS.json]");
|
|
205
|
+
exit(2);
|
|
206
|
+
}
|
|
207
|
+
const { path: gPath, groups } = loadGroups(args);
|
|
208
|
+
const group = groups[groupName];
|
|
209
|
+
if (!group?.members?.length) {
|
|
210
|
+
console.error(`group "${groupName}" not found or empty (looked in ${gPath ?? "no groups file"})`);
|
|
211
|
+
exit(2);
|
|
212
|
+
}
|
|
213
|
+
const payload = jsonStr ? JSON.parse(jsonStr) : {};
|
|
214
|
+
const p = loadPillar(args);
|
|
215
|
+
await p.start();
|
|
216
|
+
loadFleetAddressBook(fleetPath(args), p);
|
|
217
|
+
try {
|
|
218
|
+
const { results } = await p.sendGroup({ group: groupName, members: group.members, kind, payload });
|
|
219
|
+
let failed = 0;
|
|
220
|
+
for (const r of results) {
|
|
221
|
+
if (r.ok) console.log(` ✓ ${r.member} via ${r.via} ${r.envelopeId}`);
|
|
222
|
+
else { failed++; console.error(` ✗ ${r.member} ${r.error}`); }
|
|
223
|
+
}
|
|
224
|
+
console.log(`group "${groupName}": ${results.length - failed}/${results.length} delivered`);
|
|
225
|
+
if (failed) exit(1);
|
|
226
|
+
} finally {
|
|
227
|
+
await p.stop();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function cmdGroups(args) {
|
|
232
|
+
const { path: gPath, groups } = loadGroups(args);
|
|
233
|
+
const names = Object.keys(groups);
|
|
234
|
+
if (!names.length) { console.log(`no groups defined (${gPath ?? "no groups file"})`); return; }
|
|
235
|
+
for (const n of names) {
|
|
236
|
+
console.log(`${n} (${groups[n].members.length} members):`);
|
|
237
|
+
for (const m of groups[n].members) console.log(` ${m}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function cmdRecv(args) {
|
|
242
|
+
const p = loadPillar(args);
|
|
243
|
+
await p.start();
|
|
244
|
+
writeRuntimeFile(p, homePath(args)); // warm handoff: publish live addr for senders
|
|
245
|
+
const n = loadFleetAddressBook(fleetPath(args), p);
|
|
246
|
+
console.log(`listening as ${p.uuaid}`);
|
|
247
|
+
if (p.node) {
|
|
248
|
+
console.log(`peerId ${p.node.peerId.toString()}`);
|
|
249
|
+
for (const a of p.node.getMultiaddrs()) console.log(` ${a.toString()}`);
|
|
250
|
+
} else {
|
|
251
|
+
console.log(`(carrier-only mode: no libp2p listener)`);
|
|
252
|
+
}
|
|
253
|
+
if (p.carrierClient) {
|
|
254
|
+
p.startCarrierPolling();
|
|
255
|
+
console.log(`polling carriers: ${p.carriers.join(", ")}`);
|
|
256
|
+
}
|
|
257
|
+
if (n > 0) console.log(`address book: ${n} peers from ${fleetPath(args)}`);
|
|
258
|
+
|
|
259
|
+
let bridge = null;
|
|
260
|
+
if (args["zestro-bridge"] || process.env.PILLAR_ZESTRO_URL) {
|
|
261
|
+
const { ZestroBridge } = await import("../src/bridges/zestro.mjs");
|
|
262
|
+
bridge = new ZestroBridge({
|
|
263
|
+
url: process.env.PILLAR_ZESTRO_URL ?? args["zestro-url"],
|
|
264
|
+
token: process.env.PILLAR_ZESTRO_TOKEN ?? args["zestro-token"],
|
|
265
|
+
sessionId: process.env.PILLAR_ZESTRO_AGENT ?? args["zestro-agent"],
|
|
266
|
+
});
|
|
267
|
+
await bridge.connect();
|
|
268
|
+
// Never log the full URL — it embeds the auth token.
|
|
269
|
+
console.log(`zestro bridge connected to ${bridge.url.replace(/\/[0-9a-f-]{36}\/ws$/, "/<token>/ws")}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
p.onEnvelope(async (env) => {
|
|
273
|
+
const line = { at: new Date().toISOString(), from: env.sender, kind: env.kind, payload: env.payload };
|
|
274
|
+
if (env.payload?._group?.name) line.group = env.payload._group.name;
|
|
275
|
+
console.log(JSON.stringify(line));
|
|
276
|
+
if (bridge) {
|
|
277
|
+
try {
|
|
278
|
+
const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
|
|
279
|
+
console.log(` → zestro result: ${JSON.stringify(result).slice(0, 200)}`);
|
|
280
|
+
} catch (e) {
|
|
281
|
+
// command_result only arrives when the target agent's TURN completes —
|
|
282
|
+
// a busy agent (long turn) exceeds any sane timeout while the command
|
|
283
|
+
// is already queued in its tab. Timeout = accepted-unconfirmed, not failure.
|
|
284
|
+
if (/timed out/.test(e.message)) {
|
|
285
|
+
console.log(` → zestro: queued (command_result pending — agent busy)`);
|
|
286
|
+
} else {
|
|
287
|
+
console.error(` → zestro bridge error: ${e.message}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const shutdown = async () => { if (bridge) bridge.close(); await p.stop(); exit(0); };
|
|
294
|
+
process.on("SIGINT", shutdown);
|
|
295
|
+
process.on("SIGTERM", shutdown);
|
|
296
|
+
await new Promise(() => {});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function cmdPeers(args) {
|
|
300
|
+
const p = loadPillar(args);
|
|
301
|
+
await p.start();
|
|
302
|
+
const peers = p.node.getPeers();
|
|
303
|
+
console.log(`${peers.length} connected peers:`);
|
|
304
|
+
for (const peer of peers) console.log(` ${peer.toString()}`);
|
|
305
|
+
await p.stop();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function main() {
|
|
309
|
+
const args = parseArgs(argv.slice(2));
|
|
310
|
+
const cmd = args._[0];
|
|
311
|
+
switch (cmd) {
|
|
312
|
+
case "init": return cmdInit(args);
|
|
313
|
+
case "id": return cmdId(args);
|
|
314
|
+
case "send": return cmdSend(args);
|
|
315
|
+
case "send-group": return cmdSendGroup(args);
|
|
316
|
+
case "groups": return cmdGroups(args);
|
|
317
|
+
case "recv": return cmdRecv(args);
|
|
318
|
+
case "peers": return cmdPeers(args);
|
|
319
|
+
default:
|
|
320
|
+
console.error(`usage: pillar <init|id|send|send-group|groups|recv|peers> [--home DIR]`);
|
|
321
|
+
exit(2);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
main().catch(e => { console.error(e); exit(1); });
|