@muhaven/mcp 0.2.9 → 0.4.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 +111 -0
- package/dist/broker.cjs +887 -41
- package/dist/broker.d.cts +31 -1
- package/dist/broker.d.ts +31 -1
- package/dist/broker.js +887 -42
- package/dist/index.cjs +735 -14
- package/dist/index.d.cts +301 -14
- package/dist/index.d.ts +301 -14
- package/dist/index.js +736 -15
- package/manifest.json +1 -1
- package/package.json +2 -1
package/dist/broker.js
CHANGED
|
@@ -2,10 +2,12 @@ import path, { join, dirname, resolve } from 'path';
|
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { platform, release, hostname, homedir } from 'os';
|
|
4
4
|
import { exec, spawn } from 'child_process';
|
|
5
|
+
import { createInterface } from 'readline';
|
|
5
6
|
import { connect, createServer } from 'net';
|
|
6
7
|
import { mkdir, chmod, writeFile, readFile, unlink, rename, readdir, stat } from 'fs/promises';
|
|
7
8
|
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
|
|
8
9
|
import { randomBytes } from 'crypto';
|
|
10
|
+
import { parseAbi, encodeFunctionData, decodeAbiParameters } from 'viem';
|
|
9
11
|
|
|
10
12
|
var getFilename = () => fileURLToPath(import.meta.url);
|
|
11
13
|
var getDirname = () => path.dirname(getFilename());
|
|
@@ -163,22 +165,45 @@ function loadBrokerConfig(env = process.env) {
|
|
|
163
165
|
env.MUHAVEN_DASHBOARD_URL,
|
|
164
166
|
DEFAULT_DASHBOARD_URL
|
|
165
167
|
);
|
|
168
|
+
const chainRpcUrlRaw = readEnv("MUHAVEN_BROKER_RPC_URL", env) ?? readEnv("MUHAVEN_BUNDLER_URL", env);
|
|
169
|
+
const chainRpcUrl = chainRpcUrlRaw === void 0 ? void 0 : resolvePublicUrlEnv(
|
|
170
|
+
"MUHAVEN_BROKER_RPC_URL",
|
|
171
|
+
chainRpcUrlRaw,
|
|
172
|
+
chainRpcUrlRaw
|
|
173
|
+
);
|
|
174
|
+
const callbackServiceSecretRaw = readEnv("BROKER_CALLBACK_SERVICE_SECRET", env);
|
|
175
|
+
let callbackServiceSecret;
|
|
176
|
+
if (callbackServiceSecretRaw !== void 0) {
|
|
177
|
+
if (callbackServiceSecretRaw.length < 16) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
"BROKER_CALLBACK_SERVICE_SECRET must be at least 16 characters (matches backend with-service-secret middleware floor)"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
callbackServiceSecret = callbackServiceSecretRaw;
|
|
183
|
+
}
|
|
184
|
+
const outboundOriginHeader = readEnv("MUHAVEN_BROKER_ORIGIN", env) ?? dashboardBaseUrl;
|
|
166
185
|
return {
|
|
167
186
|
endpoint,
|
|
168
187
|
sessionKeyHex,
|
|
169
188
|
maxRequestBytes,
|
|
170
189
|
requestTimeoutMs,
|
|
171
190
|
backendBaseUrl,
|
|
172
|
-
dashboardBaseUrl
|
|
191
|
+
dashboardBaseUrl,
|
|
192
|
+
chainRpcUrl,
|
|
193
|
+
callbackServiceSecret,
|
|
194
|
+
outboundOriginHeader
|
|
173
195
|
};
|
|
174
196
|
}
|
|
175
197
|
|
|
176
198
|
// src/broker/protocol.ts
|
|
177
|
-
var BROKER_PROTOCOL_VERSION = "0.
|
|
199
|
+
var BROKER_PROTOCOL_VERSION = "0.5.0";
|
|
178
200
|
var HASH_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
179
201
|
var ADDRESS_HEX_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
180
202
|
var SELECTOR_HEX_RE = /^0x[0-9a-fA-F]{8}$/;
|
|
181
203
|
var HEX_PREFIXED_RE = /^0x[0-9a-fA-F]*$/;
|
|
204
|
+
var ENABLE_DATA_HEX_RE = /^0x[0-9a-fA-F]{2,65536}$/;
|
|
205
|
+
var ENABLE_SIG_HEX_RE = /^0x[0-9a-fA-F]{256,16384}$/;
|
|
206
|
+
var UINT32_MAX = 4294967295;
|
|
182
207
|
var JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
183
208
|
var SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
184
209
|
var UINT256_DEC_RE = /^(0|[1-9][0-9]{0,77})$/;
|
|
@@ -298,6 +323,43 @@ function parsePolicySnapshot(raw) {
|
|
|
298
323
|
if (!isOptionalPermissionId(obj.permissionId)) {
|
|
299
324
|
return { error: "snapshot.permissionId must be a 0x-prefixed 4-byte hex when provided" };
|
|
300
325
|
}
|
|
326
|
+
const enableData = obj.enableData;
|
|
327
|
+
const enableSig = obj.enableSig;
|
|
328
|
+
const validatorNonce = obj.validatorNonce;
|
|
329
|
+
const installPresent = [enableData, enableSig, validatorNonce].filter(
|
|
330
|
+
(v) => v !== void 0
|
|
331
|
+
).length;
|
|
332
|
+
if (installPresent !== 0 && installPresent !== 3) {
|
|
333
|
+
return {
|
|
334
|
+
error: "snapshot.{enableData,enableSig,validatorNonce} must be all-present or all-absent (Option D Commit 3 install-material trio)"
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (enableData !== void 0) {
|
|
338
|
+
if (typeof enableData !== "string" || !ENABLE_DATA_HEX_RE.test(enableData)) {
|
|
339
|
+
return {
|
|
340
|
+
error: "snapshot.enableData must be a 0x-prefixed hex string of 2..65536 chars when provided"
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (enableSig !== void 0) {
|
|
345
|
+
if (typeof enableSig !== "string" || !ENABLE_SIG_HEX_RE.test(enableSig)) {
|
|
346
|
+
return {
|
|
347
|
+
error: "snapshot.enableSig must be a 0x-prefixed hex string of 256..16384 chars (WebAuthn envelope) when provided"
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (validatorNonce !== void 0) {
|
|
352
|
+
if (typeof validatorNonce !== "number" || !Number.isInteger(validatorNonce) || validatorNonce < 0 || validatorNonce > UINT32_MAX) {
|
|
353
|
+
return {
|
|
354
|
+
error: "snapshot.validatorNonce must be an integer in [0, 2^32-1] when provided"
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (installPresent === 3 && obj.permissionId === void 0) {
|
|
359
|
+
return {
|
|
360
|
+
error: "snapshot install material requires permissionId in the same snapshot"
|
|
361
|
+
};
|
|
362
|
+
}
|
|
301
363
|
return {
|
|
302
364
|
sessionId: obj.sessionId,
|
|
303
365
|
mode: "scoped",
|
|
@@ -310,7 +372,10 @@ function parsePolicySnapshot(raw) {
|
|
|
310
372
|
mintedAtSec: obj.mintedAtSec,
|
|
311
373
|
...obj.consentActionHash === void 0 ? {} : { consentActionHash: obj.consentActionHash.toLowerCase() },
|
|
312
374
|
...obj.consentTextSha256 === void 0 ? {} : { consentTextSha256: obj.consentTextSha256.toLowerCase() },
|
|
313
|
-
...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() }
|
|
375
|
+
...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() },
|
|
376
|
+
...enableData === void 0 ? {} : { enableData: enableData.toLowerCase() },
|
|
377
|
+
...enableSig === void 0 ? {} : { enableSig: enableSig.toLowerCase() },
|
|
378
|
+
...validatorNonce === void 0 ? {} : { validatorNonce }
|
|
314
379
|
};
|
|
315
380
|
}
|
|
316
381
|
function parseBrokerRequest(line) {
|
|
@@ -489,6 +554,72 @@ function parseBrokerRequest(line) {
|
|
|
489
554
|
}
|
|
490
555
|
case "get_active_session_id":
|
|
491
556
|
return { type: "get_active_session_id" };
|
|
557
|
+
case "current_nonce": {
|
|
558
|
+
if (!isAddressHex(obj.accountAddress)) {
|
|
559
|
+
return {
|
|
560
|
+
type: "error",
|
|
561
|
+
code: "invalid_request",
|
|
562
|
+
message: "current_nonce.accountAddress must be a 0x-prefixed 20-byte hex"
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
type: "current_nonce",
|
|
567
|
+
accountAddress: obj.accountAddress.toLowerCase()
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
case "notify_userop_landed": {
|
|
571
|
+
if (!isSessionIdShape(obj.sessionId)) {
|
|
572
|
+
return {
|
|
573
|
+
type: "error",
|
|
574
|
+
code: "invalid_request",
|
|
575
|
+
message: "notify_userop_landed.sessionId must be 1-128 chars [A-Za-z0-9_-]"
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (!isAddressHex(obj.accountAddress)) {
|
|
579
|
+
return {
|
|
580
|
+
type: "error",
|
|
581
|
+
code: "invalid_request",
|
|
582
|
+
message: "notify_userop_landed.accountAddress must be a 0x-prefixed 20-byte hex"
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
if (!isSelectorHex(obj.permissionId)) {
|
|
586
|
+
return {
|
|
587
|
+
type: "error",
|
|
588
|
+
code: "invalid_request",
|
|
589
|
+
message: "notify_userop_landed.permissionId must be a 0x-prefixed 4-byte hex"
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
if (!isHashHex(obj.txHash)) {
|
|
593
|
+
return {
|
|
594
|
+
type: "error",
|
|
595
|
+
code: "invalid_request",
|
|
596
|
+
message: "notify_userop_landed.txHash must be a 0x-prefixed 32-byte hex"
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
if (typeof obj.blockNumber !== "number" || !Number.isFinite(obj.blockNumber) || !Number.isInteger(obj.blockNumber) || obj.blockNumber < 0) {
|
|
600
|
+
return {
|
|
601
|
+
type: "error",
|
|
602
|
+
code: "invalid_request",
|
|
603
|
+
message: "notify_userop_landed.blockNumber must be a non-negative integer"
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
if (typeof obj.logIndex !== "number" || !Number.isFinite(obj.logIndex) || !Number.isInteger(obj.logIndex) || obj.logIndex < 0) {
|
|
607
|
+
return {
|
|
608
|
+
type: "error",
|
|
609
|
+
code: "invalid_request",
|
|
610
|
+
message: "notify_userop_landed.logIndex must be a non-negative integer"
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
type: "notify_userop_landed",
|
|
615
|
+
sessionId: obj.sessionId,
|
|
616
|
+
accountAddress: obj.accountAddress.toLowerCase(),
|
|
617
|
+
permissionId: obj.permissionId.toLowerCase(),
|
|
618
|
+
txHash: obj.txHash.toLowerCase(),
|
|
619
|
+
blockNumber: obj.blockNumber,
|
|
620
|
+
logIndex: obj.logIndex
|
|
621
|
+
};
|
|
622
|
+
}
|
|
492
623
|
default:
|
|
493
624
|
return {
|
|
494
625
|
type: "error",
|
|
@@ -648,6 +779,33 @@ var BrokerClient = class {
|
|
|
648
779
|
}
|
|
649
780
|
return res;
|
|
650
781
|
}
|
|
782
|
+
// ── Wave 5 Option D Commit 3 — install-mode pre-check + callback notify ──
|
|
783
|
+
async currentNonce(accountAddress) {
|
|
784
|
+
const res = await this.exchange({ type: "current_nonce", accountAddress });
|
|
785
|
+
if (res.type !== "current_nonce") {
|
|
786
|
+
throw new BrokerClientError(
|
|
787
|
+
"protocol_error",
|
|
788
|
+
`expected current_nonce response, got ${res.type}`
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
if (res.accountAddress.toLowerCase() !== accountAddress.toLowerCase()) {
|
|
792
|
+
throw new BrokerClientError(
|
|
793
|
+
"protocol_error",
|
|
794
|
+
`current_nonce echo mismatch (requested ${accountAddress}, got ${res.accountAddress})`
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
return res;
|
|
798
|
+
}
|
|
799
|
+
async notifyUseropLanded(args) {
|
|
800
|
+
const res = await this.exchange({ type: "notify_userop_landed", ...args });
|
|
801
|
+
if (res.type !== "notify_userop_landed") {
|
|
802
|
+
throw new BrokerClientError(
|
|
803
|
+
"protocol_error",
|
|
804
|
+
`expected notify_userop_landed response, got ${res.type}`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
return res;
|
|
808
|
+
}
|
|
651
809
|
/**
|
|
652
810
|
* Detect whether the running daemon speaks Path D (protocol 0.4.0+).
|
|
653
811
|
* Wraps `hello()` with a semver-gte comparison so the MCP tool layer
|
|
@@ -1438,11 +1596,285 @@ function checkPolicy(input) {
|
|
|
1438
1596
|
}
|
|
1439
1597
|
return { ok: true };
|
|
1440
1598
|
}
|
|
1599
|
+
var KERNEL_V3_CURRENT_NONCE_ABI = parseAbi([
|
|
1600
|
+
"function currentNonce() view returns (uint32)"
|
|
1601
|
+
]);
|
|
1602
|
+
var ChainRpcError = class extends Error {
|
|
1603
|
+
constructor(message, cause) {
|
|
1604
|
+
super(message);
|
|
1605
|
+
this.cause = cause;
|
|
1606
|
+
this.name = "ChainRpcError";
|
|
1607
|
+
}
|
|
1608
|
+
cause;
|
|
1609
|
+
};
|
|
1610
|
+
var CallbackError = class extends Error {
|
|
1611
|
+
constructor(message, cause) {
|
|
1612
|
+
super(message);
|
|
1613
|
+
this.cause = cause;
|
|
1614
|
+
this.name = "CallbackError";
|
|
1615
|
+
}
|
|
1616
|
+
cause;
|
|
1617
|
+
};
|
|
1618
|
+
var CALLBACK_RETRY_SCHEDULE_MS = [
|
|
1619
|
+
5e3,
|
|
1620
|
+
15e3,
|
|
1621
|
+
6e4,
|
|
1622
|
+
5 * 6e4
|
|
1623
|
+
];
|
|
1624
|
+
var CALLBACK_MAX_ELAPSED_MS = 60 * 6e4;
|
|
1625
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
|
|
1626
|
+
var BrokerOutbound = class {
|
|
1627
|
+
constructor(config, log = () => {
|
|
1628
|
+
}) {
|
|
1629
|
+
this.config = config;
|
|
1630
|
+
this.log = log;
|
|
1631
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
1632
|
+
this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
1633
|
+
this.setTimeoutImpl = config.setTimeout ?? setTimeout;
|
|
1634
|
+
this.clearTimeoutImpl = config.clearTimeout ?? clearTimeout;
|
|
1635
|
+
}
|
|
1636
|
+
config;
|
|
1637
|
+
log;
|
|
1638
|
+
fetchImpl;
|
|
1639
|
+
fetchTimeoutMs;
|
|
1640
|
+
setTimeoutImpl;
|
|
1641
|
+
clearTimeoutImpl;
|
|
1642
|
+
/**
|
|
1643
|
+
* Wave 5 Option D Commit 3 (multi-agent review SecEng-MED-3) —
|
|
1644
|
+
* in-process dedup of `notify_userop_landed` callbacks. Map of
|
|
1645
|
+
* `<sessionId>:<txHash>` → the in-flight retry loop's Promise.
|
|
1646
|
+
* Repeated IPC calls with the same key fold into the existing
|
|
1647
|
+
* loop instead of spawning a parallel POST. Defends against a
|
|
1648
|
+
* local-socket peer flooding the broker with replay attempts +
|
|
1649
|
+
* caps the retry-budget waste at one loop per real install.
|
|
1650
|
+
*/
|
|
1651
|
+
inflightCallbacks = /* @__PURE__ */ new Map();
|
|
1652
|
+
/**
|
|
1653
|
+
* Read the kernel's `currentNonce()` view via `eth_call` against the
|
|
1654
|
+
* configured chain RPC. Returns a uint32. Throws `ChainRpcError` when
|
|
1655
|
+
* unconfigured / network failed / RPC returned non-decodable bytes.
|
|
1656
|
+
*/
|
|
1657
|
+
async currentNonce(accountAddress) {
|
|
1658
|
+
if (!this.config.chainRpcUrl) {
|
|
1659
|
+
throw new ChainRpcError(
|
|
1660
|
+
"broker chain RPC unconfigured \u2014 set MUHAVEN_BROKER_RPC_URL or MUHAVEN_BUNDLER_URL"
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
const data = encodeFunctionData({
|
|
1664
|
+
abi: KERNEL_V3_CURRENT_NONCE_ABI,
|
|
1665
|
+
functionName: "currentNonce"
|
|
1666
|
+
});
|
|
1667
|
+
const body = JSON.stringify({
|
|
1668
|
+
jsonrpc: "2.0",
|
|
1669
|
+
id: 1,
|
|
1670
|
+
method: "eth_call",
|
|
1671
|
+
params: [{ to: accountAddress, data }, "latest"]
|
|
1672
|
+
});
|
|
1673
|
+
let res;
|
|
1674
|
+
const ac = new AbortController();
|
|
1675
|
+
const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
|
|
1676
|
+
try {
|
|
1677
|
+
res = await this.fetchImpl(this.config.chainRpcUrl, {
|
|
1678
|
+
method: "POST",
|
|
1679
|
+
headers: {
|
|
1680
|
+
"Content-Type": "application/json",
|
|
1681
|
+
Accept: "application/json",
|
|
1682
|
+
Origin: this.config.outboundOriginHeader
|
|
1683
|
+
},
|
|
1684
|
+
body,
|
|
1685
|
+
signal: ac.signal
|
|
1686
|
+
});
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
throw new ChainRpcError(
|
|
1689
|
+
`chain RPC fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1690
|
+
err
|
|
1691
|
+
);
|
|
1692
|
+
} finally {
|
|
1693
|
+
this.clearTimeoutImpl(timer);
|
|
1694
|
+
}
|
|
1695
|
+
if (!res.ok) {
|
|
1696
|
+
throw new ChainRpcError(
|
|
1697
|
+
`chain RPC returned HTTP ${res.status}`
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
let parsed;
|
|
1701
|
+
try {
|
|
1702
|
+
parsed = await res.json();
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
throw new ChainRpcError(
|
|
1705
|
+
`chain RPC returned non-JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
if (parsed.error) {
|
|
1709
|
+
throw new ChainRpcError(
|
|
1710
|
+
`chain RPC error: ${parsed.error.message ?? "unknown"}`
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
if (typeof parsed.result !== "string" || !/^0x[0-9a-fA-F]*$/.test(parsed.result)) {
|
|
1714
|
+
throw new ChainRpcError(
|
|
1715
|
+
`chain RPC returned non-hex result: ${JSON.stringify(parsed.result).slice(0, 80)}`
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
let nonce;
|
|
1719
|
+
try {
|
|
1720
|
+
const decoded = decodeAbiParameters(
|
|
1721
|
+
[{ type: "uint32" }],
|
|
1722
|
+
parsed.result
|
|
1723
|
+
);
|
|
1724
|
+
nonce = Number(decoded[0]);
|
|
1725
|
+
} catch (err) {
|
|
1726
|
+
throw new ChainRpcError(
|
|
1727
|
+
`failed to decode currentNonce result: ${err instanceof Error ? err.message : String(err)}`
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
if (!Number.isFinite(nonce) || nonce < 0 || nonce > 4294967295) {
|
|
1731
|
+
throw new ChainRpcError(`currentNonce out of uint32 range: ${nonce}`);
|
|
1732
|
+
}
|
|
1733
|
+
return nonce;
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Whether the callback path is wired (both secret + backend URL set).
|
|
1737
|
+
* The `notify_userop_landed` daemon handler checks this and returns
|
|
1738
|
+
* `callback_unconfigured` when false so the operator sees the gap.
|
|
1739
|
+
*/
|
|
1740
|
+
isCallbackConfigured() {
|
|
1741
|
+
return Boolean(this.config.callbackServiceSecret) && Boolean(this.config.backendBaseUrl);
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Queue a `validator-enabled` callback POST to the backend. Returns
|
|
1745
|
+
* immediately; the retry loop runs in the background (5s / 15s / 60s
|
|
1746
|
+
* / 5m, max 1h elapsed). Failures are logged but do NOT propagate to
|
|
1747
|
+
* the IPC caller — the chain indexer is the authoritative safety
|
|
1748
|
+
* net.
|
|
1749
|
+
*
|
|
1750
|
+
* Idempotency: every POST carries an `Idempotency-Key` header
|
|
1751
|
+
* `<sessionId>:validator-enabled`. The backend route is no-op if the
|
|
1752
|
+
* mirror row's `enable_status` is already `'enabled'` (because the
|
|
1753
|
+
* chain indexer raced ahead).
|
|
1754
|
+
*
|
|
1755
|
+
* Returns a Promise resolved when the loop terminates (success or
|
|
1756
|
+
* max-elapsed). Callers don't need to await; tests use it for
|
|
1757
|
+
* deterministic assertions.
|
|
1758
|
+
*/
|
|
1759
|
+
enqueueValidatorEnabledCallback(args) {
|
|
1760
|
+
if (!this.isCallbackConfigured()) {
|
|
1761
|
+
return Promise.resolve({
|
|
1762
|
+
ok: false,
|
|
1763
|
+
attempts: 0,
|
|
1764
|
+
lastError: "callback_unconfigured"
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
const dedupKey = `${args.sessionId}:${args.txHash.toLowerCase()}:${args.accountAddress.toLowerCase()}`;
|
|
1768
|
+
const existing = this.inflightCallbacks.get(dedupKey);
|
|
1769
|
+
if (existing) {
|
|
1770
|
+
this.log("info", "validator-enabled callback already in flight \u2014 folded", {
|
|
1771
|
+
sessionId: args.sessionId
|
|
1772
|
+
});
|
|
1773
|
+
return existing;
|
|
1774
|
+
}
|
|
1775
|
+
const promise = this.runCallbackLoop(args).finally(() => {
|
|
1776
|
+
this.inflightCallbacks.delete(dedupKey);
|
|
1777
|
+
});
|
|
1778
|
+
this.inflightCallbacks.set(dedupKey, promise);
|
|
1779
|
+
return promise;
|
|
1780
|
+
}
|
|
1781
|
+
async runCallbackLoop(args) {
|
|
1782
|
+
const url = `${this.config.backendBaseUrl.replace(/\/+$/, "")}/api/v1/agent/policy/scoped-session/${encodeURIComponent(args.sessionId)}/validator-enabled`;
|
|
1783
|
+
const body = JSON.stringify({
|
|
1784
|
+
userId: args.userId,
|
|
1785
|
+
accountAddress: args.accountAddress,
|
|
1786
|
+
permissionId: args.permissionId,
|
|
1787
|
+
txHash: args.txHash,
|
|
1788
|
+
blockNumber: args.blockNumber,
|
|
1789
|
+
logIndex: args.logIndex
|
|
1790
|
+
});
|
|
1791
|
+
const startedAt = Date.now();
|
|
1792
|
+
let attempts = 0;
|
|
1793
|
+
let lastError;
|
|
1794
|
+
for (let i = 0; i <= CALLBACK_RETRY_SCHEDULE_MS.length; i++) {
|
|
1795
|
+
if (i > 0) {
|
|
1796
|
+
const delay = CALLBACK_RETRY_SCHEDULE_MS[i - 1] ?? 0;
|
|
1797
|
+
const elapsed = Date.now() - startedAt;
|
|
1798
|
+
if (elapsed + delay > CALLBACK_MAX_ELAPSED_MS) {
|
|
1799
|
+
lastError = `retry budget exhausted after ${attempts} attempts (last error: ${lastError ?? "unknown"})`;
|
|
1800
|
+
this.log("error", "validator-enabled callback abandoned", {
|
|
1801
|
+
sessionId: args.sessionId,
|
|
1802
|
+
attempts,
|
|
1803
|
+
lastError
|
|
1804
|
+
});
|
|
1805
|
+
return { ok: false, attempts, lastError };
|
|
1806
|
+
}
|
|
1807
|
+
await this.sleep(delay);
|
|
1808
|
+
}
|
|
1809
|
+
attempts++;
|
|
1810
|
+
try {
|
|
1811
|
+
const ok = await this.postCallback(url, body, args.sessionId);
|
|
1812
|
+
if (ok) {
|
|
1813
|
+
this.log("info", "validator-enabled callback succeeded", {
|
|
1814
|
+
sessionId: args.sessionId,
|
|
1815
|
+
attempts
|
|
1816
|
+
});
|
|
1817
|
+
return { ok: true, attempts };
|
|
1818
|
+
}
|
|
1819
|
+
lastError = "non-2xx response";
|
|
1820
|
+
} catch (err) {
|
|
1821
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
1822
|
+
this.log("warn", "validator-enabled callback attempt failed", {
|
|
1823
|
+
sessionId: args.sessionId,
|
|
1824
|
+
attempt: attempts,
|
|
1825
|
+
err: lastError
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
this.log("error", "validator-enabled callback retry budget exhausted", {
|
|
1830
|
+
sessionId: args.sessionId,
|
|
1831
|
+
attempts,
|
|
1832
|
+
lastError
|
|
1833
|
+
});
|
|
1834
|
+
return { ok: false, attempts, lastError };
|
|
1835
|
+
}
|
|
1836
|
+
async postCallback(url, body, sessionId) {
|
|
1837
|
+
const ac = new AbortController();
|
|
1838
|
+
const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
|
|
1839
|
+
let res;
|
|
1840
|
+
try {
|
|
1841
|
+
res = await this.fetchImpl(url, {
|
|
1842
|
+
method: "POST",
|
|
1843
|
+
headers: {
|
|
1844
|
+
"Content-Type": "application/json",
|
|
1845
|
+
Accept: "application/json",
|
|
1846
|
+
Authorization: `Bearer ${this.config.callbackServiceSecret}`,
|
|
1847
|
+
"Idempotency-Key": `${sessionId}:validator-enabled`,
|
|
1848
|
+
Origin: this.config.outboundOriginHeader
|
|
1849
|
+
},
|
|
1850
|
+
body,
|
|
1851
|
+
signal: ac.signal
|
|
1852
|
+
});
|
|
1853
|
+
} finally {
|
|
1854
|
+
this.clearTimeoutImpl(timer);
|
|
1855
|
+
}
|
|
1856
|
+
if (res.status === 409) {
|
|
1857
|
+
this.log("info", "callback returned 409 (row already enabled \u2014 idempotent)", {
|
|
1858
|
+
sessionId
|
|
1859
|
+
});
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
if (res.ok) return true;
|
|
1863
|
+
throw new CallbackError(
|
|
1864
|
+
`backend callback returned HTTP ${res.status}`
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
sleep(ms) {
|
|
1868
|
+
return new Promise((resolve) => {
|
|
1869
|
+
this.setTimeoutImpl(() => resolve(), ms);
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1441
1873
|
|
|
1442
1874
|
// src/broker/daemon.ts
|
|
1443
1875
|
var noopLogger = (_e) => {
|
|
1444
1876
|
};
|
|
1445
|
-
async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore) {
|
|
1877
|
+
async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore, outbound) {
|
|
1446
1878
|
switch (req.type) {
|
|
1447
1879
|
case "hello": {
|
|
1448
1880
|
let hasJwt = false;
|
|
@@ -1650,6 +2082,57 @@ async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.fl
|
|
|
1650
2082
|
);
|
|
1651
2083
|
}
|
|
1652
2084
|
}
|
|
2085
|
+
case "current_nonce": {
|
|
2086
|
+
if (!outbound) {
|
|
2087
|
+
return errorResponse(
|
|
2088
|
+
"chain_rpc_failed",
|
|
2089
|
+
"broker daemon was not configured with a chain RPC URL"
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
try {
|
|
2093
|
+
const nonce = await outbound.currentNonce(req.accountAddress);
|
|
2094
|
+
return {
|
|
2095
|
+
type: "current_nonce",
|
|
2096
|
+
nonce,
|
|
2097
|
+
accountAddress: req.accountAddress
|
|
2098
|
+
};
|
|
2099
|
+
} catch (err) {
|
|
2100
|
+
if (err instanceof ChainRpcError) {
|
|
2101
|
+
return errorResponse("chain_rpc_failed", err.message);
|
|
2102
|
+
}
|
|
2103
|
+
return errorResponse(
|
|
2104
|
+
"chain_rpc_failed",
|
|
2105
|
+
err instanceof Error ? err.message : "chain RPC eth_call failed"
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
case "notify_userop_landed": {
|
|
2110
|
+
if (!outbound) {
|
|
2111
|
+
return errorResponse(
|
|
2112
|
+
"callback_unconfigured",
|
|
2113
|
+
"broker daemon was not configured with the callback service secret"
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
if (!outbound.isCallbackConfigured()) {
|
|
2117
|
+
return errorResponse(
|
|
2118
|
+
"callback_unconfigured",
|
|
2119
|
+
"BROKER_CALLBACK_SERVICE_SECRET or backend URL is unset \u2014 validator-enabled callback skipped (chain indexer is the safety net)"
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
2122
|
+
void outbound.enqueueValidatorEnabledCallback({
|
|
2123
|
+
sessionId: req.sessionId,
|
|
2124
|
+
accountAddress: req.accountAddress,
|
|
2125
|
+
permissionId: req.permissionId,
|
|
2126
|
+
txHash: req.txHash,
|
|
2127
|
+
blockNumber: req.blockNumber,
|
|
2128
|
+
logIndex: req.logIndex
|
|
2129
|
+
});
|
|
2130
|
+
return {
|
|
2131
|
+
type: "notify_userop_landed",
|
|
2132
|
+
queued: true,
|
|
2133
|
+
sessionId: req.sessionId
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
1653
2136
|
}
|
|
1654
2137
|
}
|
|
1655
2138
|
function errorResponse(code, message) {
|
|
@@ -1680,6 +2163,7 @@ var BrokerDaemon = class {
|
|
|
1680
2163
|
config;
|
|
1681
2164
|
keystore;
|
|
1682
2165
|
policyStore;
|
|
2166
|
+
outbound;
|
|
1683
2167
|
/**
|
|
1684
2168
|
* Whether a session-key private half is actually loaded. `false` =
|
|
1685
2169
|
* daemon booted in read-only posture (no `MUHAVEN_BROKER_SESSION_KEY`
|
|
@@ -1700,6 +2184,15 @@ var BrokerDaemon = class {
|
|
|
1700
2184
|
}
|
|
1701
2185
|
this.keystore = options.keystore ?? null;
|
|
1702
2186
|
this.policyStore = options.policyStore ?? new FilePolicyStore(FilePolicyStore.defaultDir());
|
|
2187
|
+
this.outbound = options.outbound ?? new BrokerOutbound(
|
|
2188
|
+
{
|
|
2189
|
+
chainRpcUrl: options.config.chainRpcUrl,
|
|
2190
|
+
backendBaseUrl: options.config.backendBaseUrl,
|
|
2191
|
+
callbackServiceSecret: options.config.callbackServiceSecret,
|
|
2192
|
+
outboundOriginHeader: options.config.outboundOriginHeader ?? options.config.dashboardBaseUrl
|
|
2193
|
+
},
|
|
2194
|
+
(level, msg, meta) => (options.logger ?? noopLogger)({ level, msg, meta })
|
|
2195
|
+
);
|
|
1703
2196
|
this.log = options.logger ?? noopLogger;
|
|
1704
2197
|
this.server = createServer((socket) => this.onConnection(socket));
|
|
1705
2198
|
}
|
|
@@ -1832,7 +2325,8 @@ var BrokerDaemon = class {
|
|
|
1832
2325
|
},
|
|
1833
2326
|
pid: process.pid
|
|
1834
2327
|
},
|
|
1835
|
-
this.policyStore
|
|
2328
|
+
this.policyStore,
|
|
2329
|
+
this.outbound
|
|
1836
2330
|
);
|
|
1837
2331
|
socket.end(serializeResponse(res));
|
|
1838
2332
|
} catch (err) {
|
|
@@ -1875,6 +2369,63 @@ async function runBrokerDaemonCli() {
|
|
|
1875
2369
|
await new Promise(() => {
|
|
1876
2370
|
});
|
|
1877
2371
|
}
|
|
2372
|
+
|
|
2373
|
+
// src/broker/session-input.ts
|
|
2374
|
+
var SESSION_KEY_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
2375
|
+
function validateSessionKeyShape(key) {
|
|
2376
|
+
if (key.length === 0) return "session key is empty";
|
|
2377
|
+
if (!key.startsWith("0x")) return "session key must be 0x-prefixed";
|
|
2378
|
+
if (!SESSION_KEY_HEX_RE.test(key)) {
|
|
2379
|
+
return `session key must be a 0x-prefixed 32-byte hex string (got ${key.length} chars; expected 66)`;
|
|
2380
|
+
}
|
|
2381
|
+
return null;
|
|
2382
|
+
}
|
|
2383
|
+
async function resolveSessionKey(opts) {
|
|
2384
|
+
const { sessionFlag, policy, deps } = opts;
|
|
2385
|
+
if (sessionFlag !== void 0) {
|
|
2386
|
+
let raw;
|
|
2387
|
+
if (sessionFlag === "-") {
|
|
2388
|
+
const stdin = await deps.readStdinAll();
|
|
2389
|
+
raw = stdin.trim();
|
|
2390
|
+
if (raw.length === 0) {
|
|
2391
|
+
return { kind: "error", message: "--session - was given but stdin was empty" };
|
|
2392
|
+
}
|
|
2393
|
+
} else {
|
|
2394
|
+
raw = sessionFlag.trim();
|
|
2395
|
+
}
|
|
2396
|
+
const shapeErr2 = validateSessionKeyShape(raw);
|
|
2397
|
+
if (shapeErr2) return { kind: "error", message: shapeErr2 };
|
|
2398
|
+
return { kind: "key", key: raw };
|
|
2399
|
+
}
|
|
2400
|
+
if (!deps.isTty) {
|
|
2401
|
+
if (policy === "mint-fallback") return { kind: "mint" };
|
|
2402
|
+
return {
|
|
2403
|
+
kind: "error",
|
|
2404
|
+
message: "no session key provided and stdin is not a TTY \u2014 pass --session <key> (or `--session -` to pipe it), or run `muhaven-broker setup` to mint a fresh key"
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
const hasKey = await deps.promptYesNo(
|
|
2408
|
+
"Do you have a session key from the dashboard? [Y/n] "
|
|
2409
|
+
);
|
|
2410
|
+
if (!hasKey) {
|
|
2411
|
+
if (policy === "mint-fallback") return { kind: "mint" };
|
|
2412
|
+
return {
|
|
2413
|
+
kind: "error",
|
|
2414
|
+
message: "a session key is required for this command \u2014 paste the dashboard-minted key, or run `muhaven-broker setup` to mint one"
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
const pasted = (await deps.promptSecret("Paste the session key: ")).trim();
|
|
2418
|
+
const shapeErr = validateSessionKeyShape(pasted);
|
|
2419
|
+
if (shapeErr) {
|
|
2420
|
+
return {
|
|
2421
|
+
kind: "error",
|
|
2422
|
+
message: `${shapeErr} \u2014 re-run and paste the key from the dashboard's session-reveal modal`
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
return { kind: "key", key: pasted };
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// src/broker/setup.ts
|
|
1878
2429
|
var DANGEROUS_NODE_ENV_VARS = [
|
|
1879
2430
|
"NODE_OPTIONS",
|
|
1880
2431
|
"NODE_TLS_REJECT_UNAUTHORIZED",
|
|
@@ -1962,6 +2513,26 @@ function validateBrokerEndpointFlag(value, platformId) {
|
|
|
1962
2513
|
}
|
|
1963
2514
|
return null;
|
|
1964
2515
|
}
|
|
2516
|
+
var LOGIN_SEED_ENV_KEYS = [
|
|
2517
|
+
"MUHAVEN_BACKEND_URL",
|
|
2518
|
+
"MUHAVEN_DASHBOARD_URL",
|
|
2519
|
+
"MUHAVEN_BROKER_ENDPOINT"
|
|
2520
|
+
];
|
|
2521
|
+
async function withSeededLoginEnv(effectiveEnv, fn) {
|
|
2522
|
+
const originalValues = {};
|
|
2523
|
+
for (const k of LOGIN_SEED_ENV_KEYS) {
|
|
2524
|
+
originalValues[k] = process.env[k];
|
|
2525
|
+
if (effectiveEnv[k]) process.env[k] = effectiveEnv[k];
|
|
2526
|
+
}
|
|
2527
|
+
try {
|
|
2528
|
+
return await fn();
|
|
2529
|
+
} finally {
|
|
2530
|
+
for (const k of LOGIN_SEED_ENV_KEYS) {
|
|
2531
|
+
if (originalValues[k] === void 0) delete process.env[k];
|
|
2532
|
+
else process.env[k] = originalValues[k];
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
1965
2536
|
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1966
2537
|
async function waitForBroker(options) {
|
|
1967
2538
|
const timeoutMs = options.timeoutMs ?? 8e3;
|
|
@@ -2211,12 +2782,32 @@ async function runSetup(argv, deps) {
|
|
|
2211
2782
|
}
|
|
2212
2783
|
let sessionKey = effectiveEnv.MUHAVEN_BROKER_SESSION_KEY;
|
|
2213
2784
|
let mintedKey = false;
|
|
2214
|
-
if (
|
|
2215
|
-
sessionKey = deps.mintSessionKey();
|
|
2216
|
-
mintedKey = true;
|
|
2217
|
-
deps.print("Session key: minted fresh (secp256k1, ephemeral to this daemon).");
|
|
2218
|
-
} else {
|
|
2785
|
+
if (sessionKey && sessionKey.length > 0) {
|
|
2219
2786
|
deps.print("Session key: using MUHAVEN_BROKER_SESSION_KEY from env.");
|
|
2787
|
+
} else {
|
|
2788
|
+
const sessionInput = deps.sessionInput ?? {
|
|
2789
|
+
isTty: false,
|
|
2790
|
+
readStdinAll: async () => "",
|
|
2791
|
+
promptYesNo: async () => false,
|
|
2792
|
+
promptSecret: async () => ""
|
|
2793
|
+
};
|
|
2794
|
+
const resolution = await resolveSessionKey({
|
|
2795
|
+
sessionFlag: void 0,
|
|
2796
|
+
policy: "mint-fallback",
|
|
2797
|
+
deps: sessionInput
|
|
2798
|
+
});
|
|
2799
|
+
if (resolution.kind === "error") {
|
|
2800
|
+
deps.printErr(`error: ${resolution.message}`);
|
|
2801
|
+
return 2;
|
|
2802
|
+
}
|
|
2803
|
+
if (resolution.kind === "key") {
|
|
2804
|
+
sessionKey = resolution.key;
|
|
2805
|
+
deps.print("Session key: using the pasted dashboard key.");
|
|
2806
|
+
} else {
|
|
2807
|
+
sessionKey = deps.mintSessionKey();
|
|
2808
|
+
mintedKey = true;
|
|
2809
|
+
deps.print("Session key: minted fresh (secp256k1, ephemeral to this daemon).");
|
|
2810
|
+
}
|
|
2220
2811
|
}
|
|
2221
2812
|
effectiveEnv.MUHAVEN_BROKER_SESSION_KEY = sessionKey;
|
|
2222
2813
|
if (flags.foreground) {
|
|
@@ -2300,21 +2891,7 @@ async function runSetup(argv, deps) {
|
|
|
2300
2891
|
if (flags.dashboardBaseUrl) {
|
|
2301
2892
|
loginArgv.push("--dashboard-base-url", flags.dashboardBaseUrl);
|
|
2302
2893
|
}
|
|
2303
|
-
const
|
|
2304
|
-
const originalValues = {};
|
|
2305
|
-
for (const k of restorationKeys) {
|
|
2306
|
-
originalValues[k] = process.env[k];
|
|
2307
|
-
if (effectiveEnv[k]) process.env[k] = effectiveEnv[k];
|
|
2308
|
-
}
|
|
2309
|
-
let code;
|
|
2310
|
-
try {
|
|
2311
|
-
code = await deps.runLogin(loginArgv);
|
|
2312
|
-
} finally {
|
|
2313
|
-
for (const k of restorationKeys) {
|
|
2314
|
-
if (originalValues[k] === void 0) delete process.env[k];
|
|
2315
|
-
else process.env[k] = originalValues[k];
|
|
2316
|
-
}
|
|
2317
|
-
}
|
|
2894
|
+
const code = await withSeededLoginEnv(effectiveEnv, () => deps.runLogin(loginArgv));
|
|
2318
2895
|
if (code !== 0) {
|
|
2319
2896
|
deps.printErr(
|
|
2320
2897
|
"Setup: login step failed \u2014 daemon is still running, re-run `muhaven-broker login` to retry."
|
|
@@ -2398,13 +2975,15 @@ async function runStop(deps) {
|
|
|
2398
2975
|
deps.print("Broker daemon: not running, nothing to stop.");
|
|
2399
2976
|
return 0;
|
|
2400
2977
|
}
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2978
|
+
if (deps.clearJwtOnStop ?? true) {
|
|
2979
|
+
try {
|
|
2980
|
+
await broker.clearJwt();
|
|
2981
|
+
deps.print("JWT cleared from keystore.");
|
|
2982
|
+
} catch (err) {
|
|
2983
|
+
deps.print(
|
|
2984
|
+
`Warning: clearJwt failed (${err instanceof Error ? err.message : String(err)}); continuing with daemon shutdown.`
|
|
2985
|
+
);
|
|
2986
|
+
}
|
|
2408
2987
|
}
|
|
2409
2988
|
const pid = hello.pid;
|
|
2410
2989
|
if (pid === void 0) {
|
|
@@ -2462,6 +3041,190 @@ function defaultKillProcess(pid, signal) {
|
|
|
2462
3041
|
}
|
|
2463
3042
|
}
|
|
2464
3043
|
|
|
3044
|
+
// src/broker/bring-up.ts
|
|
3045
|
+
function parseBringUpFlags(argv) {
|
|
3046
|
+
let session;
|
|
3047
|
+
let noLaunchBrowser = false;
|
|
3048
|
+
let skipLogin = false;
|
|
3049
|
+
let brokerEndpoint;
|
|
3050
|
+
let backendBaseUrl;
|
|
3051
|
+
let dashboardBaseUrl;
|
|
3052
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3053
|
+
const a = argv[i];
|
|
3054
|
+
if (a === "--no-launch-browser") noLaunchBrowser = true;
|
|
3055
|
+
else if (a === "--skip-login") skipLogin = true;
|
|
3056
|
+
else if (a === "--session") {
|
|
3057
|
+
const next = argv[i + 1];
|
|
3058
|
+
if (next === void 0) {
|
|
3059
|
+
throw new Error("--session requires a value (a 0x\u2026 key, or `-` to read from stdin)");
|
|
3060
|
+
}
|
|
3061
|
+
if (next !== "-" && next.startsWith("-")) {
|
|
3062
|
+
throw new Error(`--session requires a key value (or \`-\` for stdin), got flag: ${next}`);
|
|
3063
|
+
}
|
|
3064
|
+
session = argv[++i];
|
|
3065
|
+
} else if (a === "--broker-endpoint" && i + 1 < argv.length) brokerEndpoint = argv[++i];
|
|
3066
|
+
else if (a === "--backend-base-url" && i + 1 < argv.length) backendBaseUrl = argv[++i];
|
|
3067
|
+
else if (a === "--dashboard-base-url" && i + 1 < argv.length) dashboardBaseUrl = argv[++i];
|
|
3068
|
+
else throw new Error(`unknown flag: ${a}`);
|
|
3069
|
+
}
|
|
3070
|
+
return { session, noLaunchBrowser, skipLogin, brokerEndpoint, backendBaseUrl, dashboardBaseUrl };
|
|
3071
|
+
}
|
|
3072
|
+
function usageLine(mode) {
|
|
3073
|
+
return `usage: muhaven-broker ${mode} --session <key|-> [--no-launch-browser] [--skip-login]
|
|
3074
|
+
[--broker-endpoint PATH] [--backend-base-url URL]
|
|
3075
|
+
[--dashboard-base-url URL]
|
|
3076
|
+
(omit --session to be asked interactively; pipe the key with \`--session -\`)`;
|
|
3077
|
+
}
|
|
3078
|
+
async function runBringUp(mode, argv, deps) {
|
|
3079
|
+
let flags;
|
|
3080
|
+
try {
|
|
3081
|
+
flags = parseBringUpFlags(argv);
|
|
3082
|
+
} catch (err) {
|
|
3083
|
+
deps.printErr(`error: ${err.message}`);
|
|
3084
|
+
deps.printErr(usageLine(mode));
|
|
3085
|
+
return 2;
|
|
3086
|
+
}
|
|
3087
|
+
if (flags.backendBaseUrl) {
|
|
3088
|
+
const e = validateHttpUrlFlag("--backend-base-url", flags.backendBaseUrl);
|
|
3089
|
+
if (e) {
|
|
3090
|
+
deps.printErr(`error: ${e}`);
|
|
3091
|
+
return 2;
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
if (flags.dashboardBaseUrl) {
|
|
3095
|
+
const e = validateHttpUrlFlag("--dashboard-base-url", flags.dashboardBaseUrl);
|
|
3096
|
+
if (e) {
|
|
3097
|
+
deps.printErr(`error: ${e}`);
|
|
3098
|
+
return 2;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
if (flags.brokerEndpoint) {
|
|
3102
|
+
const e = validateBrokerEndpointFlag(flags.brokerEndpoint, deps.platformId);
|
|
3103
|
+
if (e) {
|
|
3104
|
+
deps.printErr(`error: ${e}`);
|
|
3105
|
+
return 2;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
const resolution = await resolveSessionKey({
|
|
3109
|
+
sessionFlag: flags.session,
|
|
3110
|
+
policy: "require",
|
|
3111
|
+
deps: deps.sessionPrompt
|
|
3112
|
+
});
|
|
3113
|
+
if (resolution.kind !== "key") {
|
|
3114
|
+
const message = resolution.kind === "error" ? resolution.message : "no session key resolved";
|
|
3115
|
+
deps.printErr(`error: ${message}`);
|
|
3116
|
+
return 2;
|
|
3117
|
+
}
|
|
3118
|
+
const sessionKey = resolution.key;
|
|
3119
|
+
const overrides = applyEnvDefaults({
|
|
3120
|
+
env: deps.env,
|
|
3121
|
+
platformId: deps.platformId,
|
|
3122
|
+
osRelease: deps.osRelease
|
|
3123
|
+
});
|
|
3124
|
+
const effectiveEnv = {};
|
|
3125
|
+
for (const [k, v] of Object.entries(deps.env)) {
|
|
3126
|
+
if (typeof v === "string") effectiveEnv[k] = v;
|
|
3127
|
+
}
|
|
3128
|
+
for (const [k, v] of Object.entries(overrides.toSet)) effectiveEnv[k] = v;
|
|
3129
|
+
if (flags.brokerEndpoint) effectiveEnv.MUHAVEN_BROKER_ENDPOINT = flags.brokerEndpoint;
|
|
3130
|
+
if (flags.backendBaseUrl) effectiveEnv.MUHAVEN_BACKEND_URL = flags.backendBaseUrl;
|
|
3131
|
+
if (flags.dashboardBaseUrl) effectiveEnv.MUHAVEN_DASHBOARD_URL = flags.dashboardBaseUrl;
|
|
3132
|
+
for (const name of overrides.preserved) deps.print(`Env preserved: ${name} (set in your shell)`);
|
|
3133
|
+
for (const [k, v] of Object.entries(overrides.toSet)) deps.print(`Env defaulted: ${k}=${v}`);
|
|
3134
|
+
const config = loadMcpConfig(effectiveEnv);
|
|
3135
|
+
const broker = deps.newBrokerClient(config.brokerEndpoint, config.brokerTimeoutMs);
|
|
3136
|
+
let running = false;
|
|
3137
|
+
try {
|
|
3138
|
+
await broker.hello();
|
|
3139
|
+
running = true;
|
|
3140
|
+
} catch {
|
|
3141
|
+
running = false;
|
|
3142
|
+
}
|
|
3143
|
+
if (mode === "start") {
|
|
3144
|
+
if (running) {
|
|
3145
|
+
deps.printErr(
|
|
3146
|
+
`Broker daemon is already running at ${config.brokerEndpoint}. To rotate its key use: muhaven-broker update --session <key>`
|
|
3147
|
+
);
|
|
3148
|
+
return 1;
|
|
3149
|
+
}
|
|
3150
|
+
deps.print("Broker daemon: not running \u2014 starting one (detached) on the provided key ...");
|
|
3151
|
+
} else {
|
|
3152
|
+
if (running) {
|
|
3153
|
+
deps.print("Broker daemon: running \u2014 stopping it before installing the new key ...");
|
|
3154
|
+
const stopCode = await deps.stopDaemon(config.brokerEndpoint, config.brokerTimeoutMs);
|
|
3155
|
+
if (stopCode !== 0) {
|
|
3156
|
+
deps.printErr(
|
|
3157
|
+
`Broker daemon stop returned ${stopCode}; refusing to start a second daemon on the same endpoint. Resolve the running daemon (muhaven-broker doctor) and retry.`
|
|
3158
|
+
);
|
|
3159
|
+
return stopCode;
|
|
3160
|
+
}
|
|
3161
|
+
} else {
|
|
3162
|
+
deps.print("Broker daemon: not running \u2014 `update` will start a fresh one on the provided key.");
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
const daemonPid = deps.spawnDaemon({
|
|
3166
|
+
binPath: deps.resolveBinPath(),
|
|
3167
|
+
env: {
|
|
3168
|
+
...overrides.toSet,
|
|
3169
|
+
MUHAVEN_BROKER_ENDPOINT: config.brokerEndpoint,
|
|
3170
|
+
MUHAVEN_BACKEND_URL: effectiveEnv.MUHAVEN_BACKEND_URL,
|
|
3171
|
+
MUHAVEN_DASHBOARD_URL: effectiveEnv.MUHAVEN_DASHBOARD_URL,
|
|
3172
|
+
MUHAVEN_BROKER_SESSION_KEY: sessionKey
|
|
3173
|
+
}
|
|
3174
|
+
});
|
|
3175
|
+
let ready;
|
|
3176
|
+
try {
|
|
3177
|
+
ready = await deps.waitForBroker({ broker });
|
|
3178
|
+
} catch (err) {
|
|
3179
|
+
deps.printErr(err.message);
|
|
3180
|
+
deps.printErr(
|
|
3181
|
+
" hint: check that no other broker is bound to the same endpoint (muhaven-broker doctor)."
|
|
3182
|
+
);
|
|
3183
|
+
return 1;
|
|
3184
|
+
}
|
|
3185
|
+
deps.print(`Broker daemon: ready (PID ${daemonPid}, endpoint ${config.brokerEndpoint}).`);
|
|
3186
|
+
try {
|
|
3187
|
+
const h = await broker.hello();
|
|
3188
|
+
const hasKey = h.hasSessionKey ?? true;
|
|
3189
|
+
if (!hasKey) {
|
|
3190
|
+
deps.printErr(
|
|
3191
|
+
"Broker came up in READ-ONLY posture \u2014 the session key did not reach the daemon, so it cannot sign. Stop it (muhaven-broker stop) and retry."
|
|
3192
|
+
);
|
|
3193
|
+
return 1;
|
|
3194
|
+
}
|
|
3195
|
+
if (h.sessionKeyAddress) deps.print(`Broker signer: ${h.sessionKeyAddress}`);
|
|
3196
|
+
} catch {
|
|
3197
|
+
}
|
|
3198
|
+
if (flags.skipLogin) {
|
|
3199
|
+
deps.print("Login: skipped per --skip-login.");
|
|
3200
|
+
} else if (ready.hasJwt) {
|
|
3201
|
+
deps.print("Login: skipped \u2014 JWT already in keystore (reused).");
|
|
3202
|
+
} else {
|
|
3203
|
+
const loginArgv = [];
|
|
3204
|
+
if (flags.noLaunchBrowser) loginArgv.push("--no-launch-browser");
|
|
3205
|
+
if (flags.brokerEndpoint) loginArgv.push("--broker-endpoint", flags.brokerEndpoint);
|
|
3206
|
+
if (flags.backendBaseUrl) loginArgv.push("--backend-base-url", flags.backendBaseUrl);
|
|
3207
|
+
if (flags.dashboardBaseUrl) loginArgv.push("--dashboard-base-url", flags.dashboardBaseUrl);
|
|
3208
|
+
const code = await withSeededLoginEnv(effectiveEnv, () => deps.runLogin(loginArgv));
|
|
3209
|
+
if (code !== 0) {
|
|
3210
|
+
deps.printErr(
|
|
3211
|
+
"Login step failed \u2014 the daemon is running on the new key; re-run `muhaven-broker login` to retry."
|
|
3212
|
+
);
|
|
3213
|
+
return code;
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
deps.print("");
|
|
3217
|
+
deps.print("================================");
|
|
3218
|
+
deps.print(mode === "start" ? "Broker started." : "Session key rotated.");
|
|
3219
|
+
deps.print(` Daemon PID : ${daemonPid}`);
|
|
3220
|
+
const killCmd = deps.platformId === "win32" ? `Stop-Process -Id ${daemonPid}` : `kill ${daemonPid}`;
|
|
3221
|
+
deps.print(` Stop daemon: ${killCmd} (or: muhaven-broker stop)`);
|
|
3222
|
+
deps.print(` Endpoint : ${config.brokerEndpoint}`);
|
|
3223
|
+
deps.print(" Rotate key : muhaven-broker update --session <new-key>");
|
|
3224
|
+
deps.print("================================");
|
|
3225
|
+
return 0;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
2465
3228
|
// src/broker/cli.ts
|
|
2466
3229
|
function print(line) {
|
|
2467
3230
|
process.stdout.write(line + "\n");
|
|
@@ -2774,6 +3537,11 @@ function printUsage() {
|
|
|
2774
3537
|
print(" (claude-code today; claude-desktop / cursor reserved for Wave 5)");
|
|
2775
3538
|
print(" [--register-scope user|project|local] scope for the host-config write");
|
|
2776
3539
|
print(" (default: user \u2014 every project sees the server)");
|
|
3540
|
+
print(" start Bring the daemon up on a DASHBOARD-minted session key (daemon NOT running)");
|
|
3541
|
+
print(" --session <key|-> the key (or `-` to read it from stdin); omit to be");
|
|
3542
|
+
print(" asked interactively. [--skip-login] [--no-launch-browser]");
|
|
3543
|
+
print(" update Rotate the session key on a running daemon (stop \u2192 swap \u2192 restart,");
|
|
3544
|
+
print(" reusing the existing JWT). --session <key|-> (or interactive).");
|
|
2777
3545
|
print(" stop Cleanly stop a running daemon (SIGTERM with SIGKILL fallback");
|
|
2778
3546
|
print(" after 5s). Also clears the keystore JWT as a best effort.");
|
|
2779
3547
|
print(" login Acquire a JWT via the device-code flow + store in keystore");
|
|
@@ -2785,7 +3553,7 @@ function printUsage() {
|
|
|
2785
3553
|
}
|
|
2786
3554
|
function getBrokerPackageVersion() {
|
|
2787
3555
|
{
|
|
2788
|
-
return "0.
|
|
3556
|
+
return "0.4.0";
|
|
2789
3557
|
}
|
|
2790
3558
|
}
|
|
2791
3559
|
function printVersion() {
|
|
@@ -2821,6 +3589,66 @@ function defaultShellOut(cmd, argv) {
|
|
|
2821
3589
|
});
|
|
2822
3590
|
});
|
|
2823
3591
|
}
|
|
3592
|
+
function stdinIsTty() {
|
|
3593
|
+
return process.stdin.isTTY === true;
|
|
3594
|
+
}
|
|
3595
|
+
function promptYesNo(question) {
|
|
3596
|
+
return new Promise((resolve) => {
|
|
3597
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3598
|
+
rl.question(question, (answer) => {
|
|
3599
|
+
rl.close();
|
|
3600
|
+
const a = answer.trim().toLowerCase();
|
|
3601
|
+
resolve(a === "" || a === "y" || a === "yes");
|
|
3602
|
+
});
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
function promptSecret(question) {
|
|
3606
|
+
return new Promise((resolve) => {
|
|
3607
|
+
process.stdout.write(`${question.trimEnd()} (input hidden)
|
|
3608
|
+
`);
|
|
3609
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3610
|
+
const rlAny = rl;
|
|
3611
|
+
rlAny._writeToOutput = () => {
|
|
3612
|
+
};
|
|
3613
|
+
rl.question("", (answer) => {
|
|
3614
|
+
rl.close();
|
|
3615
|
+
process.stdout.write("\n");
|
|
3616
|
+
resolve(answer);
|
|
3617
|
+
});
|
|
3618
|
+
});
|
|
3619
|
+
}
|
|
3620
|
+
async function readStdinAll() {
|
|
3621
|
+
if (process.stdin.isTTY) return "";
|
|
3622
|
+
const chunks = [];
|
|
3623
|
+
for await (const chunk of process.stdin) {
|
|
3624
|
+
chunks.push(Buffer.from(chunk));
|
|
3625
|
+
}
|
|
3626
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
3627
|
+
}
|
|
3628
|
+
function makeSessionPromptDeps() {
|
|
3629
|
+
return {
|
|
3630
|
+
isTty: stdinIsTty(),
|
|
3631
|
+
readStdinAll,
|
|
3632
|
+
promptYesNo,
|
|
3633
|
+
promptSecret
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
3636
|
+
function makeStopDeps(clearJwtOnStop, override) {
|
|
3637
|
+
const resolved = override ?? (() => {
|
|
3638
|
+
const config = loadMcpConfig();
|
|
3639
|
+
return { endpoint: config.brokerEndpoint, brokerTimeoutMs: config.brokerTimeoutMs };
|
|
3640
|
+
})();
|
|
3641
|
+
return {
|
|
3642
|
+
print,
|
|
3643
|
+
printErr,
|
|
3644
|
+
newBrokerClient: (endpoint, timeoutMs) => new BrokerClient({ endpoint, timeoutMs }),
|
|
3645
|
+
killProcess: defaultKillProcess,
|
|
3646
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
3647
|
+
endpoint: resolved.endpoint,
|
|
3648
|
+
brokerTimeoutMs: resolved.brokerTimeoutMs,
|
|
3649
|
+
clearJwtOnStop
|
|
3650
|
+
};
|
|
3651
|
+
}
|
|
2824
3652
|
async function runSetup2(argv) {
|
|
2825
3653
|
const deps = {
|
|
2826
3654
|
print,
|
|
@@ -2835,22 +3663,35 @@ async function runSetup2(argv) {
|
|
|
2835
3663
|
env: process.env,
|
|
2836
3664
|
platformId: process.platform,
|
|
2837
3665
|
osRelease: release(),
|
|
2838
|
-
shellOut: defaultShellOut
|
|
3666
|
+
shellOut: defaultShellOut,
|
|
3667
|
+
sessionInput: makeSessionPromptDeps()
|
|
2839
3668
|
};
|
|
2840
3669
|
return runSetup(argv, deps);
|
|
2841
3670
|
}
|
|
2842
3671
|
async function runStop2() {
|
|
2843
|
-
|
|
2844
|
-
|
|
3672
|
+
return runStop(makeStopDeps(true));
|
|
3673
|
+
}
|
|
3674
|
+
function makeBringUpDeps() {
|
|
3675
|
+
return {
|
|
2845
3676
|
print,
|
|
2846
3677
|
printErr,
|
|
2847
3678
|
newBrokerClient: (endpoint, timeoutMs) => new BrokerClient({ endpoint, timeoutMs }),
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
3679
|
+
spawnDaemon,
|
|
3680
|
+
waitForBroker,
|
|
3681
|
+
// `update` stops the old daemon but PRESERVES the JWT (key rotation
|
|
3682
|
+
// must not force a device-code re-login). Targets the resolved
|
|
3683
|
+
// endpoint so a `--broker-endpoint` override stops the right daemon.
|
|
3684
|
+
stopDaemon: (endpoint, brokerTimeoutMs) => runStop(makeStopDeps(false, { endpoint, brokerTimeoutMs })),
|
|
3685
|
+
runLogin,
|
|
3686
|
+
resolveBinPath: resolveBrokerBinPath,
|
|
3687
|
+
env: process.env,
|
|
3688
|
+
platformId: process.platform,
|
|
3689
|
+
osRelease: release(),
|
|
3690
|
+
sessionPrompt: makeSessionPromptDeps()
|
|
2852
3691
|
};
|
|
2853
|
-
|
|
3692
|
+
}
|
|
3693
|
+
async function runStartOrUpdate(mode, argv) {
|
|
3694
|
+
return runBringUp(mode, argv, makeBringUpDeps());
|
|
2854
3695
|
}
|
|
2855
3696
|
async function runCli(argv) {
|
|
2856
3697
|
const [sub, ...rest] = argv;
|
|
@@ -2860,6 +3701,10 @@ async function runCli(argv) {
|
|
|
2860
3701
|
return 0;
|
|
2861
3702
|
case "setup":
|
|
2862
3703
|
return runSetup2(rest);
|
|
3704
|
+
case "start":
|
|
3705
|
+
return runStartOrUpdate("start", rest);
|
|
3706
|
+
case "update":
|
|
3707
|
+
return runStartOrUpdate("update", rest);
|
|
2863
3708
|
case "stop":
|
|
2864
3709
|
return runStop2();
|
|
2865
3710
|
case "login":
|
|
@@ -2883,4 +3728,4 @@ async function runCli(argv) {
|
|
|
2883
3728
|
}
|
|
2884
3729
|
}
|
|
2885
3730
|
|
|
2886
|
-
export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup2 as runSetup, runStop2 as runStop };
|
|
3731
|
+
export { getBrokerPackageVersion, parseLoginFlags, runCli, runDoctor, runLogin, runLogout, runSetup2 as runSetup, runStartOrUpdate, runStop2 as runStop };
|