@jaw.id/cli 0.0.3 → 0.0.7
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/base-command.js +1 -2
- package/dist/base-command.js.map +1 -1
- package/dist/commands/config/set.js +19 -3
- package/dist/commands/config/set.js.map +1 -1
- package/dist/commands/config/show.js +1 -2
- package/dist/commands/config/show.js.map +1 -1
- package/dist/commands/disconnect.js +439 -36
- package/dist/commands/disconnect.js.map +1 -1
- package/dist/commands/mcp/index.js +486 -251
- package/dist/commands/mcp/index.js.map +1 -1
- package/dist/commands/rpc/call.js +429 -204
- package/dist/commands/rpc/call.js.map +1 -1
- package/dist/commands/version.js +134 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/index.js +455 -203
- package/dist/index.js.map +1 -1
- package/dist/lib/bridge-singleton.js +457 -205
- package/dist/lib/bridge-singleton.js.map +1 -1
- package/dist/lib/config.js +17 -2
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/crypto.js +74 -0
- package/dist/lib/crypto.js.map +1 -0
- package/dist/lib/paths.js +1 -2
- package/dist/lib/paths.js.map +1 -1
- package/dist/lib/session-store.js +1 -2
- package/dist/lib/session-store.js.map +1 -1
- package/dist/lib/validation.js +12 -1
- package/dist/lib/validation.js.map +1 -1
- package/dist/lib/ws-bridge.js +321 -59
- package/dist/lib/ws-bridge.js.map +1 -1
- package/dist/mcp/handlers/config.js +19 -4
- package/dist/mcp/handlers/config.js.map +1 -1
- package/dist/mcp/handlers/daemon.js +447 -72
- package/dist/mcp/handlers/daemon.js.map +1 -1
- package/dist/mcp/handlers/rpc.js +442 -235
- package/dist/mcp/handlers/rpc.js.map +1 -1
- package/dist/mcp/server.js +486 -251
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.js +1 -1
- package/dist/mcp/tools.js.map +1 -1
- package/oclif.manifest.json +70 -2
- package/package.json +5 -1
- package/dist/lib/ws-daemon.js +0 -382
- package/dist/lib/ws-daemon.js.map +0 -1
package/dist/mcp/handlers/rpc.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import * as fs2 from 'fs';
|
|
3
|
+
import * as crypto from 'crypto';
|
|
3
4
|
import * as path from 'path';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
import { spawn, execSync } from 'child_process';
|
|
6
5
|
import * as os from 'os';
|
|
7
|
-
import * as crypto from 'crypto';
|
|
8
6
|
import WebSocket from 'ws';
|
|
9
7
|
|
|
10
8
|
// src/mcp/tools.ts
|
|
@@ -20,7 +18,7 @@ var rpcMethodSchema = {
|
|
|
20
18
|
)
|
|
21
19
|
};
|
|
22
20
|
({
|
|
23
|
-
key: z.enum(["apiKey", "defaultChain", "keysUrl", "paymasterUrl", "ens"]).describe("Config key"),
|
|
21
|
+
key: z.enum(["apiKey", "defaultChain", "keysUrl", "paymasterUrl", "ens", "relayUrl"]).describe("Config key"),
|
|
24
22
|
value: z.string().describe("Config value")
|
|
25
23
|
});
|
|
26
24
|
|
|
@@ -51,8 +49,7 @@ var PATHS = {
|
|
|
51
49
|
root: JAW_DIR,
|
|
52
50
|
config: path.join(JAW_DIR, "config.json"),
|
|
53
51
|
session: path.join(JAW_DIR, "session.json"),
|
|
54
|
-
|
|
55
|
-
daemonLog: path.join(JAW_DIR, "daemon.log")
|
|
52
|
+
relay: path.join(JAW_DIR, "relay.json")
|
|
56
53
|
};
|
|
57
54
|
|
|
58
55
|
// src/lib/validation.ts
|
|
@@ -66,6 +63,17 @@ function isValidKeysUrl(url) {
|
|
|
66
63
|
return false;
|
|
67
64
|
}
|
|
68
65
|
}
|
|
66
|
+
function isValidRelayUrl(url) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = new URL(url);
|
|
69
|
+
const isTrustedHost = parsed.hostname.endsWith(".jaw.id") || parsed.hostname === "jaw.id" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
70
|
+
const isSecure = parsed.protocol === "wss:" || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
|
71
|
+
const isWebSocket = parsed.protocol === "wss:" || parsed.protocol === "ws:";
|
|
72
|
+
return isTrustedHost && isSecure && isWebSocket;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
69
77
|
|
|
70
78
|
// src/lib/config.ts
|
|
71
79
|
function ensureDir(dir) {
|
|
@@ -85,49 +93,217 @@ function loadConfig() {
|
|
|
85
93
|
);
|
|
86
94
|
}
|
|
87
95
|
}
|
|
96
|
+
|
|
97
|
+
// src/lib/crypto.ts
|
|
98
|
+
var subtle = globalThis.crypto.subtle;
|
|
99
|
+
async function generateKeyPair() {
|
|
100
|
+
return subtle.generateKey(
|
|
101
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
102
|
+
true,
|
|
103
|
+
["deriveKey"]
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
async function deriveSharedSecret(privateKey, peerPublicKey) {
|
|
107
|
+
return subtle.deriveKey(
|
|
108
|
+
{ name: "ECDH", public: peerPublicKey },
|
|
109
|
+
privateKey,
|
|
110
|
+
{ name: "AES-GCM", length: 256 },
|
|
111
|
+
false,
|
|
112
|
+
["encrypt", "decrypt"]
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
async function encryptMessage(sharedSecret, payload) {
|
|
116
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
117
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
118
|
+
const cipherBuf = await subtle.encrypt(
|
|
119
|
+
{ name: "AES-GCM", iv },
|
|
120
|
+
sharedSecret,
|
|
121
|
+
plaintext
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
iv: bufferToBase64(iv),
|
|
125
|
+
ciphertext: bufferToBase64(new Uint8Array(cipherBuf))
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function decryptMessage(sharedSecret, envelope) {
|
|
129
|
+
const iv = Buffer.from(envelope.iv, "base64");
|
|
130
|
+
const ciphertext = Buffer.from(envelope.ciphertext, "base64");
|
|
131
|
+
const plainBuf = await subtle.decrypt(
|
|
132
|
+
{ name: "AES-GCM", iv },
|
|
133
|
+
sharedSecret,
|
|
134
|
+
ciphertext
|
|
135
|
+
);
|
|
136
|
+
return JSON.parse(new TextDecoder().decode(plainBuf));
|
|
137
|
+
}
|
|
138
|
+
async function exportKeyToHex(type, key) {
|
|
139
|
+
const format = type === "private" ? "pkcs8" : "spki";
|
|
140
|
+
const buf = await subtle.exportKey(format, key);
|
|
141
|
+
return bytesToHex(new Uint8Array(buf));
|
|
142
|
+
}
|
|
143
|
+
async function importKeyFromHex(type, hex) {
|
|
144
|
+
const format = type === "private" ? "pkcs8" : "spki";
|
|
145
|
+
return subtle.importKey(
|
|
146
|
+
format,
|
|
147
|
+
Buffer.from(hexToBytes(hex)),
|
|
148
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
149
|
+
true,
|
|
150
|
+
type === "private" ? ["deriveKey"] : []
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
function bytesToHex(bytes) {
|
|
154
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
155
|
+
}
|
|
156
|
+
function hexToBytes(hex) {
|
|
157
|
+
if (hex.length % 2 !== 0) throw new Error("Invalid hex: odd length");
|
|
158
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
159
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
160
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
161
|
+
}
|
|
162
|
+
return bytes;
|
|
163
|
+
}
|
|
164
|
+
function bufferToBase64(buf) {
|
|
165
|
+
return Buffer.from(buf).toString("base64");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/lib/ws-bridge.ts
|
|
88
169
|
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
170
|
+
var MAX_MESSAGE_BYTES = 5 * 1024 * 1024;
|
|
171
|
+
var BROWSER_REOPEN_COOLDOWN_MS = 5e3;
|
|
172
|
+
var MAX_RECONNECT_ATTEMPTS = 3;
|
|
173
|
+
var RECONNECT_BASE_DELAY_MS = 1e3;
|
|
89
174
|
var WSBridge = class {
|
|
90
|
-
|
|
91
|
-
|
|
175
|
+
relayUrl;
|
|
176
|
+
session;
|
|
92
177
|
timeout;
|
|
178
|
+
config;
|
|
179
|
+
privateKeyHex;
|
|
180
|
+
publicKeyHex;
|
|
181
|
+
peerPublicKeyHex;
|
|
182
|
+
sharedSecret = null;
|
|
93
183
|
ws = null;
|
|
184
|
+
disposed = false;
|
|
185
|
+
// Auto-reopen browser state
|
|
186
|
+
onBrowserNeeded;
|
|
187
|
+
onPeerKeyChanged;
|
|
188
|
+
lastBrowserOpenTime = 0;
|
|
189
|
+
// Reconnection state
|
|
190
|
+
reconnectAttempts = 0;
|
|
191
|
+
/** Updated after key exchange — caller should persist this. */
|
|
192
|
+
get peerPublicKey() {
|
|
193
|
+
return this.peerPublicKeyHex;
|
|
194
|
+
}
|
|
94
195
|
constructor(options) {
|
|
95
|
-
this.
|
|
96
|
-
this.
|
|
196
|
+
this.relayUrl = options.relayUrl;
|
|
197
|
+
this.session = options.session;
|
|
97
198
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
199
|
+
this.config = options.config;
|
|
200
|
+
this.privateKeyHex = options.privateKeyHex;
|
|
201
|
+
this.publicKeyHex = options.publicKeyHex;
|
|
202
|
+
this.peerPublicKeyHex = options.peerPublicKeyHex;
|
|
98
203
|
}
|
|
99
204
|
/**
|
|
100
|
-
* Connect to the
|
|
205
|
+
* Connect to the relay and wait for the browser to be ready.
|
|
206
|
+
*
|
|
207
|
+
* @param onBrowserNeeded — called when the relay reports no browser connected.
|
|
208
|
+
* @param onPeerKeyChanged — called when a key_exchange updates the peer key.
|
|
101
209
|
*/
|
|
102
|
-
async connect() {
|
|
210
|
+
async connect(onBrowserNeeded, onPeerKeyChanged) {
|
|
211
|
+
this.onBrowserNeeded = onBrowserNeeded;
|
|
212
|
+
this.onPeerKeyChanged = onPeerKeyChanged;
|
|
213
|
+
if (this.peerPublicKeyHex) {
|
|
214
|
+
await this.deriveSecret();
|
|
215
|
+
}
|
|
216
|
+
return this.connectInternal(onBrowserNeeded, onPeerKeyChanged);
|
|
217
|
+
}
|
|
218
|
+
async connectInternal(onBrowserNeeded, onPeerKeyChanged) {
|
|
103
219
|
return new Promise((resolve, reject) => {
|
|
104
|
-
const url =
|
|
220
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
105
221
|
const ws = new WebSocket(url);
|
|
222
|
+
let browserOpened = false;
|
|
223
|
+
let resolved = false;
|
|
224
|
+
let expectingKeyExchange = !this.peerPublicKeyHex;
|
|
106
225
|
const timer = setTimeout(() => {
|
|
107
226
|
ws.close();
|
|
108
227
|
reject(
|
|
109
228
|
new Error(
|
|
110
|
-
"Browser
|
|
229
|
+
"Browser did not connect in time.\nRun `jaw disconnect` then try again."
|
|
111
230
|
)
|
|
112
231
|
);
|
|
113
232
|
}, 3e4);
|
|
114
|
-
|
|
233
|
+
const sendEncryptedInit = async () => {
|
|
234
|
+
if (!this.sharedSecret) return;
|
|
235
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
236
|
+
type: "init",
|
|
237
|
+
apiKey: this.config.apiKey,
|
|
238
|
+
chainId: this.config.chainId,
|
|
239
|
+
ens: this.config.ens,
|
|
240
|
+
paymasterUrl: this.config.paymasterUrl
|
|
241
|
+
});
|
|
242
|
+
this.sendRaw(ws, JSON.stringify({ type: "encrypted", ...envelope }));
|
|
243
|
+
};
|
|
244
|
+
const waitForReady = () => {
|
|
245
|
+
const readyTimer = setTimeout(() => {
|
|
246
|
+
ws.close();
|
|
247
|
+
reject(new Error("Browser SDK did not become ready in time."));
|
|
248
|
+
}, 15e3);
|
|
249
|
+
const onMsg = async (data) => {
|
|
250
|
+
const msg = safeParse(data);
|
|
251
|
+
if (!msg) return;
|
|
252
|
+
if (msg.type === "encrypted" && this.sharedSecret) {
|
|
253
|
+
try {
|
|
254
|
+
const inner = await decryptMessage(
|
|
255
|
+
this.sharedSecret,
|
|
256
|
+
msg
|
|
257
|
+
);
|
|
258
|
+
if (inner.type === "ready") {
|
|
259
|
+
clearTimeout(readyTimer);
|
|
260
|
+
ws.off("message", onMsg);
|
|
261
|
+
this.reconnectAttempts = 0;
|
|
262
|
+
resolve();
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
ws.on("message", onMsg);
|
|
269
|
+
};
|
|
270
|
+
const onBrowserReady = async () => {
|
|
271
|
+
if (resolved) return;
|
|
272
|
+
resolved = true;
|
|
115
273
|
clearTimeout(timer);
|
|
274
|
+
waitForReady();
|
|
275
|
+
await sendEncryptedInit();
|
|
276
|
+
};
|
|
277
|
+
ws.on("open", () => {
|
|
116
278
|
this.ws = ws;
|
|
117
279
|
});
|
|
118
|
-
ws.on("message", (data) => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
280
|
+
ws.on("message", async (data) => {
|
|
281
|
+
const msg = safeParse(data);
|
|
282
|
+
if (!msg) return;
|
|
283
|
+
if (msg.type === "status") {
|
|
284
|
+
if (msg.browserConnected) {
|
|
285
|
+
if (this.sharedSecret) {
|
|
286
|
+
await onBrowserReady();
|
|
287
|
+
} else {
|
|
288
|
+
expectingKeyExchange = true;
|
|
289
|
+
}
|
|
290
|
+
} else if (!browserOpened && onBrowserNeeded) {
|
|
291
|
+
browserOpened = true;
|
|
292
|
+
expectingKeyExchange = true;
|
|
293
|
+
onBrowserNeeded().catch(() => {
|
|
294
|
+
});
|
|
295
|
+
}
|
|
128
296
|
} else if (msg.type === "browser_connected") {
|
|
129
|
-
|
|
130
|
-
|
|
297
|
+
expectingKeyExchange = true;
|
|
298
|
+
} else if (msg.type === "browser_disconnected") {
|
|
299
|
+
this.handleBrowserDisconnect();
|
|
300
|
+
} else if (msg.type === "key_exchange" && expectingKeyExchange) {
|
|
301
|
+
expectingKeyExchange = false;
|
|
302
|
+
const peerKey = msg.publicKey;
|
|
303
|
+
this.peerPublicKeyHex = peerKey;
|
|
304
|
+
await this.deriveSecret();
|
|
305
|
+
onPeerKeyChanged?.(peerKey);
|
|
306
|
+
await onBrowserReady();
|
|
131
307
|
}
|
|
132
308
|
});
|
|
133
309
|
ws.on("error", (err) => {
|
|
@@ -136,18 +312,32 @@ var WSBridge = class {
|
|
|
136
312
|
});
|
|
137
313
|
ws.on("close", () => {
|
|
138
314
|
clearTimeout(timer);
|
|
315
|
+
if (!this.disposed) {
|
|
316
|
+
this.handleRelayDisconnect();
|
|
317
|
+
}
|
|
139
318
|
});
|
|
140
319
|
});
|
|
141
320
|
}
|
|
142
321
|
/**
|
|
143
|
-
* Send an RPC request through the
|
|
322
|
+
* Send an encrypted RPC request through the relay to the browser SDK.
|
|
144
323
|
*/
|
|
145
324
|
async request(method, params) {
|
|
146
325
|
const ws = this.ws;
|
|
147
326
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
148
|
-
throw new Error("Not connected to
|
|
327
|
+
throw new Error("Not connected to relay");
|
|
328
|
+
}
|
|
329
|
+
if (!this.sharedSecret) {
|
|
330
|
+
throw new Error("No shared secret \u2014 key exchange not completed");
|
|
149
331
|
}
|
|
150
332
|
const id = crypto.randomUUID();
|
|
333
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
334
|
+
type: "rpc_request",
|
|
335
|
+
id,
|
|
336
|
+
method,
|
|
337
|
+
params
|
|
338
|
+
});
|
|
339
|
+
const serialized = JSON.stringify({ type: "encrypted", ...envelope });
|
|
340
|
+
assertMessageSize(serialized, method);
|
|
151
341
|
return new Promise((resolve, reject) => {
|
|
152
342
|
const timer = setTimeout(() => {
|
|
153
343
|
reject(
|
|
@@ -157,58 +347,91 @@ var WSBridge = class {
|
|
|
157
347
|
);
|
|
158
348
|
this.close();
|
|
159
349
|
}, this.timeout);
|
|
160
|
-
const onMessage = (data) => {
|
|
161
|
-
|
|
350
|
+
const onMessage = async (data) => {
|
|
351
|
+
const msg = safeParse(data);
|
|
352
|
+
if (!msg || msg.type !== "encrypted" || !this.sharedSecret) return;
|
|
162
353
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
354
|
+
const inner = await decryptMessage(
|
|
355
|
+
this.sharedSecret,
|
|
356
|
+
msg
|
|
357
|
+
);
|
|
358
|
+
if (inner.type === "rpc_response" && inner.id === id) {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
ws.off("message", onMessage);
|
|
361
|
+
if (inner.success) {
|
|
362
|
+
resolve(inner.data);
|
|
363
|
+
} else {
|
|
364
|
+
const err = inner.error;
|
|
365
|
+
reject(
|
|
366
|
+
new Error(
|
|
367
|
+
err ? `[${err.code}] ${err.message}` : "Request failed"
|
|
368
|
+
)
|
|
369
|
+
);
|
|
370
|
+
}
|
|
179
371
|
}
|
|
372
|
+
} catch {
|
|
180
373
|
}
|
|
181
374
|
};
|
|
182
375
|
ws.on("message", onMessage);
|
|
183
|
-
|
|
184
|
-
JSON.stringify({
|
|
185
|
-
id,
|
|
186
|
-
type: "rpc_request",
|
|
187
|
-
method,
|
|
188
|
-
params
|
|
189
|
-
})
|
|
190
|
-
);
|
|
376
|
+
this.sendRaw(ws, serialized);
|
|
191
377
|
});
|
|
192
378
|
}
|
|
193
|
-
/**
|
|
194
|
-
* Check if the WebSocket connection is open.
|
|
195
|
-
*/
|
|
196
379
|
isOpen() {
|
|
197
380
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
198
381
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
382
|
+
async shutdown() {
|
|
383
|
+
this.disposed = true;
|
|
384
|
+
if (this.ws?.readyState === WebSocket.OPEN && this.sharedSecret) {
|
|
385
|
+
try {
|
|
386
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
387
|
+
type: "shutdown"
|
|
388
|
+
});
|
|
389
|
+
this.sendRaw(
|
|
390
|
+
this.ws,
|
|
391
|
+
JSON.stringify({ type: "encrypted", ...envelope })
|
|
392
|
+
);
|
|
393
|
+
} catch {
|
|
394
|
+
}
|
|
205
395
|
}
|
|
206
396
|
this.close();
|
|
207
397
|
}
|
|
208
398
|
/**
|
|
209
|
-
*
|
|
399
|
+
* Connect to relay and send shutdown directly — no init/ready handshake.
|
|
400
|
+
* Used by `jaw disconnect` when we just need to tell the browser to close.
|
|
210
401
|
*/
|
|
402
|
+
async connectAndShutdown() {
|
|
403
|
+
if (!this.peerPublicKeyHex) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
this.disposed = true;
|
|
407
|
+
await this.deriveSecret();
|
|
408
|
+
return new Promise((resolve) => {
|
|
409
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
410
|
+
const ws = new WebSocket(url);
|
|
411
|
+
const timer = setTimeout(() => {
|
|
412
|
+
try {
|
|
413
|
+
ws.close();
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
resolve();
|
|
417
|
+
}, 3e3);
|
|
418
|
+
ws.on("open", async () => {
|
|
419
|
+
this.ws = ws;
|
|
420
|
+
try {
|
|
421
|
+
await this.shutdown();
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
resolve();
|
|
426
|
+
});
|
|
427
|
+
ws.on("error", () => {
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
resolve();
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
}
|
|
211
433
|
close() {
|
|
434
|
+
this.disposed = true;
|
|
212
435
|
if (this.ws) {
|
|
213
436
|
try {
|
|
214
437
|
this.ws.close();
|
|
@@ -217,171 +440,173 @@ var WSBridge = class {
|
|
|
217
440
|
this.ws = null;
|
|
218
441
|
}
|
|
219
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Auto-reopen browser when browser_disconnected is received from relay.
|
|
445
|
+
* Respects a cooldown to prevent rapid re-opening.
|
|
446
|
+
*/
|
|
447
|
+
handleBrowserDisconnect() {
|
|
448
|
+
if (this.disposed || !this.onBrowserNeeded) return;
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
if (now - this.lastBrowserOpenTime < BROWSER_REOPEN_COOLDOWN_MS) {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
this.lastBrowserOpenTime = now;
|
|
454
|
+
this.sharedSecret = null;
|
|
455
|
+
this.peerPublicKeyHex = null;
|
|
456
|
+
this.onBrowserNeeded().catch(() => {
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Attempt to reconnect to the relay with exponential backoff
|
|
461
|
+
* when the WebSocket connection drops unexpectedly.
|
|
462
|
+
*/
|
|
463
|
+
handleRelayDisconnect() {
|
|
464
|
+
if (this.disposed) return;
|
|
465
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
|
|
466
|
+
const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts);
|
|
467
|
+
this.reconnectAttempts++;
|
|
468
|
+
setTimeout(() => {
|
|
469
|
+
if (this.disposed) return;
|
|
470
|
+
this.connectInternal(this.onBrowserNeeded, this.onPeerKeyChanged).catch(
|
|
471
|
+
() => {
|
|
472
|
+
}
|
|
473
|
+
);
|
|
474
|
+
}, delay);
|
|
475
|
+
}
|
|
476
|
+
/** Send a raw string over the WebSocket, enforcing message size limits. */
|
|
477
|
+
sendRaw(ws, data) {
|
|
478
|
+
ws.send(data);
|
|
479
|
+
}
|
|
480
|
+
async deriveSecret() {
|
|
481
|
+
if (!this.peerPublicKeyHex) return;
|
|
482
|
+
const privateKey = await importKeyFromHex("private", this.privateKeyHex);
|
|
483
|
+
const peerPublicKey = await importKeyFromHex(
|
|
484
|
+
"public",
|
|
485
|
+
this.peerPublicKeyHex
|
|
486
|
+
);
|
|
487
|
+
this.sharedSecret = await deriveSharedSecret(privateKey, peerPublicKey);
|
|
488
|
+
}
|
|
220
489
|
};
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
dir = path.dirname(dir);
|
|
490
|
+
function assertMessageSize(serialized, method) {
|
|
491
|
+
const byteLength = Buffer.byteLength(serialized, "utf-8");
|
|
492
|
+
if (byteLength > MAX_MESSAGE_BYTES) {
|
|
493
|
+
const sizeMB = (byteLength / (1024 * 1024)).toFixed(2);
|
|
494
|
+
throw new Error(
|
|
495
|
+
`Message for ${method} is too large (${sizeMB} MB, limit ${MAX_MESSAGE_BYTES / (1024 * 1024)} MB). Try reducing the number of calls in your batch.`
|
|
496
|
+
);
|
|
229
497
|
}
|
|
230
|
-
throw new Error("Cannot find ws-daemon.js in dist tree");
|
|
231
498
|
}
|
|
232
|
-
|
|
233
|
-
var LOCK_PATH = path.join(PATHS.root, "daemon.lock");
|
|
234
|
-
function isDaemonProcess(pid) {
|
|
235
|
-
if (!Number.isInteger(pid) || pid <= 0 || pid > 4194304) return false;
|
|
499
|
+
function safeParse(data) {
|
|
236
500
|
try {
|
|
237
|
-
|
|
501
|
+
return JSON.parse(data.toString());
|
|
238
502
|
} catch {
|
|
239
|
-
return
|
|
240
|
-
}
|
|
241
|
-
try {
|
|
242
|
-
const cmd = execSync(`ps -p ${String(pid)} -o command=`, {
|
|
243
|
-
encoding: "utf-8",
|
|
244
|
-
timeout: 3e3
|
|
245
|
-
}).trim();
|
|
246
|
-
return cmd.includes("ws-daemon");
|
|
247
|
-
} catch {
|
|
248
|
-
return false;
|
|
503
|
+
return null;
|
|
249
504
|
}
|
|
250
505
|
}
|
|
251
|
-
|
|
506
|
+
|
|
507
|
+
// src/lib/bridge-singleton.ts
|
|
508
|
+
var DEFAULT_KEYS_URL = "https://keys.jaw.id";
|
|
509
|
+
var DEFAULT_RELAY_URL = "wss://relay.jaw.id";
|
|
510
|
+
function loadRelaySession() {
|
|
252
511
|
try {
|
|
253
|
-
if (!fs2.existsSync(PATHS.
|
|
254
|
-
const raw = fs2.readFileSync(PATHS.
|
|
255
|
-
const
|
|
256
|
-
if (!
|
|
257
|
-
try {
|
|
258
|
-
fs2.unlinkSync(PATHS.bridge);
|
|
259
|
-
} catch {
|
|
260
|
-
}
|
|
512
|
+
if (!fs2.existsSync(PATHS.relay)) return null;
|
|
513
|
+
const raw = fs2.readFileSync(PATHS.relay, "utf-8");
|
|
514
|
+
const parsed = JSON.parse(raw);
|
|
515
|
+
if (!parsed.session || !parsed.relayUrl || !parsed.privateKey || !parsed.publicKey) {
|
|
261
516
|
return null;
|
|
262
517
|
}
|
|
263
|
-
return
|
|
518
|
+
return parsed;
|
|
264
519
|
} catch {
|
|
265
520
|
return null;
|
|
266
521
|
}
|
|
267
522
|
}
|
|
268
|
-
|
|
269
|
-
let info = loadBridgeInfo();
|
|
270
|
-
if (!info) {
|
|
271
|
-
info = await spawnDaemon(options);
|
|
272
|
-
}
|
|
273
|
-
const bridge = new WSBridge({
|
|
274
|
-
port: info.port,
|
|
275
|
-
token: info.token,
|
|
276
|
-
timeout: options.timeout
|
|
277
|
-
});
|
|
278
|
-
await bridge.connect();
|
|
279
|
-
return bridge;
|
|
280
|
-
}
|
|
281
|
-
function acquireLock() {
|
|
523
|
+
function saveRelaySession(info) {
|
|
282
524
|
ensureDir(PATHS.root);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
} catch (err) {
|
|
288
|
-
if (err.code === "EEXIST") {
|
|
289
|
-
try {
|
|
290
|
-
const lockPid = parseInt(fs2.readFileSync(LOCK_PATH, "utf-8").trim(), 10);
|
|
291
|
-
if (Number.isInteger(lockPid) && lockPid > 0) {
|
|
292
|
-
try {
|
|
293
|
-
process.kill(lockPid, 0);
|
|
294
|
-
return null;
|
|
295
|
-
} catch {
|
|
296
|
-
try {
|
|
297
|
-
fs2.unlinkSync(LOCK_PATH);
|
|
298
|
-
} catch {
|
|
299
|
-
}
|
|
300
|
-
return acquireLock();
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
} catch {
|
|
304
|
-
try {
|
|
305
|
-
fs2.unlinkSync(LOCK_PATH);
|
|
306
|
-
} catch {
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
throw err;
|
|
312
|
-
}
|
|
525
|
+
fs2.writeFileSync(PATHS.relay, JSON.stringify(info, null, 2) + "\n", {
|
|
526
|
+
encoding: "utf-8",
|
|
527
|
+
mode: 384
|
|
528
|
+
});
|
|
313
529
|
}
|
|
314
|
-
function
|
|
315
|
-
try {
|
|
316
|
-
fs2.closeSync(fd);
|
|
317
|
-
} catch {
|
|
318
|
-
}
|
|
530
|
+
function deleteRelaySession() {
|
|
319
531
|
try {
|
|
320
|
-
fs2.unlinkSync(
|
|
532
|
+
if (fs2.existsSync(PATHS.relay)) fs2.unlinkSync(PATHS.relay);
|
|
321
533
|
} catch {
|
|
322
534
|
}
|
|
323
535
|
}
|
|
324
|
-
async function
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const info = loadBridgeInfo();
|
|
332
|
-
if (info) return info;
|
|
333
|
-
}
|
|
334
|
-
throw new Error(
|
|
335
|
-
"Another process is starting the daemon. Timed out waiting for it."
|
|
336
|
-
);
|
|
536
|
+
async function getBridge(options) {
|
|
537
|
+
const config = loadConfig();
|
|
538
|
+
const keysUrl = options.keysUrl ?? config.keysUrl ?? DEFAULT_KEYS_URL;
|
|
539
|
+
const relayUrl = options.relayUrl ?? config.relayUrl ?? DEFAULT_RELAY_URL;
|
|
540
|
+
const chainId = options.chainId ?? config.defaultChain ?? 1;
|
|
541
|
+
if (!isValidKeysUrl(keysUrl)) {
|
|
542
|
+
throw new Error(`Untrusted keysUrl: ${keysUrl}. Must be a *.jaw.id domain (HTTPS) or localhost.`);
|
|
337
543
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (!isValidKeysUrl(keysUrl)) {
|
|
344
|
-
throw new Error(
|
|
345
|
-
`Untrusted keysUrl: ${keysUrl}. Must be a *.jaw.id domain (HTTPS) or localhost.`
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
const daemonArgs = {
|
|
349
|
-
keysUrl,
|
|
350
|
-
chainId: options.chainId ?? config.defaultChain ?? 1,
|
|
351
|
-
ens: options.ens ?? config.ens,
|
|
352
|
-
paymasterUrl: options.paymasterUrl ?? config.paymasterUrl,
|
|
353
|
-
timeout: options.timeout ?? 12e4
|
|
354
|
-
};
|
|
355
|
-
const daemonScript = path.join(findDistDir(), "lib", "ws-daemon.js");
|
|
544
|
+
if (!isValidRelayUrl(relayUrl)) {
|
|
545
|
+
throw new Error(`Untrusted relayUrl: ${relayUrl}. Must be wss://*.jaw.id or ws://localhost.`);
|
|
546
|
+
}
|
|
547
|
+
let relaySession = loadRelaySession();
|
|
548
|
+
if (relaySession && relaySession.relayUrl === relayUrl) {
|
|
356
549
|
try {
|
|
357
|
-
|
|
550
|
+
return await connectBridge(relaySession, options, chainId, keysUrl, relayUrl);
|
|
358
551
|
} catch {
|
|
552
|
+
deleteRelaySession();
|
|
553
|
+
relaySession = null;
|
|
359
554
|
}
|
|
360
|
-
const logFd = fs2.openSync(PATHS.daemonLog, "w", 384);
|
|
361
|
-
const child = spawn(
|
|
362
|
-
process.execPath,
|
|
363
|
-
[daemonScript, JSON.stringify(daemonArgs)],
|
|
364
|
-
{
|
|
365
|
-
detached: true,
|
|
366
|
-
stdio: ["ignore", logFd, logFd],
|
|
367
|
-
// Pass API key via env var instead of process args to avoid ps aux exposure
|
|
368
|
-
env: { ...process.env, JAW_DAEMON_API_KEY: options.apiKey }
|
|
369
|
-
}
|
|
370
|
-
);
|
|
371
|
-
child.unref();
|
|
372
|
-
fs2.closeSync(logFd);
|
|
373
|
-
const deadline = Date.now() + 15e3;
|
|
374
|
-
while (Date.now() < deadline) {
|
|
375
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
376
|
-
const info = loadBridgeInfo();
|
|
377
|
-
if (info) return info;
|
|
378
|
-
}
|
|
379
|
-
throw new Error(
|
|
380
|
-
`Daemon failed to start within 15s. Check ${PATHS.daemonLog} for details.`
|
|
381
|
-
);
|
|
382
|
-
} finally {
|
|
383
|
-
releaseLock(lockFd);
|
|
384
555
|
}
|
|
556
|
+
const session = await createNewSession(relayUrl);
|
|
557
|
+
saveRelaySession(session);
|
|
558
|
+
return await connectBridge(session, options, chainId, keysUrl, relayUrl);
|
|
559
|
+
}
|
|
560
|
+
async function createNewSession(relayUrl) {
|
|
561
|
+
const kp = await generateKeyPair();
|
|
562
|
+
const privateKey = await exportKeyToHex("private", kp.privateKey);
|
|
563
|
+
const publicKey = await exportKeyToHex("public", kp.publicKey);
|
|
564
|
+
return {
|
|
565
|
+
session: crypto.randomUUID(),
|
|
566
|
+
relayUrl,
|
|
567
|
+
privateKey,
|
|
568
|
+
publicKey,
|
|
569
|
+
peerPublicKey: null,
|
|
570
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async function connectBridge(relaySession, options, chainId, keysUrl, relayUrl) {
|
|
574
|
+
const config = loadConfig();
|
|
575
|
+
const bridge = new WSBridge({
|
|
576
|
+
relayUrl,
|
|
577
|
+
session: relaySession.session,
|
|
578
|
+
timeout: options.timeout,
|
|
579
|
+
config: {
|
|
580
|
+
apiKey: options.apiKey,
|
|
581
|
+
chainId,
|
|
582
|
+
ens: options.ens ?? config.ens,
|
|
583
|
+
paymasterUrl: options.paymasterUrl ?? config.paymasterUrl
|
|
584
|
+
},
|
|
585
|
+
privateKeyHex: relaySession.privateKey,
|
|
586
|
+
publicKeyHex: relaySession.publicKey,
|
|
587
|
+
peerPublicKeyHex: relaySession.peerPublicKey
|
|
588
|
+
});
|
|
589
|
+
await bridge.connect(
|
|
590
|
+
// onBrowserNeeded
|
|
591
|
+
async () => {
|
|
592
|
+
const bridgeUrl = buildBridgeUrl(keysUrl, relaySession.session, relayUrl, relaySession.publicKey);
|
|
593
|
+
const { default: open } = await import('open');
|
|
594
|
+
await open(bridgeUrl);
|
|
595
|
+
},
|
|
596
|
+
// onPeerKeyChanged
|
|
597
|
+
(newPeerKey) => {
|
|
598
|
+
relaySession.peerPublicKey = newPeerKey;
|
|
599
|
+
saveRelaySession(relaySession);
|
|
600
|
+
}
|
|
601
|
+
);
|
|
602
|
+
return bridge;
|
|
603
|
+
}
|
|
604
|
+
function buildBridgeUrl(keysUrl, session, relayUrl, cliPublicKeyHex) {
|
|
605
|
+
const url = new URL("/cli-bridge", keysUrl);
|
|
606
|
+
url.searchParams.set("session", session);
|
|
607
|
+
url.searchParams.set("relay", relayUrl);
|
|
608
|
+
url.hash = `pk=${cliPublicKeyHex}`;
|
|
609
|
+
return url.toString();
|
|
385
610
|
}
|
|
386
611
|
|
|
387
612
|
// src/mcp/handlers/rpc.ts
|
|
@@ -394,51 +619,33 @@ function resolveApiKey() {
|
|
|
394
619
|
}
|
|
395
620
|
return apiKey;
|
|
396
621
|
}
|
|
397
|
-
var cachedBridge = null;
|
|
398
|
-
async function getOrCreateBridge(chainId) {
|
|
399
|
-
if (cachedBridge && cachedBridge.isOpen()) {
|
|
400
|
-
return cachedBridge;
|
|
401
|
-
}
|
|
402
|
-
const config = loadConfig();
|
|
403
|
-
const apiKey = resolveApiKey();
|
|
404
|
-
cachedBridge = await getBridge({
|
|
405
|
-
keysUrl: config.keysUrl,
|
|
406
|
-
apiKey,
|
|
407
|
-
chainId: chainId ?? config.defaultChain,
|
|
408
|
-
ens: config.ens,
|
|
409
|
-
paymasterUrl: config.paymasterUrl
|
|
410
|
-
});
|
|
411
|
-
return cachedBridge;
|
|
412
|
-
}
|
|
413
|
-
function closeCachedBridge() {
|
|
414
|
-
if (cachedBridge) {
|
|
415
|
-
cachedBridge.close();
|
|
416
|
-
cachedBridge = null;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
function isBridgeCached() {
|
|
420
|
-
return cachedBridge !== null && cachedBridge.isOpen();
|
|
421
|
-
}
|
|
422
622
|
function registerRpcTool(server) {
|
|
423
623
|
server.tool(
|
|
424
624
|
"jaw_rpc",
|
|
425
625
|
"Execute any JAW.id wallet RPC method via the browser bridge. Supports transactions, signing, permissions, and queries. Methods that require signing will open the browser for passkey authentication. IMPORTANT: Read the jaw://api-reference resource for the full list of methods, and jaw://api-reference/{method} for detailed parameter formats and examples.",
|
|
426
626
|
rpcMethodSchema,
|
|
427
627
|
async (params) => {
|
|
628
|
+
const config = loadConfig();
|
|
629
|
+
const apiKey = resolveApiKey();
|
|
630
|
+
const bridge = await getBridge({
|
|
631
|
+
keysUrl: config.keysUrl,
|
|
632
|
+
apiKey,
|
|
633
|
+
chainId: params.chainId ?? config.defaultChain,
|
|
634
|
+
ens: config.ens,
|
|
635
|
+
paymasterUrl: config.paymasterUrl
|
|
636
|
+
});
|
|
428
637
|
try {
|
|
429
|
-
const bridge = await getOrCreateBridge(params.chainId);
|
|
430
638
|
const result = await bridge.request(params.method, params.params);
|
|
431
639
|
return mcpResult(result);
|
|
432
640
|
} catch (err) {
|
|
433
|
-
if (cachedBridge && !cachedBridge.isOpen()) {
|
|
434
|
-
cachedBridge = null;
|
|
435
|
-
}
|
|
436
641
|
return mcpError(err);
|
|
642
|
+
} finally {
|
|
643
|
+
bridge.close();
|
|
437
644
|
}
|
|
438
645
|
}
|
|
439
646
|
);
|
|
440
647
|
}
|
|
441
648
|
|
|
442
|
-
export {
|
|
649
|
+
export { registerRpcTool };
|
|
443
650
|
//# sourceMappingURL=rpc.js.map
|
|
444
651
|
//# sourceMappingURL=rpc.js.map
|