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