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 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/ — sent message copies (moved to .sent/ after delivery)
44
- shared/ files shared with the mesh (plaintext)
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 — mesh config, peers, keyring
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-encrypted if peer's age key is on file)
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 the mesh
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
- - If you have a peer's age key messages are encrypted automatically
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`. Any agent can register, discover others, and send messages.
156
+ Public registry at `registry.openfused.dev`. Works as a keyserver endpoint is optional.
155
157
 
156
158
  ```bash
157
- # Register (auto-names as yourname.openfused.net)
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
- # Or register with a custom domain
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 (`*_to-{your-name}.json`)
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
- ### Message envelope format
209
+ ### Outbox layout
204
210
 
205
- Filenames encode routing metadata so agents know what's for them:
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
- {timestamp}_from-{sender}_to-{recipient}.json
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
- Examples:
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
- Agents only process files matching `_to-{their-name}` or `_to-all`.
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}` (encrypted) |
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
- console.log(`${e.name} ${addr} ${trust}`);
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 <name>")
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 (name, opts) => {
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.find((e) => e.name === name || e.fingerprint === name);
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 <name>")
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 (name, opts) => {
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.find((e) => e.name === name || e.fingerprint === name);
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
- .requiredOption("-e, --endpoint <url>", "Endpoint URL where peers can reach you")
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
- console.log(` Endpoint: ${manifest.endpoint}`);
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 sentDir = join(store.root, "outbox", ".sent");
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(join(store.root, "outbox", filename), join(sentDir, filename));
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
@@ -4,6 +4,7 @@ export interface SignedMessage {
4
4
  message: string;
5
5
  signature: string;
6
6
  publicKey: string;
7
+ encryptionKey?: string;
7
8
  encrypted?: boolean;
8
9
  }
9
10
  export interface KeyringEntry {
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
- return { from, timestamp, message, signature, publicKey, encrypted: false };
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
- return { from, timestamp, message: encoded, signature, publicKey, encrypted: true };
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.e || !fields.pk)
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
- // Look up peer's encryption key in keyring
187
- const entry = config.keyring.find((e) => e.name === peerId || e.address.startsWith(`${peerId}@`));
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?.encryptionKey) {
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
- // Envelope filename includes short fingerprint to disambiguate name collisions.
196
- // If recipient isn't in keyring, omit fingerprint — keeps filenames matchable.
197
- const shortFp = entry ? `-${entry.fingerprint.replace(/:/g, "").slice(0, 8)}` : "";
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}_to-${peerId}${shortFp}.json`;
200
- await writeFile(join(this.root, "outbox", filename), serializeSignedMessage(signed));
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
- // autoTrust (workspace mode): any key in keyring is trusted, but key must still
216
- // be present prevents random internet keys from appearing verified in a workspace
217
- // that's accidentally exposed to the network.
218
- const inKeyring = config.keyring.some((k) => k.signingKey.trim() === signed.publicKey.trim());
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
- ? inKeyring
221
- : config.keyring.some((k) => k.trusted && k.signingKey.trim() === signed.publicKey.trim());
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
- async function archiveSent(outboxDir, fname) {
51
- const sentDir = join(outboxDir, ".sent");
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(join(outboxDir, fname), join(sentDir, fname));
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 outboxDir = join(store.root, "outbox");
88
- const filePath = join(outboxDir, filename);
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/${filename}`,
117
+ `${transport.host}:${transport.path}/inbox/${baseName}`,
108
118
  ]);
109
119
  }
110
120
  // Delivered — archive to .sent/
111
- await archiveSent(outboxDir, filename);
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 fname of await readdir(outboxDir)) {
269
- if (!fname.endsWith(".json"))
270
- continue;
271
- if (!fname.includes(`_to-${peer.name}-`) && !fname.includes(`_to-${peer.name}.json`) && !fname.includes(peer.id))
272
- continue;
273
- try {
274
- const body = await readFile(join(outboxDir, fname), "utf-8");
275
- const r = await fetch(`${baseUrl}/inbox`, {
276
- method: "POST",
277
- headers: { "Content-Type": "application/json" },
278
- body,
279
- });
280
- if (r.ok) {
281
- await archiveSent(outboxDir, fname);
282
- pushed.push(fname);
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
- catch (e) {
288
- errors.push(`push ${fname}: ${e.message}`);
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`, // new format with fingerprint
330
- "--include", `*_to-${myName}.json`, // legacy format without fingerprint
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 fname of await readdir(outboxDir)) {
348
- if (!fname.endsWith(".json"))
349
- continue;
350
- if (!fname.includes(`_to-${peer.name}-`) && !fname.includes(`_to-${peer.name}.json`) && !fname.includes(peer.id))
351
- continue;
352
- try {
353
- await execFile("rsync", ["-az", join(outboxDir, fname), `${host}:${remotePath}/inbox/${fname}`]);
354
- await archiveSent(outboxDir, fname);
355
- pushed.push(fname);
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
- catch (e) {
358
- errors.push(`push ${fname}: ${e.stderr || e.message}`);
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
- // Check keyring for trust not just signature math. Without this,
23
- // any random keypair shows as [VERIFIED] in watch mode output.
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((k) => k.signingKey.trim() === signed.publicKey.trim())
30
- : config.keyring.some((k) => k.trusted && k.signingKey.trim() === signed.publicKey.trim());
31
+ ? config.keyring.some(keyMatchesName)
32
+ : config.keyring.some((k) => k.trusted && keyMatchesName(k));
31
33
  verified = trusted;
32
34
  }
33
35
  catch { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openfused",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "description": "The file protocol for AI agent context. Encrypted, signed, peer-to-peer.",
5
5
  "license": "MIT",
6
6
  "type": "module",