@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.
Files changed (29) hide show
  1. package/dist/_agentcoreName-DZDWEYD3.js +0 -0
  2. package/dist/_twak-4XF4H5PL.js +0 -0
  3. package/dist/bag.js +353 -71
  4. package/dist/{chunk-YZ4WH5MD.js → chunk-H4X2OOLA.js} +59 -17
  5. package/dist/chunk-RO726HJG.js +0 -0
  6. package/dist/chunk-U7IDQ3K5.js +0 -0
  7. package/dist/{deployCli-A4EW5FTF.js → deployCli-22NMZ4G7.js} +3 -1
  8. package/package.json +15 -13
  9. package/recipes/agent/code/{{PKG}}/signing.ts.tmpl +2 -2
  10. package/recipes/agent/recipe.toml +1 -1
  11. package/recipes/mpp-buyer/recipe.toml +1 -1
  12. package/recipes/runtimes/agentcore/code/{{PKG}}/dualMain.ts.tmpl +2 -0
  13. package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +14 -6
  14. package/recipes/runtimes/agentcore/code/{{PKG}}/mcpMain.ts.tmpl +63 -30
  15. package/recipes/runtimes/agentcore/code/{{PKG}}/requestLimits.ts.tmpl +178 -0
  16. package/recipes/runtimes/agentcore/code/{{PKG}}/sellerCore.ts.tmpl +3 -0
  17. package/recipes/runtimes/agentcore/code/{{PKG}}/unifiedMain.ts.tmpl +2 -0
  18. package/recipes/runtimes/agentcore/recipe.toml +1 -1
  19. package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +14 -6
  20. package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +63 -30
  21. package/recipes/runtimes/azure-foundry/code/{{PKG}}/requestLimits.ts.tmpl +178 -0
  22. package/recipes/runtimes/azure-foundry/code/{{PKG}}/sellerCore.ts.tmpl +3 -0
  23. package/recipes/runtimes/azure-foundry/code/{{PKG}}/unifiedMain.ts.tmpl +2 -0
  24. package/recipes/runtimes/azure-foundry/recipe.toml +1 -1
  25. package/recipes/wallet/recipe.toml +0 -1
  26. package/recipes/x402-buyer/recipe.toml +1 -1
  27. package/skills/references/bnbagent-studio-selling-via-b402.md +1 -1
  28. package/skills/references/bnbagent-studio-using-altana-wallet.md +3 -2
  29. 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.3",
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
- const name = e instanceof Error ? e.constructor.name : "Error";
98
- const msg = e instanceof Error ? e.message : String(e);
99
- return { error: `${name}: ${msg}`, skill };
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
- const name = e instanceof Error ? e.constructor.name : "Error";
139
- const msg = e instanceof Error ? e.message : String(e);
140
- throw A2AError.internalError(`${name}: ${msg}`);
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
- // here (wallet/signing failure) is left to PROPAGATE the MCP SDK turns
300
- // it into a tool result with `isError: true` (MCP's tool-execution-error
301
- // channel), the peer of the A2A executor throwing
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
- const request = { task_description, terms: terms ?? {} };
307
- const clamped = signing.clampPrice(signing.listPrice());
308
- return toolResult(await signing.signQuote(request, clamped));
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: `${name}: ${msg}`,
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.reason,
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
- const spec = await signing.jobSpec(jid);
371
- const task =
372
- spec !== null
373
- ? JSON.stringify({ task: spec.task, terms: spec.terms })
374
- : `job ${jid}`;
375
- const prompt =
376
- "You accepted and were paid for the following job. Produce the deliverable " +
377
- `now. Be complete and self-contained.\n\nJOB CONTEXT:\n${task}`;
378
- // An unexpected fault below (LLM unavailable, RPC/submit hiccup) is
379
- // left to PROPAGATE the MCP SDK returns it as an isError tool result
380
- // (the peer of the A2A executor's internalError/-32603). Only the
381
- // deterministic, classified outcome SubmitPermanentlyUnsupportedError
382
- // is a "rejected" business result.
383
- const work = await runLlm(prompt);
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: e.message,
436
+ reason: "seller wallet does not support result submission",
405
437
  });
406
438
  }
407
- throw e;
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.3",
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",
@@ -7,7 +7,6 @@ node = ["@bnbagent/studio-runtime"]
7
7
 
8
8
  [dependencies.altana]
9
9
  node = ["@altananetwork/sdk@0.7.1"]
10
-
11
10
  [dependencies.turnkey]
12
11
  node = ["@turnkey/sdk-server@8.1.0", "@turnkey/viem@0.14.34"]
13
12
 
@@ -6,7 +6,7 @@ status = "v0.0.x"
6
6
  [dependencies]
7
7
  node = [
8
8
  "@bnbagent/studio-runtime",
9
- "@bnbagent/sdk@0.5.3",
9
+ "@bnbagent/sdk@0.5.5",
10
10
  "ai@^7.0.29", # AI SDK `tool` wrappers around the buyer functions
11
11
  "zod@^3.25.0", # tool input schemas
12
12
  ]