openfused 0.3.23 → 0.4.1

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/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import * as registry from "./registry.js";
8
8
  import { fingerprint } from "./crypto.js";
9
9
  import { resolve, join } from "node:path";
10
10
  import { readFile } from "node:fs/promises";
11
- import { parseValiditySections, buildValidityReport } from "./validity.js";
11
+ import { WasmCore } from "./wasm-core.js";
12
12
  import { createRequire } from "node:module";
13
13
  const VERSION = createRequire(import.meta.url)("../package.json").version;
14
14
  // Enable proxy support: Node.js built-in fetch doesn't respect HTTP_PROXY env vars.
@@ -305,20 +305,8 @@ program
305
305
  const store = new ContextStore(resolve(opts.dir));
306
306
  let prunedCount = 0;
307
307
  if (opts.pruneStale) {
308
- // Soft-expiry pruning: rewrite CONTEXT.md with stale sections stripped
309
- const content = await store.readContext();
310
- const sections = parseValiditySections(content);
311
- const staleSections = sections.filter((s) => s.expired);
312
- if (staleSections.length > 0) {
313
- // Remove stale annotated sections from file
314
- let updated = content;
315
- for (const s of staleSections) {
316
- // Strip the section text from the file (simple text removal)
317
- updated = updated.replace(s.sectionText, "[STALE — archived by openfuse compact --prune-stale]");
318
- }
319
- await store.writeContext(updated);
320
- prunedCount = staleSections.length;
321
- }
308
+ const core = new WasmCore(resolve(opts.dir));
309
+ prunedCount = await core.pruneStale();
322
310
  }
323
311
  const { moved, kept } = await store.compactContext();
324
312
  if (moved === 0 && prunedCount === 0) {
@@ -345,29 +333,29 @@ program
345
333
  console.error("No context store found. Run `openfuse init` first.");
346
334
  process.exit(1);
347
335
  }
348
- const content = await store.readContext();
349
- const sections = parseValiditySections(content);
350
- const report = buildValidityReport(sections);
336
+ const core = new WasmCore(resolve(opts.dir));
337
+ const report = await core.validate();
351
338
  if (opts.json) {
352
339
  console.log(JSON.stringify(report, null, 2));
353
340
  return;
354
341
  }
355
- if (report.total === 0) {
342
+ const total = report.entries.length;
343
+ if (total === 0) {
356
344
  console.log("No validity-annotated sections found.");
357
345
  console.log("Add `<!-- validity: 6h -->` before time-sensitive context entries.");
358
346
  return;
359
347
  }
360
- console.log(`Validity check: ${report.fresh} fresh, ${report.stale} stale (of ${report.total} annotated)`);
348
+ console.log(`Validity check: ${report.fresh} fresh, ${report.stale} stale (of ${total} annotated)`);
361
349
  if (report.stale > 0) {
362
- console.log("\nStale sections (confidence < 0.1):");
350
+ console.log("\nStale sections (confidence < 0.5):");
363
351
  for (const e of report.entries.filter((e) => e.expired)) {
364
- const age = e.addedAt ? ` written ${e.addedAt}` : "";
365
- console.log(` [${e.ttlLabel} TTL${age}] ${e.preview}`);
352
+ const age = e.added ? ` written ${e.added}` : "";
353
+ console.log(` [${e.ttl_str} TTL${age}] ${e.header}`);
366
354
  }
367
355
  console.log("\nRun `openfuse compact --prune-stale` to archive stale sections.");
368
356
  }
369
357
  else {
370
- console.log("All annotated sections are within their validity windows.");
358
+ console.log("All annotated sections are within their validity windows.");
371
359
  }
372
360
  });
373
361
  // --- share ---
