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