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