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