Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Tests

on:
push:
branches: [ "**" ]
pull_request:
branches: [ "**" ]

jobs:
unit:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
Comment on lines +13 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden GitHub Actions usage by pinning SHAs and disabling credential persistence.

Line 14 and Line 17 use tag refs instead of commit SHAs, and Line 14 doesn’t disable persisted credentials. This weakens CI supply-chain and token hygiene.

Suggested fix
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@<FULL_LENGTH_COMMIT_SHA>
+        with:
+          persist-credentials: false

       - name: Set up Node.js
-        uses: actions/setup-node@v4
+        uses: actions/setup-node@<FULL_LENGTH_COMMIT_SHA>
         with:
           node-version: 20
           cache: npm
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 13-14: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 14-14: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 17-17: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/test.yml around lines 13 - 20, The workflow uses floating
tags for actions and leaves checkout credentials persisted; update the uses
references to fixed commit SHAs instead of actions/checkout@v4 and
actions/setup-node@v4, and add persist-credentials: false to the
actions/checkout step to avoid keeping the GITHUB_TOKEN in the workspace; keep
the existing setup-node input (node-version: 20 and cache: npm) but reference
the setup-node action by its pinned commit SHA as well.


- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npm test

# Fast, dependency-free guardrail suite — also runs on its own so a
# failure here is unambiguous even if install/other suites have issues.
- name: Budget guardrail tests
run: npm run test:budget
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
"record:narrated": "node src/render-narrated-demo.js",
"setup": "node src/setup-wallets.js",
"setup:usdc": "node src/setup-usdc.js",
"test": "node src/agents/settlement-header.test.js",
"test": "node src/agents/settlement-header.test.js && node tests/orchestrator.budget.test.js",
"test:parser": "node src/agents/settlement-header.test.js",
"test:budget": "node tests/orchestrator.budget.test.js",
"test:demo": "node src/demo.js"
},
"keywords": [
Expand Down
159 changes: 159 additions & 0 deletions src/agents/budget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Budget guardrails & payment-outcome accounting for the orchestrator.
*
* This module is intentionally dependency-free (no SDK, network, or config
* imports) so the guardrail logic can be unit-tested deterministically in
* isolation — mirroring the `settlement-header.js` pattern. `orchestrator.js`
* imports these helpers so the tests guard the *real* code path rather than a
* re-implementation.
*
* All monetary values are USDC. Costs are derived from `agent.price` (a string
* in the registry), matching the orchestrator's historical behavior.
*/

/**
* Resolve an agent's per-call cost as a number.
* Returns NaN for malformed prices (preserving `parseFloat` semantics).
* @param {{ price?: string }} agent
* @returns {number}
*/
export function agentCost(agent) {
return parseFloat(agent?.price);
}

/**
* Remaining budget headroom (may be negative if already overspent).
* @param {number} budget
* @param {number} totalSpent
* @returns {number}
*/
export function remainingBudget(budget, totalSpent) {
return budget - totalSpent;
}

/**
* Format a USDC amount for display/reporting (4 decimal places).
* @param {number} value
* @returns {string}
*/
export function formatAmount(value) {
return Number(value).toFixed(4);
}

/**
* Core guardrail predicate: would running this step push spend over budget?
* Strict greater-than means a step whose cost exactly consumes the remaining
* budget is still allowed to run.
* @param {number} totalSpent
* @param {number} cost
* @param {number} budget
* @returns {boolean}
*/
export function exceedsBudget(totalSpent, cost, budget) {
return totalSpent + cost > budget;
}
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat non-finite values as over-budget to prevent guardrail bypass.

If cost (or totalSpent) is NaN, this predicate always returns false, which disables budget enforcement and can poison later accounting with NaN.

Proposed fix
 export function exceedsBudget(totalSpent, cost, budget) {
-  return totalSpent + cost > budget;
+  if (!Number.isFinite(totalSpent) || !Number.isFinite(cost) || !Number.isFinite(budget)) {
+    return true;
+  }
+  return totalSpent + cost > budget;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agents/budget.js` around lines 52 - 54, The exceedsBudget function
currently allows non-finite inputs (NaN, Infinity) to bypass budget checks;
update exceedsBudget(totalSpent, cost, budget) to first validate Number.isFinite
for totalSpent, cost, and budget and immediately return true (treat as
over-budget) if any are not finite, otherwise compute totalSpent + cost > budget
as before; this prevents NaN propagation and guardrail bypass while keeping the
existing comparison logic in place.


/**
* Build the skipped-step record pushed onto `results` when a step is denied
* by the budget guardrail.
* @param {{ id: string, price: string }} agent
* @param {number} budget
* @param {number} totalSpent
* @returns {{ agentId: string, skipped: true, reason: string }}
*/
export function buildSkipResult(agent, budget, totalSpent) {
return {
agentId: agent.id,
skipped: true,
reason: `Budget limit (${formatAmount(remainingBudget(budget, totalSpent))} USDC remaining, need ${agent.price})`,
};
}

/**
* Build the `budget_limit` broadcast event payload (without a timestamp; the
* caller is responsible for stamping it so this stays deterministic).
* @param {{ name: string, price: string }} agent
* @param {number} budget
* @param {number} totalSpent
* @returns {{ type: 'budget_limit', agent: string, cost: string, remaining: string }}
*/
export function buildBudgetLimitEvent(agent, budget, totalSpent) {
return {
type: 'budget_limit',
agent: agent.name,
cost: agent.price,
remaining: formatAmount(remainingBudget(budget, totalSpent)),
};
}

/**
* Classify a single step's payment outcome into an accounting bucket.
* Anything that is not a confirmed x402 or direct-XLM payment counts as unpaid.
* @param {string|undefined|null} paidVia
* @returns {'x402' | 'stellar-xlm' | 'unpaid'}
*/
export function paymentBucket(paidVia) {
if (paidVia === 'x402') return 'x402';
if (paidVia === 'stellar-xlm-direct') return 'stellar-xlm';
return 'unpaid';
}

/**
* Tally a list of `paidVia` values into the three counters the orchestrator
* reports. Empty input yields all zeros.
* @param {Array<string|undefined|null>} paidViaList
* @returns {{ x402PaymentCount: number, xlmFallbackCount: number, unpaidCount: number }}
*/
export function tallyPaymentOutcomes(paidViaList) {
const counts = { x402PaymentCount: 0, xlmFallbackCount: 0, unpaidCount: 0 };
for (const paidVia of paidViaList || []) {
const bucket = paymentBucket(paidVia);
if (bucket === 'x402') counts.x402PaymentCount += 1;
else if (bucket === 'stellar-xlm') counts.xlmFallbackCount += 1;
else counts.unpaidCount += 1;
}
return counts;
}

/**
* Summarize the dominant payment protocol used across a run.
* @param {number} x402Count
* @param {number} xlmFallbackCount
* @returns {'x402' | 'stellar-xlm' | 'mixed' | 'none'}
*/
export function paymentProtocolSummary(x402Count, xlmFallbackCount) {
if (x402Count > 0 && xlmFallbackCount === 0) return 'x402';
if (x402Count === 0 && xlmFallbackCount > 0) return 'stellar-xlm';
if (x402Count > 0 && xlmFallbackCount > 0) return 'mixed';
return 'none';
}

/**
* Whether the budget has been fully consumed (used for the final summary).
* @param {number} totalSpent
* @param {number} budget
* @returns {boolean}
*/
export function isBudgetExhausted(totalSpent, budget) {
return totalSpent >= budget;
}

/**
* Count steps that actually ran (everything not explicitly skipped).
* NOTE: this intentionally counts "agent not found" error entries as used,
* preserving the orchestrator's existing behavior — see the test that pins it.
* @param {Array<{ skipped?: boolean }>} results
* @returns {number}
*/
export function countUsed(results) {
return results.filter((r) => !r.skipped).length;
}

/**
* Count steps skipped by the budget guardrail.
* @param {Array<{ skipped?: boolean }>} results
* @returns {number}
*/
export function countSkipped(results) {
return results.filter((r) => r.skipped).length;
}
48 changes: 23 additions & 25 deletions src/agents/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import { getBalance, sendPayment } from '../stellar/wallet.js';
import { x402Client, x402HTTPClient, wrapFetchWithPayment } from '@x402/fetch';
import { ExactStellarScheme, createEd25519Signer } from '@x402/stellar';
import { parseSettlementHeader, extractTxHash } from './settlement-header.js';
import {
agentCost,
exceedsBudget,
buildSkipResult,
buildBudgetLimitEvent,
paymentBucket,
paymentProtocolSummary,
isBudgetExhausted,
countUsed,
countSkipped,
} from './budget.js';

// const anthropic = ... (imported from services.js)

Expand Down Expand Up @@ -119,13 +130,6 @@ async function parseResponseBody(response) {
}
}

function paymentProtocolSummary(x402Count, xlmFallbackCount) {
if (x402Count > 0 && xlmFallbackCount === 0) return 'x402';
if (x402Count === 0 && xlmFallbackCount > 0) return 'stellar-xlm';
if (x402Count > 0 && xlmFallbackCount > 0) return 'mixed';
return 'none';
}

async function callAgentViaX402(agent, input, broadcastFn) {
const baseUrl = config.internalBaseUrl;
const endpointFn = PREMIUM_ENDPOINT_MAP[agent.id];
Expand Down Expand Up @@ -352,21 +356,14 @@ Respond ONLY with valid JSON (no markdown, no code fences):
continue;
}

const cost = parseFloat(agent.price);
const cost = agentCost(agent);

if (totalSpent + cost > budget) {
if (exceedsBudget(totalSpent, cost, budget)) {
broadcastFn?.({
type: 'budget_limit',
agent: agent.name,
cost: agent.price,
remaining: (budget - totalSpent).toFixed(4),
...buildBudgetLimitEvent(agent, budget, totalSpent),
timestamp: new Date().toISOString(),
});
results.push({
agentId: agent.id,
skipped: true,
reason: `Budget limit (${(budget - totalSpent).toFixed(4)} USDC remaining, need ${agent.price})`,
});
results.push(buildSkipResult(agent, budget, totalSpent));
continue;
}

Expand Down Expand Up @@ -394,8 +391,9 @@ Respond ONLY with valid JSON (no markdown, no code fences):
}
totalSpent += cost;

if (agentResponse.paidVia === 'x402') x402PaymentCount += 1;
else if (agentResponse.paidVia === 'stellar-xlm-direct') xlmFallbackCount += 1;
const bucket = paymentBucket(agentResponse.paidVia);
if (bucket === 'x402') x402PaymentCount += 1;
else if (bucket === 'stellar-xlm') xlmFallbackCount += 1;
else unpaidCount += 1;

const agentResult = {
Expand Down Expand Up @@ -443,16 +441,16 @@ Respond ONLY with valid JSON (no markdown, no code fences):
}

const elapsed = Date.now() - startTime;
const budgetExhausted = totalSpent >= budget;
const budgetExhausted = isBudgetExhausted(totalSpent, budget);
const paymentProtocol = paymentProtocolSummary(x402PaymentCount, xlmFallbackCount);
const successfulPayments = payments.filter(p => p.paymentSuccess);
const successfulTxs = successfulPayments.filter(p => p.txHash);

broadcastFn?.({
type: 'orchestrator_complete',
totalSpent: totalSpent.toFixed(4),
agentsUsed: results.filter(r => !r.skipped).length,
agentsSkipped: results.filter(r => r.skipped).length,
agentsUsed: countUsed(results),
agentsSkipped: countSkipped(results),
elapsed: `${elapsed}ms`,
budgetExhausted,
paymentProtocol,
Expand All @@ -470,8 +468,8 @@ Respond ONLY with valid JSON (no markdown, no code fences):
budget,
totalSpent: totalSpent.toFixed(4),
budgetExhausted,
agentsUsed: results.filter(r => !r.skipped).length,
agentsSkipped: results.filter(r => r.skipped).length,
agentsUsed: countUsed(results),
agentsSkipped: countSkipped(results),
paymentProtocol,
x402PaymentCount,
xlmFallbackCount,
Expand Down
Loading