openfused 0.4.5 → 0.5.0

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
@@ -313,6 +313,18 @@ openfused serve --store ./my-context --token "$OPENFUSE_TOKEN" --gc-days 7
313
313
 
314
314
  Rate limiting, IP filtering, and TLS belong at the reverse proxy layer (nginx, Caddy, cloudflared). The daemon focuses on application logic.
315
315
 
316
+ **Isolation:** Run the daemon as a dedicated non-root user with access only to the store directory. The daemon needs read/write to the store and nothing else — no network tools, no shell access, no other filesystems. In Docker this is automatic (container isolation). On bare metal:
317
+
318
+ ```bash
319
+ # Create isolated user
320
+ sudo useradd -r -s /usr/sbin/nologin -d /var/lib/openfused openfused
321
+ sudo mkdir -p /var/lib/openfused/store
322
+ sudo chown -R openfused: /var/lib/openfused
323
+
324
+ # Run as that user
325
+ sudo -u openfused openfused serve --store /var/lib/openfused/store --public --token "$TOKEN"
326
+ ```
327
+
316
328
  Endpoints:
317
329
 
318
330
  | Endpoint | Method | Auth | Purpose |
package/dist/cli.js CHANGED
@@ -118,7 +118,38 @@ program
118
118
  const store = new ContextStore(resolve(opts.dir));
119
119
  if (opts.set) {
120
120
  await store.writeProfile(opts.set);
121
- console.log("Profile updated.");
121
+ console.log("Profile updated locally.");
122
+ // Push to remote endpoint if agent has an HTTP peer for itself
123
+ const config = await store.readConfig();
124
+ const endpoint = config.peers?.find((p) => p.name === config.name && p.url?.startsWith("http"))?.url;
125
+ if (endpoint) {
126
+ try {
127
+ const { signChallenge } = await import("./crypto.js");
128
+ const timestamp = new Date().toISOString();
129
+ const challenge = `PROFILE:${config.name}:${timestamp}`;
130
+ const { signature, publicKey } = await signChallenge(store.root, challenge);
131
+ const resp = await fetch(`${endpoint}/profile/${encodeURIComponent(config.name)}`, {
132
+ method: "PUT",
133
+ headers: {
134
+ "Content-Type": "text/plain",
135
+ "X-OpenFuse-PublicKey": publicKey,
136
+ "X-OpenFuse-Signature": signature,
137
+ "X-OpenFuse-Timestamp": timestamp,
138
+ },
139
+ body: opts.set,
140
+ });
141
+ if (resp.ok) {
142
+ console.log("Profile synced to hosted mailbox.");
143
+ }
144
+ else {
145
+ const err = await resp.text();
146
+ console.error(`Failed to sync profile to mailbox: ${err}`);
147
+ }
148
+ }
149
+ catch (e) {
150
+ console.error(`Failed to sync profile: ${e.message}`);
151
+ }
152
+ }
122
153
  }