package/dist/crypto.d.ts CHANGED
@@ -23,8 +23,6 @@ export declare function generateKeys(storeRoot: string): Promise<{
23
23
  export declare function hasKeys(storeRoot: string): Promise<boolean>;
24
24
  export declare function fingerprint(publicKey: string): string;
25
25
  export declare function loadAgeRecipient(storeRoot: string): Promise<string>;
26
- /** Sign a raw challenge string — used for outbox authentication.
27
- * Returns { signature, publicKey } without the full SignedMessage envelope. */
28
26
  export declare function signChallenge(storeRoot: string, challenge: string): Promise<{
29
27
  signature: string;
30
28
  publicKey: string;
package/dist/crypto.js CHANGED
@@ -1,39 +1,20 @@
1
- // --- Why Ed25519 + age? ---
2
- // Ed25519: fast, deterministic, no padding oracle attacks, widely supported (SSH, FIDO2, libsodium).
3
- // age over PGP: simpler API, no config footguns, no Web of Trust baggage — just X25519+ChaCha20-Poly1305.
4
- // Two separate keypairs because signing (Ed25519) and encryption (X25519) are distinct operations;
5
- // combining them would violate key-separation best practice.
6
- import { generateKeyPairSync, sign, verify, createPrivateKey, createPublicKey, createHash } from "node:crypto";
7
- import { readFile, writeFile, mkdir } from "node:fs/promises";
1
+ // Crypto module delegates to Rust WASM core for all operations.
2
+ // Keeps the same public API so cli.ts, sync.ts, watch.ts, registry.ts don't change.
3
+ import { createHash, verify as cryptoVerify, createPublicKey } from "node:crypto";
4
+ import { readFile } from "node:fs/promises";
8
5
  import { join } from "node:path";
9
6
  import { existsSync } from "node:fs";
10
- import { Encrypter, Decrypter, generateIdentity, identityToRecipient } from "age-encryption";
7
+ import { WasmCore } from "./wasm-core.js";
11
8
  const KEY_DIR = ".keys";
12
9
  // --- Key generation ---
13
10
  export async function generateKeys(storeRoot) {
14
- const keyDir = join(storeRoot, KEY_DIR);
15
- await mkdir(keyDir, { recursive: true });
16
- // Ed25519 signing keypair
17
- const { publicKey: pubObj, privateKey: privObj } = generateKeyPairSync("ed25519");
18
- const pubJwk = pubObj.export({ format: "jwk" });
19
- const privJwk = privObj.export({ format: "jwk" });
20
- const publicHex = Buffer.from(pubJwk.x, "base64url").toString("hex");
21
- const privateHex = Buffer.from(privJwk.d, "base64url").toString("hex");
22
- await writeFile(join(keyDir, "public.key"), publicHex, { mode: 0o644 });
23
- await writeFile(join(keyDir, "private.key"), privateHex, { mode: 0o600 });
24
- // age encryption keypair
25
- const ageIdentity = await generateIdentity();
26
- const ageRecipient = await identityToRecipient(ageIdentity);
27
- await writeFile(join(keyDir, "age.key"), ageIdentity, { mode: 0o600 });
28
- await writeFile(join(keyDir, "age.pub"), ageRecipient, { mode: 0o644 });
29
- return { publicKey: publicHex, encryptionKey: ageRecipient };
11
+ const core = new WasmCore(storeRoot);
12
+ return core.generateKeys();
30
13
  }
31
14
  export async function hasKeys(storeRoot) {
32
15
  return existsSync(join(storeRoot, KEY_DIR, "private.key"));
33
16
  }
34
17
  // --- Fingerprint ---
35
- // SHA-256 truncated to 16 bytes, displayed as colon-separated hex pairs (GPG-style).
36
- // Human-readable so agents can verify identities out-of-band — same UX as SSH fingerprints.
37
18
  export function fingerprint(publicKey) {
38
19
  const hash = createHash("sha256").update(publicKey).digest();
39
20
  const pairs = [];
@@ -47,71 +28,27 @@ export function fingerprint(publicKey) {
47
28
  return groups.join(":");
48
29
  }
49
30
  // --- Signing ---
50
- async function loadPrivateKey(storeRoot) {
51
- const privHex = (await readFile(join(storeRoot, KEY_DIR, "private.key"), "utf-8")).trim();
52
- const pubHex = (await readFile(join(storeRoot, KEY_DIR, "public.key"), "utf-8")).trim();
53
- const d = Buffer.from(privHex, "hex").toString("base64url");
54
- const x = Buffer.from(pubHex, "hex").toString("base64url");
55
- return createPrivateKey({ key: { kty: "OKP", crv: "Ed25519", d, x }, format: "jwk" });
56
- }
57
- async function loadPublicKeyHex(storeRoot) {
58
- return (await readFile(join(storeRoot, KEY_DIR, "public.key"), "utf-8")).trim();
59
- }
60
31
  export async function loadAgeRecipient(storeRoot) {
61
32
  return (await readFile(join(storeRoot, KEY_DIR, "age.pub"), "utf-8")).trim();
62
33
  }
63
- async function loadAgeIdentity(storeRoot) {
64
- return (await readFile(join(storeRoot, KEY_DIR, "age.key"), "utf-8")).trim();
65
- }
66
- /** Sign a raw challenge string — used for outbox authentication.
67
- * Returns { signature, publicKey } without the full SignedMessage envelope. */
68
34
  export async function signChallenge(storeRoot, challenge) {
69
- const privateKey = await loadPrivateKey(storeRoot);
70
- const publicKey = await loadPublicKeyHex(storeRoot);
71
- const signature = sign(null, Buffer.from(challenge), privateKey).toString("base64");
72
- return { signature, publicKey };
35
+ const core = new WasmCore(storeRoot);
36
+ return core.signChallenge(challenge);
73
37
  }
74
38
  export async function signMessage(storeRoot, from, message) {
75
- const privateKey = await loadPrivateKey(storeRoot);
76
- const publicKey = await loadPublicKeyHex(storeRoot);
77
- const timestamp = new Date().toISOString();
78
- const payload = Buffer.from(`${from}\n${timestamp}\n${message}`);
79
- const signature = sign(null, payload, privateKey).toString("base64");
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 };
39
+ const core = new WasmCore(storeRoot);
40
+ return core.signMessage(from, message);
87
41
  }
88
- // --- Encrypt-then-sign ---
89
- // Encrypt first, then sign the ciphertext. This order matters:
90
- // 1. Proves WHO sent the ciphertext (non-repudiation on the encrypted blob)
91
- // 2. Prevents Surreptitious Forwarding — signature covers the encrypted form,
92
- // so a relay can't strip the signature and re-sign for a different recipient.
93
- // 3. Signature is verifiable by anyone without needing the decryption key.
94
42
  export async function signAndEncrypt(storeRoot, from, plaintext, recipientAgeKey) {
95
- const ciphertext = await ageEncrypt(plaintext, recipientAgeKey);
96
- const encoded = Buffer.from(ciphertext).toString("base64");
97
- const privateKey = await loadPrivateKey(storeRoot);
98
- const publicKey = await loadPublicKeyHex(storeRoot);
99
- const timestamp = new Date().toISOString();
100
- const payload = Buffer.from(`${from}\n${timestamp}\n${encoded}`);
101
- const signature = sign(null, payload, privateKey).toString("base64");
102
- let encryptionKey;
103
- try {
104
- encryptionKey = await loadAgeRecipient(storeRoot);
105
- }
106
- catch { }
107
- return { from, timestamp, message: encoded, signature, publicKey, encryptionKey, encrypted: true };
43
+ const core = new WasmCore(storeRoot);
44
+ return core.signAndEncrypt(from, plaintext, recipientAgeKey);
108
45
  }
109
46
  export function verifyMessage(signed) {
110
47
  try {
111
48
  const payload = Buffer.from(`${signed.from}\n${signed.timestamp}\n${signed.message}`);
112
49
  const x = Buffer.from(signed.publicKey.trim(), "hex").toString("base64url");
113
50
  const pubKey = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x }, format: "jwk" });
114
- return verify(null, payload, pubKey, Buffer.from(signed.signature, "base64"));
51
+ return cryptoVerify(null, payload, pubKey, Buffer.from(signed.signature, "base64"));
115
52
  }
