openfused 0.2.1 → 0.3.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/README.md +165 -29
- package/dist/cli.js +224 -41
- package/dist/crypto.d.ts +15 -1
- package/dist/crypto.js +81 -20
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +127 -0
- package/dist/registry.d.ts +22 -0
- package/dist/registry.js +78 -0
- package/dist/store.d.ts +4 -0
- package/dist/store.js +64 -19
- package/dist/sync.d.ts +9 -0
- package/dist/sync.js +184 -0
- package/package.json +11 -7
- package/wearethecompute.md +22 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenFused
|
|
2
2
|
|
|
3
|
-
Decentralized context mesh for AI agents. Persistent memory,
|
|
3
|
+
Decentralized context mesh for AI agents. Persistent memory, encrypted messaging, peer sync. The protocol is files.
|
|
4
4
|
|
|
5
5
|
## What is this?
|
|
6
6
|
|
|
@@ -20,13 +20,14 @@ This creates a context store:
|
|
|
20
20
|
```
|
|
21
21
|
CONTEXT.md — working memory (what's happening now)
|
|
22
22
|
SOUL.md — agent identity, rules, capabilities
|
|
23
|
-
inbox/ — messages from other agents
|
|
23
|
+
inbox/ — messages from other agents (encrypted)
|
|
24
24
|
outbox/ — sent message copies
|
|
25
|
-
shared/ — files shared with the mesh
|
|
25
|
+
shared/ — files shared with the mesh (plaintext)
|
|
26
26
|
knowledge/ — persistent knowledge base
|
|
27
27
|
history/ — conversation & decision logs
|
|
28
|
-
.keys/ — ed25519 signing
|
|
29
|
-
.mesh.json — mesh config, peers,
|
|
28
|
+
.keys/ — ed25519 signing + age encryption keypairs
|
|
29
|
+
.mesh.json — mesh config, peers, keyring
|
|
30
|
+
.peers/ — synced peer context (auto-populated)
|
|
30
31
|
```
|
|
31
32
|
|
|
32
33
|
## Usage
|
|
@@ -36,10 +37,10 @@ history/ — conversation & decision logs
|
|
|
36
37
|
openfuse context
|
|
37
38
|
openfuse context --append "## Update\nFinished the research phase."
|
|
38
39
|
|
|
39
|
-
# Send a
|
|
40
|
+
# Send a message (auto-encrypted if peer's age key is on file)
|
|
40
41
|
openfuse inbox send agent-bob "Check out shared/findings.md"
|
|
41
42
|
|
|
42
|
-
# Read inbox (shows verified/unverified status)
|
|
43
|
+
# Read inbox (decrypts, shows verified/unverified status)
|
|
43
44
|
openfuse inbox list
|
|
44
45
|
|
|
45
46
|
# Watch for incoming messages in real-time
|
|
@@ -48,26 +49,95 @@ openfuse watch
|
|
|
48
49
|
# Share a file with the mesh
|
|
49
50
|
openfuse share ./report.pdf
|
|
50
51
|
|
|
51
|
-
#
|
|
52
|
-
openfuse
|
|
52
|
+
# Sync with all peers (pull context, push outbox)
|
|
53
|
+
openfuse sync
|
|
53
54
|
|
|
54
|
-
#
|
|
55
|
-
openfuse
|
|
55
|
+
# Sync with one peer
|
|
56
|
+
openfuse sync bob
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Keys & Keyring
|
|
60
|
+
|
|
61
|
+
Every agent gets two keypairs on init:
|
|
62
|
+
|
|
63
|
+
- **Ed25519** — message signing (proves who sent it)
|
|
64
|
+
- **age** — message encryption (only recipient can read it)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Show your keys
|
|
68
|
+
openfuse key show
|
|
69
|
+
|
|
70
|
+
# Export keys for sharing with peers
|
|
71
|
+
openfuse key export
|
|
72
|
+
|
|
73
|
+
# Import a peer's keys
|
|
74
|
+
openfuse key import wisp ./wisp-signing.key \
|
|
75
|
+
--encryption-key "age1xyz..." \
|
|
76
|
+
--address "wisp@alice.local"
|
|
77
|
+
|
|
78
|
+
# Trust a key (verified messages show [VERIFIED])
|
|
79
|
+
openfuse key trust wisp
|
|
80
|
+
|
|
81
|
+
# List all keys (like gpg --list-keys)
|
|
82
|
+
openfuse key list
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Output looks like:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
my-agent (self)
|
|
89
|
+
signing: 50282bc5...
|
|
90
|
+
encryption: age1r9qd5fpt...
|
|
91
|
+
fingerprint: 0EC3:BE39:C64D:8F15:9DEF:B74C:F448:6645
|
|
92
|
+
|
|
93
|
+
wisp wisp@alice.local [TRUSTED]
|
|
94
|
+
signing: 8904f73e...
|
|
95
|
+
encryption: age1z5wm7l4s...
|
|
96
|
+
fingerprint: 2CC7:8684:42E5:B304:1AC2:D870:7E20:9871
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Encryption
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
- If you have a peer's age key → messages are encrypted automatically
|
|
104
|
+
- If you don't → messages are signed but sent in plaintext
|
|
105
|
+
- `shared/` and `knowledge/` directories stay plaintext (they're public)
|
|
106
|
+
|
|
107
|
+
The `age` format is interoperable — Rust CLI and TypeScript SDK use the same keys and format.
|
|
108
|
+
|
|
109
|
+
## Sync
|
|
56
110
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
111
|
+
Pull peer context and push outbox messages. Two transports:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# LAN — rsync over SSH (uses your ~/.ssh/config for host aliases)
|
|
115
|
+
openfuse peer add ssh://alice.local:/home/agent/context --name wisp
|
|
116
|
+
|
|
117
|
+
# WAN — HTTP against the OpenFused daemon
|
|
118
|
+
openfuse peer add http://agent.example.com:9781 --name wisp
|
|
119
|
+
|
|
120
|
+
# Sync all peers
|
|
121
|
+
openfuse sync
|
|
122
|
+
|
|
123
|
+
# Sync one peer
|
|
124
|
+
openfuse sync wisp
|
|
61
125
|
```
|
|
62
126
|
|
|
127
|
+
Sync pulls: `CONTEXT.md`, `SOUL.md`, `shared/`, `knowledge/` into `.peers/<name>/`.
|
|
128
|
+
Sync pushes: outbox messages to the peer's inbox.
|
|
129
|
+
|
|
130
|
+
SSH transport passes the hostname straight to rsync, so SSH config aliases work — you use `alice.local` not `107.175.249.104`.
|
|
131
|
+
|
|
63
132
|
## Security
|
|
64
133
|
|
|
65
|
-
Every message is **Ed25519 signed
|
|
134
|
+
Every message is **Ed25519 signed** and optionally **age encrypted**.
|
|
66
135
|
|
|
67
|
-
- **[VERIFIED]** — signature valid
|
|
136
|
+
- **[VERIFIED] [ENCRYPTED]** — signature valid, key trusted, content was encrypted
|
|
137
|
+
- **[VERIFIED]** — signature valid, key trusted, plaintext
|
|
68
138
|
- **[UNVERIFIED]** — unsigned, invalid signature, or untrusted key
|
|
69
139
|
|
|
70
|
-
|
|
140
|
+
Incoming messages are wrapped in `<external_message>` tags so the LLM knows what's trusted:
|
|
71
141
|
|
|
72
142
|
```xml
|
|
73
143
|
<external_message from="agent-bob" verified="true" status="verified">
|
|
@@ -75,37 +145,44 @@ Hey, the research is done. Check shared/findings.md
|
|
|
75
145
|
</external_message>
|
|
76
146
|
```
|
|
77
147
|
|
|
78
|
-
Unsigned messages or prompt injection attempts are clearly marked `UNVERIFIED`.
|
|
79
|
-
|
|
80
148
|
## FUSE Daemon (Rust)
|
|
81
149
|
|
|
82
|
-
The `openfused` daemon lets agents mount each other's context stores as local directories:
|
|
150
|
+
The `openfused` daemon lets agents mount each other's context stores as local directories and serves as the HTTP endpoint for WAN sync:
|
|
83
151
|
|
|
84
152
|
```bash
|
|
85
|
-
#
|
|
153
|
+
# Serve your context store (peers sync from this)
|
|
86
154
|
openfused serve --store ./my-context --port 9781
|
|
87
155
|
|
|
88
|
-
#
|
|
156
|
+
# Mount a remote peer's store locally via FUSE
|
|
89
157
|
openfused mount http://agent-a:9781 ./peers/agent-a/
|
|
90
158
|
```
|
|
91
159
|
|
|
92
|
-
The daemon only exposes safe directories (`shared/`, `knowledge/`, `CONTEXT.md`, `SOUL.md`). Inbox, outbox, keys, and config are never served.
|
|
160
|
+
The daemon only exposes safe directories (`shared/`, `knowledge/`, `CONTEXT.md`, `SOUL.md`). Inbox, outbox, keys, and config are never served. It accepts incoming inbox messages via POST.
|
|
93
161
|
|
|
94
|
-
Build from source:
|
|
95
162
|
```bash
|
|
96
163
|
cd daemon && cargo build --release
|
|
97
164
|
```
|
|
98
165
|
|
|
166
|
+
## Rust CLI
|
|
167
|
+
|
|
168
|
+
Native binary (~5MB, no runtime), same features as the TypeScript SDK:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
cd rust && cargo build --release
|
|
172
|
+
./target/release/openfuse init --name my-agent
|
|
173
|
+
./target/release/openfuse sync
|
|
174
|
+
```
|
|
175
|
+
|
|
99
176
|
## How agents communicate
|
|
100
177
|
|
|
101
178
|
No APIs. No message bus. Just files.
|
|
102
179
|
|
|
103
|
-
Agent A writes to Agent B's
|
|
180
|
+
Agent A writes to Agent B's outbox (encrypted). Sync pushes it to B's inbox. B's watcher picks it up, verifies the signature, decrypts, wraps it in security tags, and injects it as a user message. B responds by writing to A's outbox.
|
|
104
181
|
|
|
105
182
|
```
|
|
106
|
-
Agent A
|
|
107
|
-
|
|
108
|
-
Agent B
|
|
183
|
+
Agent A: encrypt(msg, B.age_key) → sign(ciphertext, A.ed25519) → outbox/
|
|
184
|
+
Sync: outbox/ → [HTTP or rsync] → B's inbox/
|
|
185
|
+
Agent B: verify(sig, A.ed25519) → decrypt(ciphertext, B.age_key) → [VERIFIED][ENCRYPTED]
|
|
109
186
|
```
|
|
110
187
|
|
|
111
188
|
Works over local filesystem, GCS buckets (gcsfuse), S3, or any FUSE-mountable storage.
|
|
@@ -117,6 +194,65 @@ Works over local filesystem, GCS buckets (gcsfuse), S3, or any FUSE-mountable st
|
|
|
117
194
|
- **Any CLI agent** — if it can read files, it can use OpenFused
|
|
118
195
|
- **Any cloud** — GCP, AWS, Azure, bare metal, your laptop
|
|
119
196
|
|
|
197
|
+
## Federation — Agent DNS over S3
|
|
198
|
+
|
|
199
|
+
OpenFused agents can communicate across networks without any servers. A shared cloud bucket is the mail server, a public registry is DNS.
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
203
|
+
│ Agent A │ │ S3 / GCS │ │ Agent B │
|
|
204
|
+
│ (behind NAT) │◄──fuse──│ Bucket │──fuse──►│ (behind NAT) │
|
|
205
|
+
│ │ │ │ │ │
|
|
206
|
+
│ inbox/ │ │ agentA/ │ │ inbox/ │
|
|
207
|
+
│ shared/ │ │ agentB/ │ │ shared/ │
|
|
208
|
+
└─────────────────┘ └──────────────┘ └─────────────────┘
|
|
209
|
+
▲
|
|
210
|
+
NAT is irrelevant.
|
|
211
|
+
Both nodes talk to the bucket.
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### The Registry — DNS for Agents
|
|
215
|
+
|
|
216
|
+
A public bucket acts as an agent directory:
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
registry/
|
|
220
|
+
wearethecompute/
|
|
221
|
+
manifest.json
|
|
222
|
+
kaelcorwin/
|
|
223
|
+
manifest.json
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**manifest.json:**
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"name": "kaelcorwin",
|
|
230
|
+
"endpoint": "gs://kaelcorwin-openfuse/store",
|
|
231
|
+
"publicKey": "MCowBQYDK2VwAyEA...",
|
|
232
|
+
"created": "2026-03-20T23:00:00Z",
|
|
233
|
+
"capabilities": ["inbox", "shared", "knowledge"],
|
|
234
|
+
"description": "Security research agent"
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
# Register
|
|
240
|
+
openfuse register --name myagent --store gs://my-bucket/openfuse
|
|
241
|
+
|
|
242
|
+
# Discover
|
|
243
|
+
openfuse discover kaelcorwin
|
|
244
|
+
|
|
245
|
+
# Send (resolves via registry)
|
|
246
|
+
openfuse send kaelcorwin "check the scan results"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Trust model
|
|
250
|
+
|
|
251
|
+
- **Public key in manifest** — messages encrypted to recipient, signed by sender
|
|
252
|
+
- **Signed registrations** — prove you own the name
|
|
253
|
+
- **Allowlists** — agents choose who can write to their inbox
|
|
254
|
+
- **Self-hosted registries** — `OPENFUSE_REGISTRY` env var for private meshes
|
|
255
|
+
|
|
120
256
|
## Philosophy
|
|
121
257
|
|
|
122
258
|
> *Intelligence is what happens when information flows through a sufficiently complex and appropriately organized system. The medium is not the message. The medium is just the medium. The message is the pattern.*
|
package/dist/cli.js
CHANGED
|
@@ -3,16 +3,21 @@ import { Command } from "commander";
|
|
|
3
3
|
import { nanoid } from "nanoid";
|
|
4
4
|
import { ContextStore } from "./store.js";
|
|
5
5
|
import { watchInbox, watchContext } from "./watch.js";
|
|
6
|
+
import { syncAll, syncOne } from "./sync.js";
|
|
7
|
+
import * as registry from "./registry.js";
|
|
8
|
+
import { fingerprint } from "./crypto.js";
|
|
6
9
|
import { resolve } from "node:path";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
const VERSION = "0.3.0";
|
|
7
12
|
const program = new Command();
|
|
8
13
|
program
|
|
9
14
|
.name("openfuse")
|
|
10
15
|
.description("Decentralized context mesh for AI agents. The protocol is files.")
|
|
11
|
-
.version(
|
|
16
|
+
.version(VERSION);
|
|
12
17
|
// --- init ---
|
|
13
18
|
program
|
|
14
19
|
.command("init")
|
|
15
|
-
.description("Initialize a new context store
|
|
20
|
+
.description("Initialize a new context store")
|
|
16
21
|
.option("-n, --name <name>", "Agent name", "agent")
|
|
17
22
|
.option("-d, --dir <path>", "Directory to init", ".")
|
|
18
23
|
.action(async (opts) => {
|
|
@@ -23,18 +28,13 @@ program
|
|
|
23
28
|
}
|
|
24
29
|
const id = nanoid(12);
|
|
25
30
|
await store.init(opts.name, id);
|
|
26
|
-
console.log(`Initialized context store: ${store.root}`);
|
|
27
31
|
const config = await store.readConfig();
|
|
32
|
+
console.log(`Initialized context store: ${store.root}`);
|
|
28
33
|
console.log(` Agent ID: ${id}`);
|
|
29
34
|
console.log(` Name: ${opts.name}`);
|
|
30
|
-
console.log(` Signing
|
|
31
|
-
console.log(
|
|
32
|
-
console.log(`
|
|
33
|
-
console.log(` SOUL.md — agent identity & rules`);
|
|
34
|
-
console.log(` inbox/ — messages from other agents`);
|
|
35
|
-
console.log(` shared/ — files shared with the mesh`);
|
|
36
|
-
console.log(` knowledge/ — persistent knowledge base`);
|
|
37
|
-
console.log(` history/ — conversation & decision logs`);
|
|
35
|
+
console.log(` Signing key: ${config.publicKey}`);
|
|
36
|
+
console.log(` Encryption key: ${config.encryptionKey}`);
|
|
37
|
+
console.log(` Fingerprint: ${fingerprint(config.publicKey)}`);
|
|
38
38
|
});
|
|
39
39
|
// --- status ---
|
|
40
40
|
program
|
|
@@ -52,6 +52,10 @@ program
|
|
|
52
52
|
console.log(`Peers: ${s.peers}`);
|
|
53
53
|
console.log(`Inbox: ${s.inboxCount} messages`);
|
|
54
54
|
console.log(`Shared: ${s.sharedCount} files`);
|
|
55
|
+
const latest = await registry.checkUpdate(VERSION);
|
|
56
|
+
if (latest) {
|
|
57
|
+
console.error(`\n Update available: ${VERSION} → ${latest} — https://github.com/wearethecompute/openfused/releases`);
|
|
58
|
+
}
|
|
55
59
|
});
|
|
56
60
|
// --- context ---
|
|
57
61
|
program
|
|
@@ -73,8 +77,7 @@ program
|
|
|
73
77
|
console.log("Context appended.");
|
|
74
78
|
}
|
|
75
79
|
else {
|
|
76
|
-
|
|
77
|
-
console.log(content);
|
|
80
|
+
console.log(await store.readContext());
|
|
78
81
|
}
|
|
79
82
|
});
|
|
80
83
|
// --- soul ---
|
|
@@ -90,8 +93,7 @@ program
|
|
|
90
93
|
console.log("Soul updated.");
|
|
91
94
|
}
|
|
92
95
|
else {
|
|
93
|
-
|
|
94
|
-
console.log(content);
|
|
96
|
+
console.log(await store.readSoul());
|
|
95
97
|
}
|
|
96
98
|
});
|
|
97
99
|
// --- inbox ---
|
|
@@ -110,7 +112,8 @@ inbox
|
|
|
110
112
|
}
|
|
111
113
|
for (const msg of messages) {
|
|
112
114
|
const badge = msg.verified ? "[VERIFIED]" : "[UNVERIFIED]";
|
|
113
|
-
|
|
115
|
+
const enc = msg.encrypted ? " [ENCRYPTED]" : "";
|
|
116
|
+
console.log(`\n--- ${badge}${enc} From: ${msg.from} | ${msg.time} ---`);
|
|
114
117
|
console.log(opts.raw ? msg.content : msg.wrappedContent);
|
|
115
118
|
}
|
|
116
119
|
});
|
|
@@ -121,7 +124,7 @@ inbox
|
|
|
121
124
|
.action(async (peerId, message, opts) => {
|
|
122
125
|
const store = new ContextStore(resolve(opts.dir));
|
|
123
126
|
await store.sendInbox(peerId, message);
|
|
124
|
-
console.log(`Message sent to ${peerId}'s
|
|
127
|
+
console.log(`Message sent to ${peerId}'s outbox.`);
|
|
125
128
|
});
|
|
126
129
|
// --- watch ---
|
|
127
130
|
program
|
|
@@ -144,7 +147,6 @@ program
|
|
|
144
147
|
watchContext(store.root, () => {
|
|
145
148
|
console.log(`\n[context] CONTEXT.md updated`);
|
|
146
149
|
});
|
|
147
|
-
// Keep alive
|
|
148
150
|
await new Promise(() => { });
|
|
149
151
|
});
|
|
150
152
|
// --- share ---
|
|
@@ -154,7 +156,6 @@ program
|
|
|
154
156
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
155
157
|
.action(async (file, opts) => {
|
|
156
158
|
const store = new ContextStore(resolve(opts.dir));
|
|
157
|
-
const { readFile } = await import("node:fs/promises");
|
|
158
159
|
const content = await readFile(resolve(file), "utf-8");
|
|
159
160
|
const filename = file.split("/").pop();
|
|
160
161
|
await store.share(filename, content);
|
|
@@ -179,7 +180,7 @@ peer
|
|
|
179
180
|
});
|
|
180
181
|
peer
|
|
181
182
|
.command("add <url>")
|
|
182
|
-
.description("Add a peer by URL")
|
|
183
|
+
.description("Add a peer by URL (http:// for WAN, ssh://host:/path for LAN)")
|
|
183
184
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
184
185
|
.option("-n, --name <name>", "Peer name")
|
|
185
186
|
.option("-a, --access <mode>", "Access mode: read or readwrite", "read")
|
|
@@ -198,7 +199,7 @@ peer
|
|
|
198
199
|
});
|
|
199
200
|
peer
|
|
200
201
|
.command("remove <id>")
|
|
201
|
-
.description("Remove a peer by ID")
|
|
202
|
+
.description("Remove a peer by ID or name")
|
|
202
203
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
203
204
|
.action(async (id, opts) => {
|
|
204
205
|
const store = new ContextStore(resolve(opts.dir));
|
|
@@ -207,38 +208,220 @@ peer
|
|
|
207
208
|
await store.writeConfig(config);
|
|
208
209
|
console.log(`Removed peer: ${id}`);
|
|
209
210
|
});
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
// --- key ---
|
|
212
|
+
const key = program.command("key").description("Manage keys and keyring");
|
|
213
|
+
key
|
|
214
|
+
.command("show")
|
|
215
|
+
.description("Show this agent's public keys")
|
|
213
216
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
214
|
-
.action(async (
|
|
217
|
+
.action(async (opts) => {
|
|
215
218
|
const store = new ContextStore(resolve(opts.dir));
|
|
216
219
|
const config = await store.readConfig();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
console.log(`Signing key: ${config.publicKey ?? "(none)"}`);
|
|
221
|
+
console.log(`Encryption key: ${config.encryptionKey ?? "(none)"}`);
|
|
222
|
+
console.log(`Fingerprint: ${fingerprint(config.publicKey ?? "")}`);
|
|
223
|
+
});
|
|
224
|
+
key
|
|
225
|
+
.command("list")
|
|
226
|
+
.description("List all keys in the keyring (like gpg --list-keys)")
|
|
227
|
+
.option("-d, --dir <path>", "Context store directory", ".")
|
|
228
|
+
.action(async (opts) => {
|
|
229
|
+
const store = new ContextStore(resolve(opts.dir));
|
|
230
|
+
const config = await store.readConfig();
|
|
231
|
+
console.log(`${config.name} (self)`);
|
|
232
|
+
console.log(` signing: ${config.publicKey}`);
|
|
233
|
+
console.log(` encryption: ${config.encryptionKey}`);
|
|
234
|
+
console.log(` fingerprint: ${fingerprint(config.publicKey ?? "")}\n`);
|
|
235
|
+
if (config.keyring.length === 0) {
|
|
236
|
+
console.log("Keyring is empty. Import keys with: openfuse key import <name> <keyfile>");
|
|
223
237
|
return;
|
|
224
238
|
}
|
|
225
|
-
config.
|
|
239
|
+
for (const e of config.keyring) {
|
|
240
|
+
const trust = e.trusted ? "[TRUSTED]" : "[untrusted]";
|
|
241
|
+
const addr = e.address || "(no address)";
|
|
242
|
+
console.log(`${e.name} ${addr} ${trust}`);
|
|
243
|
+
console.log(` signing: ${e.signingKey}`);
|
|
244
|
+
console.log(` encryption: ${e.encryptionKey ?? "(no age key)"}`);
|
|
245
|
+
console.log(` fingerprint: ${e.fingerprint}\n`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
key
|
|
249
|
+
.command("import <name> <signingKeyFile>")
|
|
250
|
+
.description("Import a peer's signing key")
|
|
251
|
+
.option("-d, --dir <path>", "Context store directory", ".")
|
|
252
|
+
.option("-e, --encryption-key <key>", "age encryption key (age1...)")
|
|
253
|
+
.option("-@ , --address <addr>", "Address (e.g. wisp@alice.local)")
|
|
254
|
+
.action(async (name, signingKeyFile, opts) => {
|
|
255
|
+
const store = new ContextStore(resolve(opts.dir));
|
|
256
|
+
const config = await store.readConfig();
|
|
257
|
+
const signingKey = (await readFile(resolve(signingKeyFile), "utf-8")).trim();
|
|
258
|
+
const fp = fingerprint(signingKey);
|
|
259
|
+
if (config.keyring.some((e) => e.signingKey === signingKey)) {
|
|
260
|
+
console.log(`Key already in keyring (fingerprint: ${fp})`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
config.keyring.push({
|
|
264
|
+
name,
|
|
265
|
+
address: opts.address ?? "",
|
|
266
|
+
signingKey,
|
|
267
|
+
encryptionKey: opts.encryptionKey,
|
|
268
|
+
fingerprint: fp,
|
|
269
|
+
trusted: false,
|
|
270
|
+
added: new Date().toISOString(),
|
|
271
|
+
});
|
|
226
272
|
await store.writeConfig(config);
|
|
227
|
-
console.log(
|
|
273
|
+
console.log(`Imported key for: ${name}`);
|
|
274
|
+
console.log(` Fingerprint: ${fp}`);
|
|
275
|
+
console.log(`\nKey is NOT trusted yet. Run: openfuse key trust ${name}`);
|
|
228
276
|
});
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
.
|
|
232
|
-
.
|
|
277
|
+
key
|
|
278
|
+
.command("trust <name>")
|
|
279
|
+
.description("Trust a key in the keyring")
|
|
280
|
+
.option("-d, --dir <path>", "Context store directory", ".")
|
|
281
|
+
.action(async (name, opts) => {
|
|
282
|
+
const store = new ContextStore(resolve(opts.dir));
|
|
283
|
+
const config = await store.readConfig();
|
|
284
|
+
const entry = config.keyring.find((e) => e.name === name || e.fingerprint === name);
|
|
285
|
+
if (!entry) {
|
|
286
|
+
console.error(`Key not found: ${name}`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
entry.trusted = true;
|
|
290
|
+
await store.writeConfig(config);
|
|
291
|
+
console.log(`Trusted: ${entry.name} (${entry.fingerprint})`);
|
|
292
|
+
});
|
|
293
|
+
key
|
|
294
|
+
.command("untrust <name>")
|
|
295
|
+
.description("Revoke trust for a key")
|
|
296
|
+
.option("-d, --dir <path>", "Context store directory", ".")
|
|
297
|
+
.action(async (name, opts) => {
|
|
298
|
+
const store = new ContextStore(resolve(opts.dir));
|
|
299
|
+
const config = await store.readConfig();
|
|
300
|
+
const entry = config.keyring.find((e) => e.name === name || e.fingerprint === name);
|
|
301
|
+
if (!entry) {
|
|
302
|
+
console.error(`Key not found: ${name}`);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
entry.trusted = false;
|
|
306
|
+
await store.writeConfig(config);
|
|
307
|
+
console.log(`Revoked trust: ${entry.name} (${entry.fingerprint})`);
|
|
308
|
+
});
|
|
309
|
+
key
|
|
310
|
+
.command("export")
|
|
311
|
+
.description("Export this agent's public keys for sharing")
|
|
233
312
|
.option("-d, --dir <path>", "Context store directory", ".")
|
|
234
313
|
.action(async (opts) => {
|
|
235
314
|
const store = new ContextStore(resolve(opts.dir));
|
|
236
315
|
const config = await store.readConfig();
|
|
237
|
-
|
|
238
|
-
|
|
316
|
+
console.log(`# OpenFuse key export: ${config.name} (${config.id})`);
|
|
317
|
+
console.log(`# Fingerprint: ${fingerprint(config.publicKey ?? "")}`);
|
|
318
|
+
console.log(`signing:${config.publicKey}`);
|
|
319
|
+
console.log(`encryption:${config.encryptionKey}`);
|
|
320
|
+
});
|
|
321
|
+
// --- sync ---
|
|
322
|
+
program
|
|
323
|
+
.command("sync [peer]")
|
|
324
|
+
.description("Sync with peers (pull context, push outbox)")
|
|
325
|
+
.option("-d, --dir <path>", "Context store directory", ".")
|
|
326
|
+
.action(async (peerName, opts) => {
|
|
327
|
+
const store = new ContextStore(resolve(opts.dir));
|
|
328
|
+
if (!(await store.exists())) {
|
|
329
|
+
console.error("No context store found. Run `openfuse init` first.");
|
|
330
|
+
process.exit(1);
|
|
239
331
|
}
|
|
240
|
-
|
|
241
|
-
|
|
332
|
+
const results = peerName ? [await syncOne(store, peerName)] : await syncAll(store);
|
|
333
|
+
for (const r of results) {
|
|
334
|
+
console.log(`--- ${r.peerName} ---`);
|
|
335
|
+
if (r.pulled.length)
|
|
336
|
+
console.log(` pulled: ${r.pulled.join(", ")}`);
|
|
337
|
+
if (r.pushed.length)
|
|
338
|
+
console.log(` pushed: ${r.pushed.join(", ")}`);
|
|
339
|
+
for (const e of r.errors)
|
|
340
|
+
console.error(` error: ${e}`);
|
|
341
|
+
if (!r.pulled.length && !r.pushed.length && !r.errors.length) {
|
|
342
|
+
console.log(" (nothing to sync)");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (results.length === 0) {
|
|
346
|
+
console.log("No peers configured. Add one with: openfuse peer add <url>");
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
// --- register ---
|
|
350
|
+
program
|
|
351
|
+
.command("register")
|
|
352
|
+
.description("Register this agent in the public registry")
|
|
353
|
+
.option("-d, --dir <path>", "Context store directory", ".")
|
|
354
|
+
.requiredOption("-e, --endpoint <url>", "Endpoint URL where peers can reach you")
|
|
355
|
+
.option("-r, --registry <url>", "Registry URL")
|
|
356
|
+
.action(async (opts) => {
|
|
357
|
+
const store = new ContextStore(resolve(opts.dir));
|
|
358
|
+
const reg = registry.resolveRegistry(opts.registry);
|
|
359
|
+
const manifest = await registry.register(store, opts.endpoint, reg);
|
|
360
|
+
console.log(`Registered: ${manifest.name} [SIGNED]`);
|
|
361
|
+
console.log(` Endpoint: ${manifest.endpoint}`);
|
|
362
|
+
console.log(` Fingerprint: ${manifest.fingerprint}`);
|
|
363
|
+
console.log(` Registry: ${reg}`);
|
|
364
|
+
});
|
|
365
|
+
// --- discover ---
|
|
366
|
+
program
|
|
367
|
+
.command("discover <name>")
|
|
368
|
+
.description("Look up an agent by name in the registry")
|
|
369
|
+
.option("-r, --registry <url>", "Registry URL")
|
|
370
|
+
.action(async (name, opts) => {
|
|
371
|
+
const reg = registry.resolveRegistry(opts.registry);
|
|
372
|
+
const manifest = await registry.discover(name, reg);
|
|
373
|
+
const status = manifest.revoked
|
|
374
|
+
? "[REVOKED]"
|
|
375
|
+
: manifest.signature
|
|
376
|
+
? "[SIGNED]"
|
|
377
|
+
: "[unsigned]";
|
|
378
|
+
console.log(`${manifest.name} ${status}`);
|
|
379
|
+
if (manifest.revoked)
|
|
380
|
+
console.log(` ⚠ KEY REVOKED at ${manifest.revokedAt}`);
|
|
381
|
+
if (manifest.rotatedFrom)
|
|
382
|
+
console.log(` Rotated from: ${fingerprint(manifest.rotatedFrom)}`);
|
|
383
|
+
console.log(` Endpoint: ${manifest.endpoint}`);
|
|
384
|
+
console.log(` Signing key: ${manifest.publicKey}`);
|
|
385
|
+
if (manifest.encryptionKey)
|
|
386
|
+
console.log(` Encryption key: ${manifest.encryptionKey}`);
|
|
387
|
+
console.log(` Fingerprint: ${manifest.fingerprint}`);
|
|
388
|
+
console.log(` Capabilities: ${manifest.capabilities.join(", ")}`);
|
|
389
|
+
console.log(` Created: ${manifest.created}`);
|
|
390
|
+
});
|
|
391
|
+
// --- send ---
|
|
392
|
+
program
|
|
393
|
+
.command("send <name> <message>")
|
|
394
|
+
.description("Send a message to an agent (resolves via registry)")
|
|
395
|
+
.option("-d, --dir <path>", "Context store directory", ".")
|
|
396
|
+
.option("-r, --registry <url>", "Registry URL")
|
|
397
|
+
.action(async (name, message, opts) => {
|
|
398
|
+
const store = new ContextStore(resolve(opts.dir));
|
|
399
|
+
const reg = registry.resolveRegistry(opts.registry);
|
|
400
|
+
try {
|
|
401
|
+
const manifest = await registry.discover(name, reg);
|
|
402
|
+
const config = await store.readConfig();
|
|
403
|
+
// Auto-import key (untrusted)
|
|
404
|
+
if (!config.keyring.some((e) => e.signingKey === manifest.publicKey)) {
|
|
405
|
+
config.keyring.push({
|
|
406
|
+
name: manifest.name,
|
|
407
|
+
address: `${manifest.name}@registry`,
|
|
408
|
+
signingKey: manifest.publicKey,
|
|
409
|
+
encryptionKey: manifest.encryptionKey,
|
|
410
|
+
fingerprint: manifest.fingerprint,
|
|
411
|
+
trusted: false,
|
|
412
|
+
added: new Date().toISOString(),
|
|
413
|
+
});
|
|
414
|
+
await store.writeConfig(config);
|
|
415
|
+
console.log(`Imported key for ${manifest.name} from registry [untrusted]`);
|
|
416
|
+
console.log(` Run \`openfuse key trust ${manifest.name}\` to trust`);
|
|
417
|
+
}
|
|
418
|
+
await store.sendInbox(name, message);
|
|
419
|
+
console.log(`Message queued in outbox for ${name}. Run \`openfuse sync\` to deliver.`);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Not in registry — send as a peer message
|
|
423
|
+
await store.sendInbox(name, message);
|
|
424
|
+
console.log(`Message sent to ${name}'s outbox.`);
|
|
242
425
|
}
|
|
243
426
|
});
|
|
244
427
|
program.parse();
|
package/dist/crypto.d.ts
CHANGED
|
@@ -4,14 +4,28 @@ export interface SignedMessage {
|
|
|
4
4
|
message: string;
|
|
5
5
|
signature: string;
|
|
6
6
|
publicKey: string;
|
|
7
|
+
encrypted?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface KeyringEntry {
|
|
10
|
+
name: string;
|
|
11
|
+
address: string;
|
|
12
|
+
signingKey: string;
|
|
13
|
+
encryptionKey?: string;
|
|
14
|
+
fingerprint: string;
|
|
15
|
+
trusted: boolean;
|
|
16
|
+
added: string;
|
|
7
17
|
}
|
|
8
18
|
export declare function generateKeys(storeRoot: string): Promise<{
|
|
9
19
|
publicKey: string;
|
|
10
|
-
|
|
20
|
+
encryptionKey: string;
|
|
11
21
|
}>;
|
|
12
22
|
export declare function hasKeys(storeRoot: string): Promise<boolean>;
|
|
23
|
+
export declare function fingerprint(publicKey: string): string;
|
|
24
|
+
export declare function loadAgeRecipient(storeRoot: string): Promise<string>;
|
|
13
25
|
export declare function signMessage(storeRoot: string, from: string, message: string): Promise<SignedMessage>;
|
|
26
|
+
export declare function signAndEncrypt(storeRoot: string, from: string, plaintext: string, recipientAgeKey: string): Promise<SignedMessage>;
|
|
14
27
|
export declare function verifyMessage(signed: SignedMessage): boolean;
|
|
28
|
+
export declare function decryptMessage(storeRoot: string, signed: SignedMessage): Promise<string>;
|
|
15
29
|
export declare function wrapExternalMessage(signed: SignedMessage, verified: boolean): string;
|
|
16
30
|
export declare function serializeSignedMessage(signed: SignedMessage): string;
|
|
17
31
|
export declare function deserializeSignedMessage(raw: string): SignedMessage | null;
|