123
154
  else {
124
155
  console.log(await store.readProfile());
@@ -131,19 +162,64 @@ inbox
131
162
  .description("List inbox messages")
132
163
  .option("-d, --dir <path>", "Context store directory", ".")
133
164
  .option("--raw", "Show raw content instead of wrapped")
165
+ .option("--all", "Show all messages including unverified (default: verified + subscribed)")
166
+ .option("--trusted", "Show only trusted messages")
167
+ .option("--no-sync", "Skip pulling from remote peers before listing")
134
168
  .action(async (opts) => {
135
169
  const store = new ContextStore(resolve(opts.dir));
136
- const messages = await store.readInbox();
137
- if (messages.length === 0) {
170
+ // Auto-sync: pull new messages from remote peers before listing
171
+ if (opts.sync !== false) {
172
+ try {
173
+ const config = await store.readConfig();
174
+ if (config.peers.length > 0) {
175
+ const { syncAll } = await import("./sync.js");
176
+ await syncAll(store);
177
+ }
178
+ }
179
+ catch { }
180
+ }
181
+ const allMessages = await store.readInbox();
182
+ // Default: show verified (trusted + subscribed). Use --all for everything.
183
+ let messages;
184
+ if (opts.all) {
185
+ messages = allMessages;
186
+ }
187
+ else if (opts.trusted) {
188
+ messages = allMessages.filter((m) => m.verified && m.trusted);
189
+ }
190
+ else {
191
+ messages = allMessages.filter((m) => m.verified && (m.trusted || m.subscribed));
192
+ }
193
+ const hidden = allMessages.length - messages.length;
194
+ if (messages.length === 0 && hidden === 0) {
138
195
  console.log("Inbox is empty.");
139
196
  return;
140
197
  }
198
+ if (messages.length === 0 && hidden > 0) {
199
+ console.log(`Inbox has ${hidden} message(s) from unsubscribed/untrusted senders.`);
200
+ console.log(`Run with --all to see them, or: openfuse key trust <name> / openfuse subscribe <name>`);
201
+ return;
202
+ }
141
203
  for (const msg of messages) {
142
- const badge = msg.verified ? "[VERIFIED]" : "[UNVERIFIED]";
143
- const enc = msg.encrypted ? " [ENCRYPTED]" : "";
144
- console.log(`\n--- ${badge}${enc} From: ${msg.from} | ${msg.time} ---`);
204
+ // Build badge: [VERIFIED] [TRUSTED] [INTERNAL] etc.
205
+ const badges = [];
206
+ badges.push(msg.verified ? "[VERIFIED]" : "[UNVERIFIED]");
207
+ if (msg.trusted)
208
+ badges.push("[TRUSTED]");
209
+ if (msg.subscribed)
210
+ badges.push("[SUBSCRIBED]");
211
+ if (msg.relationship)
212
+ badges.push(`[${msg.relationship.toUpperCase()}]`);
213
+ if (msg.encrypted)
214
+ badges.push("[ENCRYPTED]");
215
+ const badgeStr = badges.join(" ");
216
+ const noteStr = msg.note ? ` (${msg.note})` : "";
217
+ console.log(`\n--- ${badgeStr} From: ${msg.from}${noteStr} | ${msg.time} ---`);
145
218
  console.log(opts.raw ? msg.content : msg.wrappedContent);
146
219
  }
220
+ if (hidden > 0) {
221
+ console.log(`\n(${hidden} message(s) hidden — use --all to show)`);
222
+ }
147
223
  });
148
224
  inbox
149
225
  .command("archive [file]")
@@ -506,13 +582,25 @@ key
506
582
  .command("trust <query>")
507
583
  .description("Trust a key in the keyring (name, name:fingerprint, or fingerprint)")
508
584
  .option("-d, --dir <path>", "Context store directory", ".")
585
+ .option("--internal", "Mark as internal (same org/team)")
586
+ .option("--external", "Mark as external (partner/vendor)")
587
+ .option("--note <text>", "Private note about this peer")
509
588
  .action(async (query, opts) => {
510
589
  const store = new ContextStore(resolve(opts.dir));
511
590
  const config = await store.readConfig();
512
591
  const entry = resolveKeyring(config.keyring, query);
513
592
  entry.trusted = true;
593
+ if (opts.internal)
594
+ entry.relationship = "internal";
595
+ else if (opts.external)
596
+ entry.relationship = "external";
597
+ if (opts.note)
598
+ entry.note = opts.note;
514
599
  await store.writeConfig(config);
515
- console.log(`Trusted: ${entry.name} (${entry.fingerprint})`);
600
+ const rel = entry.relationship ? ` [${entry.relationship.toUpperCase()}]` : "";
601
+ console.log(`Trusted: ${entry.name} (${entry.fingerprint})${rel}`);
602
+ if (entry.note)
603
+ console.log(` Note: ${entry.note}`);
516
604
  });
517
605
  key
518
606
  .command("untrust <query>")
@@ -538,6 +626,141 @@ key
538
626
  console.log(`signing:${config.publicKey}`);
539
627
  console.log(`encryption:${config.encryptionKey}`);
540
628
  });
