xytara 3.1.10 → 3.1.11

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/.env.example CHANGED
@@ -17,6 +17,7 @@ XYTARA_X402_TOPUP_UNITS=1000
17
17
  # The runtime mints a fresh CDP Bearer JWT per request when both are set.
18
18
  CDP_API_KEY_ID=
19
19
  CDP_API_KEY_SECRET=
20
+ # (XYTARA_X402_CDP_KEY_ID / XYTARA_X402_CDP_KEY_SECRET are accepted as aliases for the two above)
20
21
  XYTARA_PAYMENT_MAX_AGE_SECONDS=300
21
22
  XYTARA_WALLET_REGISTRY_JSON=[]
22
23
  XYTARA_TREASURY_DESTINATION_ID=
@@ -24,6 +25,9 @@ XYTARA_TREASURY_DESTINATION_KIND=
24
25
  XYTARA_TREASURY_ACCOUNT_ID=
25
26
  XYTARA_TREASURY_EXTERNAL_REF=
26
27
  XYTARA_TREASURY_DESTINATIONS_JSON={}
28
+ # optional file-backed alternatives to the two *_JSON values above (blank = use the JSON values)
29
+ XYTARA_WALLET_REGISTRY_PATH=
30
+ XYTARA_TREASURY_DESTINATIONS_PATH=
27
31
  XYTARA_BSV_TERANODE_NETWORK=mainnet
28
32
  XYTARA_BSV_TERANODE_RPC_URL=
29
33
  XYTARA_BSV_TERANODE_MODE=opreturn
@@ -37,6 +41,18 @@ XYTARA_BSV_TERANODE_SETTLEMENT_RUNTIME_MODE=mock
37
41
  # operator write surfaces fail closed until both tokens are set
38
42
  XYTARA_CREDIT_BRIDGE_BEARER_TOKEN=
39
43
  XYTARA_ACCOUNT_AUTH_BEARER_TOKEN=
44
+ # commerce-bridge write surface (distinct from the credit bridge); also fails closed until set
45
+ XYTARA_COMMERCE_BRIDGE_BEARER_TOKEN=
46
+ # optional: require spend-proof binding on spend-credential use (blank = runtime default)
47
+ XYTARA_REQUIRE_SPEND_PROOF_BINDING=
48
+ # hosted-checkout webhook verification (blank = runtime defaults; set the secret before going live)
49
+ XYTARA_CHECKOUT_WEBHOOK_SECRET=
50
+ XYTARA_HOSTED_CHECKOUT_WEBHOOK_SECRET=
51
+ XYTARA_CHECKOUT_WEBHOOK_VERIFICATION_REQUIRED=
52
+ XYTARA_CHECKOUT_WEBHOOK_MAX_AGE_SECONDS=
53
+ # optional commerce pack map / prep TTL overrides (blank = runtime defaults)
54
+ XYTARA_COMMERCE_PACK_MAP_JSON=
55
+ XYTARA_COMMERCE_PREP_TTL_MS=
40
56
  XYTARA_CALLBACK_DELIVERY_ENABLED=true
41
57
  XYTARA_CALLBACK_SIGNING_SECRET=
42
58
  XYTARA_CALLBACK_TIMEOUT_MS=5000
@@ -47,10 +63,52 @@ XYTARA_SETTLEMENT_REFRESH_INTERVAL_MS=1500
47
63
  XYTARA_RATE_LIMIT_ENABLED=true
48
64
  XYTARA_RATE_LIMIT_WINDOW_MS=60000
49
65
  XYTARA_RATE_LIMIT_MAX_REQUESTS=600
50
- XYTARA_EXTERNAL_EXECUTION_REQUIRED=false
66
+ # optional: scope the rate limit per route instead of globally (blank = runtime default)
67
+ XYTARA_RATE_LIMIT_ROUTE_SCOPED=
68
+ XYTARA_REQUEST_TIMEOUT_MS=30000
69
+ XYTARA_HEADERS_TIMEOUT_MS=15000
51
70
  XYTARA_EXECUTION_TARGETS_JSON=[]
