@uuaid/pillar 0.2.0-alpha.10
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 +114 -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 +479 -0
- package/package.json +59 -0
- package/src/address-book.mjs +42 -0
- package/src/bridges/zestro.mjs +155 -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 +194 -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 +483 -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);
|