@muhaven/mcp 0.1.0
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/CHANGELOG.md +123 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/muhaven-broker.cjs +11 -0
- package/bin/muhaven-mcp.cjs +11 -0
- package/dist/broker.cjs +1117 -0
- package/dist/broker.d.cts +16 -0
- package/dist/broker.d.ts +16 -0
- package/dist/broker.js +1112 -0
- package/dist/index.cjs +1972 -0
- package/dist/index.d.cts +698 -0
- package/dist/index.d.ts +698 -0
- package/dist/index.js +1942 -0
- package/manifest.json +98 -0
- package/package.json +104 -0
- package/tool-hashes.json +93 -0
package/dist/broker.cjs
ADDED
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var os = require('os');
|
|
4
|
+
var child_process = require('child_process');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var net = require('net');
|
|
7
|
+
var promises = require('fs/promises');
|
|
8
|
+
var accounts = require('viem/accounts');
|
|
9
|
+
|
|
10
|
+
// src/broker/cli.ts
|
|
11
|
+
var DEFAULT_BACKEND_URL = "https://api.muhaven.app";
|
|
12
|
+
var DEFAULT_DASHBOARD_URL = "https://muhaven.app";
|
|
13
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
|
|
14
|
+
var DEFAULT_BROKER_TIMEOUT_MS = 5e3;
|
|
15
|
+
var DEFAULT_BROKER_MAX_BYTES = 64 * 1024;
|
|
16
|
+
var DEFAULT_JWT_CACHE_TTL_SEC = 30;
|
|
17
|
+
function defaultBrokerEndpoint() {
|
|
18
|
+
if (os.platform() === "win32") {
|
|
19
|
+
const user = process.env.USERNAME ?? "default";
|
|
20
|
+
const sanitized = user.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
21
|
+
return `\\\\.\\pipe\\muhaven-broker-${sanitized}`;
|
|
22
|
+
}
|
|
23
|
+
return path.join(os.homedir(), ".muhaven", "broker.sock");
|
|
24
|
+
}
|
|
25
|
+
function readEnv(name, env) {
|
|
26
|
+
const value = env[name];
|
|
27
|
+
if (value === void 0 || value === "") return void 0;
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function readEnvBool(name, defaultValue, env) {
|
|
31
|
+
const raw = readEnv(name, env);
|
|
32
|
+
if (raw === void 0) return defaultValue;
|
|
33
|
+
return /^(1|true|yes|on)$/i.test(raw);
|
|
34
|
+
}
|
|
35
|
+
function readEnvInt(name, defaultValue, env) {
|
|
36
|
+
const raw = readEnv(name, env);
|
|
37
|
+
if (raw === void 0) return defaultValue;
|
|
38
|
+
const parsed = Number.parseInt(raw, 10);
|
|
39
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
40
|
+
throw new Error(`${name} must be a positive integer (got "${raw}")`);
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
function deriveAllowedHosts(baseUrl) {
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(baseUrl);
|
|
47
|
+
return [u.host];
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`MUHAVEN_BACKEND_URL is not a valid URL (got "${baseUrl}")`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function trimTrailingSlash(s) {
|
|
53
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
54
|
+
}
|
|
55
|
+
function loadMcpConfig(env = process.env) {
|
|
56
|
+
const backendBaseUrl = trimTrailingSlash(env.MUHAVEN_BACKEND_URL ?? DEFAULT_BACKEND_URL);
|
|
57
|
+
const dashboardBaseUrl = trimTrailingSlash(env.MUHAVEN_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
|
|
58
|
+
const brokerEndpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
59
|
+
const readOnly = readEnvBool("MUHAVEN_READ_ONLY", false, env);
|
|
60
|
+
const requestTimeoutMs = readEnvInt("MUHAVEN_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, env);
|
|
61
|
+
const brokerTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
|
|
62
|
+
const jwtCacheTtlSec = readEnvInt("MUHAVEN_JWT_CACHE_TTL_SEC", DEFAULT_JWT_CACHE_TTL_SEC, env);
|
|
63
|
+
return {
|
|
64
|
+
backendBaseUrl,
|
|
65
|
+
dashboardBaseUrl,
|
|
66
|
+
brokerEndpoint,
|
|
67
|
+
readOnly,
|
|
68
|
+
requestTimeoutMs,
|
|
69
|
+
brokerTimeoutMs,
|
|
70
|
+
allowedBackendHosts: deriveAllowedHosts(backendBaseUrl),
|
|
71
|
+
jwtCacheTtlSec
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
var PRIVKEY_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
75
|
+
function loadBrokerConfig(env = process.env) {
|
|
76
|
+
const sessionKeyHex = env.MUHAVEN_BROKER_SESSION_KEY;
|
|
77
|
+
if (!sessionKeyHex) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"MUHAVEN_BROKER_SESSION_KEY is required (0x-prefixed 32-byte hex). Mint a session key via the dashboard policy-template install flow."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (!PRIVKEY_HEX_RE.test(sessionKeyHex)) {
|
|
83
|
+
throw new Error("MUHAVEN_BROKER_SESSION_KEY must be a 0x-prefixed 32-byte hex string");
|
|
84
|
+
}
|
|
85
|
+
const endpoint = env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint();
|
|
86
|
+
const maxRequestBytes = readEnvInt("MUHAVEN_BROKER_MAX_BYTES", DEFAULT_BROKER_MAX_BYTES, env);
|
|
87
|
+
const requestTimeoutMs = readEnvInt("MUHAVEN_BROKER_TIMEOUT_MS", DEFAULT_BROKER_TIMEOUT_MS, env);
|
|
88
|
+
return {
|
|
89
|
+
endpoint,
|
|
90
|
+
sessionKeyHex,
|
|
91
|
+
maxRequestBytes,
|
|
92
|
+
requestTimeoutMs
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
var BrokerClientError = class extends Error {
|
|
96
|
+
constructor(code, message, cause) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.code = code;
|
|
99
|
+
this.cause = cause;
|
|
100
|
+
this.name = "BrokerClientError";
|
|
101
|
+
}
|
|
102
|
+
code;
|
|
103
|
+
cause;
|
|
104
|
+
};
|
|
105
|
+
var BrokerClient = class {
|
|
106
|
+
constructor(options) {
|
|
107
|
+
this.options = options;
|
|
108
|
+
}
|
|
109
|
+
options;
|
|
110
|
+
async hello() {
|
|
111
|
+
const res = await this.exchange({ type: "hello" });
|
|
112
|
+
if (res.type !== "hello") {
|
|
113
|
+
throw new BrokerClientError("protocol_error", `expected hello response, got ${res.type}`);
|
|
114
|
+
}
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
async signHash(hash, intent) {
|
|
118
|
+
const res = await this.exchange({
|
|
119
|
+
type: "sign_hash",
|
|
120
|
+
hash,
|
|
121
|
+
...intent ? { intent } : {}
|
|
122
|
+
});
|
|
123
|
+
if (res.type !== "sign_hash") {
|
|
124
|
+
throw new BrokerClientError(
|
|
125
|
+
"protocol_error",
|
|
126
|
+
`expected sign_hash response, got ${res.type}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return res;
|
|
130
|
+
}
|
|
131
|
+
async storeJwt(jwt, expiresAtSec) {
|
|
132
|
+
const res = await this.exchange({
|
|
133
|
+
type: "store_jwt",
|
|
134
|
+
jwt,
|
|
135
|
+
...expiresAtSec === void 0 ? {} : { expiresAtSec }
|
|
136
|
+
});
|
|
137
|
+
if (res.type !== "store_jwt") {
|
|
138
|
+
throw new BrokerClientError(
|
|
139
|
+
"protocol_error",
|
|
140
|
+
`expected store_jwt response, got ${res.type}`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return res;
|
|
144
|
+
}
|
|
145
|
+
async getJwt() {
|
|
146
|
+
const res = await this.exchange({ type: "get_jwt" });
|
|
147
|
+
if (res.type !== "get_jwt") {
|
|
148
|
+
throw new BrokerClientError(
|
|
149
|
+
"protocol_error",
|
|
150
|
+
`expected get_jwt response, got ${res.type}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return res;
|
|
154
|
+
}
|
|
155
|
+
async clearJwt() {
|
|
156
|
+
const res = await this.exchange({ type: "clear_jwt" });
|
|
157
|
+
if (res.type !== "clear_jwt") {
|
|
158
|
+
throw new BrokerClientError(
|
|
159
|
+
"protocol_error",
|
|
160
|
+
`expected clear_jwt response, got ${res.type}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
exchange(request) {
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
let socket;
|
|
167
|
+
let buffer = "";
|
|
168
|
+
let settled = false;
|
|
169
|
+
const settleErr = (err) => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
settled = true;
|
|
172
|
+
socket?.destroy();
|
|
173
|
+
reject(err);
|
|
174
|
+
};
|
|
175
|
+
const settleOk = (res) => {
|
|
176
|
+
if (settled) return;
|
|
177
|
+
settled = true;
|
|
178
|
+
socket?.destroy();
|
|
179
|
+
resolve(res);
|
|
180
|
+
};
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
settleErr(new BrokerClientError("timeout", "broker IPC timeout"));
|
|
183
|
+
}, this.options.timeoutMs);
|
|
184
|
+
try {
|
|
185
|
+
socket = net.connect(this.options.endpoint);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
settleErr(new BrokerClientError("connect_failed", "cannot connect to broker", err));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
socket.once("connect", () => {
|
|
192
|
+
socket.write(JSON.stringify(request) + "\n");
|
|
193
|
+
});
|
|
194
|
+
socket.on("data", (chunk) => {
|
|
195
|
+
buffer += chunk.toString("utf8");
|
|
196
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
197
|
+
if (newlineIdx < 0) return;
|
|
198
|
+
const line = buffer.slice(0, newlineIdx);
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(line);
|
|
202
|
+
if (parsed.type === "error") {
|
|
203
|
+
settleErr(
|
|
204
|
+
new BrokerClientError("broker_error", `${parsed.code}: ${parsed.message}`)
|
|
205
|
+
);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
settleOk(parsed);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
settleErr(new BrokerClientError("protocol_error", "invalid JSON from broker", err));
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
socket.on("error", (err) => {
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
settleErr(new BrokerClientError("connect_failed", err.message, err));
|
|
216
|
+
});
|
|
217
|
+
socket.on("close", () => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
if (!settled) {
|
|
220
|
+
settleErr(new BrokerClientError("protocol_error", "broker closed without response"));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// src/auth/device-flow.ts
|
|
228
|
+
var DEFAULT_POLL_INTERVAL_MS = 2e3;
|
|
229
|
+
var DEFAULT_OVERALL_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
230
|
+
var DeviceFlowAbortedError = class extends Error {
|
|
231
|
+
constructor(detail) {
|
|
232
|
+
super(`device flow aborted: ${detail.code}`);
|
|
233
|
+
this.detail = detail;
|
|
234
|
+
this.name = "DeviceFlowAbortedError";
|
|
235
|
+
}
|
|
236
|
+
detail;
|
|
237
|
+
};
|
|
238
|
+
var DeviceFlowClient = class {
|
|
239
|
+
constructor(options) {
|
|
240
|
+
this.options = options;
|
|
241
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
242
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
243
|
+
this.nowMs = options.nowMs ?? (() => Date.now());
|
|
244
|
+
}
|
|
245
|
+
options;
|
|
246
|
+
fetchImpl;
|
|
247
|
+
sleep;
|
|
248
|
+
nowMs;
|
|
249
|
+
/**
|
|
250
|
+
* Run the full ceremony: request a code, yield events for the caller
|
|
251
|
+
* to display the URL, then poll until authorized / denied / expired.
|
|
252
|
+
* Throws `DeviceFlowAbortedError` on terminal failure.
|
|
253
|
+
*/
|
|
254
|
+
async *run(opts) {
|
|
255
|
+
const overallTimeout = opts?.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS;
|
|
256
|
+
const code = await this.requestCode();
|
|
257
|
+
yield { type: "code_issued", code };
|
|
258
|
+
const startedAt = this.nowMs();
|
|
259
|
+
const pollMs = Math.max(1, code.pollIntervalSec) * 1e3;
|
|
260
|
+
let attempt = 0;
|
|
261
|
+
while (this.nowMs() - startedAt < overallTimeout) {
|
|
262
|
+
attempt += 1;
|
|
263
|
+
yield { type: "polling", attempt, nextPollMs: pollMs };
|
|
264
|
+
await this.sleep(pollMs);
|
|
265
|
+
const res = await this.pollOnce(code.deviceCode);
|
|
266
|
+
switch (res.state) {
|
|
267
|
+
case "pending":
|
|
268
|
+
continue;
|
|
269
|
+
case "authorized":
|
|
270
|
+
if (!res.jwt) {
|
|
271
|
+
throw new DeviceFlowAbortedError({
|
|
272
|
+
code: "invalid_response",
|
|
273
|
+
body: { reason: "authorized state missing jwt" }
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
yield {
|
|
277
|
+
type: "authorized",
|
|
278
|
+
jwt: res.jwt,
|
|
279
|
+
expiresAtSec: res.expiresAtSec ?? null,
|
|
280
|
+
scope: res.scope ?? null
|
|
281
|
+
};
|
|
282
|
+
return {
|
|
283
|
+
jwt: res.jwt,
|
|
284
|
+
expiresAtSec: res.expiresAtSec ?? null,
|
|
285
|
+
scope: res.scope ?? null
|
|
286
|
+
};
|
|
287
|
+
case "denied":
|
|
288
|
+
yield { type: "denied", reason: res.reason };
|
|
289
|
+
throw new DeviceFlowAbortedError({ code: "denied", reason: res.reason });
|
|
290
|
+
case "expired":
|
|
291
|
+
yield { type: "expired" };
|
|
292
|
+
throw new DeviceFlowAbortedError({ code: "expired" });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
throw new DeviceFlowAbortedError({ code: "timeout" });
|
|
296
|
+
}
|
|
297
|
+
async requestCode() {
|
|
298
|
+
const url = new URL("/api/v1/auth/device/code", this.options.backendBaseUrl);
|
|
299
|
+
const requesterMetadata = {
|
|
300
|
+
processName: this.options.requesterMetadata?.processName ?? "muhaven-broker",
|
|
301
|
+
hostname: this.options.requesterMetadata?.hostname ?? "",
|
|
302
|
+
os: this.options.requesterMetadata?.os ?? ""
|
|
303
|
+
};
|
|
304
|
+
let res;
|
|
305
|
+
try {
|
|
306
|
+
res = await this.fetchImpl(url, {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
309
|
+
body: JSON.stringify({ requesterMetadata })
|
|
310
|
+
});
|
|
311
|
+
} catch (err) {
|
|
312
|
+
throw new DeviceFlowAbortedError({ code: "network", cause: err });
|
|
313
|
+
}
|
|
314
|
+
if (res.status === 429) {
|
|
315
|
+
throw new DeviceFlowAbortedError({ code: "rate_limited" });
|
|
316
|
+
}
|
|
317
|
+
if (res.status >= 400) {
|
|
318
|
+
const body2 = await safeJson(res);
|
|
319
|
+
throw new DeviceFlowAbortedError({ code: "invalid_response", status: res.status, body: body2 });
|
|
320
|
+
}
|
|
321
|
+
const body = await safeJson(res);
|
|
322
|
+
if (!body || !body.deviceCode || !body.userCode) {
|
|
323
|
+
throw new DeviceFlowAbortedError({ code: "invalid_response", status: res.status, body });
|
|
324
|
+
}
|
|
325
|
+
const verificationUri = `${trim(this.options.dashboardBaseUrl)}/link`;
|
|
326
|
+
const verificationUriComplete = `${verificationUri}?code=${encodeURIComponent(body.userCode)}`;
|
|
327
|
+
return {
|
|
328
|
+
deviceCode: body.deviceCode,
|
|
329
|
+
userCode: body.userCode,
|
|
330
|
+
verificationUri,
|
|
331
|
+
verificationUriComplete,
|
|
332
|
+
expiresInSec: body.expiresInSec ?? 300,
|
|
333
|
+
pollIntervalSec: body.pollIntervalSec ?? Math.floor(DEFAULT_POLL_INTERVAL_MS / 1e3)
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
async pollOnce(deviceCode) {
|
|
337
|
+
const url = new URL("/api/v1/auth/device/token", this.options.backendBaseUrl);
|
|
338
|
+
let res;
|
|
339
|
+
try {
|
|
340
|
+
res = await this.fetchImpl(url, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
343
|
+
body: JSON.stringify({ deviceCode })
|
|
344
|
+
});
|
|
345
|
+
} catch (err) {
|
|
346
|
+
throw new DeviceFlowAbortedError({ code: "network", cause: err });
|
|
347
|
+
}
|
|
348
|
+
if (res.status === 429) {
|
|
349
|
+
return { state: "pending" };
|
|
350
|
+
}
|
|
351
|
+
const body = await safeJson(res);
|
|
352
|
+
if (!body || typeof body.state !== "string") {
|
|
353
|
+
throw new DeviceFlowAbortedError({ code: "invalid_response", status: res.status, body });
|
|
354
|
+
}
|
|
355
|
+
return body;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
function trim(s) {
|
|
359
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
360
|
+
}
|
|
361
|
+
async function safeJson(res) {
|
|
362
|
+
try {
|
|
363
|
+
return await res.json();
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
var KEYRING_SERVICE = "muhaven.mcp";
|
|
369
|
+
var KEYRING_ACCOUNT = "jwt";
|
|
370
|
+
async function loadKeyringModule() {
|
|
371
|
+
try {
|
|
372
|
+
const moduleName = "@napi-rs/keyring";
|
|
373
|
+
return await import(moduleName);
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
var OsKeystore = class {
|
|
379
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
380
|
+
constructor(entry) {
|
|
381
|
+
this.entry = entry;
|
|
382
|
+
}
|
|
383
|
+
entry;
|
|
384
|
+
backend = "os";
|
|
385
|
+
available = true;
|
|
386
|
+
async set(record) {
|
|
387
|
+
try {
|
|
388
|
+
this.entry.setPassword(JSON.stringify(record));
|
|
389
|
+
} catch (err) {
|
|
390
|
+
this.available = false;
|
|
391
|
+
throw new KeystoreError(
|
|
392
|
+
"os_keystore_unavailable",
|
|
393
|
+
`OS keychain rejected write \u2014 ${asMessage(err)}. Try MUHAVEN_KEYRING=file or run \`muhaven-broker doctor\`.`,
|
|
394
|
+
err
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async get() {
|
|
399
|
+
try {
|
|
400
|
+
const raw = this.entry.getPassword();
|
|
401
|
+
if (!raw) return null;
|
|
402
|
+
return parseRecord(raw);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
this.available = false;
|
|
405
|
+
throw new KeystoreError(
|
|
406
|
+
"os_keystore_unavailable",
|
|
407
|
+
`OS keychain read failed \u2014 ${asMessage(err)}. Try MUHAVEN_KEYRING=file or run \`muhaven-broker doctor\`.`,
|
|
408
|
+
err
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async clear() {
|
|
413
|
+
try {
|
|
414
|
+
this.entry.deletePassword();
|
|
415
|
+
} catch (err) {
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
var FileKeystore = class {
|
|
420
|
+
constructor(path) {
|
|
421
|
+
this.path = path;
|
|
422
|
+
}
|
|
423
|
+
path;
|
|
424
|
+
backend = "file";
|
|
425
|
+
available = true;
|
|
426
|
+
static defaultPath() {
|
|
427
|
+
return path.join(os.homedir(), ".muhaven", "jwt");
|
|
428
|
+
}
|
|
429
|
+
async set(record) {
|
|
430
|
+
const parent = path.dirname(this.path);
|
|
431
|
+
await promises.mkdir(parent, { recursive: true, mode: 448 });
|
|
432
|
+
await promises.chmod(parent, 448).catch(() => void 0);
|
|
433
|
+
await promises.writeFile(this.path, JSON.stringify(record), { mode: 384 });
|
|
434
|
+
await promises.chmod(this.path, 384).catch(() => void 0);
|
|
435
|
+
}
|
|
436
|
+
async get() {
|
|
437
|
+
try {
|
|
438
|
+
const raw = await promises.readFile(this.path, "utf8");
|
|
439
|
+
return parseRecord(raw);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
if (err.code === "ENOENT") return null;
|
|
442
|
+
throw new KeystoreError("file_read_failed", asMessage(err), err);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async clear() {
|
|
446
|
+
try {
|
|
447
|
+
await promises.unlink(this.path);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
if (err.code === "ENOENT") return;
|
|
450
|
+
throw new KeystoreError("file_clear_failed", asMessage(err), err);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
var KeystoreError = class extends Error {
|
|
455
|
+
constructor(code, message, cause) {
|
|
456
|
+
super(message);
|
|
457
|
+
this.code = code;
|
|
458
|
+
this.cause = cause;
|
|
459
|
+
this.name = "KeystoreError";
|
|
460
|
+
}
|
|
461
|
+
code;
|
|
462
|
+
cause;
|
|
463
|
+
};
|
|
464
|
+
function parseRecord(raw) {
|
|
465
|
+
try {
|
|
466
|
+
const parsed = JSON.parse(raw);
|
|
467
|
+
if (!parsed || typeof parsed.jwt !== "string") return null;
|
|
468
|
+
return {
|
|
469
|
+
jwt: parsed.jwt,
|
|
470
|
+
expiresAtSec: typeof parsed.expiresAtSec === "number" ? parsed.expiresAtSec : null,
|
|
471
|
+
storedAtSec: typeof parsed.storedAtSec === "number" ? parsed.storedAtSec : 0
|
|
472
|
+
};
|
|
473
|
+
} catch {
|
|
474
|
+
throw new KeystoreError("malformed_record", "keystore record is not valid JSON");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function asMessage(err) {
|
|
478
|
+
return err instanceof Error ? err.message : String(err);
|
|
479
|
+
}
|
|
480
|
+
async function openKeystore(options = {}) {
|
|
481
|
+
const envPref = process.env.MUHAVEN_KEYRING?.toLowerCase();
|
|
482
|
+
const wantFile = options.preferred === "file" || envPref === "file";
|
|
483
|
+
const filePath = options.filePath ?? FileKeystore.defaultPath();
|
|
484
|
+
if (wantFile) {
|
|
485
|
+
return { keystore: new FileKeystore(filePath), fallbackReason: null };
|
|
486
|
+
}
|
|
487
|
+
const mod = await loadKeyringModule();
|
|
488
|
+
if (!mod) {
|
|
489
|
+
return {
|
|
490
|
+
keystore: new FileKeystore(filePath),
|
|
491
|
+
fallbackReason: "@napi-rs/keyring not installed for this platform"
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const Entry = mod.Entry;
|
|
495
|
+
if (!Entry) {
|
|
496
|
+
return {
|
|
497
|
+
keystore: new FileKeystore(filePath),
|
|
498
|
+
fallbackReason: "@napi-rs/keyring loaded but Entry constructor missing"
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
let entry;
|
|
502
|
+
try {
|
|
503
|
+
entry = new Entry(KEYRING_SERVICE, KEYRING_ACCOUNT);
|
|
504
|
+
const raw = entry.getPassword();
|
|
505
|
+
if (raw && typeof raw === "string") {
|
|
506
|
+
const parsed = parseRecord(raw);
|
|
507
|
+
if (parsed === null) {
|
|
508
|
+
return {
|
|
509
|
+
keystore: new FileKeystore(filePath),
|
|
510
|
+
fallbackReason: "OS keychain held a record that did not contain a recognizable JWT \u2014 falling back to file. Run `muhaven-broker logout` if you want to clean it up."
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} catch (err) {
|
|
515
|
+
const isMalformed = err instanceof KeystoreError && err.code === "malformed_record";
|
|
516
|
+
return {
|
|
517
|
+
keystore: new FileKeystore(filePath),
|
|
518
|
+
fallbackReason: isMalformed ? `OS keychain held a malformed JWT record \u2014 falling back to file. ${asMessage(err)}` : `OS keychain probe failed: ${asMessage(err)}`
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return { keystore: new OsKeystore(entry), fallbackReason: null };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/broker/protocol.ts
|
|
525
|
+
var BROKER_PROTOCOL_VERSION = "0.2.0";
|
|
526
|
+
var HASH_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
527
|
+
var JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
528
|
+
function isHashHex(value) {
|
|
529
|
+
return typeof value === "string" && HASH_HEX_RE.test(value);
|
|
530
|
+
}
|
|
531
|
+
function isJwtShape(value) {
|
|
532
|
+
return typeof value === "string" && value.length <= 8192 && JWT_RE.test(value);
|
|
533
|
+
}
|
|
534
|
+
function parseBrokerRequest(line) {
|
|
535
|
+
let parsed;
|
|
536
|
+
try {
|
|
537
|
+
parsed = JSON.parse(line);
|
|
538
|
+
} catch {
|
|
539
|
+
return { type: "error", code: "invalid_request", message: "request is not valid JSON" };
|
|
540
|
+
}
|
|
541
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
542
|
+
return { type: "error", code: "invalid_request", message: "request must be a JSON object" };
|
|
543
|
+
}
|
|
544
|
+
const obj = parsed;
|
|
545
|
+
switch (obj.type) {
|
|
546
|
+
case "hello":
|
|
547
|
+
return { type: "hello" };
|
|
548
|
+
case "sign_hash": {
|
|
549
|
+
const hash = obj.hash;
|
|
550
|
+
if (!isHashHex(hash)) {
|
|
551
|
+
return {
|
|
552
|
+
type: "error",
|
|
553
|
+
code: "invalid_request",
|
|
554
|
+
message: "sign_hash.hash must be a 0x-prefixed 32-byte hex string"
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const intent = obj.intent;
|
|
558
|
+
const intentValid = intent === void 0 || typeof intent === "object" && intent !== null && typeof intent.tool === "string";
|
|
559
|
+
if (!intentValid) {
|
|
560
|
+
return {
|
|
561
|
+
type: "error",
|
|
562
|
+
code: "invalid_request",
|
|
563
|
+
message: "sign_hash.intent.tool must be a string when provided"
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
type: "sign_hash",
|
|
568
|
+
hash,
|
|
569
|
+
...intent === void 0 ? {} : { intent }
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
case "store_jwt": {
|
|
573
|
+
const jwt = obj.jwt;
|
|
574
|
+
if (!isJwtShape(jwt)) {
|
|
575
|
+
return {
|
|
576
|
+
type: "error",
|
|
577
|
+
code: "invalid_request",
|
|
578
|
+
message: "store_jwt.jwt must be a JWT-shaped string \u22648192 chars"
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const expiresAtSec = obj.expiresAtSec;
|
|
582
|
+
const expiresValid = expiresAtSec === void 0 || typeof expiresAtSec === "number" && Number.isFinite(expiresAtSec) && expiresAtSec > 0;
|
|
583
|
+
if (!expiresValid) {
|
|
584
|
+
return {
|
|
585
|
+
type: "error",
|
|
586
|
+
code: "invalid_request",
|
|
587
|
+
message: "store_jwt.expiresAtSec must be a positive number when provided"
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
type: "store_jwt",
|
|
592
|
+
jwt,
|
|
593
|
+
...expiresAtSec === void 0 ? {} : { expiresAtSec }
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
case "get_jwt":
|
|
597
|
+
return { type: "get_jwt" };
|
|
598
|
+
case "clear_jwt":
|
|
599
|
+
return { type: "clear_jwt" };
|
|
600
|
+
default:
|
|
601
|
+
return {
|
|
602
|
+
type: "error",
|
|
603
|
+
code: "unsupported_type",
|
|
604
|
+
message: `unsupported request type: ${String(obj.type)}`
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function serializeResponse(res) {
|
|
609
|
+
return JSON.stringify(res) + "\n";
|
|
610
|
+
}
|
|
611
|
+
var ViemSigner = class {
|
|
612
|
+
account;
|
|
613
|
+
constructor(privateKey) {
|
|
614
|
+
this.account = accounts.privateKeyToAccount(privateKey);
|
|
615
|
+
}
|
|
616
|
+
get address() {
|
|
617
|
+
return this.account.address;
|
|
618
|
+
}
|
|
619
|
+
async signHash(hash) {
|
|
620
|
+
return this.account.sign({ hash });
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// src/broker/daemon.ts
|
|
625
|
+
var noopLogger = (_e) => {
|
|
626
|
+
};
|
|
627
|
+
async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3)) {
|
|
628
|
+
switch (req.type) {
|
|
629
|
+
case "hello": {
|
|
630
|
+
let hasJwt = false;
|
|
631
|
+
try {
|
|
632
|
+
const record = await keystore.get();
|
|
633
|
+
hasJwt = record !== null;
|
|
634
|
+
} catch {
|
|
635
|
+
hasJwt = false;
|
|
636
|
+
}
|
|
637
|
+
return {
|
|
638
|
+
type: "hello",
|
|
639
|
+
version: BROKER_PROTOCOL_VERSION,
|
|
640
|
+
sessionKeyAddress: signer.address,
|
|
641
|
+
hasJwt
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
case "sign_hash": {
|
|
645
|
+
const signature = await signer.signHash(req.hash);
|
|
646
|
+
return { type: "sign_hash", signature, signerAddress: signer.address };
|
|
647
|
+
}
|
|
648
|
+
case "store_jwt": {
|
|
649
|
+
try {
|
|
650
|
+
await keystore.set({
|
|
651
|
+
jwt: req.jwt,
|
|
652
|
+
expiresAtSec: req.expiresAtSec ?? null,
|
|
653
|
+
storedAtSec: nowSec()
|
|
654
|
+
});
|
|
655
|
+
return { type: "store_jwt", stored: true };
|
|
656
|
+
} catch (err) {
|
|
657
|
+
return errorResponse(
|
|
658
|
+
"keystore_unavailable",
|
|
659
|
+
err instanceof Error ? err.message : "keystore write failed"
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
case "get_jwt": {
|
|
664
|
+
try {
|
|
665
|
+
const record = await keystore.get();
|
|
666
|
+
return {
|
|
667
|
+
type: "get_jwt",
|
|
668
|
+
jwt: record?.jwt ?? null,
|
|
669
|
+
expiresAtSec: record?.expiresAtSec ?? null
|
|
670
|
+
};
|
|
671
|
+
} catch (err) {
|
|
672
|
+
return errorResponse(
|
|
673
|
+
"keystore_unavailable",
|
|
674
|
+
err instanceof Error ? err.message : "keystore read failed"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
case "clear_jwt": {
|
|
679
|
+
try {
|
|
680
|
+
await keystore.clear();
|
|
681
|
+
return { type: "clear_jwt", cleared: true };
|
|
682
|
+
} catch (err) {
|
|
683
|
+
return errorResponse(
|
|
684
|
+
"keystore_unavailable",
|
|
685
|
+
err instanceof Error ? err.message : "keystore clear failed"
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function errorResponse(code, message) {
|
|
692
|
+
return { type: "error", code, message };
|
|
693
|
+
}
|
|
694
|
+
async function prepareEndpoint(endpoint) {
|
|
695
|
+
if (os.platform() === "win32") return;
|
|
696
|
+
const parent = path.dirname(endpoint);
|
|
697
|
+
await promises.mkdir(parent, { recursive: true, mode: 448 });
|
|
698
|
+
await promises.chmod(parent, 448);
|
|
699
|
+
try {
|
|
700
|
+
const s = await promises.stat(endpoint);
|
|
701
|
+
if (s.isSocket() || s.isFIFO()) {
|
|
702
|
+
await promises.unlink(endpoint);
|
|
703
|
+
}
|
|
704
|
+
} catch (err) {
|
|
705
|
+
if (err.code !== "ENOENT") throw err;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async function applySocketPermissions(endpoint) {
|
|
709
|
+
if (os.platform() === "win32") return;
|
|
710
|
+
await promises.chmod(endpoint, 384);
|
|
711
|
+
}
|
|
712
|
+
var BrokerDaemon = class {
|
|
713
|
+
server;
|
|
714
|
+
signer;
|
|
715
|
+
log;
|
|
716
|
+
config;
|
|
717
|
+
keystore;
|
|
718
|
+
constructor(options) {
|
|
719
|
+
this.config = options.config;
|
|
720
|
+
this.signer = options.signer ?? new ViemSigner(options.config.sessionKeyHex);
|
|
721
|
+
this.keystore = options.keystore ?? null;
|
|
722
|
+
this.log = options.logger ?? noopLogger;
|
|
723
|
+
this.server = net.createServer((socket) => this.onConnection(socket));
|
|
724
|
+
}
|
|
725
|
+
async start() {
|
|
726
|
+
if (!this.keystore) {
|
|
727
|
+
const { keystore, fallbackReason } = await openKeystore();
|
|
728
|
+
this.keystore = keystore;
|
|
729
|
+
if (fallbackReason) {
|
|
730
|
+
this.log({
|
|
731
|
+
level: "warn",
|
|
732
|
+
msg: "OS keychain unavailable \u2014 falling back to file-backed keystore",
|
|
733
|
+
meta: { reason: fallbackReason }
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
await prepareEndpoint(this.config.endpoint);
|
|
738
|
+
await new Promise((resolve, reject) => {
|
|
739
|
+
const onError = (err) => {
|
|
740
|
+
this.server.off("listening", onListening);
|
|
741
|
+
reject(err);
|
|
742
|
+
};
|
|
743
|
+
const onListening = () => {
|
|
744
|
+
this.server.off("error", onError);
|
|
745
|
+
resolve();
|
|
746
|
+
};
|
|
747
|
+
this.server.once("error", onError);
|
|
748
|
+
this.server.once("listening", onListening);
|
|
749
|
+
this.server.listen(this.config.endpoint);
|
|
750
|
+
});
|
|
751
|
+
await applySocketPermissions(this.config.endpoint);
|
|
752
|
+
this.log({
|
|
753
|
+
level: "info",
|
|
754
|
+
msg: "broker daemon listening",
|
|
755
|
+
meta: {
|
|
756
|
+
endpoint: this.config.endpoint,
|
|
757
|
+
signer: this.signer.address,
|
|
758
|
+
keystore: this.keystore.backend,
|
|
759
|
+
version: BROKER_PROTOCOL_VERSION
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
return this.config.endpoint;
|
|
763
|
+
}
|
|
764
|
+
async stop() {
|
|
765
|
+
await new Promise((resolve, reject) => {
|
|
766
|
+
this.server.close((err) => err ? reject(err) : resolve());
|
|
767
|
+
});
|
|
768
|
+
if (os.platform() !== "win32") {
|
|
769
|
+
try {
|
|
770
|
+
await promises.unlink(this.config.endpoint);
|
|
771
|
+
} catch (err) {
|
|
772
|
+
if (err.code !== "ENOENT") {
|
|
773
|
+
this.log({ level: "warn", msg: "failed to unlink socket on stop", meta: { err } });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
this.log({ level: "info", msg: "broker daemon stopped" });
|
|
778
|
+
}
|
|
779
|
+
onConnection(socket) {
|
|
780
|
+
let buffer = "";
|
|
781
|
+
let bytesReceived = 0;
|
|
782
|
+
const timeout = setTimeout(() => {
|
|
783
|
+
this.log({ level: "warn", msg: "connection timeout \u2014 closing socket" });
|
|
784
|
+
socket.destroy();
|
|
785
|
+
}, this.config.requestTimeoutMs);
|
|
786
|
+
const cleanup = () => {
|
|
787
|
+
clearTimeout(timeout);
|
|
788
|
+
socket.removeAllListeners("data");
|
|
789
|
+
socket.removeAllListeners("end");
|
|
790
|
+
socket.removeAllListeners("error");
|
|
791
|
+
};
|
|
792
|
+
socket.on("data", (chunk) => {
|
|
793
|
+
bytesReceived += chunk.length;
|
|
794
|
+
if (bytesReceived > this.config.maxRequestBytes) {
|
|
795
|
+
const res = errorResponse("payload_too_large", "request exceeded maxRequestBytes");
|
|
796
|
+
socket.end(serializeResponse(res));
|
|
797
|
+
cleanup();
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
buffer += chunk.toString("utf8");
|
|
801
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
802
|
+
if (newlineIdx < 0) return;
|
|
803
|
+
const trailing = buffer.slice(newlineIdx + 1);
|
|
804
|
+
if (trailing.length > 0) {
|
|
805
|
+
const res = errorResponse(
|
|
806
|
+
"invalid_request",
|
|
807
|
+
"broker is single-shot \u2014 extra bytes after first newline"
|
|
808
|
+
);
|
|
809
|
+
socket.end(serializeResponse(res));
|
|
810
|
+
cleanup();
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const line = buffer.slice(0, newlineIdx);
|
|
814
|
+
const parsed = parseBrokerRequest(line);
|
|
815
|
+
void this.runAndRespond(parsed, socket).finally(() => {
|
|
816
|
+
cleanup();
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
socket.on("error", (err) => {
|
|
820
|
+
this.log({ level: "warn", msg: "socket error", meta: { err: err.message } });
|
|
821
|
+
cleanup();
|
|
822
|
+
});
|
|
823
|
+
socket.on("end", () => {
|
|
824
|
+
cleanup();
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
async runAndRespond(parsed, socket) {
|
|
828
|
+
if ("type" in parsed && parsed.type === "error") {
|
|
829
|
+
socket.end(serializeResponse(parsed));
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (!this.keystore) {
|
|
833
|
+
socket.end(
|
|
834
|
+
serializeResponse(errorResponse("internal", "keystore not initialized"))
|
|
835
|
+
);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
const res = await handleBrokerRequest(parsed, this.signer, this.keystore);
|
|
840
|
+
socket.end(serializeResponse(res));
|
|
841
|
+
} catch (err) {
|
|
842
|
+
this.log({
|
|
843
|
+
level: "error",
|
|
844
|
+
msg: "handler failed",
|
|
845
|
+
meta: { err: err instanceof Error ? err.message : String(err) }
|
|
846
|
+
});
|
|
847
|
+
socket.end(
|
|
848
|
+
serializeResponse(errorResponse("internal", "broker handler failed; check broker logs"))
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
async function runBrokerDaemonCli() {
|
|
854
|
+
const config = loadBrokerConfig();
|
|
855
|
+
const daemon = new BrokerDaemon({
|
|
856
|
+
config,
|
|
857
|
+
logger: (e) => {
|
|
858
|
+
const line = JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), ...e });
|
|
859
|
+
process.stderr.write(line + "\n");
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
await daemon.start();
|
|
863
|
+
const shutdown = async () => {
|
|
864
|
+
await daemon.stop();
|
|
865
|
+
process.exit(0);
|
|
866
|
+
};
|
|
867
|
+
process.on("SIGINT", () => void shutdown());
|
|
868
|
+
process.on("SIGTERM", () => void shutdown());
|
|
869
|
+
await new Promise(() => {
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/broker/cli.ts
|
|
874
|
+
function print(line) {
|
|
875
|
+
process.stdout.write(line + "\n");
|
|
876
|
+
}
|
|
877
|
+
function printErr(line) {
|
|
878
|
+
process.stderr.write(line + "\n");
|
|
879
|
+
}
|
|
880
|
+
function detectMcpHost() {
|
|
881
|
+
return process.env.MCP_HOST_NAME ?? process.env.CLAUDE_CODE_HOST ?? process.env.npm_lifecycle_event ?? "muhaven-broker-cli";
|
|
882
|
+
}
|
|
883
|
+
function detectEnvironment() {
|
|
884
|
+
const warnings = [];
|
|
885
|
+
const isWsl = os.platform() === "linux" && (process.env.WSL_DISTRO_NAME !== void 0 || /microsoft/i.test(os.release()));
|
|
886
|
+
if (isWsl) {
|
|
887
|
+
warnings.push("WSL2 detected \u2014 Secret Service is usually absent. Use MUHAVEN_KEYRING=file.");
|
|
888
|
+
}
|
|
889
|
+
if (process.env.REMOTE_CONTAINERS === "true" || process.env.CODESPACES === "true") {
|
|
890
|
+
warnings.push(
|
|
891
|
+
"devcontainer / Codespace detected \u2014 keystore in container FS is ephemeral on rebuild."
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
if (process.env.SSH_CONNECTION) {
|
|
895
|
+
warnings.push("SSH session detected \u2014 D-Bus / Secret Service is typically unavailable.");
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
kind: isWsl ? "linux/wsl2" : process.env.REMOTE_CONTAINERS === "true" ? "devcontainer" : process.env.CODESPACES === "true" ? "codespace" : `${os.platform()}/${os.release()}`,
|
|
899
|
+
warnings
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function parseLoginFlags(argv) {
|
|
903
|
+
let noLaunchBrowser = false;
|
|
904
|
+
let brokerEndpoint;
|
|
905
|
+
let backendBaseUrl;
|
|
906
|
+
let dashboardBaseUrl;
|
|
907
|
+
for (let i = 0; i < argv.length; i++) {
|
|
908
|
+
const a = argv[i];
|
|
909
|
+
if (a === "--no-launch-browser") noLaunchBrowser = true;
|
|
910
|
+
else if (a === "--broker-endpoint" && i + 1 < argv.length) {
|
|
911
|
+
brokerEndpoint = argv[++i];
|
|
912
|
+
} else if (a === "--backend-base-url" && i + 1 < argv.length) {
|
|
913
|
+
backendBaseUrl = argv[++i];
|
|
914
|
+
} else if (a === "--dashboard-base-url" && i + 1 < argv.length) {
|
|
915
|
+
dashboardBaseUrl = argv[++i];
|
|
916
|
+
} else {
|
|
917
|
+
throw new Error(`unknown flag: ${a}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return { noLaunchBrowser, brokerEndpoint, backendBaseUrl, dashboardBaseUrl };
|
|
921
|
+
}
|
|
922
|
+
async function tryLaunchBrowser(url) {
|
|
923
|
+
return new Promise((resolve) => {
|
|
924
|
+
const cmd = os.platform() === "win32" ? `cmd /c start "" "${url}"` : os.platform() === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
925
|
+
child_process.exec(cmd, (err) => resolve(err == null));
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
async function runLogin(argv) {
|
|
929
|
+
let flags;
|
|
930
|
+
try {
|
|
931
|
+
flags = parseLoginFlags(argv);
|
|
932
|
+
} catch (err) {
|
|
933
|
+
printErr(`error: ${err.message}`);
|
|
934
|
+
printErr("usage: muhaven-broker login [--no-launch-browser] [--broker-endpoint PATH] [--backend-base-url URL] [--dashboard-base-url URL]");
|
|
935
|
+
return 2;
|
|
936
|
+
}
|
|
937
|
+
const env = process.env;
|
|
938
|
+
const config = loadMcpConfig({
|
|
939
|
+
...env,
|
|
940
|
+
...flags.brokerEndpoint ? { MUHAVEN_BROKER_ENDPOINT: flags.brokerEndpoint } : {},
|
|
941
|
+
...flags.backendBaseUrl ? { MUHAVEN_BACKEND_URL: flags.backendBaseUrl } : {},
|
|
942
|
+
...flags.dashboardBaseUrl ? { MUHAVEN_DASHBOARD_URL: flags.dashboardBaseUrl } : {}
|
|
943
|
+
});
|
|
944
|
+
const broker = new BrokerClient({
|
|
945
|
+
endpoint: config.brokerEndpoint,
|
|
946
|
+
timeoutMs: config.brokerTimeoutMs
|
|
947
|
+
});
|
|
948
|
+
try {
|
|
949
|
+
await broker.hello();
|
|
950
|
+
} catch (err) {
|
|
951
|
+
printErr(
|
|
952
|
+
`cannot reach muhaven-broker daemon at ${config.brokerEndpoint}: ${err.message}`
|
|
953
|
+
);
|
|
954
|
+
printErr("hint: start the daemon first (`muhaven-broker` with no subcommand).");
|
|
955
|
+
return 1;
|
|
956
|
+
}
|
|
957
|
+
const flow = new DeviceFlowClient({
|
|
958
|
+
backendBaseUrl: config.backendBaseUrl,
|
|
959
|
+
dashboardBaseUrl: config.dashboardBaseUrl,
|
|
960
|
+
requesterMetadata: {
|
|
961
|
+
processName: detectMcpHost(),
|
|
962
|
+
hostname: os.hostname(),
|
|
963
|
+
os: `${os.platform()}/${os.release()}`
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
let lastIssuedSec = 0;
|
|
967
|
+
try {
|
|
968
|
+
const generator = flow.run();
|
|
969
|
+
let result;
|
|
970
|
+
while (true) {
|
|
971
|
+
const next = await generator.next();
|
|
972
|
+
if (next.done) {
|
|
973
|
+
result = next.value;
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
const event = next.value;
|
|
977
|
+
switch (event.type) {
|
|
978
|
+
case "code_issued":
|
|
979
|
+
lastIssuedSec = Date.now();
|
|
980
|
+
print("");
|
|
981
|
+
print(`To link this Claude / Cursor / Claude Code install to MuHaven:`);
|
|
982
|
+
print("");
|
|
983
|
+
print(` 1. Open ${event.code.verificationUriComplete}`);
|
|
984
|
+
print(` 2. Verify the device fingerprint shown on that page`);
|
|
985
|
+
print(` 3. Authorize with your passkey`);
|
|
986
|
+
print("");
|
|
987
|
+
print(`Code expires in ${event.code.expiresInSec}s.`);
|
|
988
|
+
if (!flags.noLaunchBrowser) {
|
|
989
|
+
await tryLaunchBrowser(event.code.verificationUriComplete);
|
|
990
|
+
}
|
|
991
|
+
print("Waiting for authorization\u2026");
|
|
992
|
+
break;
|
|
993
|
+
case "polling":
|
|
994
|
+
void event;
|
|
995
|
+
break;
|
|
996
|
+
case "denied":
|
|
997
|
+
printErr(`device authorization DENIED${event.reason ? `: ${event.reason}` : ""}`);
|
|
998
|
+
break;
|
|
999
|
+
case "expired":
|
|
1000
|
+
printErr("device code expired \u2014 re-run `muhaven-broker login` to issue a new one");
|
|
1001
|
+
break;
|
|
1002
|
+
case "authorized":
|
|
1003
|
+
print(`Authorized in ${Math.round((Date.now() - lastIssuedSec) / 1e3)}s.`);
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (!result) return 1;
|
|
1008
|
+
await broker.storeJwt(result.jwt, result.expiresAtSec ?? void 0);
|
|
1009
|
+
print("JWT stored in keystore. MuHaven MCP tools will use it on next call.");
|
|
1010
|
+
return 0;
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
if (err instanceof DeviceFlowAbortedError) {
|
|
1013
|
+
printErr(`device flow aborted: ${err.detail.code}`);
|
|
1014
|
+
return 1;
|
|
1015
|
+
}
|
|
1016
|
+
printErr(`unexpected error: ${err.message}`);
|
|
1017
|
+
return 1;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
async function runLogout() {
|
|
1021
|
+
const config = loadMcpConfig();
|
|
1022
|
+
const broker = new BrokerClient({
|
|
1023
|
+
endpoint: config.brokerEndpoint,
|
|
1024
|
+
timeoutMs: config.brokerTimeoutMs
|
|
1025
|
+
});
|
|
1026
|
+
try {
|
|
1027
|
+
await broker.clearJwt();
|
|
1028
|
+
print("JWT cleared from keystore.");
|
|
1029
|
+
return 0;
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
printErr(`logout failed: ${err.message}`);
|
|
1032
|
+
return 1;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async function runDoctor() {
|
|
1036
|
+
print("muhaven-broker doctor");
|
|
1037
|
+
print("=====================");
|
|
1038
|
+
const env = detectEnvironment();
|
|
1039
|
+
print(`Environment : ${env.kind}`);
|
|
1040
|
+
for (const w of env.warnings) print(` warning: ${w}`);
|
|
1041
|
+
print(`Default endpoint : ${defaultBrokerEndpoint()}`);
|
|
1042
|
+
print(`Configured endpoint: ${process.env.MUHAVEN_BROKER_ENDPOINT ?? defaultBrokerEndpoint()}`);
|
|
1043
|
+
print(`Backend URL : ${process.env.MUHAVEN_BACKEND_URL ?? "(default https://api.muhaven.app)"}`);
|
|
1044
|
+
print(`Dashboard URL : ${process.env.MUHAVEN_DASHBOARD_URL ?? "(default https://muhaven.app)"}`);
|
|
1045
|
+
const wantFile = process.env.MUHAVEN_KEYRING?.toLowerCase() === "file";
|
|
1046
|
+
print(`Keystore preference: ${wantFile ? "file (env override)" : "auto (OS keychain \u2192 file fallback)"}`);
|
|
1047
|
+
const { keystore, fallbackReason } = await openKeystore();
|
|
1048
|
+
print(`Keystore backend : ${keystore.backend}${fallbackReason ? ` (fell back: ${fallbackReason})` : ""}`);
|
|
1049
|
+
try {
|
|
1050
|
+
const original = await keystore.get();
|
|
1051
|
+
if (original) {
|
|
1052
|
+
print(`Keystore round-trip: ok (existing JWT not disturbed)`);
|
|
1053
|
+
} else {
|
|
1054
|
+
const sentinel = { jwt: "__doctor_sentinel__.x.y", expiresAtSec: null, storedAtSec: 0 };
|
|
1055
|
+
await keystore.set(sentinel);
|
|
1056
|
+
const read = await keystore.get();
|
|
1057
|
+
await keystore.clear();
|
|
1058
|
+
if (read?.jwt !== sentinel.jwt) {
|
|
1059
|
+
print(`Keystore round-trip: FAILED (wrote sentinel, read back ${read?.jwt ?? "null"})`);
|
|
1060
|
+
} else {
|
|
1061
|
+
print(`Keystore round-trip: ok`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
print(`Keystore round-trip: FAILED (${err instanceof Error ? err.message : String(err)})`);
|
|
1066
|
+
}
|
|
1067
|
+
const config = loadMcpConfig();
|
|
1068
|
+
const broker = new BrokerClient({
|
|
1069
|
+
endpoint: config.brokerEndpoint,
|
|
1070
|
+
timeoutMs: config.brokerTimeoutMs
|
|
1071
|
+
});
|
|
1072
|
+
try {
|
|
1073
|
+
const h = await broker.hello();
|
|
1074
|
+
print(`Broker daemon : reachable (proto v${h.version}, signer ${h.sessionKeyAddress}, hasJwt=${h.hasJwt})`);
|
|
1075
|
+
return 0;
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
print(`Broker daemon : NOT reachable (${err.message})`);
|
|
1078
|
+
print(" hint: start it with `muhaven-broker` (no subcommand)");
|
|
1079
|
+
return 1;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
function printUsage() {
|
|
1083
|
+
print("usage: muhaven-broker [<subcommand>] [options]");
|
|
1084
|
+
print("");
|
|
1085
|
+
print(" (no subcommand) Run the daemon (production mode)");
|
|
1086
|
+
print(" login Acquire a JWT via the device-code flow + store in keystore");
|
|
1087
|
+
print(" logout Clear the JWT from the keystore");
|
|
1088
|
+
print(" doctor Print environment + keystore + reachability report");
|
|
1089
|
+
print(" -h, --help Show this help");
|
|
1090
|
+
}
|
|
1091
|
+
async function runCli(argv) {
|
|
1092
|
+
const [sub, ...rest] = argv;
|
|
1093
|
+
switch (sub) {
|
|
1094
|
+
case void 0:
|
|
1095
|
+
await runBrokerDaemonCli();
|
|
1096
|
+
return 0;
|
|
1097
|
+
case "login":
|
|
1098
|
+
return runLogin(rest);
|
|
1099
|
+
case "logout":
|
|
1100
|
+
return runLogout();
|
|
1101
|
+
case "doctor":
|
|
1102
|
+
return runDoctor();
|
|
1103
|
+
case "-h":
|
|
1104
|
+
case "--help":
|
|
1105
|
+
printUsage();
|
|
1106
|
+
return 0;
|
|
1107
|
+
default:
|
|
1108
|
+
printErr(`unknown subcommand: ${sub}`);
|
|
1109
|
+
printUsage();
|
|
1110
|
+
return 2;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
exports.runCli = runCli;
|
|
1115
|
+
exports.runDoctor = runDoctor;
|
|
1116
|
+
exports.runLogin = runLogin;
|
|
1117
|
+
exports.runLogout = runLogout;
|