71
+ # External execution (env targets + registered providers). Cold-start ride-out retries ONLY on
72
+ # not-processed signals (408/429/503, connection refused/DNS). The budget is the TOTAL wall-clock for
73
+ # one buyer call across all retries AND failover candidates, kept inside the ~60s x402 authorization
74
+ # window so a ridden-out call still settles. Per-target timeout_ms is capped at 30000 for the same reason.
75
+ XYTARA_EXTERNAL_EXECUTION_RETRY_WINDOW_MS=45000
76
+ XYTARA_EXTERNAL_EXECUTION_MAX_RETRIES=29
77
+ XYTARA_EXTERNAL_EXECUTION_BUDGET_MS=50000
78
+ # Open execution-provider network (lib/provider_registry.js): providers self-register an endpoint +
79
+ # the capabilities they serve; the runtime routes paid calls to ACTIVE providers and splits revenue.
80
+ # Registration FAILS CLOSED until the bearer is set, UNLESS open registration is turned on. Every
81
+ # provider still lands pending until an operator activates it (the vetting gate). See the live
82
+ # machine contract at GET /v1/provider-network/contract.
83
+ XYTARA_PROVIDER_REGISTRY_BEARER_TOKEN=
84
+ # 1 = permissionless self-registration (no bearer); still rate-limited + pending-capped.
85
+ XYTARA_PROVIDER_OPEN_REGISTRATION=0
86
+ XYTARA_PROVIDER_REGISTRATION_RATE_PER_MIN=5
87
+ XYTARA_PROVIDER_REGISTRATION_RATE_GLOBAL_PER_MIN=60
88
+ XYTARA_PROVIDER_MAX_PENDING=100
89
+ # provider endpoint policy: strict (default when RENDER is set) = https only + no loopback/private/
90
+ # link-local/metadata hosts, re-checked by DNS at dispatch; permissive (default locally) for dev/tests
91
+ XYTARA_PROVIDER_ENDPOINT_POLICY=
92
+ # cap on a provider's revenue share, basis points (10000 = 100%)
93
+ XYTARA_PROVIDER_MAX_REVENUE_SHARE_BPS=10000
94
+ # health/failover: consecutive failures -> unhealthy; cooldown before a half-open retry; max candidates tried
95
+ XYTARA_PROVIDER_UNHEALTHY_THRESHOLD=3
96
+ XYTARA_PROVIDER_UNHEALTHY_COOLDOWN_MS=60000
97
+ XYTARA_PROVIDER_FAILOVER_MAX=3
98
+ # routing reputation recency decay, factor in (0,1): fades older observations so recent performance
99
+ # weighs more. 1 (default) = no decay (lifetime counts). e.g. 0.95 once the network has real history.
100
+ XYTARA_PROVIDER_REPUTATION_DECAY=1
101
+ # Provider payout auto-execution via an EXTERNAL signer (the runtime stays key-less). OFF unless the
102
+ # URL is set; otherwise payouts are recorded and the operator sends + confirms them. See services/payout-signer.
103
+ XYTARA_PAYOUT_SIGNER_URL=
104
+ XYTARA_PAYOUT_SIGNER_BEARER=
105
+ XYTARA_PAYOUT_SIGNER_TIMEOUT_MS=20000
52
106
  XYTARA_L402_ENABLED=false
53
107
  XYTARA_L402_SHARED_SECRET=
108
+ # optional L402 tuning (blank = runtime defaults)
109
+ XYTARA_L402_MAX_AGE_SECONDS=
110
+ XYTARA_L402_INVOICE_RATE_PER_MIN=
111
+ XYTARA_L402_INVOICE_RATE_GLOBAL_PER_MIN=
54
112
  XYTARA_BOLT_ENABLED=false
55
113
  XYTARA_BOLT_SHARED_SECRET=
56
114
  XYTARA_BOLT_NETWORK=mainnet
@@ -61,6 +119,8 @@ XYTARA_MPP_SHARED_SECRET=
61
119
  XYTARA_MPP_PROVIDER=stripe_tempo_compatible
62
120
  XYTARA_MPP_SETTLEMENT_FAMILY=cards_or_stablecoin_or_lightning
63
121
  XYTARA_MPP_MAX_AGE_SECONDS=300
122
+ # optional minimum MPP amount in minor units (blank = runtime default)
123
+ XYTARA_MPP_MIN_AMOUNT_MINOR=
64
124
  XYTARA_NETWORK_DNS_RESULT_ORDER=verbatim
65
125
  XYTARA_NETWORK_PREFERRED_IP_FAMILY=auto
66
126
  XYTARA_EXTERNAL_EXECUTION_IP_FAMILY=auto
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # xytara
2
2
 
