@bnbagent/studio-cli 0.0.13-alpha.6 → 0.0.13-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bag.js +354 -72
- package/dist/{chunk-LEBIBQWN.js → chunk-NTDWVEW2.js} +59 -17
- package/dist/{deployCli-H65YVV3V.js → deployCli-ZESBUWQB.js} +3 -1
- package/package.json +4 -3
- package/recipes/agent/code/{{PKG}}/signing.ts.tmpl +2 -2
- package/recipes/agent/recipe.toml +1 -1
- package/recipes/mpp-buyer/recipe.toml +1 -1
- package/recipes/runtimes/agentcore/code/{{PKG}}/dualMain.ts.tmpl +2 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +14 -6
- package/recipes/runtimes/agentcore/code/{{PKG}}/mcpMain.ts.tmpl +63 -30
- package/recipes/runtimes/agentcore/code/{{PKG}}/requestLimits.ts.tmpl +178 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/sellerCore.ts.tmpl +3 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/unifiedMain.ts.tmpl +2 -0
- package/recipes/runtimes/agentcore/recipe.toml +1 -1
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +14 -6
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +63 -30
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/requestLimits.ts.tmpl +178 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/sellerCore.ts.tmpl +3 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/unifiedMain.ts.tmpl +2 -0
- package/recipes/runtimes/azure-foundry/recipe.toml +1 -1
- package/recipes/wallet/recipe.toml +0 -1
- package/recipes/x402-buyer/recipe.toml +1 -1
- package/skills/references/bnbagent-studio-adding-to-project.md +1 -1
- package/skills/references/bnbagent-studio-operating.md +1 -1
- package/skills/references/bnbagent-studio-selling-via-b402.md +3 -3
- package/skills/references/bnbagent-studio-using-altana-wallet.md +3 -2
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
|
|
10
10
|
// src/cli/_deploy/deployCli.ts
|
|
11
11
|
import * as fs5 from "fs";
|
|
12
|
+
import { createRequire } from "module";
|
|
12
13
|
import * as os from "os";
|
|
13
14
|
import * as path6 from "path";
|
|
14
15
|
import {
|
|
@@ -415,10 +416,10 @@ function packageVersionOf(entry, packageName) {
|
|
|
415
416
|
async function pack(buildRoot, outZip) {
|
|
416
417
|
fs.rmSync(outZip, { force: true });
|
|
417
418
|
const ZipArchive = await loadZipArchive();
|
|
418
|
-
await new Promise((
|
|
419
|
+
await new Promise((resolve2, reject) => {
|
|
419
420
|
const output = fs.createWriteStream(outZip);
|
|
420
421
|
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
421
|
-
output.on("close", () =>
|
|
422
|
+
output.on("close", () => resolve2());
|
|
422
423
|
archive.on("error", (err) => reject(err));
|
|
423
424
|
archive.pipe(output);
|
|
424
425
|
for (const file of listFiles(buildRoot).sort()) {
|
|
@@ -571,6 +572,21 @@ function printErr(line) {
|
|
|
571
572
|
`);
|
|
572
573
|
}
|
|
573
574
|
|
|
575
|
+
// src/cli/_deploy/commandOverride.ts
|
|
576
|
+
function isVitestProcess(environment, argv) {
|
|
577
|
+
return environment === process.env && environment.VITEST === "true" && argv.some(
|
|
578
|
+
(arg) => /(?:^|[/\\])vitest(?:\.mjs|\.js)?$/u.test(arg) || /(?:^|[/\\])tinypool[/\\]dist[/\\]entry[/\\](?:process|worker)\.js$/u.test(
|
|
579
|
+
arg
|
|
580
|
+
)
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
function deployCommandOverrideAllowed(environment = process.env, argv = process.argv) {
|
|
584
|
+
if (isVitestProcess(environment, argv)) return true;
|
|
585
|
+
const ci = (environment.CI ?? "").trim().toLowerCase();
|
|
586
|
+
const inCi = ci !== "" && !["0", "false", "no"].includes(ci);
|
|
587
|
+
return !inCi && ["development", "test"].includes(environment.NODE_ENV ?? "");
|
|
588
|
+
}
|
|
589
|
+
|
|
574
590
|
// src/cli/preflight.ts
|
|
575
591
|
var OK = "ok";
|
|
576
592
|
var WARN = "warn";
|
|
@@ -615,29 +631,30 @@ function agentcoreFlavor(binPath) {
|
|
|
615
631
|
async function checkBunx() {
|
|
616
632
|
const override = (process.env.BNBAGENT_DEPLOY_COMMAND ?? "").trim();
|
|
617
633
|
if (override) {
|
|
634
|
+
const allowed = deployCommandOverrideAllowed();
|
|
618
635
|
return {
|
|
619
636
|
name: "bnbagent-deploy",
|
|
620
|
-
ok:
|
|
621
|
-
level: OK,
|
|
622
|
-
detail: `
|
|
623
|
-
fix: ""
|
|
637
|
+
ok: allowed,
|
|
638
|
+
level: allowed ? OK : WARN,
|
|
639
|
+
detail: allowed ? `local development/test command override (${override})` : "BNBAGENT_DEPLOY_COMMAND is disabled outside local development/tests and in CI",
|
|
640
|
+
fix: allowed ? "" : "unset BNBAGENT_DEPLOY_COMMAND"
|
|
624
641
|
};
|
|
625
642
|
}
|
|
626
|
-
const found = await whichBin("
|
|
643
|
+
const found = await whichBin("bun");
|
|
627
644
|
if (found === null) {
|
|
628
645
|
return {
|
|
629
646
|
name: "bnbagent-deploy",
|
|
630
647
|
ok: false,
|
|
631
648
|
level: WARN,
|
|
632
|
-
detail: "
|
|
633
|
-
fix: "install Bun 1.3+ (https://bun.sh)
|
|
649
|
+
detail: "bun not found (required by the declared @bnbagent/deploy-cli dependency)",
|
|
650
|
+
fix: "install Bun 1.3+ (https://bun.sh)"
|
|
634
651
|
};
|
|
635
652
|
}
|
|
636
653
|
return {
|
|
637
654
|
name: "bnbagent-deploy",
|
|
638
655
|
ok: true,
|
|
639
656
|
level: OK,
|
|
640
|
-
detail: `
|
|
657
|
+
detail: `bun on PATH (${found}); deploys use the declared @bnbagent/deploy-cli dependency`,
|
|
641
658
|
fix: ""
|
|
642
659
|
};
|
|
643
660
|
}
|
|
@@ -791,8 +808,8 @@ function packageRoot() {
|
|
|
791
808
|
}
|
|
792
809
|
}
|
|
793
810
|
function studioCliVersion() {
|
|
794
|
-
if ("0.0.13-alpha.
|
|
795
|
-
return "0.0.13-alpha.
|
|
811
|
+
if ("0.0.13-alpha.8") {
|
|
812
|
+
return "0.0.13-alpha.8";
|
|
796
813
|
}
|
|
797
814
|
const file = path3.join(packageRoot(), "package.json");
|
|
798
815
|
const pkg = JSON.parse(fs3.readFileSync(file, "utf-8"));
|
|
@@ -1035,7 +1052,7 @@ function x402DeploySummaryFromSnapshot(snapshot, runtime, destination, publicUrl
|
|
|
1035
1052
|
const settlement = [
|
|
1036
1053
|
"B402 settlement can add significant latency; buyer timeout must be at least 120 s.",
|
|
1037
1054
|
"Settlement happens before work. If work later fails, the payment is retained and is not refunded automatically.",
|
|
1038
|
-
"
|
|
1055
|
+
"Compatibility note: @bnb-chain/b402@0.2.1 guards credential replays, but an asynchronous pending response or otherwise ambiguous settlement is outcome unknown; reconcile it and do not replay the paid request."
|
|
1039
1056
|
];
|
|
1040
1057
|
if (protocol === "mpp") {
|
|
1041
1058
|
settlement.push(
|
|
@@ -1073,6 +1090,21 @@ function isTable(value) {
|
|
|
1073
1090
|
// src/cli/_deploy/deployCli.ts
|
|
1074
1091
|
var DEPLOY_CLI_VERSION = "0.5.15";
|
|
1075
1092
|
var DEPLOY_CLI_PACKAGE = `@bnbagent/deploy-cli@${DEPLOY_CLI_VERSION}`;
|
|
1093
|
+
var require2 = createRequire(import.meta.url);
|
|
1094
|
+
function resolveLocalDeployCli() {
|
|
1095
|
+
try {
|
|
1096
|
+
const manifest = require2.resolve("@bnbagent/deploy-cli/package.json");
|
|
1097
|
+
const pkg = JSON.parse(fs5.readFileSync(manifest, "utf-8"));
|
|
1098
|
+
if (pkg.version !== DEPLOY_CLI_VERSION) return null;
|
|
1099
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["bnbagent-deploy"];
|
|
1100
|
+
if (typeof bin !== "string") return null;
|
|
1101
|
+
const entry = path6.resolve(path6.dirname(manifest), bin);
|
|
1102
|
+
return fs5.statSync(entry).isFile() ? entry : null;
|
|
1103
|
+
} catch {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
var LOCAL_DEPLOY_CLI_ENTRY = resolveLocalDeployCli();
|
|
1076
1108
|
var BNB_PLATFORM_API_URL = "https://bnbagent-api.bnbchain.world";
|
|
1077
1109
|
var BNB_PLATFORM_API_URL_ENV = "BNBAGENT_API_URL";
|
|
1078
1110
|
var LEGACY_BNB_PLATFORM_API_URL_ENV = "BAG_PLATFORM_API_BASE";
|
|
@@ -1135,8 +1167,16 @@ function bnbPlatformApiUrl(environment = process.env) {
|
|
|
1135
1167
|
function deployCommand(environment = process.env) {
|
|
1136
1168
|
const override = (environment.BNBAGENT_DEPLOY_COMMAND ?? "").trim();
|
|
1137
1169
|
if (override) {
|
|
1170
|
+
if (!deployCommandOverrideAllowed(environment)) {
|
|
1171
|
+
throw new Error(
|
|
1172
|
+
"BNBAGENT_DEPLOY_COMMAND is allowed only for local development/tests and is disabled in CI"
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1138
1175
|
return override.split(/\s+/u);
|
|
1139
1176
|
}
|
|
1177
|
+
if (LOCAL_DEPLOY_CLI_ENTRY !== null) {
|
|
1178
|
+
return ["bun", LOCAL_DEPLOY_CLI_ENTRY];
|
|
1179
|
+
}
|
|
1140
1180
|
return ["bunx", "--bun", DEPLOY_CLI_PACKAGE];
|
|
1141
1181
|
}
|
|
1142
1182
|
function providerPassthrough(studio, table4) {
|
|
@@ -1351,11 +1391,11 @@ async function withDeployFiles(root, opts, fn) {
|
|
|
1351
1391
|
fs5.rmSync(scratch, { recursive: true, force: true });
|
|
1352
1392
|
}
|
|
1353
1393
|
}
|
|
1354
|
-
var
|
|
1394
|
+
var DEPLOY_CLI_HINT = "error: could not start the lockfile-pinned bnbagent-deploy; install Bun 1.3+ and reinstall @bnbagent/studio-cli.";
|
|
1355
1395
|
async function ensureRunnable(environment = process.env) {
|
|
1356
1396
|
const [bin] = deployCommand(environment);
|
|
1357
1397
|
if (bin && await whichBin(bin) === null) {
|
|
1358
|
-
printErr(
|
|
1398
|
+
printErr(DEPLOY_CLI_HINT);
|
|
1359
1399
|
return false;
|
|
1360
1400
|
}
|
|
1361
1401
|
return true;
|
|
@@ -1365,8 +1405,8 @@ var BUNX_TREE_HINT = [
|
|
|
1365
1405
|
`error: ${DEPLOY_CLI_PACKAGE} could not load its own dependencies \u2014 its `,
|
|
1366
1406
|
"bunx install directory is incomplete. Remove it and retry:\n",
|
|
1367
1407
|
' rm -rf "${TMPDIR:-/tmp}"/bunx-*-@bnbagent\n',
|
|
1368
|
-
"If it persists, clear Bun's package cache (`bun pm cache rm`)
|
|
1369
|
-
"
|
|
1408
|
+
"If it persists, clear Bun's package cache (`bun pm cache rm`) and reinstall ",
|
|
1409
|
+
"@bnbagent/studio-cli."
|
|
1370
1410
|
].join("");
|
|
1371
1411
|
function bunxInstallDir(environment = process.env) {
|
|
1372
1412
|
if (deployCommand(environment)[0] !== "bunx") {
|
|
@@ -1521,6 +1561,7 @@ export {
|
|
|
1521
1561
|
x402SellerUsesB402,
|
|
1522
1562
|
x402SellerIsFree,
|
|
1523
1563
|
b402PaymentProtocol,
|
|
1564
|
+
deployCommandOverrideAllowed,
|
|
1524
1565
|
whichBin,
|
|
1525
1566
|
agentcoreFlavor,
|
|
1526
1567
|
checkBunx,
|
|
@@ -1542,6 +1583,7 @@ export {
|
|
|
1542
1583
|
x402DeploySummaryFromSnapshot,
|
|
1543
1584
|
DEPLOY_CLI_VERSION,
|
|
1544
1585
|
DEPLOY_CLI_PACKAGE,
|
|
1586
|
+
LOCAL_DEPLOY_CLI_ENTRY,
|
|
1545
1587
|
BNB_PLATFORM_API_URL,
|
|
1546
1588
|
BNB_PLATFORM_API_URL_ENV,
|
|
1547
1589
|
LEGACY_BNB_PLATFORM_API_URL_ENV,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEPLOY_CLI_PACKAGE,
|
|
6
6
|
DEPLOY_CLI_VERSION,
|
|
7
7
|
LEGACY_BNB_PLATFORM_API_URL_ENV,
|
|
8
|
+
LOCAL_DEPLOY_CLI_ENTRY,
|
|
8
9
|
bnbEnv,
|
|
9
10
|
bnbPlatformApiUrl,
|
|
10
11
|
buildDeploySpec,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
runPlatformAccountCommand,
|
|
19
20
|
trialFromDeployCliJson,
|
|
20
21
|
withDeployFiles
|
|
21
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-NTDWVEW2.js";
|
|
22
23
|
import "./chunk-RO726HJG.js";
|
|
23
24
|
export {
|
|
24
25
|
BNB_PLATFORM_API_URL,
|
|
@@ -26,6 +27,7 @@ export {
|
|
|
26
27
|
DEPLOY_CLI_PACKAGE,
|
|
27
28
|
DEPLOY_CLI_VERSION,
|
|
28
29
|
LEGACY_BNB_PLATFORM_API_URL_ENV,
|
|
30
|
+
LOCAL_DEPLOY_CLI_ENTRY,
|
|
29
31
|
bnbEnv,
|
|
30
32
|
bnbPlatformApiUrl,
|
|
31
33
|
buildDeploySpec,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bnbagent/studio-cli",
|
|
3
|
-
"version": "0.0.13-alpha.
|
|
3
|
+
"version": "0.0.13-alpha.8",
|
|
4
4
|
"description": "Skills-first toolkit and bag CLI for BNB Chain seller agents: ERC-8004 identity, ERC-8183 escrowed commerce, and x402 payments.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bnb-chain",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"bag": "./dist/bag.js"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@bnbagent/
|
|
45
|
+
"@bnbagent/deploy-cli": "0.5.15",
|
|
46
|
+
"@bnbagent/sdk": "0.5.5",
|
|
46
47
|
"ai": "^7.0.29",
|
|
47
48
|
"archiver": "^8.0.0",
|
|
48
49
|
"commander": "^15.0.0",
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
"tar": "^7.4.0",
|
|
55
56
|
"viem": "^2.54.0",
|
|
56
57
|
"yaml": "^2.9.0",
|
|
57
|
-
"@bnbagent/studio-runtime": "0.0.13-alpha.
|
|
58
|
+
"@bnbagent/studio-runtime": "0.0.13-alpha.8"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@a2a-js/sdk": "^0.3.14",
|
|
@@ -229,8 +229,8 @@ export async function signQuote(
|
|
|
229
229
|
estimatedCompletionSeconds: est,
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
-
//
|
|
233
|
-
//
|
|
232
|
+
// SDK 0.5.4 throws QuoteSigningError when signing fails. Keep this shape
|
|
233
|
+
// check as defense in depth for injected handlers and mixed deployments.
|
|
234
234
|
if (result.accepted && (!result.negotiationHash || !result.providerSig)) {
|
|
235
235
|
throw new Error(
|
|
236
236
|
"quote accepted but provider_sig is missing (wallet sign failed); " +
|
|
@@ -80,6 +80,7 @@ import { buildAgentCard } from "./agentCard.js";
|
|
|
80
80
|
import { SellerAgentExecutor } from "./executor.js";
|
|
81
81
|
import { buildMcpServer } from "./mcpMain.js";
|
|
82
82
|
import { buildModel } from "./model.js";
|
|
83
|
+
import { requestLimitContext } from "./requestLimits.js";
|
|
83
84
|
import type { RunWork } from "./sellerCore.js";
|
|
84
85
|
import { LLM_READ_TOOLS } from "./tools.js";
|
|
85
86
|
|
|
@@ -296,6 +297,7 @@ export async function buildDualApp(): Promise<{
|
|
|
296
297
|
);
|
|
297
298
|
|
|
298
299
|
const app = express();
|
|
300
|
+
app.use(requestLimitContext);
|
|
299
301
|
|
|
300
302
|
// GET /ping status fed to AgentCore: HEALTHY_BUSY while a background
|
|
301
303
|
// delivery is in flight, else HEALTHY.
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type ExecutionEventBus,
|
|
44
44
|
type RequestContext,
|
|
45
45
|
} from "@a2a-js/sdk/server";
|
|
46
|
+
import { isCommerceRateLimitError } from "./requestLimits.js";
|
|
46
47
|
import { SellerCore } from "./sellerCore.js";
|
|
47
48
|
|
|
48
49
|
const log = {
|
|
@@ -94,9 +95,10 @@ export class SellerAgentExecutor extends SellerCore implements AgentExecutor {
|
|
|
94
95
|
} catch (e) {
|
|
95
96
|
// a skill failure must still ACK the buyer
|
|
96
97
|
log.error(`skill ${JSON.stringify(skill)} failed`, e);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
if (isCommerceRateLimitError(e)) {
|
|
99
|
+
return { status: "retry", error: "seller rate limit exceeded", skill };
|
|
100
|
+
}
|
|
101
|
+
return { error: "seller operation failed; retry later", skill };
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -135,9 +137,15 @@ export class SellerAgentExecutor extends SellerCore implements AgentExecutor {
|
|
|
135
137
|
// returned as a result above (peer of the MCP runtime: faults →
|
|
136
138
|
// isError, business outcomes → result).
|
|
137
139
|
log.error(`skill ${JSON.stringify(skill)} failed`, e);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
if (isCommerceRateLimitError(e)) {
|
|
141
|
+
result = {
|
|
142
|
+
status: "retry",
|
|
143
|
+
error: "seller rate limit exceeded",
|
|
144
|
+
skill,
|
|
145
|
+
};
|
|
146
|
+
} else {
|
|
147
|
+
throw A2AError.internalError("seller operation failed; retry later");
|
|
148
|
+
}
|
|
141
149
|
}
|
|
142
150
|
reply(eventBus, context, result);
|
|
143
151
|
};
|
|
@@ -79,6 +79,11 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
|
79
79
|
import { generateText, stepCountIs } from "ai";
|
|
80
80
|
import express from "express";
|
|
81
81
|
import { z } from "zod";
|
|
82
|
+
import {
|
|
83
|
+
isCommerceRateLimitError,
|
|
84
|
+
limitCommerceOperation,
|
|
85
|
+
requestLimitContext,
|
|
86
|
+
} from "./requestLimits.js";
|
|
82
87
|
import * as signing from "./signing.js";
|
|
83
88
|
|
|
84
89
|
const APP_NAME = "agent";
|
|
@@ -88,6 +93,11 @@ const log = {
|
|
|
88
93
|
console.error(`[seller-agent.mcp] ERROR ${msg}`, e ?? ""),
|
|
89
94
|
};
|
|
90
95
|
|
|
96
|
+
function protocolFailure(scope: string, error: unknown): never {
|
|
97
|
+
log.error(scope, error);
|
|
98
|
+
throw new Error("seller operation failed; retry later");
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
// ── Runtime secrets ───────────────────────────────────────────────────────────
|
|
92
102
|
// Keep plaintext secrets OUT of agentcore.json. When BNBAGENT_RUNTIME_SECRET_ID
|
|
93
103
|
// is set (deployed runtime), pull a JSON {ENV_NAME: value} blob from AWS
|
|
@@ -296,16 +306,24 @@ export function buildMcpServer(
|
|
|
296
306
|
annotations: COMMERCE_ANNOTATIONS,
|
|
297
307
|
},
|
|
298
308
|
// Error contract (unified with the A2A executor): an unexpected fault
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// `A2AError.internalError` → JSON-RPC -32603. Only CLASSIFIED business
|
|
303
|
-
// outcomes are returned as a normal result. So do NOT wrap this in a
|
|
304
|
-
// try/catch that masks a fault as a successful quote.
|
|
309
|
+
// becomes an MCP `isError` result with a generic public message; its full
|
|
310
|
+
// detail is logged server-side. Classified quota exhaustion is returned
|
|
311
|
+
// as a normal retry result and never as a fake quote.
|
|
305
312
|
async ({ task_description, terms }) => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
313
|
+
try {
|
|
314
|
+
await limitCommerceOperation("negotiate");
|
|
315
|
+
const request = { task_description, terms: terms ?? {} };
|
|
316
|
+
const clamped = signing.clampPrice(signing.listPrice());
|
|
317
|
+
return toolResult(await signing.signQuote(request, clamped));
|
|
318
|
+
} catch (e) {
|
|
319
|
+
if (isCommerceRateLimitError(e)) {
|
|
320
|
+
return toolResult({
|
|
321
|
+
status: "retry",
|
|
322
|
+
reason: "seller rate limit exceeded",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return protocolFailure("negotiate failed", e);
|
|
326
|
+
}
|
|
309
327
|
},
|
|
310
328
|
);
|
|
311
329
|
|
|
@@ -327,6 +345,17 @@ export function buildMcpServer(
|
|
|
327
345
|
annotations: COMMERCE_ANNOTATIONS,
|
|
328
346
|
},
|
|
329
347
|
async ({ job_id }, extra) => {
|
|
348
|
+
try {
|
|
349
|
+
await limitCommerceOperation("notify_funded");
|
|
350
|
+
} catch (e) {
|
|
351
|
+
if (isCommerceRateLimitError(e)) {
|
|
352
|
+
return toolResult({
|
|
353
|
+
status: "retry",
|
|
354
|
+
reason: "seller rate limit exceeded",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return protocolFailure("notify_funded limiter failed", e);
|
|
358
|
+
}
|
|
330
359
|
let jid: number;
|
|
331
360
|
try {
|
|
332
361
|
jid = parseJobId(job_id);
|
|
@@ -348,40 +377,43 @@ export function buildMcpServer(
|
|
|
348
377
|
} catch (e) {
|
|
349
378
|
// a failed verify is transient; tell the buyer to retry
|
|
350
379
|
log.error(`verify of job ${jid} failed`, e);
|
|
351
|
-
const name = e instanceof Error ? e.constructor.name : "Error";
|
|
352
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
353
380
|
return toolResult({
|
|
354
381
|
status: "retry",
|
|
355
382
|
job_id: jid,
|
|
356
|
-
reason:
|
|
383
|
+
reason: "chain verification temporarily unavailable",
|
|
357
384
|
});
|
|
358
385
|
}
|
|
359
386
|
if (!verdict.ok) {
|
|
360
387
|
return toolResult({
|
|
361
388
|
status: verdict.permanent ? "rejected" : "retry",
|
|
362
389
|
job_id: jid,
|
|
363
|
-
reason: verdict.
|
|
390
|
+
reason: verdict.permanent
|
|
391
|
+
? verdict.reason
|
|
392
|
+
: "chain verification temporarily unavailable",
|
|
364
393
|
});
|
|
365
394
|
}
|
|
366
395
|
|
|
367
396
|
// 2/4 — produce the deliverable (THE ONLY LLM CALL; specialise the
|
|
368
397
|
// prompt here)
|
|
369
398
|
await reportProgress(extra, 2, 4);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
spec
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
399
|
+
let work: string;
|
|
400
|
+
try {
|
|
401
|
+
const spec = await signing.jobSpec(jid);
|
|
402
|
+
const task =
|
|
403
|
+
spec !== null
|
|
404
|
+
? JSON.stringify({ task: spec.task, terms: spec.terms })
|
|
405
|
+
: `job ${jid}`;
|
|
406
|
+
const prompt =
|
|
407
|
+
"You accepted and were paid for the following job. Produce the deliverable " +
|
|
408
|
+
`now. Be complete and self-contained.\n\nJOB CONTEXT:\n${task}`;
|
|
409
|
+
work = await runLlm(prompt);
|
|
410
|
+
} catch (e) {
|
|
411
|
+
return protocolFailure(`delivery preparation for job ${jid} failed`, e);
|
|
412
|
+
}
|
|
413
|
+
// Unexpected LLM/RPC faults are logged in full, then surfaced through
|
|
414
|
+
// MCP's isError channel with a generic public message. Only the
|
|
415
|
+
// deterministic SubmitPermanentlyUnsupportedError is a classified
|
|
416
|
+
// "rejected" business result.
|
|
385
417
|
// 3/4 — sign + broadcast the on-chain submit (re-verifies FUNDED inside)
|
|
386
418
|
await reportProgress(extra, 3, 4);
|
|
387
419
|
let res: { submitTx: string; deliverableUrl: string | null };
|
|
@@ -401,10 +433,10 @@ export function buildMcpServer(
|
|
|
401
433
|
status: "rejected",
|
|
402
434
|
job_id: jid,
|
|
403
435
|
skip: true,
|
|
404
|
-
reason:
|
|
436
|
+
reason: "seller wallet does not support result submission",
|
|
405
437
|
});
|
|
406
438
|
}
|
|
407
|
-
|
|
439
|
+
return protocolFailure(`submit of job ${jid} failed`, e);
|
|
408
440
|
}
|
|
409
441
|
|
|
410
442
|
// 4/4 — done
|
|
@@ -606,6 +638,7 @@ async function main(): Promise<void> {
|
|
|
606
638
|
});
|
|
607
639
|
|
|
608
640
|
const app = express();
|
|
641
|
+
app.use(requestLimitContext);
|
|
609
642
|
|
|
610
643
|
if (seller.state !== "disabled") {
|
|
611
644
|
app.all(
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-level quotas for the two public seller operations.
|
|
3
|
+
*
|
|
4
|
+
* A process-wide bucket is always enforced, including when the hosting
|
|
5
|
+
* platform exposes no trustworthy caller identity. A second per-caller
|
|
6
|
+
* bucket is enabled only when the operator names a header that its trusted
|
|
7
|
+
* edge sets after stripping caller-supplied values. Request payload fields
|
|
8
|
+
* and forwarded IP headers are deliberately never treated as identities.
|
|
9
|
+
* The defaults are process-local; multi-replica owners can inject async
|
|
10
|
+
* shared limiters without making that infrastructure mandatory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
import { RateLimitExceeded, SlidingWindowLimiter } from "@bnbagent/sdk/utils";
|
|
15
|
+
import type { NextFunction, Request, Response } from "express";
|
|
16
|
+
|
|
17
|
+
type CommerceOperation = "negotiate" | "notify_funded";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_GLOBAL_MAX = 120;
|
|
20
|
+
const DEFAULT_CALLER_MAX = 20;
|
|
21
|
+
const DEFAULT_WINDOW_SECONDS = 60;
|
|
22
|
+
const DEFAULT_MAX_CALLERS = 10_000;
|
|
23
|
+
const SHARED_LIMITER_TIMEOUT_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
export interface CommerceRateLimiter {
|
|
26
|
+
/** Consume one request; honor cancellation and reject denied requests. */
|
|
27
|
+
check(key: string, signal?: AbortSignal): void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommerceRateLimiters {
|
|
31
|
+
readonly global: CommerceRateLimiter;
|
|
32
|
+
readonly caller: CommerceRateLimiter;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CachedLimiters extends CommerceRateLimiters {
|
|
36
|
+
readonly configKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const callerContext = new AsyncLocalStorage<string | undefined>();
|
|
40
|
+
let cached: CachedLimiters | undefined;
|
|
41
|
+
let injected: CommerceRateLimiters | undefined;
|
|
42
|
+
let warnedProcessLocal = false;
|
|
43
|
+
|
|
44
|
+
/** Replace process-local counters with application-owned shared limiters. */
|
|
45
|
+
export function setCommerceRateLimiters(
|
|
46
|
+
value: CommerceRateLimiters | null,
|
|
47
|
+
): void {
|
|
48
|
+
injected = value ?? undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveEnv(name: string, fallback: number): number {
|
|
52
|
+
const raw = process.env[name];
|
|
53
|
+
if (raw === undefined || !/^\d+$/u.test(raw)) return fallback;
|
|
54
|
+
const value = Number(raw);
|
|
55
|
+
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function limiters(): CommerceRateLimiters {
|
|
59
|
+
if (injected) return injected;
|
|
60
|
+
const environment = (
|
|
61
|
+
process.env.ENV ||
|
|
62
|
+
process.env.ENVIRONMENT ||
|
|
63
|
+
process.env.NODE_ENV ||
|
|
64
|
+
""
|
|
65
|
+
)
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
if (
|
|
69
|
+
!warnedProcessLocal &&
|
|
70
|
+
!["dev", "development", "test"].includes(environment)
|
|
71
|
+
) {
|
|
72
|
+
warnedProcessLocal = true;
|
|
73
|
+
console.warn(
|
|
74
|
+
"[seller-agent] this process is using process-local rate limits; " +
|
|
75
|
+
"inject shared limiters or enforce equivalent limits at a trusted edge before scaling out.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const names = [
|
|
79
|
+
"SELLER_RATE_LIMIT_GLOBAL_MAX_REQUESTS",
|
|
80
|
+
"SELLER_RATE_LIMIT_CALLER_MAX_REQUESTS",
|
|
81
|
+
"SELLER_RATE_LIMIT_WINDOW_SECONDS",
|
|
82
|
+
"SELLER_RATE_LIMIT_MAX_CALLERS",
|
|
83
|
+
] as const;
|
|
84
|
+
const configKey = names.map((name) => process.env[name] ?? "").join("\0");
|
|
85
|
+
if (cached?.configKey === configKey) return cached;
|
|
86
|
+
|
|
87
|
+
const windowSeconds = positiveEnv(
|
|
88
|
+
"SELLER_RATE_LIMIT_WINDOW_SECONDS",
|
|
89
|
+
DEFAULT_WINDOW_SECONDS,
|
|
90
|
+
);
|
|
91
|
+
cached = {
|
|
92
|
+
configKey,
|
|
93
|
+
global: new SlidingWindowLimiter(
|
|
94
|
+
positiveEnv("SELLER_RATE_LIMIT_GLOBAL_MAX_REQUESTS", DEFAULT_GLOBAL_MAX),
|
|
95
|
+
windowSeconds,
|
|
96
|
+
2,
|
|
97
|
+
),
|
|
98
|
+
caller: new SlidingWindowLimiter(
|
|
99
|
+
positiveEnv("SELLER_RATE_LIMIT_CALLER_MAX_REQUESTS", DEFAULT_CALLER_MAX),
|
|
100
|
+
windowSeconds,
|
|
101
|
+
positiveEnv("SELLER_RATE_LIMIT_MAX_CALLERS", DEFAULT_MAX_CALLERS),
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
return cached;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function checkLimiter(
|
|
108
|
+
limiter: CommerceRateLimiter,
|
|
109
|
+
key: string,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
try {
|
|
114
|
+
await Promise.race([
|
|
115
|
+
Promise.resolve(limiter.check(key, controller.signal)),
|
|
116
|
+
new Promise<never>((_resolve, reject) => {
|
|
117
|
+
timer = setTimeout(
|
|
118
|
+
() => {
|
|
119
|
+
controller.abort();
|
|
120
|
+
reject(new RateLimitExceeded("Seller rate limiter unavailable"));
|
|
121
|
+
},
|
|
122
|
+
SHARED_LIMITER_TIMEOUT_MS,
|
|
123
|
+
);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
}),
|
|
126
|
+
]);
|
|
127
|
+
} finally {
|
|
128
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function trustedCaller(headers: Request["headers"]): string | undefined {
|
|
133
|
+
const header = (process.env.SELLER_TRUSTED_CALLER_HEADER ?? "")
|
|
134
|
+
.trim()
|
|
135
|
+
.toLowerCase();
|
|
136
|
+
if (!/^[a-z0-9-]+$/u.test(header)) return undefined;
|
|
137
|
+
|
|
138
|
+
const raw = headers[header];
|
|
139
|
+
if (typeof raw !== "string") return undefined;
|
|
140
|
+
const value = raw.trim();
|
|
141
|
+
if (value.length === 0 || value.length > 256 || /[\r\n]/u.test(value)) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Carry only an operator-configured, edge-authenticated identity. */
|
|
148
|
+
export function withTrustedCaller<T>(
|
|
149
|
+
headers: Request["headers"],
|
|
150
|
+
work: () => T,
|
|
151
|
+
): T {
|
|
152
|
+
return callerContext.run(trustedCaller(headers), work);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Express middleware that makes the trusted identity available to handlers. */
|
|
156
|
+
export function requestLimitContext(
|
|
157
|
+
req: Request,
|
|
158
|
+
_res: Response,
|
|
159
|
+
next: NextFunction,
|
|
160
|
+
): void {
|
|
161
|
+
withTrustedCaller(req.headers, next);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Consume both the mandatory process bucket and optional caller bucket. */
|
|
165
|
+
export async function limitCommerceOperation(
|
|
166
|
+
operation: CommerceOperation,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const active = limiters();
|
|
169
|
+
await checkLimiter(active.global, operation);
|
|
170
|
+
const caller = callerContext.getStore();
|
|
171
|
+
if (caller !== undefined) {
|
|
172
|
+
await checkLimiter(active.caller, `${operation}:${caller}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function isCommerceRateLimitError(error: unknown): boolean {
|
|
177
|
+
return error instanceof RateLimitExceeded;
|
|
178
|
+
}
|