@muhaven/mcp 0.2.8 → 0.3.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 +120 -0
- package/dist/broker.cjs +499 -6
- package/dist/broker.js +499 -6
- package/dist/index.cjs +832 -15
- package/dist/index.d.cts +301 -14
- package/dist/index.d.ts +301 -14
- package/dist/index.js +833 -16
- package/manifest.json +1 -1
- package/package.json +2 -1
package/dist/broker.js
CHANGED
|
@@ -6,6 +6,7 @@ import { connect, createServer } from 'net';
|
|
|
6
6
|
import { mkdir, chmod, writeFile, readFile, unlink, rename, readdir, stat } from 'fs/promises';
|
|
7
7
|
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts';
|
|
8
8
|
import { randomBytes } from 'crypto';
|
|
9
|
+
import { parseAbi, encodeFunctionData, decodeAbiParameters } from 'viem';
|
|
9
10
|
|
|
10
11
|
var getFilename = () => fileURLToPath(import.meta.url);
|
|
11
12
|
var getDirname = () => path.dirname(getFilename());
|
|
@@ -163,22 +164,45 @@ function loadBrokerConfig(env = process.env) {
|
|
|
163
164
|
env.MUHAVEN_DASHBOARD_URL,
|
|
164
165
|
DEFAULT_DASHBOARD_URL
|
|
165
166
|
);
|
|
167
|
+
const chainRpcUrlRaw = readEnv("MUHAVEN_BROKER_RPC_URL", env) ?? readEnv("MUHAVEN_BUNDLER_URL", env);
|
|
168
|
+
const chainRpcUrl = chainRpcUrlRaw === void 0 ? void 0 : resolvePublicUrlEnv(
|
|
169
|
+
"MUHAVEN_BROKER_RPC_URL",
|
|
170
|
+
chainRpcUrlRaw,
|
|
171
|
+
chainRpcUrlRaw
|
|
172
|
+
);
|
|
173
|
+
const callbackServiceSecretRaw = readEnv("BROKER_CALLBACK_SERVICE_SECRET", env);
|
|
174
|
+
let callbackServiceSecret;
|
|
175
|
+
if (callbackServiceSecretRaw !== void 0) {
|
|
176
|
+
if (callbackServiceSecretRaw.length < 16) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
"BROKER_CALLBACK_SERVICE_SECRET must be at least 16 characters (matches backend with-service-secret middleware floor)"
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
callbackServiceSecret = callbackServiceSecretRaw;
|
|
182
|
+
}
|
|
183
|
+
const outboundOriginHeader = readEnv("MUHAVEN_BROKER_ORIGIN", env) ?? dashboardBaseUrl;
|
|
166
184
|
return {
|
|
167
185
|
endpoint,
|
|
168
186
|
sessionKeyHex,
|
|
169
187
|
maxRequestBytes,
|
|
170
188
|
requestTimeoutMs,
|
|
171
189
|
backendBaseUrl,
|
|
172
|
-
dashboardBaseUrl
|
|
190
|
+
dashboardBaseUrl,
|
|
191
|
+
chainRpcUrl,
|
|
192
|
+
callbackServiceSecret,
|
|
193
|
+
outboundOriginHeader
|
|
173
194
|
};
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
// src/broker/protocol.ts
|
|
177
|
-
var BROKER_PROTOCOL_VERSION = "0.
|
|
198
|
+
var BROKER_PROTOCOL_VERSION = "0.5.0";
|
|
178
199
|
var HASH_HEX_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
179
200
|
var ADDRESS_HEX_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
180
201
|
var SELECTOR_HEX_RE = /^0x[0-9a-fA-F]{8}$/;
|
|
181
202
|
var HEX_PREFIXED_RE = /^0x[0-9a-fA-F]*$/;
|
|
203
|
+
var ENABLE_DATA_HEX_RE = /^0x[0-9a-fA-F]{2,65536}$/;
|
|
204
|
+
var ENABLE_SIG_HEX_RE = /^0x[0-9a-fA-F]{256,16384}$/;
|
|
205
|
+
var UINT32_MAX = 4294967295;
|
|
182
206
|
var JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
183
207
|
var SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
184
208
|
var UINT256_DEC_RE = /^(0|[1-9][0-9]{0,77})$/;
|
|
@@ -298,6 +322,43 @@ function parsePolicySnapshot(raw) {
|
|
|
298
322
|
if (!isOptionalPermissionId(obj.permissionId)) {
|
|
299
323
|
return { error: "snapshot.permissionId must be a 0x-prefixed 4-byte hex when provided" };
|
|
300
324
|
}
|
|
325
|
+
const enableData = obj.enableData;
|
|
326
|
+
const enableSig = obj.enableSig;
|
|
327
|
+
const validatorNonce = obj.validatorNonce;
|
|
328
|
+
const installPresent = [enableData, enableSig, validatorNonce].filter(
|
|
329
|
+
(v) => v !== void 0
|
|
330
|
+
).length;
|
|
331
|
+
if (installPresent !== 0 && installPresent !== 3) {
|
|
332
|
+
return {
|
|
333
|
+
error: "snapshot.{enableData,enableSig,validatorNonce} must be all-present or all-absent (Option D Commit 3 install-material trio)"
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (enableData !== void 0) {
|
|
337
|
+
if (typeof enableData !== "string" || !ENABLE_DATA_HEX_RE.test(enableData)) {
|
|
338
|
+
return {
|
|
339
|
+
error: "snapshot.enableData must be a 0x-prefixed hex string of 2..65536 chars when provided"
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (enableSig !== void 0) {
|
|
344
|
+
if (typeof enableSig !== "string" || !ENABLE_SIG_HEX_RE.test(enableSig)) {
|
|
345
|
+
return {
|
|
346
|
+
error: "snapshot.enableSig must be a 0x-prefixed hex string of 256..16384 chars (WebAuthn envelope) when provided"
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (validatorNonce !== void 0) {
|
|
351
|
+
if (typeof validatorNonce !== "number" || !Number.isInteger(validatorNonce) || validatorNonce < 0 || validatorNonce > UINT32_MAX) {
|
|
352
|
+
return {
|
|
353
|
+
error: "snapshot.validatorNonce must be an integer in [0, 2^32-1] when provided"
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (installPresent === 3 && obj.permissionId === void 0) {
|
|
358
|
+
return {
|
|
359
|
+
error: "snapshot install material requires permissionId in the same snapshot"
|
|
360
|
+
};
|
|
361
|
+
}
|
|
301
362
|
return {
|
|
302
363
|
sessionId: obj.sessionId,
|
|
303
364
|
mode: "scoped",
|
|
@@ -310,7 +371,10 @@ function parsePolicySnapshot(raw) {
|
|
|
310
371
|
mintedAtSec: obj.mintedAtSec,
|
|
311
372
|
...obj.consentActionHash === void 0 ? {} : { consentActionHash: obj.consentActionHash.toLowerCase() },
|
|
312
373
|
...obj.consentTextSha256 === void 0 ? {} : { consentTextSha256: obj.consentTextSha256.toLowerCase() },
|
|
313
|
-
...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() }
|
|
374
|
+
...obj.permissionId === void 0 ? {} : { permissionId: obj.permissionId.toLowerCase() },
|
|
375
|
+
...enableData === void 0 ? {} : { enableData: enableData.toLowerCase() },
|
|
376
|
+
...enableSig === void 0 ? {} : { enableSig: enableSig.toLowerCase() },
|
|
377
|
+
...validatorNonce === void 0 ? {} : { validatorNonce }
|
|
314
378
|
};
|
|
315
379
|
}
|
|
316
380
|
function parseBrokerRequest(line) {
|
|
@@ -489,6 +553,72 @@ function parseBrokerRequest(line) {
|
|
|
489
553
|
}
|
|
490
554
|
case "get_active_session_id":
|
|
491
555
|
return { type: "get_active_session_id" };
|
|
556
|
+
case "current_nonce": {
|
|
557
|
+
if (!isAddressHex(obj.accountAddress)) {
|
|
558
|
+
return {
|
|
559
|
+
type: "error",
|
|
560
|
+
code: "invalid_request",
|
|
561
|
+
message: "current_nonce.accountAddress must be a 0x-prefixed 20-byte hex"
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
type: "current_nonce",
|
|
566
|
+
accountAddress: obj.accountAddress.toLowerCase()
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
case "notify_userop_landed": {
|
|
570
|
+
if (!isSessionIdShape(obj.sessionId)) {
|
|
571
|
+
return {
|
|
572
|
+
type: "error",
|
|
573
|
+
code: "invalid_request",
|
|
574
|
+
message: "notify_userop_landed.sessionId must be 1-128 chars [A-Za-z0-9_-]"
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
if (!isAddressHex(obj.accountAddress)) {
|
|
578
|
+
return {
|
|
579
|
+
type: "error",
|
|
580
|
+
code: "invalid_request",
|
|
581
|
+
message: "notify_userop_landed.accountAddress must be a 0x-prefixed 20-byte hex"
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (!isSelectorHex(obj.permissionId)) {
|
|
585
|
+
return {
|
|
586
|
+
type: "error",
|
|
587
|
+
code: "invalid_request",
|
|
588
|
+
message: "notify_userop_landed.permissionId must be a 0x-prefixed 4-byte hex"
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (!isHashHex(obj.txHash)) {
|
|
592
|
+
return {
|
|
593
|
+
type: "error",
|
|
594
|
+
code: "invalid_request",
|
|
595
|
+
message: "notify_userop_landed.txHash must be a 0x-prefixed 32-byte hex"
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (typeof obj.blockNumber !== "number" || !Number.isFinite(obj.blockNumber) || !Number.isInteger(obj.blockNumber) || obj.blockNumber < 0) {
|
|
599
|
+
return {
|
|
600
|
+
type: "error",
|
|
601
|
+
code: "invalid_request",
|
|
602
|
+
message: "notify_userop_landed.blockNumber must be a non-negative integer"
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
if (typeof obj.logIndex !== "number" || !Number.isFinite(obj.logIndex) || !Number.isInteger(obj.logIndex) || obj.logIndex < 0) {
|
|
606
|
+
return {
|
|
607
|
+
type: "error",
|
|
608
|
+
code: "invalid_request",
|
|
609
|
+
message: "notify_userop_landed.logIndex must be a non-negative integer"
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
type: "notify_userop_landed",
|
|
614
|
+
sessionId: obj.sessionId,
|
|
615
|
+
accountAddress: obj.accountAddress.toLowerCase(),
|
|
616
|
+
permissionId: obj.permissionId.toLowerCase(),
|
|
617
|
+
txHash: obj.txHash.toLowerCase(),
|
|
618
|
+
blockNumber: obj.blockNumber,
|
|
619
|
+
logIndex: obj.logIndex
|
|
620
|
+
};
|
|
621
|
+
}
|
|
492
622
|
default:
|
|
493
623
|
return {
|
|
494
624
|
type: "error",
|
|
@@ -648,6 +778,33 @@ var BrokerClient = class {
|
|
|
648
778
|
}
|
|
649
779
|
return res;
|
|
650
780
|
}
|
|
781
|
+
// ── Wave 5 Option D Commit 3 — install-mode pre-check + callback notify ──
|
|
782
|
+
async currentNonce(accountAddress) {
|
|
783
|
+
const res = await this.exchange({ type: "current_nonce", accountAddress });
|
|
784
|
+
if (res.type !== "current_nonce") {
|
|
785
|
+
throw new BrokerClientError(
|
|
786
|
+
"protocol_error",
|
|
787
|
+
`expected current_nonce response, got ${res.type}`
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
if (res.accountAddress.toLowerCase() !== accountAddress.toLowerCase()) {
|
|
791
|
+
throw new BrokerClientError(
|
|
792
|
+
"protocol_error",
|
|
793
|
+
`current_nonce echo mismatch (requested ${accountAddress}, got ${res.accountAddress})`
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
return res;
|
|
797
|
+
}
|
|
798
|
+
async notifyUseropLanded(args) {
|
|
799
|
+
const res = await this.exchange({ type: "notify_userop_landed", ...args });
|
|
800
|
+
if (res.type !== "notify_userop_landed") {
|
|
801
|
+
throw new BrokerClientError(
|
|
802
|
+
"protocol_error",
|
|
803
|
+
`expected notify_userop_landed response, got ${res.type}`
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return res;
|
|
807
|
+
}
|
|
651
808
|
/**
|
|
652
809
|
* Detect whether the running daemon speaks Path D (protocol 0.4.0+).
|
|
653
810
|
* Wraps `hello()` with a semver-gte comparison so the MCP tool layer
|
|
@@ -1438,11 +1595,285 @@ function checkPolicy(input) {
|
|
|
1438
1595
|
}
|
|
1439
1596
|
return { ok: true };
|
|
1440
1597
|
}
|
|
1598
|
+
var KERNEL_V3_CURRENT_NONCE_ABI = parseAbi([
|
|
1599
|
+
"function currentNonce() view returns (uint32)"
|
|
1600
|
+
]);
|
|
1601
|
+
var ChainRpcError = class extends Error {
|
|
1602
|
+
constructor(message, cause) {
|
|
1603
|
+
super(message);
|
|
1604
|
+
this.cause = cause;
|
|
1605
|
+
this.name = "ChainRpcError";
|
|
1606
|
+
}
|
|
1607
|
+
cause;
|
|
1608
|
+
};
|
|
1609
|
+
var CallbackError = class extends Error {
|
|
1610
|
+
constructor(message, cause) {
|
|
1611
|
+
super(message);
|
|
1612
|
+
this.cause = cause;
|
|
1613
|
+
this.name = "CallbackError";
|
|
1614
|
+
}
|
|
1615
|
+
cause;
|
|
1616
|
+
};
|
|
1617
|
+
var CALLBACK_RETRY_SCHEDULE_MS = [
|
|
1618
|
+
5e3,
|
|
1619
|
+
15e3,
|
|
1620
|
+
6e4,
|
|
1621
|
+
5 * 6e4
|
|
1622
|
+
];
|
|
1623
|
+
var CALLBACK_MAX_ELAPSED_MS = 60 * 6e4;
|
|
1624
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 15e3;
|
|
1625
|
+
var BrokerOutbound = class {
|
|
1626
|
+
constructor(config, log = () => {
|
|
1627
|
+
}) {
|
|
1628
|
+
this.config = config;
|
|
1629
|
+
this.log = log;
|
|
1630
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
1631
|
+
this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
1632
|
+
this.setTimeoutImpl = config.setTimeout ?? setTimeout;
|
|
1633
|
+
this.clearTimeoutImpl = config.clearTimeout ?? clearTimeout;
|
|
1634
|
+
}
|
|
1635
|
+
config;
|
|
1636
|
+
log;
|
|
1637
|
+
fetchImpl;
|
|
1638
|
+
fetchTimeoutMs;
|
|
1639
|
+
setTimeoutImpl;
|
|
1640
|
+
clearTimeoutImpl;
|
|
1641
|
+
/**
|
|
1642
|
+
* Wave 5 Option D Commit 3 (multi-agent review SecEng-MED-3) —
|
|
1643
|
+
* in-process dedup of `notify_userop_landed` callbacks. Map of
|
|
1644
|
+
* `<sessionId>:<txHash>` → the in-flight retry loop's Promise.
|
|
1645
|
+
* Repeated IPC calls with the same key fold into the existing
|
|
1646
|
+
* loop instead of spawning a parallel POST. Defends against a
|
|
1647
|
+
* local-socket peer flooding the broker with replay attempts +
|
|
1648
|
+
* caps the retry-budget waste at one loop per real install.
|
|
1649
|
+
*/
|
|
1650
|
+
inflightCallbacks = /* @__PURE__ */ new Map();
|
|
1651
|
+
/**
|
|
1652
|
+
* Read the kernel's `currentNonce()` view via `eth_call` against the
|
|
1653
|
+
* configured chain RPC. Returns a uint32. Throws `ChainRpcError` when
|
|
1654
|
+
* unconfigured / network failed / RPC returned non-decodable bytes.
|
|
1655
|
+
*/
|
|
1656
|
+
async currentNonce(accountAddress) {
|
|
1657
|
+
if (!this.config.chainRpcUrl) {
|
|
1658
|
+
throw new ChainRpcError(
|
|
1659
|
+
"broker chain RPC unconfigured \u2014 set MUHAVEN_BROKER_RPC_URL or MUHAVEN_BUNDLER_URL"
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
const data = encodeFunctionData({
|
|
1663
|
+
abi: KERNEL_V3_CURRENT_NONCE_ABI,
|
|
1664
|
+
functionName: "currentNonce"
|
|
1665
|
+
});
|
|
1666
|
+
const body = JSON.stringify({
|
|
1667
|
+
jsonrpc: "2.0",
|
|
1668
|
+
id: 1,
|
|
1669
|
+
method: "eth_call",
|
|
1670
|
+
params: [{ to: accountAddress, data }, "latest"]
|
|
1671
|
+
});
|
|
1672
|
+
let res;
|
|
1673
|
+
const ac = new AbortController();
|
|
1674
|
+
const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
|
|
1675
|
+
try {
|
|
1676
|
+
res = await this.fetchImpl(this.config.chainRpcUrl, {
|
|
1677
|
+
method: "POST",
|
|
1678
|
+
headers: {
|
|
1679
|
+
"Content-Type": "application/json",
|
|
1680
|
+
Accept: "application/json",
|
|
1681
|
+
Origin: this.config.outboundOriginHeader
|
|
1682
|
+
},
|
|
1683
|
+
body,
|
|
1684
|
+
signal: ac.signal
|
|
1685
|
+
});
|
|
1686
|
+
} catch (err) {
|
|
1687
|
+
throw new ChainRpcError(
|
|
1688
|
+
`chain RPC fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1689
|
+
err
|
|
1690
|
+
);
|
|
1691
|
+
} finally {
|
|
1692
|
+
this.clearTimeoutImpl(timer);
|
|
1693
|
+
}
|
|
1694
|
+
if (!res.ok) {
|
|
1695
|
+
throw new ChainRpcError(
|
|
1696
|
+
`chain RPC returned HTTP ${res.status}`
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
let parsed;
|
|
1700
|
+
try {
|
|
1701
|
+
parsed = await res.json();
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
throw new ChainRpcError(
|
|
1704
|
+
`chain RPC returned non-JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
if (parsed.error) {
|
|
1708
|
+
throw new ChainRpcError(
|
|
1709
|
+
`chain RPC error: ${parsed.error.message ?? "unknown"}`
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
if (typeof parsed.result !== "string" || !/^0x[0-9a-fA-F]*$/.test(parsed.result)) {
|
|
1713
|
+
throw new ChainRpcError(
|
|
1714
|
+
`chain RPC returned non-hex result: ${JSON.stringify(parsed.result).slice(0, 80)}`
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
let nonce;
|
|
1718
|
+
try {
|
|
1719
|
+
const decoded = decodeAbiParameters(
|
|
1720
|
+
[{ type: "uint32" }],
|
|
1721
|
+
parsed.result
|
|
1722
|
+
);
|
|
1723
|
+
nonce = Number(decoded[0]);
|
|
1724
|
+
} catch (err) {
|
|
1725
|
+
throw new ChainRpcError(
|
|
1726
|
+
`failed to decode currentNonce result: ${err instanceof Error ? err.message : String(err)}`
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
if (!Number.isFinite(nonce) || nonce < 0 || nonce > 4294967295) {
|
|
1730
|
+
throw new ChainRpcError(`currentNonce out of uint32 range: ${nonce}`);
|
|
1731
|
+
}
|
|
1732
|
+
return nonce;
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Whether the callback path is wired (both secret + backend URL set).
|
|
1736
|
+
* The `notify_userop_landed` daemon handler checks this and returns
|
|
1737
|
+
* `callback_unconfigured` when false so the operator sees the gap.
|
|
1738
|
+
*/
|
|
1739
|
+
isCallbackConfigured() {
|
|
1740
|
+
return Boolean(this.config.callbackServiceSecret) && Boolean(this.config.backendBaseUrl);
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Queue a `validator-enabled` callback POST to the backend. Returns
|
|
1744
|
+
* immediately; the retry loop runs in the background (5s / 15s / 60s
|
|
1745
|
+
* / 5m, max 1h elapsed). Failures are logged but do NOT propagate to
|
|
1746
|
+
* the IPC caller — the chain indexer is the authoritative safety
|
|
1747
|
+
* net.
|
|
1748
|
+
*
|
|
1749
|
+
* Idempotency: every POST carries an `Idempotency-Key` header
|
|
1750
|
+
* `<sessionId>:validator-enabled`. The backend route is no-op if the
|
|
1751
|
+
* mirror row's `enable_status` is already `'enabled'` (because the
|
|
1752
|
+
* chain indexer raced ahead).
|
|
1753
|
+
*
|
|
1754
|
+
* Returns a Promise resolved when the loop terminates (success or
|
|
1755
|
+
* max-elapsed). Callers don't need to await; tests use it for
|
|
1756
|
+
* deterministic assertions.
|
|
1757
|
+
*/
|
|
1758
|
+
enqueueValidatorEnabledCallback(args) {
|
|
1759
|
+
if (!this.isCallbackConfigured()) {
|
|
1760
|
+
return Promise.resolve({
|
|
1761
|
+
ok: false,
|
|
1762
|
+
attempts: 0,
|
|
1763
|
+
lastError: "callback_unconfigured"
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
const dedupKey = `${args.sessionId}:${args.txHash.toLowerCase()}:${args.accountAddress.toLowerCase()}`;
|
|
1767
|
+
const existing = this.inflightCallbacks.get(dedupKey);
|
|
1768
|
+
if (existing) {
|
|
1769
|
+
this.log("info", "validator-enabled callback already in flight \u2014 folded", {
|
|
1770
|
+
sessionId: args.sessionId
|
|
1771
|
+
});
|
|
1772
|
+
return existing;
|
|
1773
|
+
}
|
|
1774
|
+
const promise = this.runCallbackLoop(args).finally(() => {
|
|
1775
|
+
this.inflightCallbacks.delete(dedupKey);
|
|
1776
|
+
});
|
|
1777
|
+
this.inflightCallbacks.set(dedupKey, promise);
|
|
1778
|
+
return promise;
|
|
1779
|
+
}
|
|
1780
|
+
async runCallbackLoop(args) {
|
|
1781
|
+
const url = `${this.config.backendBaseUrl.replace(/\/+$/, "")}/api/v1/agent/policy/scoped-session/${encodeURIComponent(args.sessionId)}/validator-enabled`;
|
|
1782
|
+
const body = JSON.stringify({
|
|
1783
|
+
userId: args.userId,
|
|
1784
|
+
accountAddress: args.accountAddress,
|
|
1785
|
+
permissionId: args.permissionId,
|
|
1786
|
+
txHash: args.txHash,
|
|
1787
|
+
blockNumber: args.blockNumber,
|
|
1788
|
+
logIndex: args.logIndex
|
|
1789
|
+
});
|
|
1790
|
+
const startedAt = Date.now();
|
|
1791
|
+
let attempts = 0;
|
|
1792
|
+
let lastError;
|
|
1793
|
+
for (let i = 0; i <= CALLBACK_RETRY_SCHEDULE_MS.length; i++) {
|
|
1794
|
+
if (i > 0) {
|
|
1795
|
+
const delay = CALLBACK_RETRY_SCHEDULE_MS[i - 1] ?? 0;
|
|
1796
|
+
const elapsed = Date.now() - startedAt;
|
|
1797
|
+
if (elapsed + delay > CALLBACK_MAX_ELAPSED_MS) {
|
|
1798
|
+
lastError = `retry budget exhausted after ${attempts} attempts (last error: ${lastError ?? "unknown"})`;
|
|
1799
|
+
this.log("error", "validator-enabled callback abandoned", {
|
|
1800
|
+
sessionId: args.sessionId,
|
|
1801
|
+
attempts,
|
|
1802
|
+
lastError
|
|
1803
|
+
});
|
|
1804
|
+
return { ok: false, attempts, lastError };
|
|
1805
|
+
}
|
|
1806
|
+
await this.sleep(delay);
|
|
1807
|
+
}
|
|
1808
|
+
attempts++;
|
|
1809
|
+
try {
|
|
1810
|
+
const ok = await this.postCallback(url, body, args.sessionId);
|
|
1811
|
+
if (ok) {
|
|
1812
|
+
this.log("info", "validator-enabled callback succeeded", {
|
|
1813
|
+
sessionId: args.sessionId,
|
|
1814
|
+
attempts
|
|
1815
|
+
});
|
|
1816
|
+
return { ok: true, attempts };
|
|
1817
|
+
}
|
|
1818
|
+
lastError = "non-2xx response";
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
1821
|
+
this.log("warn", "validator-enabled callback attempt failed", {
|
|
1822
|
+
sessionId: args.sessionId,
|
|
1823
|
+
attempt: attempts,
|
|
1824
|
+
err: lastError
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
this.log("error", "validator-enabled callback retry budget exhausted", {
|
|
1829
|
+
sessionId: args.sessionId,
|
|
1830
|
+
attempts,
|
|
1831
|
+
lastError
|
|
1832
|
+
});
|
|
1833
|
+
return { ok: false, attempts, lastError };
|
|
1834
|
+
}
|
|
1835
|
+
async postCallback(url, body, sessionId) {
|
|
1836
|
+
const ac = new AbortController();
|
|
1837
|
+
const timer = this.setTimeoutImpl(() => ac.abort(), this.fetchTimeoutMs);
|
|
1838
|
+
let res;
|
|
1839
|
+
try {
|
|
1840
|
+
res = await this.fetchImpl(url, {
|
|
1841
|
+
method: "POST",
|
|
1842
|
+
headers: {
|
|
1843
|
+
"Content-Type": "application/json",
|
|
1844
|
+
Accept: "application/json",
|
|
1845
|
+
Authorization: `Bearer ${this.config.callbackServiceSecret}`,
|
|
1846
|
+
"Idempotency-Key": `${sessionId}:validator-enabled`,
|
|
1847
|
+
Origin: this.config.outboundOriginHeader
|
|
1848
|
+
},
|
|
1849
|
+
body,
|
|
1850
|
+
signal: ac.signal
|
|
1851
|
+
});
|
|
1852
|
+
} finally {
|
|
1853
|
+
this.clearTimeoutImpl(timer);
|
|
1854
|
+
}
|
|
1855
|
+
if (res.status === 409) {
|
|
1856
|
+
this.log("info", "callback returned 409 (row already enabled \u2014 idempotent)", {
|
|
1857
|
+
sessionId
|
|
1858
|
+
});
|
|
1859
|
+
return true;
|
|
1860
|
+
}
|
|
1861
|
+
if (res.ok) return true;
|
|
1862
|
+
throw new CallbackError(
|
|
1863
|
+
`backend callback returned HTTP ${res.status}`
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
sleep(ms) {
|
|
1867
|
+
return new Promise((resolve) => {
|
|
1868
|
+
this.setTimeoutImpl(() => resolve(), ms);
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1441
1872
|
|
|
1442
1873
|
// src/broker/daemon.ts
|
|
1443
1874
|
var noopLogger = (_e) => {
|
|
1444
1875
|
};
|
|
1445
|
-
async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore) {
|
|
1876
|
+
async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.floor(Date.now() / 1e3), options = {}, policyStore, outbound) {
|
|
1446
1877
|
switch (req.type) {
|
|
1447
1878
|
case "hello": {
|
|
1448
1879
|
let hasJwt = false;
|
|
@@ -1650,6 +2081,57 @@ async function handleBrokerRequest(req, signer, keystore, nowSec = () => Math.fl
|
|
|
1650
2081
|
);
|
|
1651
2082
|
}
|
|
1652
2083
|
}
|
|
2084
|
+
case "current_nonce": {
|
|
2085
|
+
if (!outbound) {
|
|
2086
|
+
return errorResponse(
|
|
2087
|
+
"chain_rpc_failed",
|
|
2088
|
+
"broker daemon was not configured with a chain RPC URL"
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
try {
|
|
2092
|
+
const nonce = await outbound.currentNonce(req.accountAddress);
|
|
2093
|
+
return {
|
|
2094
|
+
type: "current_nonce",
|
|
2095
|
+
nonce,
|
|
2096
|
+
accountAddress: req.accountAddress
|
|
2097
|
+
};
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
if (err instanceof ChainRpcError) {
|
|
2100
|
+
return errorResponse("chain_rpc_failed", err.message);
|
|
2101
|
+
}
|
|
2102
|
+
return errorResponse(
|
|
2103
|
+
"chain_rpc_failed",
|
|
2104
|
+
err instanceof Error ? err.message : "chain RPC eth_call failed"
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
case "notify_userop_landed": {
|
|
2109
|
+
if (!outbound) {
|
|
2110
|
+
return errorResponse(
|
|
2111
|
+
"callback_unconfigured",
|
|
2112
|
+
"broker daemon was not configured with the callback service secret"
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
if (!outbound.isCallbackConfigured()) {
|
|
2116
|
+
return errorResponse(
|
|
2117
|
+
"callback_unconfigured",
|
|
2118
|
+
"BROKER_CALLBACK_SERVICE_SECRET or backend URL is unset \u2014 validator-enabled callback skipped (chain indexer is the safety net)"
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
void outbound.enqueueValidatorEnabledCallback({
|
|
2122
|
+
sessionId: req.sessionId,
|
|
2123
|
+
accountAddress: req.accountAddress,
|
|
2124
|
+
permissionId: req.permissionId,
|
|
2125
|
+
txHash: req.txHash,
|
|
2126
|
+
blockNumber: req.blockNumber,
|
|
2127
|
+
logIndex: req.logIndex
|
|
2128
|
+
});
|
|
2129
|
+
return {
|
|
2130
|
+
type: "notify_userop_landed",
|
|
2131
|
+
queued: true,
|
|
2132
|
+
sessionId: req.sessionId
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
1653
2135
|
}
|
|
1654
2136
|
}
|
|
1655
2137
|
function errorResponse(code, message) {
|
|
@@ -1680,6 +2162,7 @@ var BrokerDaemon = class {
|
|
|
1680
2162
|
config;
|
|
1681
2163
|
keystore;
|
|
1682
2164
|
policyStore;
|
|
2165
|
+
outbound;
|
|
1683
2166
|
/**
|
|
1684
2167
|
* Whether a session-key private half is actually loaded. `false` =
|
|
1685
2168
|
* daemon booted in read-only posture (no `MUHAVEN_BROKER_SESSION_KEY`
|
|
@@ -1700,6 +2183,15 @@ var BrokerDaemon = class {
|
|
|
1700
2183
|
}
|
|
1701
2184
|
this.keystore = options.keystore ?? null;
|
|
1702
2185
|
this.policyStore = options.policyStore ?? new FilePolicyStore(FilePolicyStore.defaultDir());
|
|
2186
|
+
this.outbound = options.outbound ?? new BrokerOutbound(
|
|
2187
|
+
{
|
|
2188
|
+
chainRpcUrl: options.config.chainRpcUrl,
|
|
2189
|
+
backendBaseUrl: options.config.backendBaseUrl,
|
|
2190
|
+
callbackServiceSecret: options.config.callbackServiceSecret,
|
|
2191
|
+
outboundOriginHeader: options.config.outboundOriginHeader ?? options.config.dashboardBaseUrl
|
|
2192
|
+
},
|
|
2193
|
+
(level, msg, meta) => (options.logger ?? noopLogger)({ level, msg, meta })
|
|
2194
|
+
);
|
|
1703
2195
|
this.log = options.logger ?? noopLogger;
|
|
1704
2196
|
this.server = createServer((socket) => this.onConnection(socket));
|
|
1705
2197
|
}
|
|
@@ -1832,7 +2324,8 @@ var BrokerDaemon = class {
|
|
|
1832
2324
|
},
|
|
1833
2325
|
pid: process.pid
|
|
1834
2326
|
},
|
|
1835
|
-
this.policyStore
|
|
2327
|
+
this.policyStore,
|
|
2328
|
+
this.outbound
|
|
1836
2329
|
);
|
|
1837
2330
|
socket.end(serializeResponse(res));
|
|
1838
2331
|
} catch (err) {
|
|
@@ -2785,7 +3278,7 @@ function printUsage() {
|
|
|
2785
3278
|
}
|
|
2786
3279
|
function getBrokerPackageVersion() {
|
|
2787
3280
|
{
|
|
2788
|
-
return "0.
|
|
3281
|
+
return "0.3.0";
|
|
2789
3282
|
}
|
|
2790
3283
|
}
|
|
2791
3284
|
function printVersion() {
|