@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
package/bin/pillar.mjs
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
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 init --refresh [--directory URL] re-sync carriers + address book (keychain untouched)
|
|
7
|
+
* pillar id [--home ~/.pillar] print our UUAID + PeerId + addrs
|
|
8
|
+
* pillar send <recipient> <kind> <json> send a signed envelope
|
|
9
|
+
* pillar recv [--home ~/.pillar] [--follow] listen for envelopes and print them
|
|
10
|
+
* pillar inbox [--home ~/.pillar] [--unread] [--notify CMD] [--mark-read]
|
|
11
|
+
* one-shot: pull pending mail, print, exit
|
|
12
|
+
* pillar peers list currently connected peers
|
|
13
|
+
*
|
|
14
|
+
* All commands respect --home to point at an alternate pillar directory,
|
|
15
|
+
* useful for running multiple pillars on one machine (Zestro dogfood etc.).
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { argv, exit } from "node:process";
|
|
21
|
+
import { Keychain } from "../src/identity/keychain.mjs";
|
|
22
|
+
import { Pillar } from "../src/pillar.mjs";
|
|
23
|
+
import { loadFleetAddressBook } from "../src/address-book.mjs";
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = { _: [] };
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a.startsWith("--")) {
|
|
30
|
+
const [k, ...rest] = a.slice(2).split("=");
|
|
31
|
+
args[k] = rest.length ? rest.join("=") : (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true);
|
|
32
|
+
} else {
|
|
33
|
+
args._.push(a);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function homePath(args) {
|
|
40
|
+
return args.home || process.env.PILLAR_HOME || join(homedir(), ".pillar");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Address book default: <home>/fleet.json unless --fleet points elsewhere.
|
|
44
|
+
function fleetPath(args) {
|
|
45
|
+
return args.fleet || join(homePath(args), "fleet.json");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadPillar(args) {
|
|
49
|
+
const home = homePath(args);
|
|
50
|
+
const kcPath = join(home, "keychain.json");
|
|
51
|
+
const mbPath = join(home, "mailbox.db");
|
|
52
|
+
const cfgPath = join(home, "config.json");
|
|
53
|
+
if (!existsSync(kcPath)) {
|
|
54
|
+
console.error(`no pillar found at ${home}. run: pillar init`);
|
|
55
|
+
exit(2);
|
|
56
|
+
}
|
|
57
|
+
const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, "utf-8")) : {};
|
|
58
|
+
const kc = new Keychain(kcPath);
|
|
59
|
+
kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
60
|
+
const carriers = args.carrier
|
|
61
|
+
? [args.carrier]
|
|
62
|
+
: (process.env.PILLAR_CARRIERS ? process.env.PILLAR_CARRIERS.split(",") : (cfg.carriers ?? []));
|
|
63
|
+
return new Pillar({
|
|
64
|
+
keychain: kc,
|
|
65
|
+
mailboxPath: mbPath,
|
|
66
|
+
listenAddrs: cfg.listenAddrs ?? ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
|
|
67
|
+
bootstrapPeers: cfg.bootstrapPeers ?? [],
|
|
68
|
+
enableRelay: cfg.enableRelay ?? false,
|
|
69
|
+
dhtClientMode: cfg.dhtClientMode ?? true,
|
|
70
|
+
carriers,
|
|
71
|
+
enableLibp2p: !(args["carrier-only"] || cfg.carrierOnly === true),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function loadGroups(args) {
|
|
76
|
+
// groups.json lives next to fleet.json by default:
|
|
77
|
+
// { "groups": { "<name>": { "members": ["uuaid:...", ...] } } }
|
|
78
|
+
const p = args.groups || (args.fleet ? join(dirname(args.fleet), "groups.json") : null);
|
|
79
|
+
if (!p || !existsSync(p)) return { path: p, groups: {} };
|
|
80
|
+
return { path: p, groups: JSON.parse(readFileSync(p, "utf-8")).groups ?? {} };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeRuntimeFile(pillar, home) {
|
|
84
|
+
if (!pillar.node) return;
|
|
85
|
+
const rt = {
|
|
86
|
+
uuaid: pillar.uuaid,
|
|
87
|
+
peerId: pillar.node.peerId.toString(),
|
|
88
|
+
addrs: pillar.node.getMultiaddrs().map(a => a.toString()),
|
|
89
|
+
pid: process.pid,
|
|
90
|
+
startedAt: new Date().toISOString(),
|
|
91
|
+
};
|
|
92
|
+
writeFileSync(join(home, "runtime.json"), JSON.stringify(rt, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Carrier list + address book merge from a fetched directory — shared by
|
|
96
|
+
// `init --directory` (first contact) and `init --refresh` (re-sync without
|
|
97
|
+
// touching the keychain).
|
|
98
|
+
function carriersFromDirectory(d) {
|
|
99
|
+
return [d.carrier, ...(Array.isArray(d.seeds) ? d.seeds.map((s) => s.url) : [])].filter(Boolean);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mergeDirectoryAgents(home, d) {
|
|
103
|
+
if (!Array.isArray(d.agents)) return null;
|
|
104
|
+
const fleetPath = join(home, "fleet.json");
|
|
105
|
+
const prev = existsSync(fleetPath) ? JSON.parse(readFileSync(fleetPath, "utf-8")).fleet ?? {} : {};
|
|
106
|
+
const fleet = { ...prev };
|
|
107
|
+
let added = 0;
|
|
108
|
+
for (const a of d.agents) {
|
|
109
|
+
if (!a.uuaid || !a.publicKey) continue;
|
|
110
|
+
const key = typeof a.role === "string" && a.role ? a.role : a.uuaid;
|
|
111
|
+
fleet[key] = {
|
|
112
|
+
uuaid: a.uuaid,
|
|
113
|
+
publicKey: a.publicKey,
|
|
114
|
+
...(prev[key]?.addrs ? { addrs: prev[key].addrs } : {}),
|
|
115
|
+
...(prev[key]?.peerId ? { peerId: prev[key].peerId } : {}),
|
|
116
|
+
};
|
|
117
|
+
if (!prev[key]) added++;
|
|
118
|
+
}
|
|
119
|
+
writeFileSync(fleetPath, JSON.stringify({ fleet }, null, 2));
|
|
120
|
+
return { count: Object.keys(fleet).length, added };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function fetchDirectory(url) {
|
|
124
|
+
const res = await fetch(url);
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
console.error(`directory fetch failed: ${url} -> ${res.status}`);
|
|
127
|
+
exit(1);
|
|
128
|
+
}
|
|
129
|
+
return res.json();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function cmdInit(args) {
|
|
133
|
+
const home = homePath(args);
|
|
134
|
+
mkdirSync(home, { recursive: true });
|
|
135
|
+
|
|
136
|
+
// --refresh re-syncs carriers + address book from a directory WITHOUT
|
|
137
|
+
// touching the keychain (init --force would mint a NEW identity and break
|
|
138
|
+
// every enrolled sender). Requires an existing identity.
|
|
139
|
+
if (args.refresh) {
|
|
140
|
+
const kcPath = join(home, "keychain.json");
|
|
141
|
+
if (!existsSync(kcPath)) {
|
|
142
|
+
console.error(`refresh: no keychain at ${kcPath} — run pillar init first (refresh never mints keys)`);
|
|
143
|
+
exit(1);
|
|
144
|
+
}
|
|
145
|
+
if (typeof args.directory !== "string") {
|
|
146
|
+
console.error("refresh: --directory <url> is required");
|
|
147
|
+
exit(1);
|
|
148
|
+
}
|
|
149
|
+
const d = await fetchDirectory(args.directory);
|
|
150
|
+
const list = carriersFromDirectory(d);
|
|
151
|
+
if (!list.length) {
|
|
152
|
+
console.error(`refresh: directory ${args.directory} lists no carrier and no seeds`);
|
|
153
|
+
exit(1);
|
|
154
|
+
}
|
|
155
|
+
const cfgPath = join(home, "config.json");
|
|
156
|
+
const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, "utf-8")) : {};
|
|
157
|
+
cfg.carriers = list;
|
|
158
|
+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
159
|
+
console.log(`carriers refreshed from ${args.directory}: ${list.join(", ")}`);
|
|
160
|
+
const ab = mergeDirectoryAgents(home, d);
|
|
161
|
+
if (ab) console.log(`address book: ${ab.count} peers in ${join(home, "fleet.json")} (${ab.added} new from directory)`);
|
|
162
|
+
console.log(`refresh complete at ${home} (keychain untouched)`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const kcPath = join(home, "keychain.json");
|
|
167
|
+
if (existsSync(kcPath) && !args.force) {
|
|
168
|
+
console.error(`keychain already exists at ${kcPath}. use --force to overwrite.`);
|
|
169
|
+
exit(1);
|
|
170
|
+
}
|
|
171
|
+
const kc = new Keychain(kcPath);
|
|
172
|
+
const id = Keychain.generate();
|
|
173
|
+
kc.save(id, { passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
174
|
+
const cfg = {
|
|
175
|
+
listenAddrs: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
|
|
176
|
+
bootstrapPeers: [],
|
|
177
|
+
enableRelay: false,
|
|
178
|
+
dhtClientMode: true,
|
|
179
|
+
};
|
|
180
|
+
// P2P seed discovery: --directory fetches a pillar directory.json and wires
|
|
181
|
+
// the primary carrier plus every seeds[] entry as carriers (the LimeWire
|
|
182
|
+
// seed model — poll all, replicate to all); --carriers sets them directly.
|
|
183
|
+
if (typeof args.directory === "string") {
|
|
184
|
+
const d = await fetchDirectory(args.directory);
|
|
185
|
+
const list = carriersFromDirectory(d);
|
|
186
|
+
if (!list.length) {
|
|
187
|
+
console.error(`init: directory ${args.directory} lists no carrier and no seeds`);
|
|
188
|
+
exit(1);
|
|
189
|
+
}
|
|
190
|
+
cfg.carriers = list;
|
|
191
|
+
console.log(`carriers from directory (${args.directory}): ${list.join(", ")}`);
|
|
192
|
+
// Seed the address book from the directory's agents[] so a fresh agent can
|
|
193
|
+
// `pillar ask`/`pillar send` with zero key plumbing (keys required for E2E).
|
|
194
|
+
// Merge — never clobber a hand-curated fleet.json.
|
|
195
|
+
const ab = mergeDirectoryAgents(home, d);
|
|
196
|
+
if (ab) console.log(`address book: ${ab.count} peers in ${join(home, "fleet.json")} (${ab.added} new from directory)`);
|
|
197
|
+
} else if (args.carriers) {
|
|
198
|
+
cfg.carriers = args.carriers.split(",").map(s => s.trim()).filter(Boolean);
|
|
199
|
+
}
|
|
200
|
+
writeFileSync(join(home, "config.json"), JSON.stringify(cfg, null, 2));
|
|
201
|
+
console.log(`initialized pillar at ${home}`);
|
|
202
|
+
console.log(`uuaid: ${id.uuaid}`);
|
|
203
|
+
console.log(`publicKey: ${id.publicKeyHex}`);
|
|
204
|
+
console.log(`\nconfig: ${join(home, "config.json")}`);
|
|
205
|
+
console.log(`edit bootstrapPeers to join a network, or run again with --directory <url> / --carriers a,b to wire seeds.`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function cmdId(args) {
|
|
209
|
+
const p = loadPillar(args);
|
|
210
|
+
await p.start();
|
|
211
|
+
console.log(`uuaid: ${p.uuaid}`);
|
|
212
|
+
console.log(`publicKey: ${p.publicKeyHex}`);
|
|
213
|
+
if (p.node) {
|
|
214
|
+
console.log(`peerId: ${p.node.peerId.toString()}`);
|
|
215
|
+
console.log(`addrs:`);
|
|
216
|
+
for (const a of p.node.getMultiaddrs()) console.log(` ${a.toString()}`);
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`mode: carrier-only (no libp2p)`);
|
|
219
|
+
}
|
|
220
|
+
if (p.carriers?.length) console.log(`carriers: ${p.carriers.join(", ")}`);
|
|
221
|
+
await p.stop();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function cmdSend(args) {
|
|
225
|
+
const [recipient, kind, jsonStr] = args._.slice(1);
|
|
226
|
+
if (!recipient || !kind) {
|
|
227
|
+
console.error("usage: pillar send <recipient-uuaid> <kind> [json-payload] [--fleet FLEET.json]");
|
|
228
|
+
exit(2);
|
|
229
|
+
}
|
|
230
|
+
const payload = jsonStr ? JSON.parse(jsonStr) : {};
|
|
231
|
+
const p = loadPillar(args);
|
|
232
|
+
await p.start();
|
|
233
|
+
const n = loadFleetAddressBook(fleetPath(args), p);
|
|
234
|
+
if (n > 0) console.log(`address book: ${n} peers`);
|
|
235
|
+
try {
|
|
236
|
+
const { envelope, delivery } = await p.send({
|
|
237
|
+
recipient, kind, payload,
|
|
238
|
+
recipientPublicKey: args["to-pubkey"], // explicit key for peers outside fleet.json
|
|
239
|
+
resolveTimeoutMs: 5000,
|
|
240
|
+
});
|
|
241
|
+
console.log(`sent envelope ${envelope.id}`);
|
|
242
|
+
console.log(` to: ${recipient}`);
|
|
243
|
+
console.log(` kind: ${kind}`);
|
|
244
|
+
if (delivery?.peerId) console.log(` via peerId: ${delivery.peerId}`);
|
|
245
|
+
} finally {
|
|
246
|
+
await p.stop();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function cmdSendGroup(args) {
|
|
251
|
+
const [groupName, kind, jsonStr] = args._.slice(1);
|
|
252
|
+
if (!groupName || !kind) {
|
|
253
|
+
console.error("usage: pillar send-group <group> <kind> [json-payload] --fleet FLEET.json [--groups GROUPS.json]");
|
|
254
|
+
exit(2);
|
|
255
|
+
}
|
|
256
|
+
const { path: gPath, groups } = loadGroups(args);
|
|
257
|
+
const group = groups[groupName];
|
|
258
|
+
if (!group?.members?.length) {
|
|
259
|
+
console.error(`group "${groupName}" not found or empty (looked in ${gPath ?? "no groups file"})`);
|
|
260
|
+
exit(2);
|
|
261
|
+
}
|
|
262
|
+
const payload = jsonStr ? JSON.parse(jsonStr) : {};
|
|
263
|
+
const p = loadPillar(args);
|
|
264
|
+
await p.start();
|
|
265
|
+
loadFleetAddressBook(fleetPath(args), p);
|
|
266
|
+
try {
|
|
267
|
+
const { results } = await p.sendGroup({ group: groupName, members: group.members, kind, payload });
|
|
268
|
+
let failed = 0;
|
|
269
|
+
for (const r of results) {
|
|
270
|
+
if (r.ok) console.log(` ✓ ${r.member} via ${r.via} ${r.envelopeId}`);
|
|
271
|
+
else { failed++; console.error(` ✗ ${r.member} ${r.error}`); }
|
|
272
|
+
}
|
|
273
|
+
console.log(`group "${groupName}": ${results.length - failed}/${results.length} delivered`);
|
|
274
|
+
if (failed) exit(1);
|
|
275
|
+
} finally {
|
|
276
|
+
await p.stop();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function cmdGroups(args) {
|
|
281
|
+
const { path: gPath, groups } = loadGroups(args);
|
|
282
|
+
const names = Object.keys(groups);
|
|
283
|
+
if (!names.length) { console.log(`no groups defined (${gPath ?? "no groups file"})`); return; }
|
|
284
|
+
for (const n of names) {
|
|
285
|
+
console.log(`${n} (${groups[n].members.length} members):`);
|
|
286
|
+
for (const m of groups[n].members) console.log(` ${m}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Extract human-readable text from a zestro command_result. The server-side
|
|
291
|
+
// payload fields are unverified — defensively try the common shapes, cap at
|
|
292
|
+
// 8192 bytes (pillar-responder's MAX_REPLY_BYTES convention).
|
|
293
|
+
function replyTextFromResult(result) {
|
|
294
|
+
const text = result?.text ?? result?.result ?? result?.output ?? result?.response ?? result?.message;
|
|
295
|
+
if (typeof text === "string" && text.trim()) return text.slice(0, 8192);
|
|
296
|
+
if (typeof text !== "string" && text !== undefined && text !== null) {
|
|
297
|
+
const s = JSON.stringify(text);
|
|
298
|
+
if (s) return s.slice(0, 8192);
|
|
299
|
+
}
|
|
300
|
+
return JSON.stringify(result ?? {}).slice(0, 8192);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function cmdRecv(args) {
|
|
304
|
+
const p = loadPillar(args);
|
|
305
|
+
await p.start();
|
|
306
|
+
writeRuntimeFile(p, homePath(args)); // warm handoff: publish live addr for senders
|
|
307
|
+
const n = loadFleetAddressBook(fleetPath(args), p);
|
|
308
|
+
console.log(`listening as ${p.uuaid}`);
|
|
309
|
+
if (p.node) {
|
|
310
|
+
console.log(`peerId ${p.node.peerId.toString()}`);
|
|
311
|
+
for (const a of p.node.getMultiaddrs()) console.log(` ${a.toString()}`);
|
|
312
|
+
} else {
|
|
313
|
+
console.log(`(carrier-only mode: no libp2p listener)`);
|
|
314
|
+
}
|
|
315
|
+
if (p.carrierClient) {
|
|
316
|
+
p.startCarrierPolling();
|
|
317
|
+
console.log(`polling carriers: ${p.carriers.join(", ")}`);
|
|
318
|
+
}
|
|
319
|
+
if (n > 0) console.log(`address book: ${n} peers from ${fleetPath(args)}`);
|
|
320
|
+
|
|
321
|
+
let bridge = null;
|
|
322
|
+
if (args["zestro-bridge"] || process.env.PILLAR_ZESTRO_URL) {
|
|
323
|
+
const { ZestroBridge } = await import("../src/bridges/zestro.mjs");
|
|
324
|
+
bridge = new ZestroBridge({
|
|
325
|
+
url: process.env.PILLAR_ZESTRO_URL ?? args["zestro-url"],
|
|
326
|
+
token: process.env.PILLAR_ZESTRO_TOKEN ?? args["zestro-token"],
|
|
327
|
+
sessionId: process.env.PILLAR_ZESTRO_AGENT ?? args["zestro-agent"],
|
|
328
|
+
tabId: process.env.PILLAR_ZESTRO_TAB ?? args["zestro-tab"],
|
|
329
|
+
// Opt-in: force on a busy tab spawns a SECOND agent process resuming the
|
|
330
|
+
// same transcript (handle-spawn.js has no live-process guard). Default off.
|
|
331
|
+
force: /^1|true|yes$/i.test(process.env.PILLAR_ZESTRO_FORCE ?? "") || !!args["zestro-force"],
|
|
332
|
+
});
|
|
333
|
+
await bridge.connect();
|
|
334
|
+
// Never log the full URL — it embeds the auth token.
|
|
335
|
+
console.log(`zestro bridge connected to ${bridge.url.replace(/\/[0-9a-f-]{36}\/ws$/, "/<token>/ws")}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
p.onEnvelope(async (env) => {
|
|
339
|
+
const line = { at: new Date().toISOString(), from: env.sender, kind: env.kind, payload: env.payload };
|
|
340
|
+
if (env.payload?._group?.name) line.group = env.payload._group.name;
|
|
341
|
+
console.log(JSON.stringify(line));
|
|
342
|
+
if (bridge && env.kind === "request") {
|
|
343
|
+
// Interaction-convention return leg: ack FIRST — pillar-ask allows 120s
|
|
344
|
+
// per phase but ZestroBridge times out at 30s, so the ack goes out before
|
|
345
|
+
// the bridged agent turn starts. Reply statuses (verified against app code):
|
|
346
|
+
// "ok" = command_result received = renderer consumed / spawn initiated
|
|
347
|
+
// (NOT turn-complete — the turn's answer returns via a separate
|
|
348
|
+
// pillar send from the agent, if the prompt instructs one)
|
|
349
|
+
// "busy" = target tab busy, server rejected (fast, no 30s hang)
|
|
350
|
+
// "pending" = bridge timeout — the request was never confirmed
|
|
351
|
+
// "error" = bridge failure
|
|
352
|
+
const thread = env.payload?.thread ?? env.id;
|
|
353
|
+
const senderKey = env.transportSignature.publicKey; // open() F1: sender derives from this key
|
|
354
|
+
try {
|
|
355
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
|
|
356
|
+
const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
|
|
357
|
+
const hasText = result && typeof result === "object" &&
|
|
358
|
+
["text", "result", "output", "response", "message"].some(k => k in result);
|
|
359
|
+
const text = hasText
|
|
360
|
+
? replyTextFromResult(result)
|
|
361
|
+
: "zestro command_result received (server accepted) — agent turn answer, if any, returns via separate pillar send";
|
|
362
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "ok", text } });
|
|
363
|
+
console.log(` → zestro reply sent (${text.length} bytes)`);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
// ZestroServerError SESSION_BUSY = server rejected: target tab busy and
|
|
366
|
+
// force off. Immediate distinct status — the sender can retry or drop.
|
|
367
|
+
if (e.code === "SESSION_BUSY") {
|
|
368
|
+
console.log(` → zestro: busy (${e.message})`);
|
|
369
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "busy", reason: e.message.slice(0, 300), text: `zestro agent busy: ${e.message.slice(0, 300)}` } });
|
|
370
|
+
} else if (/timed out/.test(e.message)) {
|
|
371
|
+
// Bridge timeout = the server never confirmed delivery (renderer drop,
|
|
372
|
+
// cli-server gone, etc.) — accepted-unconfirmed, not turn-running.
|
|
373
|
+
console.log(` → zestro: unconfirmed (bridge timeout)`);
|
|
374
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "pending", text: "request forwarded to zestro agent — delivery unconfirmed" } });
|
|
375
|
+
} else {
|
|
376
|
+
console.error(` → zestro bridge error: ${e.message}`);
|
|
377
|
+
await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "reply", payload: { thread, inReplyTo: env.id, status: "error", reason: e.message.slice(0, 300), text: `zestro bridge error: ${e.message.slice(0, 300)}` } });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} else if (bridge && env.kind !== "ack" && env.kind !== "reply") {
|
|
381
|
+
try {
|
|
382
|
+
const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
|
|
383
|
+
console.log(` → zestro result: ${JSON.stringify(result).slice(0, 200)}`);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
if (e.code === "SESSION_BUSY") {
|
|
386
|
+
console.log(` → zestro: busy (${e.message})`);
|
|
387
|
+
} else if (/timed out/.test(e.message)) {
|
|
388
|
+
console.log(` → zestro: unconfirmed (bridge timeout)`);
|
|
389
|
+
} else {
|
|
390
|
+
console.error(` → zestro bridge error: ${e.message}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} else if (bridge) {
|
|
394
|
+
// ack/reply are receipts for OUR earlier sends — bridging them would
|
|
395
|
+
// inject a Zestro agent turn per receipt. Print-only (line above).
|
|
396
|
+
console.log(` → zestro: not bridged (kind=${env.kind} is a receipt)`);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const shutdown = async () => { if (bridge) bridge.close(); await p.stop(); exit(0); };
|
|
401
|
+
process.on("SIGINT", shutdown);
|
|
402
|
+
process.on("SIGTERM", shutdown);
|
|
403
|
+
await new Promise(() => {});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function cmdInbox(args) {
|
|
407
|
+
// One-shot mail check for ANY identity: point --home at the pillar dir that
|
|
408
|
+
// minted the uuaid. Pulls pending envelopes from the carrier (no long-poll),
|
|
409
|
+
// prints them decrypted, reports unread backlog, exits. --notify CMD runs a
|
|
410
|
+
// shell hook when there is mail (cron/launchd-friendly); --mark-read stamps
|
|
411
|
+
// read_at on everything printed.
|
|
412
|
+
args["carrier-only"] = true; // inbox check needs the carrier only — no libp2p listener
|
|
413
|
+
const p = loadPillar(args);
|
|
414
|
+
const fresh = [];
|
|
415
|
+
p.onEnvelope((env) => { fresh.push(env); });
|
|
416
|
+
await p.start();
|
|
417
|
+
const pulled = await p.drainCarrierInbox();
|
|
418
|
+
for (const env of fresh) {
|
|
419
|
+
console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload: env.payload }));
|
|
420
|
+
}
|
|
421
|
+
// Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
|
|
422
|
+
const { decrypt } = await import("../src/net/envelope.mjs");
|
|
423
|
+
const freshIds = new Set(fresh.map(e => e.id));
|
|
424
|
+
const backlog = (p.mailbox.inboxUnread?.(500) ?? []).filter(r => !freshIds.has(r.envelope_id));
|
|
425
|
+
if (args.unread) {
|
|
426
|
+
for (const row of backlog) {
|
|
427
|
+
const env = JSON.parse(row.envelope_json);
|
|
428
|
+
let payload = null;
|
|
429
|
+
try { payload = decrypt(p.keychain, env); } catch (_e) { payload = { error: "decrypt failed" }; }
|
|
430
|
+
console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload }));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
console.log(`inbox: ${pulled} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}`);
|
|
434
|
+
if (args["mark-read"]) {
|
|
435
|
+
for (const env of fresh) p.mailbox.markInboxRead?.(env.id);
|
|
436
|
+
if (args.unread) for (const row of backlog) p.mailbox.markInboxRead?.(row.envelope_id);
|
|
437
|
+
}
|
|
438
|
+
if (args.notify && typeof args.notify === "string" && (pulled > 0 || backlog.length > 0)) {
|
|
439
|
+
const { spawnSync } = await import("node:child_process");
|
|
440
|
+
const r = spawnSync(args.notify, {
|
|
441
|
+
shell: true,
|
|
442
|
+
input: JSON.stringify({ uuaid: p.uuaid, new: pulled, unread: backlog.length }) + "\n",
|
|
443
|
+
env: { ...process.env, PILLAR_NEW: String(pulled), PILLAR_UNREAD: String(backlog.length), PILLAR_UUAID: p.uuaid },
|
|
444
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
445
|
+
});
|
|
446
|
+
if (r.status !== 0) console.error(`notify hook exited ${r.status}`);
|
|
447
|
+
}
|
|
448
|
+
await p.stop();
|
|
449
|
+
exit(0);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function cmdPeers(args) {
|
|
453
|
+
const p = loadPillar(args);
|
|
454
|
+
await p.start();
|
|
455
|
+
const peers = p.node.getPeers();
|
|
456
|
+
console.log(`${peers.length} connected peers:`);
|
|
457
|
+
for (const peer of peers) console.log(` ${peer.toString()}`);
|
|
458
|
+
await p.stop();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function main() {
|
|
462
|
+
const args = parseArgs(argv.slice(2));
|
|
463
|
+
const cmd = args._[0];
|
|
464
|
+
switch (cmd) {
|
|
465
|
+
case "init": return cmdInit(args);
|
|
466
|
+
case "id": return cmdId(args);
|
|
467
|
+
case "send": return cmdSend(args);
|
|
468
|
+
case "send-group": return cmdSendGroup(args);
|
|
469
|
+
case "groups": return cmdGroups(args);
|
|
470
|
+
case "recv": return cmdRecv(args);
|
|
471
|
+
case "inbox": return cmdInbox(args);
|
|
472
|
+
case "peers": return cmdPeers(args);
|
|
473
|
+
default:
|
|
474
|
+
console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|peers> [--home DIR]`);
|
|
475
|
+
exit(2);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
main().catch(e => { console.error(e); exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uuaid/pillar",
|
|
3
|
+
"version": "0.2.0-alpha.10",
|
|
4
|
+
"description": "Pillar (Protocol for Inter-agent Linked Ledger And Relay) — the secure delivery layer of UACP, the Universal Agent Communication Protocol. UACP = UUAID (identity) + Envelope (signed, encrypted container) + Pillar (relay, mailbox, routing, store-and-forward). IAASO-3050 reference implementation: one command to mint an identity and join the network.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.mjs",
|
|
8
|
+
"bin": {
|
|
9
|
+
"pillar": "bin/pillar.mjs",
|
|
10
|
+
"pillar-carrier": "bin/carrier.mjs",
|
|
11
|
+
"pillar-ask": "bin/pillar-ask.mjs",
|
|
12
|
+
"pillar-responder": "bin/pillar-responder.mjs",
|
|
13
|
+
"pillar-seed": "bin/pillar-seed.mjs"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.mjs"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"bin",
|
|
21
|
+
"README.md",
|
|
22
|
+
"INTERACTION-CONVENTION.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test --test-reporter spec test/unit/*.test.mjs",
|
|
27
|
+
"test:int": "node --test --test-reporter spec test/integration/*.test.mjs",
|
|
28
|
+
"test:chaos": "node --test --test-reporter spec test/chaos/*.test.mjs",
|
|
29
|
+
"test:all": "node --test --test-reporter spec test/unit/*.test.mjs test/integration/*.test.mjs test/chaos/*.test.mjs"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
36
|
+
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
37
|
+
"@libp2p/autonat": "^3.0.26",
|
|
38
|
+
"@libp2p/bootstrap": "^12.0.29",
|
|
39
|
+
"@libp2p/circuit-relay-v2": "^4.2.11",
|
|
40
|
+
"@libp2p/crypto": "^5.1.22",
|
|
41
|
+
"@libp2p/dcutr": "^3.0.26",
|
|
42
|
+
"@libp2p/identify": "^4.1.12",
|
|
43
|
+
"@libp2p/kad-dht": "^16.4.3",
|
|
44
|
+
"@libp2p/peer-id": "^6.0.14",
|
|
45
|
+
"@libp2p/ping": "^3.1.11",
|
|
46
|
+
"@libp2p/tcp": "^11.0.26",
|
|
47
|
+
"@libp2p/webrtc": "^6.0.29",
|
|
48
|
+
"@libp2p/websockets": "^10.1.19",
|
|
49
|
+
"@multiformats/multiaddr": "^13.0.3",
|
|
50
|
+
"@noble/curves": "^2.3.0",
|
|
51
|
+
"better-sqlite3": "^11.10.0",
|
|
52
|
+
"it-pipe": "^3.0.1",
|
|
53
|
+
"it-pushable": "^3.2.4",
|
|
54
|
+
"libp2p": "^3.3.8",
|
|
55
|
+
"multiformats": "^14.0.5",
|
|
56
|
+
"uint8arrays": "^5.1.1",
|
|
57
|
+
"ws": "^8.21.3"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load a fleet.json address book into a Pillar instance.
|
|
6
|
+
*
|
|
7
|
+
* Shape: { "fleet": { "<role>": { uuaid, addrs?, peerId?, publicKey } } }.
|
|
8
|
+
* `pillar init --directory` writes one from the directory's agents[] so a
|
|
9
|
+
* fresh agent can `ask`/`send` with no manual key plumbing.
|
|
10
|
+
*/
|
|
11
|
+
export function loadFleetAddressBook(fleetPath, pillar) {
|
|
12
|
+
if (!fleetPath || !existsSync(fleetPath)) return 0;
|
|
13
|
+
const fleet = JSON.parse(readFileSync(fleetPath, "utf-8")).fleet ?? {};
|
|
14
|
+
// Warm handoff: fleet.json addrs come from a short-lived `pillar id` probe
|
|
15
|
+
// on /tcp/0, so a running daemon's REAL addr differs. Each daemon writes
|
|
16
|
+
// <home>/runtime.json on boot; prefer that addr when fresh.
|
|
17
|
+
const fleetRoot = dirname(fleetPath);
|
|
18
|
+
let n = 0;
|
|
19
|
+
for (const [role, id] of Object.entries(fleet)) {
|
|
20
|
+
if (!id.uuaid) continue;
|
|
21
|
+
if (id.uuaid === pillar.uuaid) continue; // don't self-register
|
|
22
|
+
let multiaddr = id.addrs?.[0];
|
|
23
|
+
let peerId = id.peerId;
|
|
24
|
+
const rtPath = join(fleetRoot, role, "runtime.json");
|
|
25
|
+
if (existsSync(rtPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const rt = JSON.parse(readFileSync(rtPath, "utf-8"));
|
|
28
|
+
if (rt.uuaid === id.uuaid && rt.addrs?.[0]) {
|
|
29
|
+
multiaddr = rt.addrs[0];
|
|
30
|
+
peerId = rt.peerId ?? peerId;
|
|
31
|
+
}
|
|
32
|
+
} catch (_e) {}
|
|
33
|
+
}
|
|
34
|
+
pillar.registerAddress(id.uuaid, {
|
|
35
|
+
peerId,
|
|
36
|
+
multiaddr,
|
|
37
|
+
publicKey: id.publicKey, // REQUIRED for E2E sends
|
|
38
|
+
});
|
|
39
|
+
n++;
|
|
40
|
+
}
|
|
41
|
+
return n;
|
|
42
|
+
}
|