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