116
53
  catch {
117
54
  return false;
@@ -120,24 +57,10 @@ export function verifyMessage(signed) {
120
57
  export async function decryptMessage(storeRoot, signed) {
121
58
  if (!signed.encrypted)
122
59
  return signed.message;
123
- const ciphertext = Buffer.from(signed.message, "base64");
124
- return await ageDecrypt(ciphertext, storeRoot);
125
- }
126
- // --- age encryption ---
127
- async function ageEncrypt(plaintext, recipientKey) {
128
- const e = new Encrypter();
129
- e.addRecipient(recipientKey);
130
- return await e.encrypt(plaintext);
131
- }
132
- async function ageDecrypt(ciphertext, storeRoot) {
133
- const identity = await loadAgeIdentity(storeRoot);
134
- const d = new Decrypter();
135
- d.addIdentity(identity);
136
- return await d.decrypt(ciphertext, "text");
60
+ const core = new WasmCore(storeRoot);
61
+ return core.decryptMessage(signed);
137
62
  }
138
63
  // --- Helpers ---
139
- // XML envelope wrapping — gives LLMs a structured, parseable format with clear
140
- // trust signals (verified/UNVERIFIED). HTML-escaped to prevent injection into prompts.
141
64
  export function wrapExternalMessage(signed, verified) {
142
65
  const status = verified ? "verified" : "UNVERIFIED";
143
66
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
package/dist/store.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type KeyringEntry } from "./crypto.js";
2
+ export type { KeyringEntry } from "./crypto.js";
2
3
  export interface MeshConfig {
3
4
  id: string;
4
5
  name: string;
@@ -25,6 +26,7 @@ export declare function validateName(name: string, label?: string): string;
25
26
  export declare function resolveKeyring(keyring: KeyringEntry[], query: string): KeyringEntry;
26
27
  export declare class ContextStore {
27
28
  readonly root: string;
29
+ private core;
28
30
  constructor(root: string);
29
31
  get configPath(): string;
30
32
  exists(): Promise<boolean>;
package/dist/store.js CHANGED
@@ -1,21 +1,9 @@
1
- // --- Store convention ---
2
- // The context store IS the protocol. Every agent is a directory on disk with a known layout:
3
- // CONTEXT.md working memory (mutable, private)
4
- // PROFILE.md — public address card (replaces SOUL.md: "soul" implied private identity,
5
- // but this file is shared with peers — "profile" is honest about its visibility)
6
- // inbox/ — append-only message queue from other agents
7
- // outbox/ — signed envelopes waiting to be delivered
8
- // shared/ — files explicitly published to peers
9
- // history/ — conversation logs
10
- // knowledge/ — reference docs
11
- // .keys/ — Ed25519 + age keypairs (gitignored)
12
- // .mesh.json — config, peer list, keyring
13
- // No database, no daemon required. `ls` is your status command.
14
- import { readFile, writeFile, mkdir, readdir, appendFile } from "node:fs/promises";
15
- import { join, resolve } from "node:path";
1
+ // --- Store module ---
2
+ // Delegates to Rust WASM core for all store operations.
3
+ // Same public API cli.ts, sync.ts, mcp.ts, watch.ts don't change.
4
+ import { resolve } from "node:path";
16
5
  import { existsSync } from "node:fs";
17
- import { generateKeys, signMessage, signAndEncrypt, verifyMessage, decryptMessage, deserializeSignedMessage, serializeSignedMessage, wrapExternalMessage, fingerprint, } from "./crypto.js";
18
- const STORE_DIRS = ["history", "knowledge", "inbox", "outbox", "shared", ".peers"];
6
+ import { WasmCore } from "./wasm-core.js";
19
7
  /** Validate agent/peer names: alphanumeric + hyphens + underscores + dots, 1-64 chars.
20
8
  * Rejects path traversal (../, /, \) and rsync glob chars (*, ?, [). */
21
9
  export function validateName(name, label = "Name") {
@@ -36,7 +24,6 @@ export function resolveKeyring(keyring, query) {
36
24
  let name;
37
25
  let fpPrefix;
38
26
  if (query.includes(":")) {
39
- // name:FINGERPRINT format — split on LAST colon group that looks like hex
40
27
  const colonIdx = query.lastIndexOf(":");
41
28
  const maybeFp = query.slice(colonIdx + 1);
42
29
  if (/^[0-9a-fA-F]{4,16}$/.test(maybeFp)) {
@@ -50,14 +37,11 @@ export function resolveKeyring(keyring, query) {
50
37
  else {
51
38
  name = query;
52
39
  }
53
- // Match by name (or address prefix)
54
40
  let matches = keyring.filter((k) => k.name === name || k.address.startsWith(`${name}@`));
55
- // If no name match, try bare fingerprint prefix
56
41
  if (matches.length === 0 && /^[0-9a-fA-F]{4,16}$/.test(query)) {
57
42
  const upper = query.toUpperCase();
58
43
  matches = keyring.filter((k) => k.fingerprint.replace(/:/g, "").startsWith(upper));
59
44
  }
60
- // Filter by fingerprint prefix if provided
61
45
  if (fpPrefix && matches.length > 1) {
62
46
  matches = matches.filter((k) => k.fingerprint.replace(/:/g, "").startsWith(fpPrefix));
63
47
  }
@@ -72,264 +56,65 @@ export function resolveKeyring(keyring, query) {
72
56
  }
73
57
  export class ContextStore {
74
58
  root;
59
+ core;
75
60
  constructor(root) {
76
61
  this.root = resolve(root);
62
+ this.core = new WasmCore(this.root);
77
63
  }
78
64
  get configPath() {
79
- return join(this.root, ".mesh.json");
65
+ return `${this.root}/.mesh.json`;
80
66
  }
81
67
  async exists() {
82
68
  return existsSync(this.configPath);
83
69
  }
84
70
  async init(name, id) {
85
- await mkdir(this.root, { recursive: true });
86
- for (const dir of STORE_DIRS) {
87
- await mkdir(join(this.root, dir), { recursive: true });
88
- }
89
- // Copy templates
90
- const templatesDir = new URL("../templates/", import.meta.url).pathname;
91
- for (const file of ["CONTEXT.md", "PROFILE.md"]) {
92
- const templatePath = join(templatesDir, file);
93
- const destPath = join(this.root, file);
94
- if (!existsSync(destPath)) {
95
- const content = await readFile(templatePath, "utf-8");
96
- await writeFile(destPath, content);
97
- }
98
- }
99
- const keys = await generateKeys(this.root);
100
- const config = {
101
- id,
102
- name,
103
- created: new Date().toISOString(),
104
- publicKey: keys.publicKey,
105
- encryptionKey: keys.encryptionKey,
106
- peers: [],
107
- keyring: [],
108
- };
109
- await this.writeConfig(config);
71
+ await this.core.init(name, id);
110
72
  }
111
- // Shared workspace: multiple agents mount the same directory.
112
- // CHARTER.md = system prompt (purpose, rules). CONTEXT.md = shared working memory.
113
- // tasks/ for coordination, messages/{agent}/ for DMs, _broadcast/ for all-hands.
114
73
  async initWorkspace(name, id) {
115
- await mkdir(this.root, { recursive: true });
116
- for (const dir of ["tasks", "messages", "_broadcast", "shared", "history"]) {
117
- await mkdir(join(this.root, dir), { recursive: true });
118
- }
119
- const templatesDir = new URL("../templates/", import.meta.url).pathname;
120
- for (const file of ["CHARTER.md", "CONTEXT.md"]) {
121
- const templatePath = join(templatesDir, file);
122
- const destPath = join(this.root, file);
123
- if (!existsSync(destPath)) {
124
- const content = await readFile(templatePath, "utf-8");
125
- await writeFile(destPath, content);
126
- }
127
- }
128
- // Workspaces auto-trust: all imported keys are trusted by default.
129
- // Safe because workspaces are private — you control who joins.
130
- const config = {
131
- id,
132
- name,
133
- created: new Date().toISOString(),
134
- peers: [],
135
- keyring: [],
136
- autoTrust: true,
137
- };
138
- await this.writeConfig(config);
74
+ await this.core.initWorkspace(name, id);
139
75
  }
140
76
  async readConfig() {
141
- const raw = await readFile(this.configPath, "utf-8");
142
- const config = JSON.parse(raw);
143
- // Migrate legacy trustedKeys → keyring (v0.1 stored bare public keys in a flat array;
144
- // v0.2+ uses a GPG-style keyring with trust levels, fingerprints, and encryption keys)
145
- if (config.trustedKeys && config.trustedKeys.length > 0) {
146
- if (!config.keyring)
147
- config.keyring = [];
148
- for (const key of config.trustedKeys) {
149
- const k = key.trim();
150
- if (!k || config.keyring.some((e) => e.signingKey === k))
151
- continue;
152
- config.keyring.push({
153
- name: `migrated-${k.slice(0, 8)}`,
154
- address: "",
155
- signingKey: k,
156
- fingerprint: fingerprint(k),
157
- trusted: true,
158
- added: new Date().toISOString(),
159
- });
160
- }
161
- delete config.trustedKeys;
162
- await this.writeConfig(config);
163
- }
77
+ const config = await this.core.readConfig();
78
+ // Rust skips empty arrays with skip_serializing_if — ensure keyring/peers always exist
164
79
  if (!config.keyring)
165
80
  config.keyring = [];
81
+ if (!config.peers)
82
+ config.peers = [];
166
83
  return config;
167
84
  }
168
85
  async writeConfig(config) {
169
- await writeFile(this.configPath, JSON.stringify(config, null, 2) + "\n");
86
+ await this.core.writeConfig(config);
170
87
  }
171
88
  async readContext() {
172
- return readFile(join(this.root, "CONTEXT.md"), "utf-8");
89
+ return this.core.readContext();
173
90
  }
174
91
  async writeContext(content) {
175
- await writeFile(join(this.root, "CONTEXT.md"), content);
92
+ await this.core.writeContext(content);
176
93
  }
177
- // --- Context compaction ---
178
- // Agents mark sections as [DONE] when work is complete. `openfuse compact`
179
- // moves done sections to history/YYYY-MM-DD.md, keeping CONTEXT.md lean.
180
- // Sections are delimited by markdown headers (## or ###).
181
94
  async compactContext() {
182
- const content = await this.readContext();
183
- const lines = content.split("\n");
184
- const kept = [];
185
- const done = [];
186
- let current = [];
187
- let currentDone = false;
188
- const flush = () => {
189
- if (current.length > 0) {
190
- (currentDone ? done : kept).push(current.join("\n"));
191
- current = [];
192
- currentDone = false;
193
- }
194
- };
195
- for (const line of lines) {
196
- if (/^#{1,3}\s/.test(line)) {
197
- flush();
198
- currentDone = /\[DONE\]/i.test(line);
199
- }
200
- current.push(line);
201
- }
202
- flush();
203
- if (done.length === 0)
204
- return { moved: 0, kept: kept.length };
205
- // Write kept sections back to CONTEXT.md
206
- await this.writeContext(kept.join("\n\n") || "# Context\n\n*Working memory — what's happening right now.*\n");
207
- // Append done sections to history/YYYY-MM-DD.md
208
- const historyDir = join(this.root, "history");
209
- await mkdir(historyDir, { recursive: true });
210
- const dateStr = new Date().toISOString().split("T")[0];
211
- const historyFile = join(historyDir, `${dateStr}.md`);
212
- const header = existsSync(historyFile) ? "\n---\n\n" : `# Context History — ${dateStr}\n\n`;
213
- await appendFile(historyFile, header + done.join("\n\n") + "\n");
214
- return { moved: done.length, kept: kept.length };
95
+ return this.core.compact();
215
96
  }
216
97
  async readProfile() {
217
- return readFile(join(this.root, "PROFILE.md"), "utf-8");
98
+ return this.core.readProfile();
218
99
  }
219
100
  async writeProfile(content) {
220
- await writeFile(join(this.root, "PROFILE.md"), content);
101
+ await this.core.writeProfile(content);
221
102
  }
222
- // --- Inbox ---
223
103
  async sendInbox(peerId, message) {
224
104
  const config = await this.readConfig();
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);
228
- let signed;
229
- if (entry.encryptionKey) {
230
- signed = await signAndEncrypt(this.root, config.name, message, entry.encryptionKey);
231
- }
232
- else {
233
- signed = await signMessage(this.root, config.name, message);
234
- }
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 });
239
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
240
- const filename = `${timestamp}_from-${config.name}.json`;
241
- await writeFile(join(outboxDir, filename), serializeSignedMessage(signed));
242
- return `${recipientDir}/${filename}`;
105
+ await this.core.sendInbox(peerId, message, config.name);
106
+ return peerId;
243
107
  }
244
108
  async readInbox() {
245
- const inboxDir = join(this.root, "inbox");
246
- if (!existsSync(inboxDir))
247
- return [];
248
- const config = await this.readConfig();
249
- const files = await readdir(inboxDir);
250
- const messages = [];
251
- for (const file of files.filter((f) => f.endsWith(".json") || f.endsWith(".md"))) {
252
- const raw = await readFile(join(inboxDir, file), "utf-8");
253
- const signed = deserializeSignedMessage(raw);
254
- if (signed) {
255
- const sigValid = verifyMessage(signed);
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}@`));
262
- const trusted = config.autoTrust
263
- ? config.keyring.some(keyMatchesName)
264
- : config.keyring.some((k) => k.trusted && keyMatchesName(k));
265
- const verified = sigValid && trusted;
266
- let content;
267
- if (signed.encrypted) {
268
- try {
269
- content = await decryptMessage(this.root, signed);
270
- }
271
- catch {
272
- content = "[encrypted — cannot decrypt]";
273
- }
274
- }
275
- else {
276
- content = signed.message;
277
- }
278
- messages.push({
279
- file,
280
- content,
281
- wrappedContent: wrapExternalMessage(signed, verified),
282
- from: signed.from,
283
- time: signed.timestamp,
284
- verified,
285
- encrypted: !!signed.encrypted,
286
- });
287
- }
288
- else {
289
- const parts = file.replace(/\.(md|json)$/, "").split("_");
290
- const from = parts.slice(1).join("_");
291
- messages.push({
292
- file,
293
- content: raw,
294
- wrappedContent: wrapExternalMessage({ from, timestamp: parts[0], message: raw, signature: "", publicKey: "" }, false),
295
- from,
296
- time: parts[0],
297
- verified: false,
298
- encrypted: false,
299
- });
300
- }
301
- }
302
- return messages.sort((a, b) => a.time.localeCompare(b.time));
109
+ return this.core.readInbox();
303
110
  }
304
- // --- Shared files ---
305
111
  async listShared() {
306
- const sharedDir = join(this.root, "shared");
307
- if (!existsSync(sharedDir))
308
- return [];
309
- return readdir(sharedDir);
112
+ return this.core.listShared();
310
113
  }
311
114
  async share(filename, content) {
312
- // Path traversal defense: basename extraction + ".." rejection.
313
- // Critical because MCP tools pass user-supplied filenames directly.
314
- const base = filename.split("/").pop().split("\\").pop();
315
- if (!base || base === "." || base === ".." || base.includes("..")) {
316
- throw new Error(`Invalid filename: ${filename}`);
317
- }
318
- const sharedDir = join(this.root, "shared");
319
- await mkdir(sharedDir, { recursive: true });
320
- await writeFile(join(sharedDir, base), content);
115
+ await this.core.share(filename, content);
321
116
  }
322
- // --- Status ---
323
117
  async status() {
324
- const config = await this.readConfig();
325
- const inbox = await this.readInbox();
326
- const shared = await this.listShared();
327
- return {
328
- id: config.id,
329
- name: config.name,
330
- peers: config.peers.length,
331
- inboxCount: inbox.length,
332
- sharedCount: shared.length,
333
- };
118
+ return this.core.status();
334
119
  }
335
120
  }