3
- [![smithery badge](https://smithery.ai/badge/naxytra/xytara-mcp-executor)](https://smithery.ai/servers/naxytra/xytara-mcp-executor)
3
+ Listed on [Smithery](https://smithery.ai/server/naxytra/xytara-mcp-executor) · [Glama](https://glama.ai/mcp/servers/naxytra/xytara-mcp-executor) · the official MCP registry (`com.naxytra/xytara-mcp-executor`).
4
4
 
5
5
  **x402 pay-per-call agent-commerce runtime** — the payment and execution layer of the naxytra
6
6
  machine stack. An agent discovers a capability, pays once with x402 (gasless USDC on Base), and gets
package/RELEASE_NOTES.md CHANGED
@@ -1,7 +1,11 @@
1
- `xytara` 3.1.10 adds a free-tier cold-start ride-out and a reference payout signer, and refreshes the public collateral.
1
+ `xytara` 3.1.11 hardens the open provider network's money ledger and the hosted executor, and tightens repository hygiene. No API is removed; the public provider directory now lists active providers only.
2
2
 
3
3
  Highlights:
4
4
 
5
- - **Cold-start ride-out** — external execution now retries only not-processed signals (408/429/503 and connection-refused) within the x402 settlement window, so a cold free-tier provider warms up mid-retry instead of failing an unsettled call. No always-on keepalive is required. An ambiguous timeout or reset is never retried, so there is no double-execution.
6
- - **Reference payout signer** (`services/payout-signer`) — a separately-deployed service that sends provider payouts on Base while the runtime stays key-less. Fail-closed and double-pay-safe, with an optional durable (Supabase) idempotency store so a free-tier restart cannot cause a re-send.
7
- - Refreshed README and package metadata to lead with the proven, mainnet-demonstrated capabilities.
5
+ - **Settled-amount reconciliation** — a provider's revenue share accrues on min(quote, the amount actually settled on-chain) for x402 calls; an unrecognised settlement asset accrues nothing and is flagged. Every ledger row records its settlement basis (quoted vs. settled).
6
+ - **Registration-scoped ledgers** — ledger rows and payouts belong to one registration of a `provider_id`. A removed and re-registered id never sees, inherits, or is credited by a previous owner's rows, and removal cancels that registration's pending payouts.
7
+ - **In-flight payout guard** — a payout being sent by the external signer cannot be cancelled mid-dispatch (so no freed reservation can ever be re-sent); a payout cancelled before a late tx report is reinstated for reconciliation instead of being lost.
8
+ - **One execution budget per call** — cold-start ride-out retries and failover candidates share a single wall-clock budget inside the x402 authorization window, and per-target timeouts are capped at 30s, so a routed call can always still settle.
9
+ - **Provider endpoint policy** — on hosted deploys, provider endpoints must be public `https` (loopback, private, link-local and metadata hosts are refused and re-checked by DNS at dispatch); redirects are never followed. Providers receive only a sanitized payment context — never the buyer's signed authorization.
10
+ - The MCP executor's bearer check is timing-safe and fails closed on a hosted deploy; adapter execution truth counts only enabled targets; rate-limit identity compares are timing-safe.
11
+ - Repository hygiene: a complete `.gitignore`, the publish workflow waits for the live deploy before registry publication, and `.env.example` documents every runtime knob.
@@ -108,6 +108,9 @@ function classifyTask(task, settlementReadiness) {
108
108
  }
109
109
 
110
110
  if (normalizeString(task && task.public_category) === "adapter") {
111
+ // Only ENABLED targets make an adapter lane live — a bound-but-disabled target must not be
112
+ // reported as provider_live (adapter-depth filters the same way; the two surfaces must agree).
113
+ const enabledTargets = providerTargets.filter((target) => target.enabled);
111
114
  return {
112
115
  task_ref: task.public_task_ref,
113
116
  internal_task_ref: task.task_ref,
@@ -115,10 +118,11 @@ function classifyTask(task, settlementReadiness) {
115
118
  public_category: task.public_category,
116
119
  execution_mode: task.execution_mode,
117
120
  latency_class: task.latency_class,
118
- execution_truth: providerTargets.length > 0 ? "provider_live" : "provider_wiring_required",
119
- execution_origin: providerTargets.length > 0 ? "external_execution_target" : "adapter_lane_without_bound_target",
120
- commercialization_posture: providerTargets.length > 0 ? "sellable_live_now" : "sellable_by_integration",
121
- matching_execution_target_count: providerTargets.length,
121
+ execution_truth: enabledTargets.length > 0 ? "provider_live" : "provider_wiring_required",
122
+ execution_origin: enabledTargets.length > 0 ? "external_execution_target" : "adapter_lane_without_bound_target",
123
+ commercialization_posture: enabledTargets.length > 0 ? "sellable_live_now" : "sellable_by_integration",
124
+ matching_execution_target_count: enabledTargets.length,
125
+ disabled_execution_target_count: providerTargets.length - enabledTargets.length,
122
126
  matching_execution_targets: providerTargets,
123
127
  requirements: {
124
128
  configured_provider_target_required: true
@@ -10,6 +10,7 @@ const { resolveSpendCredential, evaluateAuthorityBindingAction } = require("./ac
10
10
  const { validateIntegrationSelection } = require("../integrations/dispatch");
11
11
  const { markPaymentAccepted, settleDeferredPayment, verifyPaymentAcceptance } = require("./payment_verification");
12
12
  const { executeExternalTarget, resolveExecutionTarget } = require("./external_execution_runtime");
13
+ const { externalExecutionBudgetMs } = require("./external_execution_policy");
13
14
  const { resolveRegisteredTargets, hasActiveMatchingProvider, recordProviderHealth, recordProviderRouting, recordProviderSettlement } = require("./provider_registry");
14
15
  const { claimDeferredExecution, releaseDeferredExecution } = require("./execution_dedupe");
15
16
 
@@ -94,12 +95,36 @@ function buildCreditRejectionResponse(creditDecision, quote) {
94
95
  };
95
96
  }
96
97
 
98
+ // What a provider is allowed to see of the buyer's payment context: the verification outcome and
99
+ // scalar payment facts only. NEVER the signed x402 authorization / deferred-settlement material
100
+ // (that would hand a third-party provider the buyer's signature before settlement), and never
101
+ // signature-, secret- or raw-looking keys or nested objects from any lane's payload.
102
+ const SENSITIVE_CONTEXT_KEY = /signature|secret|private|authorization|raw|base64|header|token/i;
103
+ function sanitizeAuthorizationContextForProvider(context) {
104
+ if (!context || typeof context !== "object") return null;
105
+ const payload = context.payment_payload && typeof context.payment_payload === "object" ? context.payment_payload : null;
106
+ const safePayload = payload
107
+ ? Object.fromEntries(Object.entries(payload).filter(([key, value]) => !SENSITIVE_CONTEXT_KEY.test(key) && (value === null || typeof value !== "object")))
108
+ : null;
109
+ return {
110
+ ok: context.ok === true,
111
+ verification_mode: context.verification_mode || null,
112
+ wallet_id: context.wallet_id || null,
113
+ age_seconds: Number.isFinite(Number(context.age_seconds)) ? Number(context.age_seconds) : null,
114
+ payment_payload: safePayload
115
+ };
116
+ }
117
+
97
118
  async function buildExternalExecutionOptions(state, payload, quote, authorizationContext) {
119
+ // One wall-clock budget for the whole call (every retry of every candidate), inside the x402
120
+ // authorization window — see external_execution_policy.js.
121
+ const executionOptions = { deadline_ms: Date.now() + externalExecutionBudgetMs() };
122
+ const forwardedContext = sanitizeAuthorizationContextForProvider(authorizationContext);
98
123
  // Operator-pinned env target resolves first and is used as-is (an explicit single target,
99
124
  // no failover). Empty env + empty registry -> {} (unchanged behavior).
100
125
  const envTarget = resolveExecutionTarget(payload, quote);
101
126
  if (envTarget) {
102
- const execution = await executeExternalTarget(envTarget, payload, quote, authorizationContext);
127
+ const execution = await executeExternalTarget(envTarget, payload, quote, forwardedContext, executionOptions);
103
128
  if (!execution || !execution.ok) {
104
129
  return { error: execution || { ok: false, status: 502, reason: "external_execution_failed" } };
105
130
  }
@@ -122,7 +147,12 @@ async function buildExternalExecutionOptions(state, payload, quote, authorizatio
122
147
  }
123
148
  let lastError = null;
124
149
  for (const target of candidates) {
125
- const execution = await executeExternalTarget(target, payload, quote, authorizationContext);
150
+ if (Date.now() >= executionOptions.deadline_ms) {
151
+ // Budget spent on earlier candidates: stop here rather than start a call that cannot settle.
152
+ lastError = lastError || { ok: false, status: 504, reason: "external_execution_budget_exhausted" };
153
+ break;
154
+ }
155
+ const execution = await executeExternalTarget(target, payload, quote, forwardedContext, executionOptions);
126
156
  try { recordProviderRouting(state, target.registered_provider_id); } catch (_) { /* non-fatal */ }
127
157
  if (execution && execution.ok) {
128
158
  try { recordProviderHealth(state, target.registered_provider_id, { ok: true }); } catch (_) { /* non-fatal */ }
@@ -139,9 +169,38 @@ async function buildExternalExecutionOptions(state, payload, quote, authorizatio
139
169
  return { error: lastError };
140
170
  }
141
171
 
172
+ // Assets the x402 lane can settle in, with their on-chain decimals (needed to turn settled base
173
+ // units into the ledger's cents). Anything else cannot be reconciled and must not accrue on the quote.
174
+ const X402_ASSET_DECIMALS = new Map([
175
+ ["0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", 6], // USDC, Base mainnet (eip155:8453)
176
+ ["0x036cbd53842c5426634e7929541ec2318f3dcf7e", 6] // USDC, Base Sepolia (eip155:84532)
177
+ ]);
178
+
179
+ // The amount a provider's revenue share is computed from. The quote is what we ASKED for; on the
180
+ // x402 lane the facilitator settles exactly the advertised `accepts` amount, which may differ from
181
+ // the catalog quote — so accrue on min(quote, settled) and never on money that was not collected.
182
+ // Other lanes bind the paid amount to the quote at verification time (local_signed) or charge the
183
+ // quote directly (credits / hosted checkout), so the quote is the settled amount there.
184
+ function reconcileProviderSettlementAmount(quote, verification) {
185
+ const quoted = quote && Number.isFinite(Number(quote.amount_minor)) ? Math.max(0, Math.round(Number(quote.amount_minor))) : 0;
186
+ const mode = verification && typeof verification.verification_mode === "string" ? verification.verification_mode : null;
187
+ if (mode !== "x402_facilitator") {
188
+ return { amount_minor: quoted, quoted_minor: quoted, settled_minor: null, settlement_basis: mode ? `${mode}_quote` : "quote", reconciled: true };
189
+ }
190
+ const paymentPayload = (verification && verification.payment_payload) || {};
191
+ const decimals = X402_ASSET_DECIMALS.get(String(paymentPayload.x402_asset || "").toLowerCase());
192
+ const baseUnits = /^\d{1,30}$/.test(String(paymentPayload.x402_amount)) ? Number(paymentPayload.x402_amount) : NaN;
193
+ const centsCurrency = /^USD(_CENTS)?$/i.test(String((quote && quote.currency) || "USD_CENTS"));
194
+ if (decimals === undefined || !Number.isFinite(baseUnits) || !centsCurrency) {
195
+ return { amount_minor: 0, quoted_minor: quoted, settled_minor: null, settlement_basis: "x402_unreconciled_asset", reconciled: false };
196
+ }
197
+ const settledCents = Math.floor(baseUnits / Math.pow(10, decimals - 2));
198
+ return { amount_minor: Math.min(quoted, settledCents), quoted_minor: quoted, settled_minor: settledCents, settlement_basis: "x402_settled_min_quote", reconciled: true };
199
+ }
200
+
142
201
  // Records the provider revenue-share split for a settled call routed to a registered
143
202
  // provider. Fail-safe: metering must NEVER break or reverse a settled/charged response.
144
- function attributeProviderSettlement(state, externalExecutionOptions, payload, quote, extra) {
203
+ function attributeProviderSettlement(state, externalExecutionOptions, payload, quote, extra, verification) {
145
204
  const execution = externalExecutionOptions && externalExecutionOptions.externalExecution;
146
205
  const providerId = execution && execution.registered_provider_id;
147
206
  if (!providerId) return;
@@ -149,9 +208,14 @@ function attributeProviderSettlement(state, externalExecutionOptions, payload, q
149
208
  const taskRef = (payload && payload.task_ref)
150
209
  || (Array.isArray(payload && payload.tasks) && payload.tasks[0] && payload.tasks[0].task_ref)
151
210
  || null;
211
+ const reconciled = reconcileProviderSettlementAmount(quote, verification);
152
212
  recordProviderSettlement(state, {
153
213
  provider_id: providerId,
154
- amount_minor: quote && Number.isFinite(Number(quote.amount_minor)) ? Number(quote.amount_minor) : 0,
214
+ amount_minor: reconciled.amount_minor,
215
+ quoted_minor: reconciled.quoted_minor,
216
+ settled_minor: reconciled.settled_minor,
217
+ settlement_basis: reconciled.settlement_basis,
218
+ reconciled: reconciled.reconciled,
155
219
  currency: quote && quote.currency ? quote.currency : null,
156
220
  transaction_id: (extra && extra.transaction_id) || (quote && quote.quote_id) || null,
157
221
  settlement_ref: (extra && extra.settlement_ref) || null,
@@ -299,7 +363,7 @@ async function executeCommandRequest(state, payload, headers, options) {
299
363
  };
300
364
  attributeProviderSettlement(state, externalExecutionOptions, payload, quote, {
301
365
  transaction_id: creditSpend.credit_spend.credit_spend_id
302
- });
366
+ }, paymentVerification);
303
367
  return {
304
368
  status: 200,
305
369
  payload: createCommandResult(state, {
@@ -486,7 +550,7 @@ async function executeCommandRequest(state, payload, headers, options) {
486
550
 
487
551
  attributeProviderSettlement(state, externalExecutionOptions, payload, quote, {
488
552
  settlement_ref: (settledVerification.settlement && (settledVerification.settlement.transaction || settledVerification.settlement.txid)) || null
489
- });
553
+ }, settledVerification);
490
554
  return {
491
555
  status: 200,
492
556
  payload: createCommandResult(state, payload, quote, {
@@ -544,7 +608,7 @@ async function executeAuthorizedCommandRequest(state, payload, authorizationCont
544
608
  };
545
609
  }
546
610
 
547
- attributeProviderSettlement(state, externalExecutionOptions, payload, quote);
611
+ attributeProviderSettlement(state, externalExecutionOptions, payload, quote, undefined, paymentVerification);
548
612
  return {
549
613
  status: 200,
550
614
  payload: createCommandResult(state, payload, quote, {
@@ -557,5 +621,7 @@ async function executeAuthorizedCommandRequest(state, payload, authorizationCont
557
621
  module.exports = {
558
622
  executeAuthorizedCommandRequest,
559
623
  executeCommandRequest,
560
- buildExternalExecutionOptions
624
+ buildExternalExecutionOptions,
625
+ reconcileProviderSettlementAmount,
626
+ sanitizeAuthorizationContextForProvider
561
627
  };
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+
3
+ const dns = require("dns").promises;
4
+ const net = require("net");
5
+
6
+ // Upper bound on a single provider call. The x402 (EIP-3009) authorization a buyer signs is valid
7
+ // for roughly a minute, so a provider allowed to run longer than this could do real work for a
8
+ // payment that then fails to settle (nobody pays). Applies to env targets and registered providers.
9
+ const MAX_TARGET_TIMEOUT_MS = 30000;
10
+ const DEFAULT_TARGET_TIMEOUT_MS = 10000;
11
+ const MIN_TARGET_TIMEOUT_MS = 500;
12
+
13
+ // Total wall-clock budget for ONE buyer call across cold-start ride-out retries AND failover
14
+ // candidates, shared so three candidates can never each consume a full window. Kept inside the
15
+ // x402 settlement window (~60s) with headroom for the settle call itself.
16
+ const DEFAULT_EXECUTION_BUDGET_MS = 50000;
17
+ const MAX_EXECUTION_BUDGET_MS = 55000;
18
+
19
+ function clampTargetTimeoutMs(value) {
20
+ const n = Number(value || DEFAULT_TARGET_TIMEOUT_MS) || DEFAULT_TARGET_TIMEOUT_MS;
21
+ return Math.min(MAX_TARGET_TIMEOUT_MS, Math.max(MIN_TARGET_TIMEOUT_MS, Math.round(n)));
22
+ }
23
+
24
+ function externalExecutionBudgetMs() {
25
+ const raw = process.env.XYTARA_EXTERNAL_EXECUTION_BUDGET_MS;
26
+ if (raw === undefined || raw === "") return DEFAULT_EXECUTION_BUDGET_MS;
27
+ const n = Number(raw);
28
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_EXECUTION_BUDGET_MS;
29
+ return Math.min(MAX_EXECUTION_BUDGET_MS, Math.max(1000, Math.round(n)));
30
+ }
31
+
32
+ // Endpoint policy. "strict" (default on a hosted deploy, i.e. when RENDER is set) requires https
33
+ // and refuses endpoints at loopback / private / link-local / metadata addresses, so a registered
34
+ // provider cannot turn the runtime into a request proxy into its own network (SSRF). "permissive"
35
+ // (default locally) keeps http://127.0.0.1-style endpoints usable for tests and development.
36
+ function endpointPolicyMode() {
37
+ const raw = String(process.env.XYTARA_PROVIDER_ENDPOINT_POLICY || "").trim().toLowerCase();
38
+ if (raw === "strict" || raw === "permissive") return raw;
39
+ return process.env.RENDER ? "strict" : "permissive";
40
+ }
41
+
42
+ function isPrivateIp(ip) {
43
+ const family = net.isIP(ip);
44
+ if (family === 4) {
45
+ const p = ip.split(".").map(Number);
46
+ if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;
47
+ if (p[0] === 169 && p[1] === 254) return true; // link-local + cloud metadata
48
+ if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
49
+ if (p[0] === 192 && p[1] === 168) return true;
50
+ if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; // carrier-grade NAT
51
+ if (p[0] >= 224) return true; // multicast / reserved / broadcast
52
+ return false;
53
+ }
54
+ if (family === 6) {
55
+ const lower = ip.toLowerCase();
56
+ if (lower === "::" || lower === "::1") return true;
57
+ if (lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd")) return true; // link-local, ULA
58
+ if (lower.startsWith("::ffff:")) return isPrivateIp(lower.slice(7)); // v4-mapped
59
+ if (lower.startsWith("ff")) return true; // multicast
60
+ return false;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function isBlockedHostname(hostname) {
66
+ const h = String(hostname || "").toLowerCase().replace(/\.$/, "");
67
+ if (!h || h === "localhost" || h.endsWith(".localhost")) return true;
68
+ if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localdomain")) return true;
69
+ if (h === "metadata" || h === "metadata.google.internal") return true;
70
+ return false;
71
+ }
72
+
73
+ function hostOf(parsedUrl) {
74
+ return String(parsedUrl.hostname || "").replace(/^\[|\]$/g, "");
75
+ }
76
+
77
+ // Synchronous shape check (used at registration): protocol + literal-host rules. No DNS here.
78
+ function checkEndpointUrlShape(endpointUrl) {
79
+ const policy = endpointPolicyMode();
80
+ let parsed;
81
+ try {
82
+ parsed = new URL(String(endpointUrl || ""));
83
+ } catch (_) {
84
+ return { ok: false, reason: "endpoint_url_invalid", policy };
85
+ }
86
+ if (parsed.username || parsed.password) {
87
+ return { ok: false, reason: "endpoint_url_credentials_not_allowed", policy };
88
+ }
89
+ if (policy !== "strict") return { ok: true, policy };
90
+ if (parsed.protocol !== "https:") {
91
+ return { ok: false, reason: "endpoint_url_insecure", detail: "https:// is required for provider endpoints", policy };
92
+ }
93
+ const host = hostOf(parsed);
94
+ if (isBlockedHostname(host) || (net.isIP(host) && isPrivateIp(host))) {
95
+ return { ok: false, reason: "endpoint_url_not_public", detail: "loopback, private, link-local and metadata hosts are refused", policy };
96
+ }
97
+ return { ok: true, policy };
98
+ }
99
+
100
+ // Dispatch-time check: re-validates the shape AND resolves the host, refusing any address in a
101
+ // private range (defeats DNS rebinding to an internal address after registration). Results are
102
+ // cached briefly so the hot path adds at most one lookup per host per minute.
103
+ const resolveCache = new Map();
104
+ const RESOLVE_CACHE_TTL_MS = 60000;
105
+
106
+ async function checkEndpointBeforeDispatch(endpointUrl) {
107
+ const shape = checkEndpointUrlShape(endpointUrl);
108
+ if (!shape.ok || shape.policy !== "strict") return shape;
109
+ let host;
110
+ try {
111
+ host = hostOf(new URL(String(endpointUrl)));
112
+ } catch (_) {
113
+ return { ok: false, reason: "endpoint_url_invalid", policy: shape.policy };
114
+ }
115
+ if (net.isIP(host)) return shape; // literal already checked above
116
+ const cached = resolveCache.get(host);
117
+ if (cached && cached.expires_at > Date.now()) return cached.result;
118
+ let result;
119
+ try {
120
+ const addresses = await dns.lookup(host, { all: true, verbatim: true });
121
+ const bad = addresses.find((entry) => isPrivateIp(entry.address));
122
+ result = bad
123
+ ? { ok: false, reason: "endpoint_url_not_public", detail: "endpoint resolves to a non-public address", policy: shape.policy }
124
+ : { ok: true, policy: shape.policy };
125
+ } catch (_) {
126
+ // Unresolvable host: let the fetch surface ENOTFOUND (a not-processed, failover-safe signal).
127
+ result = { ok: true, policy: shape.policy, unresolved: true };
128
+ }
129
+ resolveCache.set(host, { result, expires_at: Date.now() + RESOLVE_CACHE_TTL_MS });
130
+ return result;
131
+ }
132
+
133
+ module.exports = {
134
+ MAX_TARGET_TIMEOUT_MS,
135
+ DEFAULT_TARGET_TIMEOUT_MS,
136
+ DEFAULT_EXECUTION_BUDGET_MS,
137
+ clampTargetTimeoutMs,
138
+ externalExecutionBudgetMs,
139
+ endpointPolicyMode,
140
+ isPrivateIp,
141
+ checkEndpointUrlShape,
142
+ checkEndpointBeforeDispatch
143
+ };
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const { buildFetchOptionsForPurpose } = require("./network_transport");
4
+ const { clampTargetTimeoutMs, checkEndpointBeforeDispatch } = require("./external_execution_policy");
4
5
 
5
6
  function normalizeString(value, fallback = "") {
6
7
  const raw = typeof value === "string" ? value.trim() : "";
@@ -35,7 +36,7 @@ function loadExecutionTargetRegistry() {
35
36
  integration_id: normalizeString(item.integration_id) || null,
36
37
  provider: normalizeString(item.provider, "external_execution"),
37
38
  auth_token: normalizeString(item.auth_token) || null,
38
- timeout_ms: Math.max(500, Number(item.timeout_ms || 10000) || 10000),
39
+ timeout_ms: clampTargetTimeoutMs(item.timeout_ms),
39
40
  enabled: item.enabled !== false
40
41
  };
41
42
  })
@@ -95,9 +96,9 @@ function executionRetryDelayMs(attempt, retryAfterHeader) {
95
96
  return Math.min(500 * Math.pow(2, attempt), 5000); // 500ms, 1000, 2000, 4000, cap 5000
96
97
  }
97
98
 
98
- async function executeExternalTargetOnce(target, body) {
99
+ async function executeExternalTargetOnce(target, body, timeoutMs) {
99
100
  const controller = new AbortController();
100
- const timer = setTimeout(() => controller.abort(), target.timeout_ms);
101
+ const timer = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || target.timeout_ms));
101
102
  try {
102
103
  const response = await fetch(target.endpoint_url, {
103
104
  method: "POST",
@@ -107,6 +108,9 @@ async function executeExternalTargetOnce(target, body) {
107
108
  },
108
109
  body,
109
110
  signal: controller.signal,
111
+ // Never follow a redirect: a provider must not be able to bounce the call (and its bearer)
112
+ // to another host, including an internal one.
113
+ redirect: "error",
110
114
  ...buildFetchOptionsForPurpose("external_execution")
111
115
  });
112
116
  const text = await response.text();
@@ -118,8 +122,39 @@ async function executeExternalTargetOnce(target, body) {
118
122
  }
119
123
  }
120
124
 
121
- async function executeExternalTarget(target, payload, quote, authorizationContext) {
125
+ async function executeExternalTarget(target, payload, quote, authorizationContext, options) {
122
126
  if (!target) return null;
127
+ const opts = ensureObject(options);
128
+ // One shared wall-clock budget for the WHOLE buyer call (every retry of every failover
129
+ // candidate), set by the caller so three candidates can never each burn a full ride-out window
130
+ // and push settlement past the x402 authorization expiry. Absent (direct callers) -> only the
131
+ // ride-out window below bounds this call.
132
+ const budgetDeadline = Number.isFinite(Number(opts.deadline_ms)) ? Number(opts.deadline_ms) : null;
133
+ const budgetExhausted = (attempts) => ({
134
+ ok: false,
135
+ status: 504,
136
+ reason: "external_execution_budget_exhausted",
137
+ provider: target.provider,
138
+ target_id: target.target_id,
139
+ failover_safe: false, // nothing left to spend on another candidate either
140
+ attempts
141
+ });
142
+ if (budgetDeadline !== null && Date.now() >= budgetDeadline) return budgetExhausted(0);
143
+ // Dispatch-time endpoint policy (strict on hosted deploys): refused BEFORE anything is sent, so
144
+ // this is a not-processed failure and failing over to the next candidate is safe.
145
+ const endpointCheck = await checkEndpointBeforeDispatch(target.endpoint_url);
146
+ if (!endpointCheck.ok) {
147
+ return {
148
+ ok: false,
149
+ status: 502,
150
+ reason: "external_execution_endpoint_rejected",
151
+ details: endpointCheck.reason,
152
+ provider: target.provider,
153
+ target_id: target.target_id,
154
+ failover_safe: true,
155
+ attempts: 0
156
+ };
157
+ }
123
158
  const body = JSON.stringify({
124
159
  command: payload && payload.command ? payload.command : "command.execute",
125
160
  task_ref: payload && payload.task_ref ? payload.task_ref : null,
@@ -143,17 +178,22 @@ async function executeExternalTarget(target, payload, quote, authorizationContex
143
178
  ? Math.max(0, Math.min(39, Number(process.env.XYTARA_EXTERNAL_EXECUTION_MAX_RETRIES) || 0))
144
179
  : 29;
145
180
  const maxAttempts = maxRetries + 1;
146
- const deadline = Date.now() + retryWindowMs;
181
+ const rideOutDeadline = Date.now() + retryWindowMs;
182
+ const deadline = budgetDeadline !== null ? Math.min(rideOutDeadline, budgetDeadline) : rideOutDeadline;
183
+ const remainingBudgetMs = () => (budgetDeadline !== null ? budgetDeadline - Date.now() : Infinity);
184
+ // A retry sleep never runs past the deadline, and a single attempt never outlives the budget.
185
+ const boundedSleep = (ms) => sleep(Math.max(0, Math.min(ms, deadline - Date.now())));
147
186
 
148
187
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
188
+ if (remainingBudgetMs() <= 0) return budgetExhausted(attempt);
149
189
  try {
150
- const r = await executeExternalTargetOnce(target, body);
190
+ const r = await executeExternalTargetOnce(target, body, Math.min(target.timeout_ms, remainingBudgetMs()));
151
191
  if (r.ok) {
152
192
  return { ok: true, provider: target.provider, target_id: target.target_id, target_response: r.parsed || null };
153
193
  }
154
194
  // Retry only a provider-rejected (not-processed) status, while the ride-out budget lasts.
155
195
  if (RETRYABLE_EXECUTION_STATUS.has(r.status) && Date.now() < deadline && attempt + 1 < maxAttempts) {
156
- await sleep(executionRetryDelayMs(attempt, r.retry_after));
196
+ await boundedSleep(executionRetryDelayMs(attempt, r.retry_after));
157
197
  continue;
158
198
  }
159
199
  return {
@@ -173,7 +213,7 @@ async function executeExternalTarget(target, payload, quote, authorizationContex
173
213
  // -> not processed -> safe to ride out too. A timeout or mid-stream reset is AMBIGUOUS -> stop.
174
214
  const notProcessed = !isAbort && NOT_PROCESSED_NET_CODES.has(connectErrorCode(error));
175
215
  if (notProcessed && Date.now() < deadline && attempt + 1 < maxAttempts) {
176
- await sleep(executionRetryDelayMs(attempt, null));
216
+ await boundedSleep(executionRetryDelayMs(attempt, null));
177
217
  continue;
178
218
  }
179
219
  return {
@@ -31,7 +31,7 @@ function buildProviderNetworkContract() {
31
31
  "3. An OPERATOR activates it (status:'active').",
32
32
  "4. When a buyer pays (x402) for a capability you serve, the runtime POSTs the job to your endpoint_url; you execute and return the deliverable.",
33
33
  "5. On your success the payment settles on-chain and your revenue_share accrues.",
34
- "6. The operator pays out your accrued balance to your payout_address (manually or via a signer)."
34
+ "6. The operator pays out your accrued balance to your payout_address (manually or via a signer). Track it yourself any time via GET /providers/<id>/earnings with your owner_secret."
35
35
  ],
36
36
 
37
37
  registration: {
@@ -82,9 +82,11 @@ function buildProviderNetworkContract() {
82
82
  body: "object — the buyer's input, e.g. { tool_name, arguments }",
83
83
  account_id: "string — the buyer's account ref",
84
84
  quote: "object — the priced quote for this call",
85
- authorization_context: "object — payment/authorization context"
85
+ authorization_context: "object — SANITIZED payment context: { ok, verification_mode, wallet_id, age_seconds, payment_payload (scalar facts only) }. Never the buyer's signed authorization, signatures, or settlement material."
86
86
  }
87
87
  },
88
+ endpoint_policy: "endpoint_url must be https and publicly routable on the hosted network (loopback, private, link-local and metadata hosts are refused, re-checked by DNS at dispatch); URLs with embedded credentials are refused; redirects are never followed.",
89
+ execution_budget: "One buyer call has a total wall-clock budget (default 50s, inside the x402 authorization window) shared across ride-out retries and failover; your timeout_ms is capped at 30000 ms. Finish well inside it — a call that outlives the authorization cannot settle.",
88
90
  response: {
89
91
  on_success: "Return HTTP 2xx with the deliverable. The runtime surfaces your output from result.output OR a top-level output_payload / output_ref.",
90
92
  example_success_bodies: [
@@ -108,8 +110,9 @@ function buildProviderNetworkContract() {
108
110
  unhealthy_cooldown_ms: intEnv("XYTARA_PROVIDER_UNHEALTHY_COOLDOWN_MS", 60000)
109
111
  },
110
112
  reputation: {
111
- description: "The runtime tracks your lifetime success rate and prefers higher-reputation providers within the healthy tier. New providers get an optimistic score (a fair first shot); one early failure is recoverable (not disqualifying).",
112
- routing_score: "(success + 2) / (success + failure + 2)"
113
+ description: "The runtime tracks your lifetime success rate and prefers higher-reputation providers within the healthy tier. New providers get an optimistic score (a fair first shot); one early failure is recoverable (not disqualifying). The operator may enable recency decay so recent performance outweighs stale history (your raw lifetime counts are still shown either way).",
114
+ routing_score: "(success + 2) / (success + failure + 2)",
115
+ recency_decay: intEnv("XYTARA_PROVIDER_REPUTATION_DECAY", 1)
113
116
  },
114
117
  failover_max_candidates: intEnv("XYTARA_PROVIDER_FAILOVER_MAX", 3),
115
118
  note: "Operator-pinned execution targets (if any) take precedence over the registry for a given capability."
@@ -117,13 +120,15 @@ function buildProviderNetworkContract() {
117
120
 
118
121
  economics: {
119
122
  settlement: "Buyers pay via x402 (gasless USDC on Base). The runtime settles on-chain only AFTER your execution succeeds — a failed call does not charge the buyer.",
120
- revenue_share: "Your provider_share = floor(settled_amount * revenue_share_bps / 10000); the platform keeps the remainder (incl. rounding).",
121
- payouts: "Your accrued share is paid to your payout_address by the operator (reconciled ledger: create -> on-chain send -> confirm). Amounts are tracked in the quote's minor unit (USD_CENTS)."
123
+ revenue_share: "Your provider_share = floor(settled_amount * revenue_share_bps / 10000); the platform keeps the remainder (incl. rounding). settled_amount = min(quote, the amount actually settled on-chain for the call) — never money that was not collected; each ledger row records its settlement_basis.",
124
+ payouts: "Your accrued share is paid to your payout_address by the operator (reconciled ledger: create -> on-chain send -> confirm). Amounts are tracked in the quote's minor unit (USD_CENTS). Ledger rows and payouts are scoped to your registration: a removed-and-re-registered provider_id starts a fresh ledger and never inherits another owner's rows."
122
125
  },
123
126
 
124
127
  discovery: {
125
- list: "GET /v1/provider-network/providers — public; shows each provider's capabilities, status, health, and reputation (never secrets).",
126
- self: "GET /v1/provider-network/providers/<provider_id> — your public record."
128
+ list: "GET /v1/provider-network/providers — public; lists ACTIVE (vetted) providers with capabilities, status, health, and reputation (never secrets). Pending/suspended records are visible to the operator only.",
129
+ self: "GET /v1/provider-network/providers/<provider_id> — your public record.",
130
+ earnings:
131
+ "GET /v1/provider-network/providers/<provider_id>/earnings — your OWN earnings (read-only): payout_state (accrued/paid/pending/available/owed), your settlement rows, and your payout history. Authenticate with YOUR owner_secret via the 'x-provider-owner-secret' header (or Authorization: Bearer <owner_secret>) — this is the provider side, it needs no operator bearer."
127
132
  },
128
133
 
129
134
  operator_gated: [
@@ -133,7 +138,7 @@ function buildProviderNetworkContract() {
133
138
  ],
134
139
 
135
140
  guidance: [
136
- "Be idempotent per request — retries and failover can re-send a provably-not-processed job.",
141
+ "Be idempotent per request — key on quote.quote_id (unique per buyer call): retries and failover can re-send a provably-not-processed job, and some proxies return 503 after forwarding a request.",
137
142
  "Be reliable — health and reputation gate your routing and ordering.",
138
143
  "Return a 429 with Retry-After when overloaded (it is treated as 'did not process' and is safely retried)."
139
144
  ]