@jaw.id/cli 0.0.3 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +486 -251
- 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 +19 -4
- package/dist/mcp/handlers/config.js.map +1 -1
- package/dist/mcp/handlers/daemon.js +447 -72
- package/dist/mcp/handlers/daemon.js.map +1 -1
- package/dist/mcp/handlers/rpc.js +442 -235
- package/dist/mcp/handlers/rpc.js.map +1 -1
- package/dist/mcp/server.js +486 -251
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.js +1 -1
- package/dist/mcp/tools.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,11 +1,8 @@
|
|
|
1
1
|
import * as fs2 from 'fs';
|
|
2
|
+
import * as crypto from 'crypto';
|
|
2
3
|
import * as path from 'path';
|
|
3
|
-
import 'url';
|
|
4
|
-
import { execSync } from 'child_process';
|
|
5
4
|
import * as os from 'os';
|
|
6
|
-
import '
|
|
7
|
-
import 'ws';
|
|
8
|
-
import { z } from 'zod';
|
|
5
|
+
import WebSocket from 'ws';
|
|
9
6
|
|
|
10
7
|
// src/mcp/handlers/daemon.ts
|
|
11
8
|
|
|
@@ -26,8 +23,7 @@ var PATHS = {
|
|
|
26
23
|
root: JAW_DIR,
|
|
27
24
|
config: path.join(JAW_DIR, "config.json"),
|
|
28
25
|
session: path.join(JAW_DIR, "session.json"),
|
|
29
|
-
|
|
30
|
-
daemonLog: path.join(JAW_DIR, "daemon.log")
|
|
26
|
+
relay: path.join(JAW_DIR, "relay.json")
|
|
31
27
|
};
|
|
32
28
|
function loadConfig() {
|
|
33
29
|
if (!fs2.existsSync(PATHS.config)) {
|
|
@@ -49,102 +45,482 @@ function redactConfig(config) {
|
|
|
49
45
|
};
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
// src/lib/
|
|
53
|
-
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
// src/lib/crypto.ts
|
|
49
|
+
var subtle = globalThis.crypto.subtle;
|
|
50
|
+
async function deriveSharedSecret(privateKey, peerPublicKey) {
|
|
51
|
+
return subtle.deriveKey(
|
|
52
|
+
{ name: "ECDH", public: peerPublicKey },
|
|
53
|
+
privateKey,
|
|
54
|
+
{ name: "AES-GCM", length: 256 },
|
|
55
|
+
false,
|
|
56
|
+
["encrypt", "decrypt"]
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
async function encryptMessage(sharedSecret, payload) {
|
|
60
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12));
|
|
61
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
62
|
+
const cipherBuf = await subtle.encrypt(
|
|
63
|
+
{ name: "AES-GCM", iv },
|
|
64
|
+
sharedSecret,
|
|
65
|
+
plaintext
|
|
66
|
+
);
|
|
67
|
+
return {
|
|
68
|
+
iv: bufferToBase64(iv),
|
|
69
|
+
ciphertext: bufferToBase64(new Uint8Array(cipherBuf))
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function decryptMessage(sharedSecret, envelope) {
|
|
73
|
+
const iv = Buffer.from(envelope.iv, "base64");
|
|
74
|
+
const ciphertext = Buffer.from(envelope.ciphertext, "base64");
|
|
75
|
+
const plainBuf = await subtle.decrypt(
|
|
76
|
+
{ name: "AES-GCM", iv },
|
|
77
|
+
sharedSecret,
|
|
78
|
+
ciphertext
|
|
79
|
+
);
|
|
80
|
+
return JSON.parse(new TextDecoder().decode(plainBuf));
|
|
81
|
+
}
|
|
82
|
+
async function importKeyFromHex(type, hex) {
|
|
83
|
+
const format = type === "private" ? "pkcs8" : "spki";
|
|
84
|
+
return subtle.importKey(
|
|
85
|
+
format,
|
|
86
|
+
Buffer.from(hexToBytes(hex)),
|
|
87
|
+
{ name: "ECDH", namedCurve: "P-256" },
|
|
88
|
+
true,
|
|
89
|
+
type === "private" ? ["deriveKey"] : []
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
function hexToBytes(hex) {
|
|
93
|
+
if (hex.length % 2 !== 0) throw new Error("Invalid hex: odd length");
|
|
94
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
95
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
96
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
97
|
+
}
|
|
98
|
+
return bytes;
|
|
99
|
+
}
|
|
100
|
+
function bufferToBase64(buf) {
|
|
101
|
+
return Buffer.from(buf).toString("base64");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/lib/ws-bridge.ts
|
|
105
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
106
|
+
var MAX_MESSAGE_BYTES = 5 * 1024 * 1024;
|
|
107
|
+
var BROWSER_REOPEN_COOLDOWN_MS = 5e3;
|
|
108
|
+
var MAX_RECONNECT_ATTEMPTS = 3;
|
|
109
|
+
var RECONNECT_BASE_DELAY_MS = 1e3;
|
|
110
|
+
var WSBridge = class {
|
|
111
|
+
relayUrl;
|
|
112
|
+
session;
|
|
113
|
+
timeout;
|
|
114
|
+
config;
|
|
115
|
+
privateKeyHex;
|
|
116
|
+
publicKeyHex;
|
|
117
|
+
peerPublicKeyHex;
|
|
118
|
+
sharedSecret = null;
|
|
119
|
+
ws = null;
|
|
120
|
+
disposed = false;
|
|
121
|
+
// Auto-reopen browser state
|
|
122
|
+
onBrowserNeeded;
|
|
123
|
+
onPeerKeyChanged;
|
|
124
|
+
lastBrowserOpenTime = 0;
|
|
125
|
+
// Reconnection state
|
|
126
|
+
reconnectAttempts = 0;
|
|
127
|
+
/** Updated after key exchange — caller should persist this. */
|
|
128
|
+
get peerPublicKey() {
|
|
129
|
+
return this.peerPublicKeyHex;
|
|
130
|
+
}
|
|
131
|
+
constructor(options) {
|
|
132
|
+
this.relayUrl = options.relayUrl;
|
|
133
|
+
this.session = options.session;
|
|
134
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
135
|
+
this.config = options.config;
|
|
136
|
+
this.privateKeyHex = options.privateKeyHex;
|
|
137
|
+
this.publicKeyHex = options.publicKeyHex;
|
|
138
|
+
this.peerPublicKeyHex = options.peerPublicKeyHex;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Connect to the relay and wait for the browser to be ready.
|
|
142
|
+
*
|
|
143
|
+
* @param onBrowserNeeded — called when the relay reports no browser connected.
|
|
144
|
+
* @param onPeerKeyChanged — called when a key_exchange updates the peer key.
|
|
145
|
+
*/
|
|
146
|
+
async connect(onBrowserNeeded, onPeerKeyChanged) {
|
|
147
|
+
this.onBrowserNeeded = onBrowserNeeded;
|
|
148
|
+
this.onPeerKeyChanged = onPeerKeyChanged;
|
|
149
|
+
if (this.peerPublicKeyHex) {
|
|
150
|
+
await this.deriveSecret();
|
|
151
|
+
}
|
|
152
|
+
return this.connectInternal(onBrowserNeeded, onPeerKeyChanged);
|
|
153
|
+
}
|
|
154
|
+
async connectInternal(onBrowserNeeded, onPeerKeyChanged) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
157
|
+
const ws = new WebSocket(url);
|
|
158
|
+
let browserOpened = false;
|
|
159
|
+
let resolved = false;
|
|
160
|
+
let expectingKeyExchange = !this.peerPublicKeyHex;
|
|
161
|
+
const timer = setTimeout(() => {
|
|
162
|
+
ws.close();
|
|
163
|
+
reject(
|
|
164
|
+
new Error(
|
|
165
|
+
"Browser did not connect in time.\nRun `jaw disconnect` then try again."
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
}, 3e4);
|
|
169
|
+
const sendEncryptedInit = async () => {
|
|
170
|
+
if (!this.sharedSecret) return;
|
|
171
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
172
|
+
type: "init",
|
|
173
|
+
apiKey: this.config.apiKey,
|
|
174
|
+
chainId: this.config.chainId,
|
|
175
|
+
ens: this.config.ens,
|
|
176
|
+
paymasterUrl: this.config.paymasterUrl
|
|
177
|
+
});
|
|
178
|
+
this.sendRaw(ws, JSON.stringify({ type: "encrypted", ...envelope }));
|
|
179
|
+
};
|
|
180
|
+
const waitForReady = () => {
|
|
181
|
+
const readyTimer = setTimeout(() => {
|
|
182
|
+
ws.close();
|
|
183
|
+
reject(new Error("Browser SDK did not become ready in time."));
|
|
184
|
+
}, 15e3);
|
|
185
|
+
const onMsg = async (data) => {
|
|
186
|
+
const msg = safeParse(data);
|
|
187
|
+
if (!msg) return;
|
|
188
|
+
if (msg.type === "encrypted" && this.sharedSecret) {
|
|
189
|
+
try {
|
|
190
|
+
const inner = await decryptMessage(
|
|
191
|
+
this.sharedSecret,
|
|
192
|
+
msg
|
|
193
|
+
);
|
|
194
|
+
if (inner.type === "ready") {
|
|
195
|
+
clearTimeout(readyTimer);
|
|
196
|
+
ws.off("message", onMsg);
|
|
197
|
+
this.reconnectAttempts = 0;
|
|
198
|
+
resolve();
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
ws.on("message", onMsg);
|
|
205
|
+
};
|
|
206
|
+
const onBrowserReady = async () => {
|
|
207
|
+
if (resolved) return;
|
|
208
|
+
resolved = true;
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
waitForReady();
|
|
211
|
+
await sendEncryptedInit();
|
|
212
|
+
};
|
|
213
|
+
ws.on("open", () => {
|
|
214
|
+
this.ws = ws;
|
|
215
|
+
});
|
|
216
|
+
ws.on("message", async (data) => {
|
|
217
|
+
const msg = safeParse(data);
|
|
218
|
+
if (!msg) return;
|
|
219
|
+
if (msg.type === "status") {
|
|
220
|
+
if (msg.browserConnected) {
|
|
221
|
+
if (this.sharedSecret) {
|
|
222
|
+
await onBrowserReady();
|
|
223
|
+
} else {
|
|
224
|
+
expectingKeyExchange = true;
|
|
225
|
+
}
|
|
226
|
+
} else if (!browserOpened && onBrowserNeeded) {
|
|
227
|
+
browserOpened = true;
|
|
228
|
+
expectingKeyExchange = true;
|
|
229
|
+
onBrowserNeeded().catch(() => {
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} else if (msg.type === "browser_connected") {
|
|
233
|
+
expectingKeyExchange = true;
|
|
234
|
+
} else if (msg.type === "browser_disconnected") {
|
|
235
|
+
this.handleBrowserDisconnect();
|
|
236
|
+
} else if (msg.type === "key_exchange" && expectingKeyExchange) {
|
|
237
|
+
expectingKeyExchange = false;
|
|
238
|
+
const peerKey = msg.publicKey;
|
|
239
|
+
this.peerPublicKeyHex = peerKey;
|
|
240
|
+
await this.deriveSecret();
|
|
241
|
+
onPeerKeyChanged?.(peerKey);
|
|
242
|
+
await onBrowserReady();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
ws.on("error", (err) => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
reject(err);
|
|
248
|
+
});
|
|
249
|
+
ws.on("close", () => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
if (!this.disposed) {
|
|
252
|
+
this.handleRelayDisconnect();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Send an encrypted RPC request through the relay to the browser SDK.
|
|
259
|
+
*/
|
|
260
|
+
async request(method, params) {
|
|
261
|
+
const ws = this.ws;
|
|
262
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
263
|
+
throw new Error("Not connected to relay");
|
|
264
|
+
}
|
|
265
|
+
if (!this.sharedSecret) {
|
|
266
|
+
throw new Error("No shared secret \u2014 key exchange not completed");
|
|
267
|
+
}
|
|
268
|
+
const id = crypto.randomUUID();
|
|
269
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
270
|
+
type: "rpc_request",
|
|
271
|
+
id,
|
|
272
|
+
method,
|
|
273
|
+
params
|
|
274
|
+
});
|
|
275
|
+
const serialized = JSON.stringify({ type: "encrypted", ...envelope });
|
|
276
|
+
assertMessageSize(serialized, method);
|
|
277
|
+
return new Promise((resolve, reject) => {
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
reject(
|
|
280
|
+
new Error(
|
|
281
|
+
`Request timed out after ${this.timeout / 1e3}s. Did you complete the action in the browser?`
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
this.close();
|
|
285
|
+
}, this.timeout);
|
|
286
|
+
const onMessage = async (data) => {
|
|
287
|
+
const msg = safeParse(data);
|
|
288
|
+
if (!msg || msg.type !== "encrypted" || !this.sharedSecret) return;
|
|
289
|
+
try {
|
|
290
|
+
const inner = await decryptMessage(
|
|
291
|
+
this.sharedSecret,
|
|
292
|
+
msg
|
|
293
|
+
);
|
|
294
|
+
if (inner.type === "rpc_response" && inner.id === id) {
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
ws.off("message", onMessage);
|
|
297
|
+
if (inner.success) {
|
|
298
|
+
resolve(inner.data);
|
|
299
|
+
} else {
|
|
300
|
+
const err = inner.error;
|
|
301
|
+
reject(
|
|
302
|
+
new Error(
|
|
303
|
+
err ? `[${err.code}] ${err.message}` : "Request failed"
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
ws.on("message", onMessage);
|
|
312
|
+
this.sendRaw(ws, serialized);
|
|
313
|
+
});
|
|
60
314
|
}
|
|
315
|
+
isOpen() {
|
|
316
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
317
|
+
}
|
|
318
|
+
async shutdown() {
|
|
319
|
+
this.disposed = true;
|
|
320
|
+
if (this.ws?.readyState === WebSocket.OPEN && this.sharedSecret) {
|
|
321
|
+
try {
|
|
322
|
+
const envelope = await encryptMessage(this.sharedSecret, {
|
|
323
|
+
type: "shutdown"
|
|
324
|
+
});
|
|
325
|
+
this.sendRaw(
|
|
326
|
+
this.ws,
|
|
327
|
+
JSON.stringify({ type: "encrypted", ...envelope })
|
|
328
|
+
);
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
this.close();
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Connect to relay and send shutdown directly — no init/ready handshake.
|
|
336
|
+
* Used by `jaw disconnect` when we just need to tell the browser to close.
|
|
337
|
+
*/
|
|
338
|
+
async connectAndShutdown() {
|
|
339
|
+
if (!this.peerPublicKeyHex) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.disposed = true;
|
|
343
|
+
await this.deriveSecret();
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
const url = `${this.relayUrl}?session=${encodeURIComponent(this.session)}&role=cli`;
|
|
346
|
+
const ws = new WebSocket(url);
|
|
347
|
+
const timer = setTimeout(() => {
|
|
348
|
+
try {
|
|
349
|
+
ws.close();
|
|
350
|
+
} catch {
|
|
351
|
+
}
|
|
352
|
+
resolve();
|
|
353
|
+
}, 3e3);
|
|
354
|
+
ws.on("open", async () => {
|
|
355
|
+
this.ws = ws;
|
|
356
|
+
try {
|
|
357
|
+
await this.shutdown();
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
resolve();
|
|
362
|
+
});
|
|
363
|
+
ws.on("error", () => {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
resolve();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
close() {
|
|
370
|
+
this.disposed = true;
|
|
371
|
+
if (this.ws) {
|
|
372
|
+
try {
|
|
373
|
+
this.ws.close();
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
this.ws = null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Auto-reopen browser when browser_disconnected is received from relay.
|
|
381
|
+
* Respects a cooldown to prevent rapid re-opening.
|
|
382
|
+
*/
|
|
383
|
+
handleBrowserDisconnect() {
|
|
384
|
+
if (this.disposed || !this.onBrowserNeeded) return;
|
|
385
|
+
const now = Date.now();
|
|
386
|
+
if (now - this.lastBrowserOpenTime < BROWSER_REOPEN_COOLDOWN_MS) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
this.lastBrowserOpenTime = now;
|
|
390
|
+
this.sharedSecret = null;
|
|
391
|
+
this.peerPublicKeyHex = null;
|
|
392
|
+
this.onBrowserNeeded().catch(() => {
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Attempt to reconnect to the relay with exponential backoff
|
|
397
|
+
* when the WebSocket connection drops unexpectedly.
|
|
398
|
+
*/
|
|
399
|
+
handleRelayDisconnect() {
|
|
400
|
+
if (this.disposed) return;
|
|
401
|
+
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
|
|
402
|
+
const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts);
|
|
403
|
+
this.reconnectAttempts++;
|
|
404
|
+
setTimeout(() => {
|
|
405
|
+
if (this.disposed) return;
|
|
406
|
+
this.connectInternal(this.onBrowserNeeded, this.onPeerKeyChanged).catch(
|
|
407
|
+
() => {
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
}, delay);
|
|
411
|
+
}
|
|
412
|
+
/** Send a raw string over the WebSocket, enforcing message size limits. */
|
|
413
|
+
sendRaw(ws, data) {
|
|
414
|
+
ws.send(data);
|
|
415
|
+
}
|
|
416
|
+
async deriveSecret() {
|
|
417
|
+
if (!this.peerPublicKeyHex) return;
|
|
418
|
+
const privateKey = await importKeyFromHex("private", this.privateKeyHex);
|
|
419
|
+
const peerPublicKey = await importKeyFromHex(
|
|
420
|
+
"public",
|
|
421
|
+
this.peerPublicKeyHex
|
|
422
|
+
);
|
|
423
|
+
this.sharedSecret = await deriveSharedSecret(privateKey, peerPublicKey);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
function assertMessageSize(serialized, method) {
|
|
427
|
+
const byteLength = Buffer.byteLength(serialized, "utf-8");
|
|
428
|
+
if (byteLength > MAX_MESSAGE_BYTES) {
|
|
429
|
+
const sizeMB = (byteLength / (1024 * 1024)).toFixed(2);
|
|
430
|
+
throw new Error(
|
|
431
|
+
`Message for ${method} is too large (${sizeMB} MB, limit ${MAX_MESSAGE_BYTES / (1024 * 1024)} MB). Try reducing the number of calls in your batch.`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function safeParse(data) {
|
|
61
436
|
try {
|
|
62
|
-
|
|
63
|
-
encoding: "utf-8",
|
|
64
|
-
timeout: 3e3
|
|
65
|
-
}).trim();
|
|
66
|
-
return cmd.includes("ws-daemon");
|
|
437
|
+
return JSON.parse(data.toString());
|
|
67
438
|
} catch {
|
|
68
|
-
return
|
|
439
|
+
return null;
|
|
69
440
|
}
|
|
70
441
|
}
|
|
71
|
-
|
|
442
|
+
|
|
443
|
+
// src/lib/bridge-singleton.ts
|
|
444
|
+
function loadRelaySession() {
|
|
72
445
|
try {
|
|
73
|
-
if (!fs2.existsSync(PATHS.
|
|
74
|
-
const raw = fs2.readFileSync(PATHS.
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
77
|
-
try {
|
|
78
|
-
fs2.unlinkSync(PATHS.bridge);
|
|
79
|
-
} catch {
|
|
80
|
-
}
|
|
446
|
+
if (!fs2.existsSync(PATHS.relay)) return null;
|
|
447
|
+
const raw = fs2.readFileSync(PATHS.relay, "utf-8");
|
|
448
|
+
const parsed = JSON.parse(raw);
|
|
449
|
+
if (!parsed.session || !parsed.relayUrl || !parsed.privateKey || !parsed.publicKey) {
|
|
81
450
|
return null;
|
|
82
451
|
}
|
|
83
|
-
return
|
|
452
|
+
return parsed;
|
|
84
453
|
} catch {
|
|
85
454
|
return null;
|
|
86
455
|
}
|
|
87
456
|
}
|
|
457
|
+
function deleteRelaySession() {
|
|
458
|
+
try {
|
|
459
|
+
if (fs2.existsSync(PATHS.relay)) fs2.unlinkSync(PATHS.relay);
|
|
460
|
+
} catch {
|
|
461
|
+
}
|
|
462
|
+
}
|
|
88
463
|
async function shutdownDaemon() {
|
|
89
|
-
const
|
|
90
|
-
if (!
|
|
464
|
+
const session = loadRelaySession();
|
|
465
|
+
if (!session) return;
|
|
91
466
|
try {
|
|
92
|
-
|
|
467
|
+
const bridge = new WSBridge({
|
|
468
|
+
relayUrl: session.relayUrl,
|
|
469
|
+
session: session.session,
|
|
470
|
+
timeout: 5e3,
|
|
471
|
+
config: { apiKey: "", chainId: 1 },
|
|
472
|
+
privateKeyHex: session.privateKey,
|
|
473
|
+
publicKeyHex: session.publicKey,
|
|
474
|
+
peerPublicKeyHex: session.peerPublicKey
|
|
475
|
+
});
|
|
476
|
+
await bridge.connectAndShutdown();
|
|
93
477
|
} catch {
|
|
94
478
|
}
|
|
479
|
+
deleteRelaySession();
|
|
480
|
+
const legacyBridge = PATHS.root + "/bridge.json";
|
|
481
|
+
const legacyLog = PATHS.root + "/daemon.log";
|
|
482
|
+
const legacyLock = PATHS.root + "/daemon.lock";
|
|
95
483
|
try {
|
|
96
|
-
if (fs2.existsSync(
|
|
484
|
+
if (fs2.existsSync(legacyBridge)) {
|
|
485
|
+
const info = JSON.parse(fs2.readFileSync(legacyBridge, "utf-8"));
|
|
486
|
+
if (info.pid && Number.isInteger(info.pid) && info.pid > 0) {
|
|
487
|
+
try {
|
|
488
|
+
process.kill(info.pid, "SIGTERM");
|
|
489
|
+
} catch {
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
97
493
|
} catch {
|
|
98
494
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"Method parameters \u2014 structure varies by method. Read the jaw://api-reference/{method} resource for the expected format."
|
|
106
|
-
),
|
|
107
|
-
chainId: z.number().optional().describe(
|
|
108
|
-
"Target chain ID (overrides default). E.g., 1 for Ethereum, 8453 for Base, 84532 for Base Sepolia"
|
|
109
|
-
)
|
|
110
|
-
});
|
|
111
|
-
({
|
|
112
|
-
key: z.enum(["apiKey", "defaultChain", "keysUrl", "paymasterUrl", "ens"]).describe("Config key"),
|
|
113
|
-
value: z.string().describe("Config value")
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// src/mcp/handlers/rpc.ts
|
|
117
|
-
var cachedBridge = null;
|
|
118
|
-
function closeCachedBridge() {
|
|
119
|
-
}
|
|
120
|
-
function isBridgeCached() {
|
|
121
|
-
return cachedBridge !== null;
|
|
495
|
+
for (const f of [legacyBridge, legacyLog, legacyLock]) {
|
|
496
|
+
try {
|
|
497
|
+
if (fs2.existsSync(f)) fs2.unlinkSync(f);
|
|
498
|
+
} catch {
|
|
499
|
+
}
|
|
500
|
+
}
|
|
122
501
|
}
|
|
123
502
|
|
|
124
503
|
// src/mcp/handlers/daemon.ts
|
|
125
504
|
function registerDaemonTools(server) {
|
|
126
505
|
server.tool(
|
|
127
506
|
"jaw_status",
|
|
128
|
-
"Check the current status of the JAW.id bridge \u2014 whether
|
|
507
|
+
"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.",
|
|
129
508
|
{},
|
|
130
509
|
async () => {
|
|
131
510
|
try {
|
|
132
|
-
let
|
|
133
|
-
let daemonPid = null;
|
|
511
|
+
let relaySession = false;
|
|
134
512
|
try {
|
|
135
|
-
if (fs2.existsSync(PATHS.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
process.kill(info.pid, 0);
|
|
139
|
-
daemonRunning = true;
|
|
513
|
+
if (fs2.existsSync(PATHS.relay)) {
|
|
514
|
+
JSON.parse(fs2.readFileSync(PATHS.relay, "utf-8"));
|
|
515
|
+
relaySession = true;
|
|
140
516
|
}
|
|
141
517
|
} catch {
|
|
142
|
-
|
|
518
|
+
relaySession = false;
|
|
143
519
|
}
|
|
144
520
|
const config = redactConfig(loadConfig());
|
|
145
521
|
const status = {
|
|
146
|
-
|
|
147
|
-
bridgeConnection:
|
|
522
|
+
relay: relaySession ? { session: true } : { session: false },
|
|
523
|
+
bridgeConnection: "disconnected",
|
|
148
524
|
config
|
|
149
525
|
};
|
|
150
526
|
return {
|
|
@@ -159,17 +535,16 @@ function registerDaemonTools(server) {
|
|
|
159
535
|
);
|
|
160
536
|
server.tool(
|
|
161
537
|
"jaw_disconnect",
|
|
162
|
-
"
|
|
538
|
+
"Close the relay session and browser tab. Call this when you are done making wallet requests to clean up resources.",
|
|
163
539
|
{},
|
|
164
540
|
async () => {
|
|
165
541
|
try {
|
|
166
|
-
closeCachedBridge();
|
|
167
542
|
await shutdownDaemon();
|
|
168
543
|
return {
|
|
169
544
|
content: [
|
|
170
545
|
{
|
|
171
546
|
type: "text",
|
|
172
|
-
text: "
|
|
547
|
+
text: "Relay session closed and browser tab dismissed."
|
|
173
548
|
}
|
|
174
549
|
]
|
|
175
550
|
};
|