629
+ // --- subscribe / unsubscribe / broadcast ---
630
+ program
631
+ .command("subscribe <name>")
632
+ .description("Subscribe to an agent's broadcasts")
633
+ .option("-d, --dir <path>", "Context store directory", ".")
634
+ .option("-r, --registry <url>", "Registry URL")
635
+ .option("--note <text>", "Private note about this agent")
636
+ .action(async (name, opts) => {
637
+ const store = new ContextStore(resolve(opts.dir));
638
+ const reg = registry.resolveRegistry(opts.registry);
639
+ const config = await store.readConfig();
640
+ let entry = config.keyring.find((e) => e.name === name);
641
+ if (!entry) {
642
+ // Auto-import from registry
643
+ try {
644
+ const manifest = await registry.discover(name, reg);
645
+ entry = {
646
+ name: manifest.name,
647
+ address: `${manifest.name}@registry`,
648
+ signingKey: manifest.publicKey,
649
+ encryptionKey: manifest.encryptionKey,
650
+ fingerprint: manifest.fingerprint,
651
+ trusted: false,
652
+ subscribed: true,
653
+ relationship: null,
654
+ note: opts.note ?? null,
655
+ added: new Date().toISOString(),
656
+ };
657
+ config.keyring.push(entry);
658
+ console.log(`Imported key for ${manifest.name} from registry`);
659
+ }
660
+ catch {
661
+ console.error(`Agent '${name}' not found in keyring or registry.`);
662
+ process.exit(1);
663
+ }
664
+ }
665
+ else {
666
+ entry.subscribed = true;
667
+ if (opts.note)
668
+ entry.note = opts.note;
669
+ }
670
+ await store.writeConfig(config);
671
+ console.log(`Subscribed to: ${entry.name} (${entry.fingerprint})`);
672
+ console.log(`Their broadcasts will appear in your inbox.`);
673
+ });
674
+ program
675
+ .command("unsubscribe <name>")
676
+ .description("Unsubscribe from an agent's broadcasts")
677
+ .option("-d, --dir <path>", "Context store directory", ".")
678
+ .action(async (name, opts) => {
679
+ const store = new ContextStore(resolve(opts.dir));
680
+ const config = await store.readConfig();
681
+ const entry = config.keyring.find((e) => e.name === name);
682
+ if (!entry) {
683
+ console.error(`Agent '${name}' not in keyring.`);
684
+ process.exit(1);
685
+ }
686
+ entry.subscribed = false;
687
+ await store.writeConfig(config);
688
+ console.log(`Unsubscribed from: ${entry.name}`);
689
+ });
690
+ program
691
+ .command("broadcast <message>")
692
+ .description("Send a message to all trusted + subscribed agents")
693
+ .option("-d, --dir <path>", "Context store directory", ".")
694
+ .option("--internal", "Only send to internal agents")
695
+ .option("--external", "Only send to external agents")
696
+ .option("--subscribers", "Only send to subscribers (not trusted)")
697
+ .option("--trusted-only", "Only send to trusted agents (skip subscribed-but-untrusted)")
698
+ .action(async (message, opts) => {
699
+ const store = new ContextStore(resolve(opts.dir));
700
+ const config = await store.readConfig();
701
+ // Build recipient list
702
+ let recipients = config.keyring.filter((e) => {
703
+ if (opts.trustedOnly)
704
+ return e.trusted;
705
+ if (opts.subscribers)
706
+ return e.subscribed;
707
+ return e.trusted || e.subscribed;
708
+ });
709
+ if (opts.internal) {
710
+ recipients = recipients.filter((e) => e.relationship === "internal");
711
+ }
712
+ else if (opts.external) {
713
+ recipients = recipients.filter((e) => e.relationship === "external");
714
+ }
715
+ if (recipients.length === 0) {
716
+ console.log("No recipients. Trust or subscribe to agents first.");
717
+ return;
718
+ }
719
+ // Warn about untrusted recipients
720
+ const untrusted = recipients.filter((e) => !e.trusted);
721
+ if (untrusted.length > 0) {
722
+ console.log(`Warning: ${untrusted.length} recipient(s) are subscribed but NOT trusted:`);
723
+ for (const u of untrusted) {
724
+ console.log(` ${u.name} (${u.fingerprint})`);
725
+ }
726
+ console.log(`Their keys have not been verified. Use --trusted-only to skip them.\n`);
727
+ }
728
+ console.log(`Broadcasting to ${recipients.length} agent(s)...`);
729
+ let delivered = 0;
730
+ let queued = 0;
731
+ for (const recipient of recipients) {
732
+ try {
733
+ await store.sendInbox(recipient.name, message);
734
+ // Try HTTP delivery
735
+ const peer = config.peers.find((p) => p.name === recipient.name && p.url?.startsWith("http"));
736
+ if (peer) {
737
+ const outboxFile = findNewestOutboxFile(store.root, recipient.name);
738
+ if (outboxFile) {
739
+ const { checkSsrf } = await import("./sync.js");
740
+ await checkSsrf(peer.url);
741
+ const body = await readFile(join(store.root, "outbox", outboxFile), "utf-8");
742
+ const inboxUrl = `${peer.url.replace(/\/$/, "")}/inbox/${encodeURIComponent(recipient.name)}`;
743
+ const r = await fetch(inboxUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body });
744
+ if (r.ok) {
745
+ const { mkdir, rename } = await import("node:fs/promises");
746
+ const filePath = join(store.root, "outbox", outboxFile);
747
+ const sentDir = join(filePath, "..", ".sent");
748
+ const baseName = outboxFile.split("/").pop();
749
+ await mkdir(sentDir, { recursive: true });
750
+ await rename(filePath, join(sentDir, baseName));
751
+ delivered++;
752
+ continue;
753
+ }
754
+ }
755
+ }
756
+ queued++;
757
+ }
758
+ catch {
759
+ queued++;
760
+ }
761
+ }
762
+ console.log(`Done. ${delivered} delivered, ${queued} queued for sync.`);
763
+ });
541
764
  // --- sync ---
