openfused 0.3.21 → 0.3.22
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/README.md +32 -20
- package/dist/cli.js +35 -25
- package/dist/crypto.d.ts +1 -0
- package/dist/crypto.js +13 -2
- package/dist/registry.js +2 -2
- package/dist/store.d.ts +3 -0
- package/dist/store.js +59 -16
- package/dist/sync.d.ts +2 -1
- package/dist/sync.js +144 -46
- package/dist/watch.js +6 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,12 +40,13 @@ openfuse init --name "project-alpha" --workspace
|
|
|
40
40
|
CONTEXT.md — working memory (what's happening now)
|
|
41
41
|
PROFILE.md — public address card (name, endpoint, keys)
|
|
42
42
|
inbox/ — messages from other agents (encrypted)
|
|
43
|
-
outbox/ —
|
|
44
|
-
|
|
43
|
+
outbox/ — per-recipient subdirs (outbox/{name}-{fingerprint}/)
|
|
44
|
+
outbox/…/.sent/ — delivered messages (archived after delivery)
|
|
45
|
+
shared/ — files shared with peers (plaintext)
|
|
45
46
|
knowledge/ — persistent knowledge base
|
|
46
47
|
history/ — archived [DONE] context (via openfuse compact)
|
|
47
48
|
.keys/ — ed25519 signing + age encryption keypairs
|
|
48
|
-
.mesh.json —
|
|
49
|
+
.mesh.json — config, peers, keyring
|
|
49
50
|
.peers/ — synced peer context (auto-populated)
|
|
50
51
|
```
|
|
51
52
|
|
|
@@ -76,7 +77,7 @@ openfuse compact
|
|
|
76
77
|
openfuse validate # scan for stale entries
|
|
77
78
|
openfuse compact --prune-stale # archive expired validity windows
|
|
78
79
|
|
|
79
|
-
# Send a message (auto-
|
|
80
|
+
# Send a message (requires recipient in keyring — auto-encrypts if age key on file)
|
|
80
81
|
openfuse inbox send agent-bob "Check out shared/findings.md"
|
|
81
82
|
|
|
82
83
|
# Read inbox (decrypts, shows verified/unverified status)
|
|
@@ -85,7 +86,7 @@ openfuse inbox list
|
|
|
85
86
|
# Watch for incoming messages in real-time
|
|
86
87
|
openfuse watch
|
|
87
88
|
|
|
88
|
-
# Share a file with
|
|
89
|
+
# Share a file with peers
|
|
89
90
|
openfuse share ./report.pdf
|
|
90
91
|
|
|
91
92
|
# Sync with all peers (pull context, push outbox)
|
|
@@ -142,7 +143,8 @@ wisp wisp.openfused.net [TRUSTED]
|
|
|
142
143
|
|
|
143
144
|
Inbox messages are **encrypted with age** (X25519 + ChaCha20-Poly1305) and **signed with Ed25519**. Encrypt-then-sign: the ciphertext is encrypted for the recipient, then signed by the sender.
|
|
144
145
|
|
|
145
|
-
-
|
|
146
|
+
- Recipient must be in your keyring before sending (`openfuse key import` or auto-imported via `openfuse send`)
|
|
147
|
+
- If you have their age key → messages are encrypted automatically
|
|
146
148
|
- If you don't → messages are signed but sent in plaintext
|
|
147
149
|
- `shared/` and `knowledge/` directories stay plaintext (they're public)
|
|
148
150
|
- `PROFILE.md` is your public address card — served to peers and synced
|
|
@@ -151,22 +153,26 @@ The `age` format is interoperable — Rust CLI and TypeScript SDK use the same k
|
|
|
151
153
|
|
|
152
154
|
## Registry — DNS for Agents
|
|
153
155
|
|
|
154
|
-
Public registry at `registry.openfused.dev`.
|
|
156
|
+
Public registry at `registry.openfused.dev`. Works as a keyserver — endpoint is optional.
|
|
155
157
|
|
|
156
158
|
```bash
|
|
157
|
-
# Register (
|
|
159
|
+
# Register keys only (no endpoint needed — keyserver mode)
|
|
160
|
+
openfuse register
|
|
161
|
+
|
|
162
|
+
# Register with an endpoint (enables direct delivery)
|
|
158
163
|
openfuse register --endpoint https://your-server.com:2053
|
|
159
164
|
|
|
160
|
-
#
|
|
165
|
+
# Register with a custom domain
|
|
161
166
|
openfuse register --name yourname.company.com --endpoint https://yourname.company.com:2053
|
|
162
167
|
|
|
163
|
-
# Discover an agent
|
|
168
|
+
# Discover an agent (returns keys + endpoint if registered)
|
|
164
169
|
openfuse discover wisp
|
|
165
170
|
|
|
166
171
|
# Send a message (resolves via registry, auto-imports key)
|
|
167
172
|
openfuse send wisp "hello"
|
|
168
173
|
```
|
|
169
174
|
|
|
175
|
+
- **Keyserver** — register your public keys without an endpoint, others can discover and trust you
|
|
170
176
|
- **Signed manifests** — prove you own the name (Ed25519 signature)
|
|
171
177
|
- **Anti-squatting** — name updates require the original key
|
|
172
178
|
- **Key revocation** — `openfuse revoke` permanently invalidates a leaked key
|
|
@@ -197,22 +203,25 @@ openfuse watch --tunnel your-server
|
|
|
197
203
|
|
|
198
204
|
Sync does three things:
|
|
199
205
|
1. **Pulls** peer's CONTEXT.md, PROFILE.md, shared/, knowledge/ into `.peers/<name>/`
|
|
200
|
-
2. **Pulls** peer's outbox for messages addressed to you (
|
|
201
|
-
3. **Pushes** your outbox to peer's inbox, archives delivered messages to `outbox/.sent/`
|
|
206
|
+
2. **Pulls** peer's outbox for messages addressed to you (from `outbox/{your-name}-{fp}/`)
|
|
207
|
+
3. **Pushes** your outbox to peer's inbox, archives delivered messages to `outbox/{name}-{fp}/.sent/`
|
|
202
208
|
|
|
203
|
-
###
|
|
209
|
+
### Outbox layout
|
|
204
210
|
|
|
205
|
-
|
|
211
|
+
Outbox uses per-recipient subdirectories named `{name}-{fingerprint}` to prevent name-squatting. The 8-char fingerprint prefix binds each directory to a specific cryptographic identity:
|
|
206
212
|
|
|
207
213
|
```
|
|
208
|
-
|
|
214
|
+
outbox/
|
|
215
|
+
├── wisp-2CC78684/
|
|
216
|
+
│ ├── 2026-03-21T07-59-44Z_from-myagent.json
|
|
217
|
+
│ └── .sent/ ← delivered messages archived here
|
|
218
|
+
├── bob-A1B2C3D4/
|
|
219
|
+
│ └── ...
|
|
209
220
|
```
|
|
210
221
|
|
|
211
|
-
|
|
212
|
-
- `2026-03-21T07-59-44Z_from-claude-code_to-wisp.json` — DM, encrypted for wisp
|
|
213
|
-
- `2026-03-21T08-00-00Z_from-wisp_to-all.json` — broadcast, signed but not encrypted
|
|
222
|
+
Sending requires the recipient to be in your keyring. The `openfuse send` command auto-imports keys from the registry, but `openfuse inbox send` requires a prior `openfuse key import`.
|
|
214
223
|
|
|
215
|
-
|
|
224
|
+
The daemon's `GET /outbox/{name}` endpoint verifies the requester's public key fingerprint matches the subdirectory — a name squatter can't pull messages intended for the real agent.
|
|
216
225
|
|
|
217
226
|
SSH transport uses hostnames from `~/.ssh/config` — not raw IPs.
|
|
218
227
|
|
|
@@ -261,7 +270,8 @@ Public mode endpoints:
|
|
|
261
270
|
| `/profile` | GET | Your PROFILE.md (public address card) |
|
|
262
271
|
| `/config` | GET | Your public keys (JSON) |
|
|
263
272
|
| `/inbox` | POST | Accept signed messages (rejects invalid signatures) |
|
|
264
|
-
| `/outbox/{name}` | GET | Pickup replies addressed to `{name}` (
|
|
273
|
+
| `/outbox/{name}` | GET | Pickup replies addressed to `{name}` (fingerprint-verified) |
|
|
274
|
+
| `/outbox/{name}/{path}` | DELETE | ACK a received message (moves to .sent/) |
|
|
265
275
|
|
|
266
276
|
## File Watching
|
|
267
277
|
|
|
@@ -309,7 +319,9 @@ Hey, the research is done. Check shared/findings.md
|
|
|
309
319
|
- Daemon body size limit (1MB)
|
|
310
320
|
- PROFILE.md is public; private config stays in your agent runtime (CLAUDE.md, etc.)
|
|
311
321
|
- Registry rate-limited on all mutation endpoints
|
|
322
|
+
- Outbox per-recipient subdirs with fingerprint binding (anti name-squatting)
|
|
312
323
|
- Outbox messages archived after delivery (no duplicate sends)
|
|
324
|
+
- Sending requires recipient in keyring (no blind sends to unknown agents)
|
|
313
325
|
- SSH URLs validated (no argument injection)
|
|
314
326
|
- XML values escaped in message wrapping (no prompt injection via attributes)
|
|
315
327
|
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { nanoid } from "nanoid";
|
|
4
|
-
import { ContextStore, validateName } from "./store.js";
|
|
4
|
+
import { ContextStore, validateName, resolveKeyring } from "./store.js";
|
|
5
5
|
import { watchInbox, watchContext, watchSync } from "./watch.js";
|
|
6
6
|
import { syncAll, syncOne, deliverOne } from "./sync.js";
|
|
7
7
|
import * as registry from "./registry.js";
|
|
@@ -459,10 +459,19 @@ key
|
|
|
459
459
|
console.log("Keyring is empty. Import keys with: openfuse key import <name> <keyfile>");
|
|
460
460
|
return;
|
|
461
461
|
}
|
|
462
|
+
// Detect name collisions for display
|
|
463
|
+
const nameCounts = new Map();
|
|
464
|
+
for (const e of config.keyring)
|
|
465
|
+
nameCounts.set(e.name, (nameCounts.get(e.name) || 0) + 1);
|
|
462
466
|
for (const e of config.keyring) {
|
|
463
467
|
const trust = e.trusted ? "[TRUSTED]" : "[untrusted]";
|
|
464
468
|
const addr = e.address || "(no address)";
|
|
465
|
-
|
|
469
|
+
const shortFp = e.fingerprint.replace(/:/g, "").slice(0, 8);
|
|
470
|
+
// Show fingerprint suffix when names collide so user knows how to disambiguate
|
|
471
|
+
const displayName = (nameCounts.get(e.name) || 0) > 1
|
|
472
|
+
? `${e.name}:${shortFp}`
|
|
473
|
+
: e.name;
|
|
474
|
+
console.log(`${displayName} ${addr} ${trust}`);
|
|
466
475
|
console.log(` signing: ${e.signingKey}`);
|
|
467
476
|
console.log(` encryption: ${e.encryptionKey ?? "(no age key)"}`);
|
|
468
477
|
console.log(` fingerprint: ${e.fingerprint}\n`);
|
|
@@ -504,33 +513,25 @@ key
|
|
|
504
513
|
}
|
|
505
514
|
});
|
|
506
515
|
key
|
|
507
|
-
.command("trust <
|
|
508
|
-
.description("Trust a key in the keyring")
|
|
516
|
+
.command("trust <query>")
|
|
517
|
+
.description("Trust a key in the keyring (name, name:fingerprint, or fingerprint)")
|
|
509
518
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
510
|
-
.action(async (
|
|
519
|
+
.action(async (query, opts) => {
|
|
511
520
|
const store = new ContextStore(resolve(opts.dir));
|
|
512
521
|
const config = await store.readConfig();
|
|
513
|
-
const entry = config.keyring
|
|
514
|
-
if (!entry) {
|
|
515
|
-
console.error(`Key not found: ${name}`);
|
|
516
|
-
process.exit(1);
|
|
517
|
-
}
|
|
522
|
+
const entry = resolveKeyring(config.keyring, query);
|
|
518
523
|
entry.trusted = true;
|
|
519
524
|
await store.writeConfig(config);
|
|
520
525
|
console.log(`Trusted: ${entry.name} (${entry.fingerprint})`);
|
|
521
526
|
});
|
|
522
527
|
key
|
|
523
|
-
.command("untrust <
|
|
524
|
-
.description("Revoke trust for a key")
|
|
528
|
+
.command("untrust <query>")
|
|
529
|
+
.description("Revoke trust for a key (name, name:fingerprint, or fingerprint)")
|
|
525
530
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
526
|
-
.action(async (
|
|
531
|
+
.action(async (query, opts) => {
|
|
527
532
|
const store = new ContextStore(resolve(opts.dir));
|
|
528
533
|
const config = await store.readConfig();
|
|
529
|
-
const entry = config.keyring
|
|
530
|
-
if (!entry) {
|
|
531
|
-
console.error(`Key not found: ${name}`);
|
|
532
|
-
process.exit(1);
|
|
533
|
-
}
|
|
534
|
+
const entry = resolveKeyring(config.keyring, query);
|
|
534
535
|
entry.trusted = false;
|
|
535
536
|
await store.writeConfig(config);
|
|
536
537
|
console.log(`Revoked trust: ${entry.name} (${entry.fingerprint})`);
|
|
@@ -581,16 +582,19 @@ program
|
|
|
581
582
|
.description("Register this agent in the public registry")
|
|
582
583
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
583
584
|
.option("-n, --name <name>", "Full agent name (defaults to {storename}.openfused.net, or set your own domain)")
|
|
584
|
-
.
|
|
585
|
+
.option("-e, --endpoint <url>", "Endpoint URL where peers can reach you (optional — keys-only registration without endpoint)")
|
|
585
586
|
.option("-r, --registry <url>", "Registry URL")
|
|
586
587
|
.action(async (opts) => {
|
|
587
588
|
const store = new ContextStore(resolve(opts.dir));
|
|
588
589
|
const reg = registry.resolveRegistry(opts.registry);
|
|
589
590
|
const config = await store.readConfig();
|
|
590
591
|
const agentName = opts.name || `${config.name}.openfused.net`;
|
|
591
|
-
const manifest = await registry.register(store, opts.endpoint, reg, agentName);
|
|
592
|
+
const manifest = await registry.register(store, opts.endpoint || "", reg, agentName);
|
|
592
593
|
console.log(`Registered: ${manifest.name} [SIGNED]`);
|
|
593
|
-
|
|
594
|
+
if (manifest.endpoint)
|
|
595
|
+
console.log(` Endpoint: ${manifest.endpoint}`);
|
|
596
|
+
else
|
|
597
|
+
console.log(` Endpoint: (none — keys-only registration)`);
|
|
594
598
|
console.log(` Fingerprint: ${manifest.fingerprint}`);
|
|
595
599
|
console.log(` DNS: _openfuse.${manifest.name}`);
|
|
596
600
|
console.log(` Registry: ${reg}`);
|
|
@@ -673,11 +677,14 @@ program
|
|
|
673
677
|
body,
|
|
674
678
|
});
|
|
675
679
|
if (r.ok) {
|
|
676
|
-
// Archive to .sent/
|
|
680
|
+
// Archive to .sent/ within the recipient subdir
|
|
677
681
|
const { mkdir, rename } = await import("node:fs/promises");
|
|
678
|
-
const
|
|
682
|
+
const filePath = join(store.root, "outbox", filename);
|
|
683
|
+
const dir = join(filePath, "..");
|
|
684
|
+
const sentDir = join(dir, ".sent");
|
|
685
|
+
const baseName = filename.includes("/") ? filename.split("/").pop() : filename;
|
|
679
686
|
await mkdir(sentDir, { recursive: true });
|
|
680
|
-
await rename(
|
|
687
|
+
await rename(filePath, join(sentDir, baseName));
|
|
681
688
|
console.log(`Delivered to ${name}.`);
|
|
682
689
|
}
|
|
683
690
|
else {
|
|
@@ -688,9 +695,12 @@ program
|
|
|
688
695
|
console.log(`Queued for ${name}. Will deliver on next sync.`);
|
|
689
696
|
}
|
|
690
697
|
}
|
|
691
|
-
else {
|
|
698
|
+
else if (manifest.endpoint) {
|
|
692
699
|
console.log(`Queued for ${name}. Run \`openfuse sync\` to deliver.`);
|
|
693
700
|
}
|
|
701
|
+
else {
|
|
702
|
+
console.log(`Queued for ${name}. Key imported but ${name} has no endpoint — they'll need to pull from your outbox, or add a peer URL with \`openfuse peer add\`.`);
|
|
703
|
+
}
|
|
694
704
|
}
|
|
695
705
|
catch {
|
|
696
706
|
// Not in registry — send as a peer message
|
package/dist/crypto.d.ts
CHANGED
package/dist/crypto.js
CHANGED
|
@@ -77,7 +77,13 @@ export async function signMessage(storeRoot, from, message) {
|
|
|
77
77
|
const timestamp = new Date().toISOString();
|
|
78
78
|
const payload = Buffer.from(`${from}\n${timestamp}\n${message}`);
|
|
79
79
|
const signature = sign(null, payload, privateKey).toString("base64");
|
|
80
|
-
|
|
80
|
+
// Include our age public key so recipients can encrypt replies without DNS lookup
|
|
81
|
+
let encryptionKey;
|
|
82
|
+
try {
|
|
83
|
+
encryptionKey = await loadAgeRecipient(storeRoot);
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
return { from, timestamp, message, signature, publicKey, encryptionKey, encrypted: false };
|
|
81
87
|
}
|
|
82
88
|
// --- Encrypt-then-sign ---
|
|
83
89
|
// Encrypt first, then sign the ciphertext. This order matters:
|
|
@@ -93,7 +99,12 @@ export async function signAndEncrypt(storeRoot, from, plaintext, recipientAgeKey
|
|
|
93
99
|
const timestamp = new Date().toISOString();
|
|
94
100
|
const payload = Buffer.from(`${from}\n${timestamp}\n${encoded}`);
|
|
95
101
|
const signature = sign(null, payload, privateKey).toString("base64");
|
|
96
|
-
|
|
102
|
+
let encryptionKey;
|
|
103
|
+
try {
|
|
104
|
+
encryptionKey = await loadAgeRecipient(storeRoot);
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
return { from, timestamp, message: encoded, signature, publicKey, encryptionKey, encrypted: true };
|
|
97
108
|
}
|
|
98
109
|
export function verifyMessage(signed) {
|
|
99
110
|
try {
|
package/dist/registry.js
CHANGED
|
@@ -86,11 +86,11 @@ async function discoverViaDns(dnsName, agentName) {
|
|
|
86
86
|
if (k && v)
|
|
87
87
|
fields[k] = v;
|
|
88
88
|
}
|
|
89
|
-
if (!fields.
|
|
89
|
+
if (!fields.pk)
|
|
90
90
|
return null;
|
|
91
91
|
return {
|
|
92
92
|
name: agentName,
|
|
93
|
-
endpoint: fields.e,
|
|
93
|
+
endpoint: fields.e || "",
|
|
94
94
|
publicKey: fields.pk,
|
|
95
95
|
encryptionKey: fields.ek || undefined,
|
|
96
96
|
fingerprint: fields.fp || "",
|
package/dist/store.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface PeerConfig {
|
|
|
20
20
|
/** Validate agent/peer names: alphanumeric + hyphens + underscores + dots, 1-64 chars.
|
|
21
21
|
* Rejects path traversal (../, /, \) and rsync glob chars (*, ?, [). */
|
|
22
22
|
export declare function validateName(name: string, label?: string): string;
|
|
23
|
+
/** Resolve a keyring entry by name, name:fingerprint, or bare fingerprint prefix.
|
|
24
|
+
* Throws if ambiguous (multiple matches) or not found. */
|
|
25
|
+
export declare function resolveKeyring(keyring: KeyringEntry[], query: string): KeyringEntry;
|
|
23
26
|
export declare class ContextStore {
|
|
24
27
|
readonly root: string;
|
|
25
28
|
constructor(root: string);
|
package/dist/store.js
CHANGED
|
@@ -30,6 +30,46 @@ export function validateName(name, label = "Name") {
|
|
|
30
30
|
}
|
|
31
31
|
return name;
|
|
32
32
|
}
|
|
33
|
+
/** Resolve a keyring entry by name, name:fingerprint, or bare fingerprint prefix.
|
|
34
|
+
* Throws if ambiguous (multiple matches) or not found. */
|
|
35
|
+
export function resolveKeyring(keyring, query) {
|
|
36
|
+
let name;
|
|
37
|
+
let fpPrefix;
|
|
38
|
+
if (query.includes(":")) {
|
|
39
|
+
// name:FINGERPRINT format — split on LAST colon group that looks like hex
|
|
40
|
+
const colonIdx = query.lastIndexOf(":");
|
|
41
|
+
const maybeFp = query.slice(colonIdx + 1);
|
|
42
|
+
if (/^[0-9a-fA-F]{4,16}$/.test(maybeFp)) {
|
|
43
|
+
name = query.slice(0, colonIdx);
|
|
44
|
+
fpPrefix = maybeFp.toUpperCase();
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
name = query;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
name = query;
|
|
52
|
+
}
|
|
53
|
+
// Match by name (or address prefix)
|
|
54
|
+
let matches = keyring.filter((k) => k.name === name || k.address.startsWith(`${name}@`));
|
|
55
|
+
// If no name match, try bare fingerprint prefix
|
|
56
|
+
if (matches.length === 0 && /^[0-9a-fA-F]{4,16}$/.test(query)) {
|
|
57
|
+
const upper = query.toUpperCase();
|
|
58
|
+
matches = keyring.filter((k) => k.fingerprint.replace(/:/g, "").startsWith(upper));
|
|
59
|
+
}
|
|
60
|
+
// Filter by fingerprint prefix if provided
|
|
61
|
+
if (fpPrefix && matches.length > 1) {
|
|
62
|
+
matches = matches.filter((k) => k.fingerprint.replace(/:/g, "").startsWith(fpPrefix));
|
|
63
|
+
}
|
|
64
|
+
if (matches.length === 0) {
|
|
65
|
+
throw new Error(`Key not found: "${query}". Run: openfuse key list`);
|
|
66
|
+
}
|
|
67
|
+
if (matches.length > 1) {
|
|
68
|
+
const options = matches.map((k) => ` ${k.name}:${k.fingerprint.replace(/:/g, "").slice(0, 8)} ${k.address}`).join("\n");
|
|
69
|
+
throw new Error(`Multiple keys match "${query}". Disambiguate with fingerprint:\n${options}`);
|
|
70
|
+
}
|
|
71
|
+
return matches[0];
|
|
72
|
+
}
|
|
33
73
|
export class ContextStore {
|
|
34
74
|
root;
|
|
35
75
|
constructor(root) {
|
|
@@ -181,24 +221,25 @@ export class ContextStore {
|
|
|
181
221
|
}
|
|
182
222
|
// --- Inbox ---
|
|
183
223
|
async sendInbox(peerId, message) {
|
|
184
|
-
validateName(peerId, "Recipient name");
|
|
185
224
|
const config = await this.readConfig();
|
|
186
|
-
//
|
|
187
|
-
|
|
225
|
+
// Resolve recipient from keyring — supports name, name:fingerprint, or bare fingerprint.
|
|
226
|
+
// Throws if ambiguous or not found.
|
|
227
|
+
const entry = resolveKeyring(config.keyring, peerId);
|
|
188
228
|
let signed;
|
|
189
|
-
if (entry
|
|
229
|
+
if (entry.encryptionKey) {
|
|
190
230
|
signed = await signAndEncrypt(this.root, config.name, message, entry.encryptionKey);
|
|
191
231
|
}
|
|
192
232
|
else {
|
|
193
233
|
signed = await signMessage(this.root, config.name, message);
|
|
194
234
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
235
|
+
const shortFp = entry.fingerprint.replace(/:/g, "").slice(0, 8);
|
|
236
|
+
const recipientDir = `${peerId}-${shortFp}`;
|
|
237
|
+
const outboxDir = join(this.root, "outbox", recipientDir);
|
|
238
|
+
await mkdir(outboxDir, { recursive: true });
|
|
198
239
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
199
|
-
const filename = `${timestamp}_from-${config.name}
|
|
200
|
-
await writeFile(join(
|
|
201
|
-
return filename
|
|
240
|
+
const filename = `${timestamp}_from-${config.name}.json`;
|
|
241
|
+
await writeFile(join(outboxDir, filename), serializeSignedMessage(signed));
|
|
242
|
+
return `${recipientDir}/${filename}`;
|
|
202
243
|
}
|
|
203
244
|
async readInbox() {
|
|
204
245
|
const inboxDir = join(this.root, "inbox");
|
|
@@ -212,13 +253,15 @@ export class ContextStore {
|
|
|
212
253
|
const signed = deserializeSignedMessage(raw);
|
|
213
254
|
if (signed) {
|
|
214
255
|
const sigValid = verifyMessage(signed);
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
256
|
+
// Identity binding: verify BOTH that the key is trusted AND that the claimed
|
|
257
|
+
// sender name matches the name we associated with that key in our keyring.
|
|
258
|
+
// Without this, a trusted agent could forge the "from" field and impersonate
|
|
259
|
+
// someone else while still showing [VERIFIED].
|
|
260
|
+
const keyMatchesName = (k) => k.signingKey.trim() === signed.publicKey.trim() &&
|
|
261
|
+
(k.name === signed.from || k.address.startsWith(`${signed.from}@`));
|
|
219
262
|
const trusted = config.autoTrust
|
|
220
|
-
?
|
|
221
|
-
: config.keyring.some((k) => k.trusted && k
|
|
263
|
+
? config.keyring.some(keyMatchesName)
|
|
264
|
+
: config.keyring.some((k) => k.trusted && keyMatchesName(k));
|
|
222
265
|
const verified = sigValid && trusted;
|
|
223
266
|
let content;
|
|
224
267
|
if (signed.encrypted) {
|
package/dist/sync.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export interface SyncResult {
|
|
|
7
7
|
pushed: string[];
|
|
8
8
|
errors: string[];
|
|
9
9
|
}
|
|
10
|
-
/** Try to deliver a single outbox message immediately. Returns true if delivered.
|
|
10
|
+
/** Try to deliver a single outbox message immediately. Returns true if delivered.
|
|
11
|
+
* filename can be "recipientDir/msg.json" (new) or "flat.json" (legacy). */
|
|
11
12
|
export declare function deliverOne(store: ContextStore, peerName: string, filename: string): Promise<boolean>;
|
|
12
13
|
export declare function syncAll(store: ContextStore): Promise<SyncResult[]>;
|
|
13
14
|
export declare function syncOne(store: ContextStore, peerName: string): Promise<SyncResult>;
|
package/dist/sync.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// so agents reference hostnames, never raw IPs that change). Both transports do the same
|
|
5
5
|
// thing: pull CONTEXT.md + PROFILE.md + shared/ + knowledge/, push outbox → peer inbox.
|
|
6
6
|
import { readFile, writeFile, mkdir, readdir, rename } from "node:fs/promises";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { execFile as execFileCb } from "node:child_process";
|
|
10
10
|
import { promisify } from "node:util";
|
|
@@ -47,10 +47,18 @@ function sanitizePeerContent(raw) {
|
|
|
47
47
|
}
|
|
48
48
|
// Archive instead of delete: preserves audit trail and lets agents review what was sent.
|
|
49
49
|
// Without this, sync would re-deliver the same message every cycle.
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// relPath can be "file.json" (flat, legacy) or "recipientDir/file.json" (new subdir layout).
|
|
51
|
+
async function archiveSent(outboxRoot, relPath) {
|
|
52
|
+
// Path traversal defense: resolve and verify we stay under outboxRoot
|
|
53
|
+
const fullPath = resolve(outboxRoot, relPath);
|
|
54
|
+
if (!fullPath.startsWith(resolve(outboxRoot) + "/")) {
|
|
55
|
+
throw new Error(`Path traversal blocked: ${relPath}`);
|
|
56
|
+
}
|
|
57
|
+
const dir = join(fullPath, "..");
|
|
58
|
+
const fname = relPath.includes("/") ? relPath.split("/").pop() : relPath;
|
|
59
|
+
const sentDir = join(dir, ".sent");
|
|
52
60
|
await mkdir(sentDir, { recursive: true });
|
|
53
|
-
await rename(
|
|
61
|
+
await rename(fullPath, join(sentDir, fname));
|
|
54
62
|
}
|
|
55
63
|
function parseUrl(url) {
|
|
56
64
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
@@ -78,16 +86,18 @@ function parseUrl(url) {
|
|
|
78
86
|
}
|
|
79
87
|
throw new Error(`Unknown URL scheme: ${url}. Use http:// or ssh://`);
|
|
80
88
|
}
|
|
81
|
-
/** Try to deliver a single outbox message immediately. Returns true if delivered.
|
|
89
|
+
/** Try to deliver a single outbox message immediately. Returns true if delivered.
|
|
90
|
+
* filename can be "recipientDir/msg.json" (new) or "flat.json" (legacy). */
|
|
82
91
|
export async function deliverOne(store, peerName, filename) {
|
|
83
92
|
const config = await store.readConfig();
|
|
84
93
|
const peer = config.peers.find((p) => p.name === peerName || p.id === peerName);
|
|
85
94
|
if (!peer)
|
|
86
95
|
return false;
|
|
87
|
-
const
|
|
88
|
-
const filePath = join(
|
|
96
|
+
const outboxRoot = join(store.root, "outbox");
|
|
97
|
+
const filePath = join(outboxRoot, filename);
|
|
89
98
|
if (!existsSync(filePath))
|
|
90
99
|
return false;
|
|
100
|
+
const baseName = filename.includes("/") ? filename.split("/").pop() : filename;
|
|
91
101
|
try {
|
|
92
102
|
const transport = parseUrl(peer.url);
|
|
93
103
|
if (transport.type === "http") {
|
|
@@ -104,11 +114,11 @@ export async function deliverOne(store, peerName, filename) {
|
|
|
104
114
|
else {
|
|
105
115
|
await execFile("rsync", [
|
|
106
116
|
"-az", filePath,
|
|
107
|
-
`${transport.host}:${transport.path}/inbox/${
|
|
117
|
+
`${transport.host}:${transport.path}/inbox/${baseName}`,
|
|
108
118
|
]);
|
|
109
119
|
}
|
|
110
120
|
// Delivered — archive to .sent/
|
|
111
|
-
await archiveSent(
|
|
121
|
+
await archiveSent(outboxRoot, filename);
|
|
112
122
|
return true;
|
|
113
123
|
}
|
|
114
124
|
catch {
|
|
@@ -262,30 +272,57 @@ async function syncHttp(store, peer, baseUrl, peerDir) {
|
|
|
262
272
|
}
|
|
263
273
|
}
|
|
264
274
|
catch { }
|
|
265
|
-
// Push outbox → peer inbox
|
|
275
|
+
// Push outbox → peer inbox (scan subdirs named {peer}-{fp}/)
|
|
266
276
|
const outboxDir = join(store.root, "outbox");
|
|
267
277
|
if (existsSync(outboxDir)) {
|
|
268
|
-
for (const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
for (const entry of await readdir(outboxDir, { withFileTypes: true })) {
|
|
279
|
+
// Match subdirs starting with peer name (new format: name-FINGERPRINT/)
|
|
280
|
+
if (entry.isDirectory() && entry.name.startsWith(`${peer.name}-`)) {
|
|
281
|
+
const subDir = join(outboxDir, entry.name);
|
|
282
|
+
for (const fname of await readdir(subDir)) {
|
|
283
|
+
if (!fname.endsWith(".json"))
|
|
284
|
+
continue;
|
|
285
|
+
const relPath = `${entry.name}/${fname}`;
|
|
286
|
+
try {
|
|
287
|
+
const body = await readFile(join(subDir, fname), "utf-8");
|
|
288
|
+
const r = await fetch(`${baseUrl}/inbox`, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "Content-Type": "application/json" },
|
|
291
|
+
body,
|
|
292
|
+
});
|
|
293
|
+
if (r.ok) {
|
|
294
|
+
await archiveSent(outboxDir, relPath);
|
|
295
|
+
pushed.push(relPath);
|
|
296
|
+
}
|
|
297
|
+
else
|
|
298
|
+
errors.push(`push ${relPath}: HTTP ${r.status}`);
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
errors.push(`push ${relPath}: ${e.message}`);
|
|
302
|
+
}
|
|
283
303
|
}
|
|
284
|
-
else
|
|
285
|
-
errors.push(`push ${fname}: HTTP ${r.status}`);
|
|
286
304
|
}
|
|
287
|
-
|
|
288
|
-
|
|
305
|
+
// Legacy flat files (pre-subdir format)
|
|
306
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
307
|
+
if (!entry.name.includes(`_to-${peer.name}-`) && !entry.name.includes(`_to-${peer.name}.json`))
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
const body = await readFile(join(outboxDir, entry.name), "utf-8");
|
|
311
|
+
const r = await fetch(`${baseUrl}/inbox`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: { "Content-Type": "application/json" },
|
|
314
|
+
body,
|
|
315
|
+
});
|
|
316
|
+
if (r.ok) {
|
|
317
|
+
await archiveSent(outboxDir, entry.name);
|
|
318
|
+
pushed.push(entry.name);
|
|
319
|
+
}
|
|
320
|
+
else
|
|
321
|
+
errors.push(`push ${entry.name}: HTTP ${r.status}`);
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
errors.push(`push ${entry.name}: ${e.message}`);
|
|
325
|
+
}
|
|
289
326
|
}
|
|
290
327
|
}
|
|
291
328
|
}
|
|
@@ -319,43 +356,104 @@ async function syncSsh(store, peer, host, remotePath, peerDir) {
|
|
|
319
356
|
}
|
|
320
357
|
// Pull peer's outbox for messages addressed to us — peer may be behind NAT
|
|
321
358
|
// and can't push to us, so we grab messages they left in their outbox for us.
|
|
359
|
+
// New layout: outbox/{name}-{fp}/*.json — pull from all dirs starting with our name.
|
|
322
360
|
const config = await store.readConfig();
|
|
323
361
|
const myName = config.name;
|
|
324
362
|
const inboxDir = join(store.root, "inbox");
|
|
325
363
|
await mkdir(inboxDir, { recursive: true });
|
|
364
|
+
// New subdir format: pull outbox/{myName}-*/*.json into a temp dir (preserves structure),
|
|
365
|
+
// then move the .json files into inbox/ (flattened). rsync --include handles the filtering;
|
|
366
|
+
// we avoid ssh commands to prevent shell injection via host/path values.
|
|
367
|
+
const tmpPull = join(store.root, ".tmp-outbox-pull");
|
|
368
|
+
try {
|
|
369
|
+
await mkdir(tmpPull, { recursive: true });
|
|
370
|
+
await execFile("rsync", [
|
|
371
|
+
"-az", "--ignore-existing",
|
|
372
|
+
"--include", `${myName}-*/`,
|
|
373
|
+
"--include", `${myName}-*/*.json`,
|
|
374
|
+
"--exclude", "*",
|
|
375
|
+
`${host}:${remotePath}/outbox/`,
|
|
376
|
+
`${tmpPull}/`,
|
|
377
|
+
]);
|
|
378
|
+
// Flatten: move .json files from subdirs into inbox/
|
|
379
|
+
if (existsSync(tmpPull)) {
|
|
380
|
+
for (const subEntry of await readdir(tmpPull, { withFileTypes: true })) {
|
|
381
|
+
if (!subEntry.isDirectory() || !subEntry.name.startsWith(`${myName}-`))
|
|
382
|
+
continue;
|
|
383
|
+
const subPath = join(tmpPull, subEntry.name);
|
|
384
|
+
for (const fname of await readdir(subPath)) {
|
|
385
|
+
if (!fname.endsWith(".json"))
|
|
386
|
+
continue;
|
|
387
|
+
const dest = join(inboxDir, fname);
|
|
388
|
+
if (!existsSync(dest)) {
|
|
389
|
+
await rename(join(subPath, fname), dest);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
pulled.push("outbox→inbox");
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
if (!String(e.stderr || e.message).includes("No such file")) {
|
|
398
|
+
errors.push(`pull outbox (subdir): ${e.stderr || e.message}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
finally {
|
|
402
|
+
// Clean up temp dir
|
|
403
|
+
try {
|
|
404
|
+
await (await import("node:fs/promises")).rm(tmpPull, { recursive: true, force: true });
|
|
405
|
+
}
|
|
406
|
+
catch { }
|
|
407
|
+
}
|
|
408
|
+
// Legacy flat format: outbox/*_to-{name}*.json
|
|
326
409
|
try {
|
|
327
410
|
await execFile("rsync", [
|
|
328
411
|
"-az", "--ignore-existing",
|
|
329
|
-
"--include", `*_to-${myName}-*.json`,
|
|
330
|
-
"--include", `*_to-${myName}.json`,
|
|
412
|
+
"--include", `*_to-${myName}-*.json`,
|
|
413
|
+
"--include", `*_to-${myName}.json`,
|
|
331
414
|
"--include", `*_to-all.json`,
|
|
332
|
-
"--include", `*_${myName}.json`, // pre-envelope legacy
|
|
333
415
|
"--exclude", "*",
|
|
334
416
|
`${host}:${remotePath}/outbox/`,
|
|
335
417
|
`${inboxDir}/`,
|
|
336
418
|
]);
|
|
337
|
-
pulled.push("outbox→inbox");
|
|
338
419
|
}
|
|
339
420
|
catch (e) {
|
|
340
421
|
if (!String(e.stderr || e.message).includes("No such file")) {
|
|
341
|
-
errors.push(`pull outbox: ${e.stderr || e.message}`);
|
|
422
|
+
errors.push(`pull outbox (legacy): ${e.stderr || e.message}`);
|
|
342
423
|
}
|
|
343
424
|
}
|
|
344
|
-
// Push our outbox → peer inbox
|
|
425
|
+
// Push our outbox → peer inbox (scan subdirs named {peer}-{fp}/)
|
|
345
426
|
const outboxDir = join(store.root, "outbox");
|
|
346
427
|
if (existsSync(outboxDir)) {
|
|
347
|
-
for (const
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
428
|
+
for (const entry of await readdir(outboxDir, { withFileTypes: true })) {
|
|
429
|
+
if (entry.isDirectory() && entry.name.startsWith(`${peer.name}-`)) {
|
|
430
|
+
const subDir = join(outboxDir, entry.name);
|
|
431
|
+
for (const fname of await readdir(subDir)) {
|
|
432
|
+
if (!fname.endsWith(".json"))
|
|
433
|
+
continue;
|
|
434
|
+
const relPath = `${entry.name}/${fname}`;
|
|
435
|
+
try {
|
|
436
|
+
await execFile("rsync", ["-az", join(subDir, fname), `${host}:${remotePath}/inbox/${fname}`]);
|
|
437
|
+
await archiveSent(outboxDir, relPath);
|
|
438
|
+
pushed.push(relPath);
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
errors.push(`push ${relPath}: ${e.stderr || e.message}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
356
444
|
}
|
|
357
|
-
|
|
358
|
-
|
|
445
|
+
// Legacy flat files
|
|
446
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
447
|
+
if (!entry.name.includes(`_to-${peer.name}-`) && !entry.name.includes(`_to-${peer.name}.json`))
|
|
448
|
+
continue;
|
|
449
|
+
try {
|
|
450
|
+
await execFile("rsync", ["-az", join(outboxDir, entry.name), `${host}:${remotePath}/inbox/${entry.name}`]);
|
|
451
|
+
await archiveSent(outboxDir, entry.name);
|
|
452
|
+
pushed.push(entry.name);
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
errors.push(`push ${entry.name}: ${e.stderr || e.message}`);
|
|
456
|
+
}
|
|
359
457
|
}
|
|
360
458
|
}
|
|
361
459
|
}
|
package/dist/watch.js
CHANGED
|
@@ -19,15 +19,17 @@ export function watchInbox(storeRoot, callback) {
|
|
|
19
19
|
const signed = deserializeSignedMessage(raw);
|
|
20
20
|
if (signed) {
|
|
21
21
|
const sigValid = verifyMessage(signed);
|
|
22
|
-
//
|
|
23
|
-
//
|
|
22
|
+
// Identity binding: key must be trusted AND name must match the keyring entry.
|
|
23
|
+
// Prevents a trusted agent from impersonating someone else via forged "from" field.
|
|
24
24
|
let verified = false;
|
|
25
25
|
if (sigValid) {
|
|
26
26
|
try {
|
|
27
27
|
const config = await store.readConfig();
|
|
28
|
+
const keyMatchesName = (k) => k.signingKey.trim() === signed.publicKey.trim() &&
|
|
29
|
+
(k.name === signed.from || k.address.startsWith(`${signed.from}@`));
|
|
28
30
|
const trusted = config.autoTrust
|
|
29
|
-
? config.keyring.some(
|
|
30
|
-
: config.keyring.some((k) => k.trusted && k
|
|
31
|
+
? config.keyring.some(keyMatchesName)
|
|
32
|
+
: config.keyring.some((k) => k.trusted && keyMatchesName(k));
|
|
31
33
|
verified = trusted;
|
|
32
34
|
}
|
|
33
35
|
catch { }
|