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