@bnbagent/studio-cli 0.0.13-alpha.7 → 0.0.13
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/_agentcoreName-DZDWEYD3.js +0 -0
- package/dist/_twak-4XF4H5PL.js +0 -0
- package/dist/bag.js +353 -71
- package/dist/{chunk-YZ4WH5MD.js → chunk-H4X2OOLA.js} +59 -17
- package/dist/chunk-RO726HJG.js +0 -0
- package/dist/chunk-U7IDQ3K5.js +0 -0
- package/dist/{deployCli-A4EW5FTF.js → deployCli-22NMZ4G7.js} +3 -1
- package/package.json +15 -13
- 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-selling-via-b402.md +1 -1
- package/skills/references/bnbagent-studio-using-altana-wallet.md +3 -2
- package/LICENSE +0 -201
|
@@ -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
|
+
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
|
|
41
41
|
import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
|
|
42
42
|
import { getWallet } from "@bnbagent/studio-runtime/wallet";
|
|
43
|
+
import { limitCommerceOperation } from "./requestLimits.js";
|
|
43
44
|
import * as defaultSigning from "./signing.js";
|
|
44
45
|
|
|
45
46
|
const log = {
|
|
@@ -227,6 +228,7 @@ export class SellerCore {
|
|
|
227
228
|
data: Record<string, unknown>,
|
|
228
229
|
): Promise<Record<string, unknown>> {
|
|
229
230
|
this.requireCommerceRail();
|
|
231
|
+
await limitCommerceOperation("negotiate");
|
|
230
232
|
let request = data.request;
|
|
231
233
|
if (request === null || typeof request !== "object" || Array.isArray(request)) {
|
|
232
234
|
const picked: Record<string, unknown> = {};
|
|
@@ -262,6 +264,7 @@ export class SellerCore {
|
|
|
262
264
|
data: Record<string, unknown>,
|
|
263
265
|
): Promise<Record<string, unknown>> {
|
|
264
266
|
this.requireCommerceRail();
|
|
267
|
+
await limitCommerceOperation("notify_funded");
|
|
265
268
|
const raw = data.job_id;
|
|
266
269
|
if (raw === undefined || raw === null || String(raw) === "") {
|
|
267
270
|
this.spawn(() => this.sweep()); // bare notify → just scan stragglers
|
|
@@ -95,6 +95,7 @@ import express from "express";
|
|
|
95
95
|
import { buildAgentCard } from "./agentCard.js";
|
|
96
96
|
import { SellerAgentExecutor } from "./executor.js";
|
|
97
97
|
import { buildModel } from "./model.js";
|
|
98
|
+
import { requestLimitContext } from "./requestLimits.js";
|
|
98
99
|
import type { RunWork } from "./sellerCore.js";
|
|
99
100
|
import { LLM_READ_TOOLS } from "./tools.js";
|
|
100
101
|
|
|
@@ -500,6 +501,7 @@ async function main(): Promise<void> {
|
|
|
500
501
|
);
|
|
501
502
|
|
|
502
503
|
const app = express();
|
|
504
|
+
app.use(requestLimitContext);
|
|
503
505
|
|
|
504
506
|
// GET /ping status fed to AgentCore: HEALTHY_BUSY while a background
|
|
505
507
|
// delivery is in flight, else HEALTHY.
|
|
@@ -32,7 +32,7 @@ node = [
|
|
|
32
32
|
# BNBAGENT_RUNTIME_SECRET_ID is set (default secretsmanager mode + platform).
|
|
33
33
|
"@aws-sdk/client-secrets-manager@^3.600.0",
|
|
34
34
|
"@bnbagent/studio-runtime",
|
|
35
|
-
"@bnbagent/sdk@0.5.
|
|
35
|
+
"@bnbagent/sdk@0.5.5",
|
|
36
36
|
# The LLM work hook (generateText + tools) and the model factory.
|
|
37
37
|
"ai@^7.0.29",
|
|
38
38
|
# Tool input schemas (AI SDK tools + MCP registerTool).
|
|
@@ -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
|
+
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
|
|
41
41
|
import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
|
|
42
42
|
import { getWallet } from "@bnbagent/studio-runtime/wallet";
|
|
43
|
+
import { limitCommerceOperation } from "./requestLimits.js";
|
|
43
44
|
import * as defaultSigning from "./signing.js";
|
|
44
45
|
|
|
45
46
|
const log = {
|
|
@@ -227,6 +228,7 @@ export class SellerCore {
|
|
|
227
228
|
data: Record<string, unknown>,
|
|
228
229
|
): Promise<Record<string, unknown>> {
|
|
229
230
|
this.requireCommerceRail();
|
|
231
|
+
await limitCommerceOperation("negotiate");
|
|
230
232
|
let request = data.request;
|
|
231
233
|
if (request === null || typeof request !== "object" || Array.isArray(request)) {
|
|
232
234
|
const picked: Record<string, unknown> = {};
|
|
@@ -262,6 +264,7 @@ export class SellerCore {
|
|
|
262
264
|
data: Record<string, unknown>,
|
|
263
265
|
): Promise<Record<string, unknown>> {
|
|
264
266
|
this.requireCommerceRail();
|
|
267
|
+
await limitCommerceOperation("notify_funded");
|
|
265
268
|
const raw = data.job_id;
|
|
266
269
|
if (raw === undefined || raw === null || String(raw) === "") {
|
|
267
270
|
this.spawn(() => this.sweep()); // bare notify → just scan stragglers
|
|
@@ -95,6 +95,7 @@ import express from "express";
|
|
|
95
95
|
import { buildAgentCard } from "./agentCard.js";
|
|
96
96
|
import { SellerAgentExecutor } from "./executor.js";
|
|
97
97
|
import { buildModel } from "./model.js";
|
|
98
|
+
import { requestLimitContext } from "./requestLimits.js";
|
|
98
99
|
import type { RunWork } from "./sellerCore.js";
|
|
99
100
|
import { LLM_READ_TOOLS } from "./tools.js";
|
|
100
101
|
|
|
@@ -500,6 +501,7 @@ async function main(): Promise<void> {
|
|
|
500
501
|
);
|
|
501
502
|
|
|
502
503
|
const app = express();
|
|
504
|
+
app.use(requestLimitContext);
|
|
503
505
|
|
|
504
506
|
// GET /ping status fed to AgentCore: HEALTHY_BUSY while a background
|
|
505
507
|
// delivery is in flight, else HEALTHY.
|
|
@@ -29,7 +29,7 @@ node = [
|
|
|
29
29
|
# on either cloud.
|
|
30
30
|
"@aws-sdk/client-secrets-manager@^3.600.0",
|
|
31
31
|
"@bnbagent/studio-runtime",
|
|
32
|
-
"@bnbagent/sdk@0.5.
|
|
32
|
+
"@bnbagent/sdk@0.5.5",
|
|
33
33
|
# The LLM work hook (generateText + tools) and the model factory
|
|
34
34
|
# (model.ts buildModel — studio.toml [llm] + the provider key env).
|
|
35
35
|
"ai@^7.0.29",
|