542
765
  program
543
766
  .command("sync [peer]")
@@ -580,6 +803,20 @@ program
580
803
  const config = await store.readConfig();
581
804
  const agentName = opts.name || `${config.name}.openfused.net`;
582
805
  const manifest = await registry.register(store, opts.endpoint || "", reg, agentName);
806
+ // Auto-add endpoint as a peer so sync/inbox list can pull from it
807
+ if (manifest.endpoint?.startsWith("http")) {
808
+ const config2 = await store.readConfig();
809
+ const selfName = config2.name;
810
+ if (!config2.peers.some((p) => p.url === manifest.endpoint && p.name === selfName)) {
811
+ config2.peers.push({
812
+ id: (await import("nanoid")).nanoid(12),
813
+ name: selfName,
814
+ url: manifest.endpoint,
815
+ access: "read",
816
+ });
817
+ await store.writeConfig(config2);
818
+ }
819
+ }
583
820
  console.log(`Registered: ${manifest.name} [SIGNED]`);
584
821
  if (manifest.endpoint)
585
822
  console.log(` Endpoint: ${manifest.endpoint}`);
@@ -595,8 +832,8 @@ program
595
832
  // --- discover ---
596
833
  program
597
834
  .command("discover <name>")
598
- .description("Look up an agent by name in the registry")
599
- .option("-r, --registry <url>", "Registry URL")
835
+ .description("Look up an agent by name via DNS")
836
+ .option("-r, --registry <url>", "Registry URL (fallback if DNS fails)")
600
837
  .action(async (name, opts) => {
601
838
  const reg = registry.resolveRegistry(opts.registry);
602
839
  const manifest = await registry.discover(name, reg);
@@ -645,6 +882,7 @@ program
645
882
  .option("-r, --registry <url>", "Registry URL")
646
883
  .option("--http", "Force HTTP delivery (uses registry endpoint)")
647
884
  .option("--ssh", "Force SSH delivery (uses local peer SSH URL)")
885
+ .option("--trust", "Auto-trust the recipient's key (skip manual fingerprint verification)")
648
886
  .action(async (name, message, opts) => {
649
887
  const store = new ContextStore(resolve(opts.dir));
650
888
  const reg = registry.resolveRegistry(opts.registry);
@@ -660,16 +898,24 @@ program
660
898
  if (manifest.endpoint?.startsWith("http"))
661
899
  httpEndpoint = manifest.endpoint;
662
900
  // Auto-import key + add as peer
663
- if (!config.keyring.some((e) => e.signingKey === manifest.publicKey)) {
901
+ const existing = config.keyring.find((e) => e.signingKey === manifest.publicKey);
902
+ if (!existing) {
664
903
  config.keyring.push({
665
904
  name: manifest.name,
666
905
  address: `${manifest.name}@registry`,
667
906
  signingKey: manifest.publicKey,
668
907
  encryptionKey: manifest.encryptionKey,
669
908
  fingerprint: manifest.fingerprint,
670
- trusted: false,
909
+ trusted: !!opts.trust,
671
910
  added: new Date().toISOString(),
672
911
  });
912
+ if (opts.trust) {
913
+ console.log(`Trusted ${manifest.name} (${manifest.fingerprint})`);
914
+ }
915
+ }
916
+ else if (opts.trust && !existing.trusted) {
917
+ existing.trusted = true;
918
+ console.log(`Trusted ${manifest.name} (${existing.fingerprint})`);
673
919
  }
674
920
  if (manifest.endpoint && !config.peers.some((p) => p.name === manifest.name)) {
675
921
  config.peers.push({
package/dist/crypto.d.ts CHANGED
@@ -14,6 +14,9 @@ export interface KeyringEntry {
14
14
  encryptionKey?: string;
15
15
  fingerprint: string;
16
16
  trusted: boolean;
17
+ subscribed?: boolean;
18
+ relationship?: string | null;
19
+ note?: string | null;
17
20
  added: string;
18
21
  }
19
22
  export declare function generateKeys(storeRoot: string): Promise<{
@@ -17,6 +17,13 @@ export interface Manifest {
17
17
  }
18
18
  export declare function resolveRegistry(flag?: string): string;
19
19
  export declare function register(store: ContextStore, endpoint: string, registry: string, name?: string): Promise<Manifest>;
20
+ export interface AgentListEntry {
21
+ name: string;
22
+ endpoint: string;
23
+ fingerprint: string;
24
+ dns: string;
25
+ }
26
+ export declare function listAgents(registry: string): Promise<AgentListEntry[]>;
20
27
  export declare function discover(name: string, registry: string): Promise<Manifest>;
21
28
  export declare function revoke(store: ContextStore, registry: string): Promise<void>;
22
29
  export declare function checkUpdate(currentVersion: string): Promise<string | null>;
package/dist/registry.js CHANGED
@@ -42,10 +42,16 @@ export async function register(store, endpoint, registry, name) {
42
42
  }
43
43
  return manifest;
44
44
  }
45
- // Discovery: try DNS TXT first (decentralized, no registry needed), fall back to Worker API.
46
- // DNS format: v=of1 e={endpoint} pk={pubkey} ek={agekey} fp={fingerprint}
47
- // Self-hosted: _openfuse.{name}.{their-domain} — user manages their own TXT records.
48
- // Our zone: _openfuse.{name}.openfused.net managed by the registry Worker on registration.
45
+ export async function listAgents(registry) {
46
+ const resp = await fetch(`${registry.replace(/\/$/, "")}/agents`);
47
+ if (!resp.ok) {
48
+ if (resp.status === 404)
49
+ throw new Error(`Registry at ${registry} does not support agent listing (/agents endpoint). Try discovering agents by name: openfuse discover <name>`);
50
+ throw new Error(`Registry returned ${resp.status}`);
51
+ }
52
+ const data = (await resp.json());
53
+ return data.agents || [];
54
+ }
49
55
  export async function discover(name, registry) {
50
56
  // If name contains a dot, it's a domain — try DNS TXT directly
51
57
  // Otherwise try DNS at openfused.net, then fall back to registry API
@@ -40,6 +40,9 @@ export interface KeyringEntry {
40
40
  encryptionKey?: string;
41
41
  fingerprint: string;
42
42
  trusted: boolean;
43
+ subscribed?: boolean;
44
+ relationship?: string | null;
45
+ note?: string | null;
43
46
  added: string;
44
47
  }
45
48
  export interface StatusInfo {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openfused",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.openfused/openfuse-mcp",
5
5
  "description": "The file protocol for AI agent context. Encrypted, signed, peer-to-peer.",
6
6
  "license": "MIT",
Binary file