From b6174dd6add52068e6063ea7259869271d98d127 Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:14:22 -0500 Subject: [PATCH] fix(security): the account key was one auto-approved command away from the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key mode put a bearer credential in process.env, and the Bash tool handed the whole environment to every subprocess it spawned. bash-guard classifies `printenv` and `echo` as safe, so `printenv BLOCKRUN_API_KEY` needed no confirmation and returned the key as tool output — into the model prompt, over the network to whichever gateway model served the turn, and into the session transcript on disk. `cat ~/.blockrun/api-key` reached the same place through Read, because only the wallet key files were behind isWalletKeyPath. secret-redact.ts already knew the brk_ shape. It just never saw this: its one call site scrubs what the USER types, not what a tool returns. The key is not a private key, but it spends — a prepaid balance, no per-call signature, no chain-level ceiling. It gets the containment the wallet keys have: - bash.ts withholds BLOCKRUN_API_KEY from the subprocess env. Franklin's own gateway calls read process.env in-process and are unaffected. - sensitive-paths.ts guards ~/.blockrun/api-key alongside the wallet keys. - streaming-executor.ts scrubs every tool result at the single funnel they all pass through, before the persist-to-disk branch so an oversized result is not written out unredacted. Closes the class, not just the one command: a key can also arrive via a config file, a `curl -v` trace, or an MCP result. Two more places key mode still assumed a wallet: - reservation.ts read an on-chain balance to gate Modal calls, so an unfunded wallet read $0 and every ModalCreate/ModalExec failed with "fund the wallet" while account credits sat unused. It now reads the credit balance, and treats an ungated account (remaining_usd null) or an unreachable gateway as no local ceiling rather than inventing one — the gateway still 402s and --max-spend still bounds the session. - getCoreInstructions() briefed the model on a wallet, a USDC balance, and a Wallet tool that returns a portal link in key mode. It now branches, and assembleInstructions() keys its cache on the pay mode so invalidateKey() demoting a session mid-run cannot serve a stale prompt. Every fix is mutation-tested: reverting any one of the five fails a named test. 727 local tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rm7cRGtC7wCofhYuHRo21h --- src/agent/context.ts | 37 +++++- src/agent/secret-redact.ts | 51 +++++++++ src/agent/streaming-executor.ts | 19 ++++ src/tools/bash.ts | 25 +++-- src/tools/modal.ts | 15 ++- src/tools/sensitive-paths.ts | 15 ++- src/wallet/reservation.ts | 22 +++- test/api-key.local.mjs | 193 ++++++++++++++++++++++++++++++++ 8 files changed, 354 insertions(+), 23 deletions(-) diff --git a/src/agent/context.ts b/src/agent/context.ts index 71f9da15..cb31ed6d 100644 --- a/src/agent/context.ts +++ b/src/agent/context.ts @@ -11,23 +11,44 @@ import { getWalletAddress as getBaseWalletAddress } from '@blockrun/llm'; import { Keypair } from '@solana/web3.js'; import bs58 from 'bs58'; import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt } from '../learnings/store.js'; +import { isKeyMode } from '../payments/auth-mode.js'; // ─── System Instructions Assembly ────────────────────────────────────────── // Composable prompt sections — each independently maintainable and conditionally includable. function getCoreInstructions(): string { - return `You are Franklin, an autonomous AI agent with a wallet. You help users with software engineering, marketing campaigns, trading signals, and any task that benefits from an agent that can reason, act, and spend. + // Key mode has no wallet. getWalletKnowledgeSection() below already branches + // on this; these lines did not, so every key-mode turn briefed the model on + // a wallet it cannot read and a USDC balance it cannot spend — then pointed + // the Wallet tool at it, which returns a portal link instead. Keep the + // spend-without-hesitating posture either way; only the funding source + // changes. + const keyMode = isKeyMode(); + const identity = keyMode + ? 'an autonomous AI agent that pays its own way with prepaid BlockRun account credits' + : 'an autonomous AI agent with a wallet'; + const walletTool = keyMode + ? '- **Wallet** — reports that this session bills prepaid account credits and where to manage them. Use it for any "what\'s my balance / how much money / billing status" question instead of running \`franklin balance\` via Bash. Free, one call.' + : '- **Wallet** — read your own chain, address, and USDC balance. Use this for any "what\'s my balance / how much money / wallet status" question instead of running \`franklin balance\` via Bash. Free, one call, never costs USDC.'; + const tradingMarket = keyMode + ? '- **TradingMarket** — current stock / FX / crypto / commodity prices (BlockRun Gateway / Pyth; billed to account credits, $0.001/stock call, free for everything else).' + : '- **TradingMarket** — current stock / FX / crypto / commodity prices (BlockRun Gateway / Pyth; wallet pays automatically, $0.001/stock call, free for everything else).'; + const spendPosture = keyMode + ? 'Your brand is spending real money to get real answers; $0.001 for a stock quote is exactly what the account credits are for. Don\'t hesitate on cents.' + : 'Your brand is spending USDC to get real answers; $0.001 for a stock quote is exactly what the wallet is for. Don\'t hesitate on cents.'; + + return `You are Franklin, ${identity}. You help users with software engineering, marketing campaigns, trading signals, and any task that benefits from an agent that can reason, act, and spend. You are an interactive agent — not a chatbot. Use the tools available to you to accomplish tasks. Your job is to be a highly capable collaborator who takes initiative, makes progress, and delivers results. # Franklin has hands You run with live tools by default: -- **Wallet** — read your own chain, address, and USDC balance. Use this for any "what's my balance / how much money / wallet status" question instead of running \`franklin balance\` via Bash. Free, one call, never costs USDC. -- **TradingMarket** — current stock / FX / crypto / commodity prices (BlockRun Gateway / Pyth; wallet pays automatically, $0.001/stock call, free for everything else). +${walletTool} +${tradingMarket} - **ExaAnswer / ExaSearch / ExaReadUrls** — cited current-events answers, semantic web search, clean URL content. - **WebSearch / WebFetch** — live web. -When a user asks for a current price, today's news, or any live-world state, **call the tool**. Refusal phrases like "I can't provide real-time data" or "check Yahoo Finance" are a bug — they belong to systems without tools. Your brand is spending USDC to get real answers; $0.001 for a stock quote is exactly what the wallet is for. Don't hesitate on cents. +When a user asks for a current price, today's news, or any live-world state, **call the tool**. Refusal phrases like "I can't provide real-time data" or "check Yahoo Finance" are a bug — they belong to systems without tools. ${spendPosture} # System - All text you output outside of tool use is displayed to the user. Use markdown for formatting. @@ -470,7 +491,13 @@ const _instructionCache = new Map(); * Result is memoized per workingDir for the process lifetime. */ export function assembleInstructions(workingDir: string, model?: string): string[] { - const cacheKey = model ? `${workingDir}::${model}` : workingDir; + // The pay mode is part of the key because getCoreInstructions() and + // getWalletKnowledgeSection() both branch on it. invalidateKey() can demote + // a session from key mode to wallet mode mid-process after a 401, and + // without this the model would keep being told it bills account credits + // long after Franklin went back to signing from the wallet. + const mode = isKeyMode() ? 'key' : 'wallet'; + const cacheKey = model ? `${workingDir}::${model}::${mode}` : `${workingDir}::${mode}`; const cached = _instructionCache.get(cacheKey); if (cached) return cached; diff --git a/src/agent/secret-redact.ts b/src/agent/secret-redact.ts index f5f6c6e6..0ab2f3b8 100644 --- a/src/agent/secret-redact.ts +++ b/src/agent/secret-redact.ts @@ -259,6 +259,57 @@ export function redactSecrets(input: string): RedactionResult { * Returns the names of env vars that were set, deduped, in stash order. * Safe to call on an empty match list (no-op). */ +/** + * Redact secrets from text Franklin did not author — tool output, not user + * input. + * + * `redactSecrets` above is the INPUT path: it scrubs what a user pastes into + * chat and hands the caller the raw values to stash on process.env. That is + * the wrong contract here. Tool output flows straight into the model prompt + * (over the network, to whichever gateway model serves the turn) and into the + * persisted session transcript on disk, so the value must not survive at all + * and there is nothing to stash. + * + * The case that forced this: `printenv` and `echo` are auto-approved by + * bash-guard as safe commands, so `printenv BLOCKRUN_API_KEY` needed no + * confirmation and returned the account bearer key as tool output. Stripping + * the variable from the Bash subprocess env (tools/bash.ts) closes that exact + * command; this closes the class — a key can still reach output via a config + * file, a `curl -v` trace, an MCP result, or a shell history dump. + * + * `extraLiterals` carries values known only at runtime, e.g. the configured + * API key when it does not match a published prefix shape. Empty and very + * short entries are ignored so a blank env var cannot blank out the text. + */ +export function redactSecretsInOutput( + input: string, + extraLiterals: readonly string[] = [], +): { text: string; labels: string[] } { + if (!input) return { text: input, labels: [] }; + let text = input; + const labels: string[] = []; + + for (const literal of extraLiterals) { + const value = literal?.trim(); + // 8 is below any credential this guards and above the length at which a + // stray value would collide with ordinary output. + if (!value || value.length < 8) continue; + if (!text.includes(value)) continue; + text = text.split(value).join('[REDACTED:configured_credential]'); + labels.push('configured_credential'); + } + + for (const { label, pattern } of SECRET_PATTERNS) { + pattern.lastIndex = 0; + if (!pattern.test(text)) continue; + pattern.lastIndex = 0; + text = text.replace(pattern, `[REDACTED:${label}]`); + labels.push(label); + } + + return { text, labels: [...new Set(labels)] }; +} + export function stashSecretsToEnv(matches: RedactionMatch[]): string[] { const set: string[] = []; for (const m of matches) { diff --git a/src/agent/streaming-executor.ts b/src/agent/streaming-executor.ts index b8a9afb7..814f2df4 100644 --- a/src/agent/streaming-executor.ts +++ b/src/agent/streaming-executor.ts @@ -19,6 +19,8 @@ import type { HookEngine } from '../hooks/runner.js'; import type { HookInput } from '../hooks/types.js'; import { estimateSpendUsd, isSpendTool } from '../tools/spend-tools.js'; import { BLOCKRUN_DIR } from '../config.js'; +import { redactSecretsInOutput } from './secret-redact.js'; +import { resolvePayMode } from '../payments/auth-mode.js'; import { logger } from '../logger.js'; interface PendingTool { @@ -337,6 +339,23 @@ export class StreamingExecutor { logger.info(`[franklin] Slow tool: ${invocation.name} ${status} after ${(execElapsed / 1000).toFixed(1)}s${preview ? ` — ${preview.slice(0, 80)}` : ''}`); } + // Scrub credentials before the output goes anywhere. This is the one + // funnel every tool result passes through, and it runs BEFORE the + // persist-to-disk branch below so a redacted body is what reaches the + // model, the transcript, and the on-disk overflow file alike. + // + // resolvePayMode() is memoised per process, so reading the configured + // key here costs nothing per tool call. + const payMode = resolvePayMode(); + const redacted = redactSecretsInOutput( + result.output, + payMode.kind === 'key' ? [payMode.key] : [], + ); + if (redacted.labels.length > 0) { + logger.warn(`[franklin] Redacted ${redacted.labels.join(', ')} from ${invocation.name} output`); + result = { ...result, output: redacted.text }; + } + // Persist large results to disk with preview. // Instead of just truncating, save the full result to disk so it can be re-read later. if (result.output.length > PERSIST_THRESHOLD) { diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 2aca7f38..92944bb1 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -333,15 +333,26 @@ function executeCommand(command: string, timeoutMs: number, ctx: ExecutionScope) const shell = fs.existsSync('/bin/bash') ? '/bin/bash' : (process.env.SHELL || '/bin/sh'); let child: ReturnType; try { + // The account bearer key is deliberately withheld from the subprocess. + // bash-guard auto-approves `printenv` and `echo` as safe, so + // `printenv BLOCKRUN_API_KEY` ran with no confirmation and returned the + // key as tool output — into the model prompt and the saved transcript. + // Wallet private keys are never in the environment at all; this gives + // the key the same treatment. Franklin's own gateway calls read + // process.env in-process and are unaffected, and a user who genuinely + // needs the key in a shell can export it there themselves. + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + FRANKLIN: '1', // Let scripts detect they're running inside Franklin + FRANKLIN_WORKDIR: ctx.workingDir, + RUNCODE: '1', // Backwards compat + RUNCODE_WORKDIR: ctx.workingDir, + }; + delete childEnv.BLOCKRUN_API_KEY; + child = spawn(shell, ['-c', command], { cwd: ctx.workingDir, - env: { - ...process.env, - FRANKLIN: '1', // Let scripts detect they're running inside Franklin - FRANKLIN_WORKDIR: ctx.workingDir, - RUNCODE: '1', // Backwards compat - RUNCODE_WORKDIR: ctx.workingDir, - }, + env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], // Put the shell in its own process group (pgid = pid) so a timeout // can SIGTERM the entire tree. Without this, signalling only the diff --git a/src/tools/modal.ts b/src/tools/modal.ts index da88f388..806e0b38 100644 --- a/src/tools/modal.ts +++ b/src/tools/modal.ts @@ -37,8 +37,8 @@ import { SOLANA_NETWORK, } from '@blockrun/llm'; import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; -import { loadChain, VERSION} from '../config.js'; -import { gatewayBase, gatewayHeaders } from '../payments/auth-mode.js'; +import { loadChain, VERSION, DASHBOARD_URL } from '../config.js'; +import { gatewayBase, gatewayHeaders, isKeyMode } from '../payments/auth-mode.js'; import { walletReservation, AMBIGUOUS_GRACE_MS, type ReservationToken } from '../wallet/reservation.js'; import { recordUsage } from '../stats/tracker.js'; import { logger } from '../logger.js'; @@ -278,8 +278,13 @@ function describeHeadroom(): string { `unconfirmed (released automatically after the on-chain balance refreshes)`, ); } - if (parts.length === 0) return 'Fund the wallet.'; - return parts.join('; ') + ' — wait for those to clear or fund the wallet.'; + // In key mode there is no wallet to fund — the ceiling is the prepaid + // balance, so send the user to the right place. + const topUp = isKeyMode() + ? `Top up account credits at ${DASHBOARD_URL}.` + : 'Fund the wallet.'; + if (parts.length === 0) return topUp; + return parts.join('; ') + ` — wait for those to clear, or ${topUp[0].toLowerCase()}${topUp.slice(1)}`; } function signalError(signal: AbortSignal): Error { @@ -466,7 +471,7 @@ export const modalCreateCapability: CapabilityHandler = { if (!reservation) { return { output: - `Insufficient USDC for ModalCreate (${tier}, ~${fmtUsd(price)}). ` + + `Insufficient ${isKeyMode() ? 'account credit' : 'USDC'} for ModalCreate (${tier}, ~${fmtUsd(price)}). ` + describeHeadroom(), isError: true, }; diff --git a/src/tools/sensitive-paths.ts b/src/tools/sensitive-paths.ts index ad7299b9..3fc35516 100644 --- a/src/tools/sensitive-paths.ts +++ b/src/tools/sensitive-paths.ts @@ -1,5 +1,5 @@ /** - * Wallet secret-material guard for the model-facing file tools. + * Secret-material guard for the model-facing file tools. * * Franklin IS the wallet: a steered model (prompt injection via a fetched page, * MCP result, file contents, etc.) that can Read/Write/Edit arbitrary paths @@ -12,14 +12,23 @@ import fs from 'node:fs'; import path from 'node:path'; -import { BLOCKRUN_DIR } from '../config.js'; +import { API_KEY_FILE, BLOCKRUN_DIR } from '../config.js'; -/** Private-key / wallet-secret files under ~/.blockrun. */ +/** + * Secret-material files under ~/.blockrun. + * + * Appended to, never reordered: test/local.mjs indexes this list positionally. + */ const WALLET_KEY_FILES = [ path.join(BLOCKRUN_DIR, '.session'), // EVM private key (0x hex) path.join(BLOCKRUN_DIR, '.solana-session'), // Solana secret key (base58) path.join(BLOCKRUN_DIR, '.solana-session-key2'), path.join(BLOCKRUN_DIR, 'solana-wallet.json'), // legacy { address, private_key } + // Account bearer key (brk_...). Not a private key, but it spends: it draws + // on a prepaid balance with no per-call signature and no chain-level + // ceiling, so `cat ~/.blockrun/api-key` is exfiltration in one Read. It + // belongs behind the same guard as the wallet keys above. + API_KEY_FILE, ]; // macOS (APFS) and Windows are case-INSENSITIVE by default, and fs.realpathSync diff --git a/src/wallet/reservation.ts b/src/wallet/reservation.ts index 2afbddfa..7d2c945c 100644 --- a/src/wallet/reservation.ts +++ b/src/wallet/reservation.ts @@ -31,6 +31,7 @@ import { setupAgentWallet, setupAgentSolanaWallet } from '@blockrun/llm'; import { loadChain } from '../config.js'; +import { fetchCreditBalance, isKeyMode } from '../payments/auth-mode.js'; export interface ReservationToken { id: string; @@ -47,7 +48,22 @@ const BALANCE_CACHE_MS = 5_000; */ export const AMBIGUOUS_GRACE_MS = 30_000; -async function readChainBalance(): Promise { +async function readSpendableBalance(): Promise { + // Key mode has no wallet to read. Gating on one made every Modal call fail + // with "Insufficient USDC — fund the wallet" for a user whose account + // credits were fine, because an unfunded (or absent) wallet reads $0. + // + // The right ceiling in key mode is the prepaid balance, so use it when the + // gateway reports one. `remainingUsd: null` means an ungated account with + // no ceiling, and a null balance means the gateway was unreachable — in + // both cases return Infinity rather than invent a limit. That is not + // "unbounded": the gateway still 402s when credits run out, and --max-spend + // still bounds the session. This layer only prevents Franklin from + // over-committing against a balance it can actually see. + if (isKeyMode()) { + const credit = await fetchCreditBalance().catch(() => null); + return credit?.remainingUsd ?? Number.POSITIVE_INFINITY; + } if (loadChain() === 'solana') { const client = await setupAgentSolanaWallet({ silent: true }); return client.getBalance(); @@ -61,7 +77,7 @@ class WalletReservationManager { private ambiguous = new Map(); private cachedBalance: { value: number; fetchedAt: number } | null = null; private balanceFetchInflight: Promise | null = null; - private balanceFetcher: () => Promise = readChainBalance; + private balanceFetcher: () => Promise = readSpendableBalance; private async fetchBalance(): Promise { if (this.cachedBalance && Date.now() - this.cachedBalance.fetchedAt < BALANCE_CACHE_MS) { @@ -181,7 +197,7 @@ class WalletReservationManager { this.ambiguous.clear(); this.cachedBalance = null; this.balanceFetchInflight = null; - this.balanceFetcher = fetcher ?? readChainBalance; + this.balanceFetcher = fetcher ?? readSpendableBalance; } /** Testing only — seed the balance cache so hold() never touches RPC. */ diff --git a/test/api-key.local.mjs b/test/api-key.local.mjs index 0d257d90..6a828f66 100644 --- a/test/api-key.local.mjs +++ b/test/api-key.local.mjs @@ -456,6 +456,199 @@ test('surf quotes one flat price, computed rather than typed', async () => { assert.match(desc, /settlement fee/, 'and names the wallet-rail fee separately'); }); +// ── Credential containment ──────────────────────────────────────────────── +// The key spends against a prepaid balance with no per-call signature, so it +// gets the same containment the wallet private keys already had. + +test('the API key never reaches a Bash subprocess', async () => { + clean(); + process.env.BLOCKRUN_API_KEY = VALID_KEY; + const { bashCapability } = await import('../dist/tools/bash.js'); + const ctx = { workingDir: TEST_HOME, abortSignal: new AbortController().signal }; + + // printenv and echo are auto-approved by bash-guard, so this is the exact + // command that needed no confirmation before the fix. + const printed = await bashCapability.execute({ command: 'printenv BLOCKRUN_API_KEY || echo ABSENT' }, ctx); + assert.ok(!printed.output.includes(VALID_KEY), 'key must not appear in Bash output'); + assert.match(printed.output, /ABSENT/, 'the variable is unset in the child, not merely empty'); + + // The parent process still has it — Franklin's own gateway calls read + // process.env in-process and must be unaffected. + assert.equal(process.env.BLOCKRUN_API_KEY, VALID_KEY, 'parent env is untouched'); + clean(); +}); + +test('the on-disk API key is guarded like a wallet private key', async () => { + const { isWalletKeyPath, WALLET_KEY_PATHS } = await import('../dist/tools/sensitive-paths.js'); + assert.equal(isWalletKeyPath(KEY_FILE), true, '~/.blockrun/api-key must be protected'); + assert.ok(WALLET_KEY_PATHS.includes(KEY_FILE), 'and listed among the guarded paths'); + // Not a blanket ban on the directory. + assert.equal(isWalletKeyPath(join(BLOCKRUN_DIR, 'sessions.json')), false); + assert.equal(isWalletKeyPath(KEY_FILE + '.bak'), false, 'exact file only, not siblings'); +}); + +test('tool output is scrubbed of a key that reaches it by any route', async () => { + const { redactSecretsInOutput } = await import('../dist/agent/secret-redact.js'); + + // Pattern route: a brk_ key embedded in output nobody vetted. + const shaped = redactSecretsInOutput(`config: BLOCKRUN_API_KEY=${VALID_KEY} done`); + assert.ok(!shaped.text.includes(VALID_KEY), 'shaped key is removed'); + assert.match(shaped.text, /\[REDACTED:blockrun_api_key\]/); + assert.ok(shaped.labels.includes('blockrun_api_key')); + + // Literal route: a configured key that does not match a published shape + // still gets scrubbed, because the caller passes the live value. + const odd = 'legacy-key-not-brk-shaped-01234567'; + const literal = redactSecretsInOutput(`token=${odd}`, [odd]); + assert.ok(!literal.text.includes(odd), 'configured literal is removed'); + assert.ok(literal.labels.includes('configured_credential')); + + // Every occurrence, not just the first. + const twice = redactSecretsInOutput(`${VALID_KEY} and again ${VALID_KEY}`); + assert.ok(!twice.text.includes(VALID_KEY), 'all occurrences removed'); + + // Clean output is returned untouched, and a blank/short literal cannot + // blank out the text. + const clean1 = redactSecretsInOutput('nothing secret here', ['', ' ', 'ab']); + assert.equal(clean1.text, 'nothing secret here'); + assert.deepEqual(clean1.labels, []); +}); + +test('the tool-result funnel actually applies the redactor', async () => { + // The unit test above proves redactSecretsInOutput works. This proves it is + // wired: streaming-executor is the single choke point every tool result + // passes through, and the scrub must happen BEFORE the persist-to-disk + // branch or an oversized result would be written to disk unredacted. + const src = await readFile(new URL('../dist/agent/streaming-executor.js', import.meta.url), 'utf-8'); + // Anchor on the assignment that applies the scrub and on the persist CALL + // SITE (`output: persistLargeResult(`), not the function definition near + // the top of the file. + const applied = src.indexOf('output: redacted.text'); + const persistCall = src.indexOf('output: persistLargeResult('); + assert.ok(applied > 0, 'streaming-executor must apply the redacted output to the result'); + assert.ok(persistCall > 0, 'sanity: the persist call site still exists'); + assert.ok(applied < persistCall, 'redaction must run before the result is persisted to disk'); +}); + +test('key mode does not gate Modal on a wallet balance it does not have', async () => { + clean(); + process.env.BLOCKRUN_API_KEY = VALID_KEY; + auth.resetPayModeCache(); + assert.equal(auth.isKeyMode(), true, 'precondition: key mode is active'); + + const { walletReservation } = await import('../dist/wallet/reservation.js'); + walletReservation._resetForTests(); // real balance path, empty cache + + // Record what the balance read actually asks for. Asserting on this is what + // makes the test see the branch: if key mode fell through to the wallet + // path it would talk to a chain RPC and never request /v1/credits. + const seen = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + seen.push(String(url instanceof Request ? url.url : url)); + return new Response( + JSON.stringify({ account_id: 'acct_test', billing_mode: 'prepaid', currency: 'USD', + granted_usd: 50, spent_usd: 10, remaining_usd: 40, blocked: false }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }; + try { + // No wallet exists under this temp HOME. Before the fix the balance read + // resolved to $0 and hold() refused every Modal call. + const token = await walletReservation.hold(0.01); + assert.ok(seen.some((u) => u.includes('/v1/credits')), + `balance must come from the credit endpoint, got: ${JSON.stringify(seen)}`); + assert.ok(token, 'a $0.01 hold must succeed against $40 of credit'); + walletReservation.release(token); + + // And the ceiling is real, not merely absent: $40 of credit refuses $41. + walletReservation._resetForTests(); + assert.equal(await walletReservation.hold(41), null, 'a known credit balance still bounds holds'); + } finally { + globalThis.fetch = originalFetch; + walletReservation._resetForTests(); + clean(); + } +}); + +test('an ungated account has no local ceiling rather than a zero one', async () => { + clean(); + process.env.BLOCKRUN_API_KEY = VALID_KEY; + auth.resetPayModeCache(); + + const { walletReservation } = await import('../dist/wallet/reservation.js'); + walletReservation._resetForTests(); + + const seen = []; + const originalFetch = globalThis.fetch; + // remaining_usd null is the documented ungated shape — never read as zero. + globalThis.fetch = async (url) => { + seen.push(String(url instanceof Request ? url.url : url)); + return new Response( + JSON.stringify({ account_id: 'a', billing_mode: 'ungated', currency: 'USD', + granted_usd: 0, spent_usd: 9999, remaining_usd: null, blocked: false }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }; + try { + const token = await walletReservation.hold(5); + assert.ok(seen.some((u) => u.includes('/v1/credits')), 'consulted the credit endpoint'); + assert.ok(token, 'an ungated account must not be told it is broke'); + walletReservation.release(token); + } finally { + globalThis.fetch = originalFetch; + walletReservation._resetForTests(); + clean(); + } +}); + +test('an unreachable credit endpoint does not strand a paying user', async () => { + clean(); + process.env.BLOCKRUN_API_KEY = VALID_KEY; + auth.resetPayModeCache(); + + const { walletReservation } = await import('../dist/wallet/reservation.js'); + walletReservation._resetForTests(); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new Error('network down'); }; + try { + // fetchCreditBalance returns null on any failure. Falling back to a wallet + // read here would reintroduce the bug; inventing $0 would be worse. + const token = await walletReservation.hold(0.01); + assert.ok(token, 'a gateway outage must not block a call the gateway would accept'); + walletReservation.release(token); + } finally { + globalThis.fetch = originalFetch; + walletReservation._resetForTests(); + clean(); + } +}); + +test('the system prompt does not promise a wallet in key mode', async () => { + const { assembleInstructions } = await import('../dist/agent/context.js'); + + clean(); + auth.resetPayModeCache(); + const walletPrompt = assembleInstructions(TEST_HOME).join('\n'); + + process.env.BLOCKRUN_API_KEY = VALID_KEY; + auth.resetPayModeCache(); + // Same workingDir on purpose: proves the instruction cache is keyed on the + // pay mode and does not serve the wallet-mode prompt to a key-mode session. + const keyPrompt = assembleInstructions(TEST_HOME).join('\n'); + clean(); + + assert.match(walletPrompt, /agent with a wallet/, 'wallet mode keeps its identity line'); + assert.doesNotMatch(keyPrompt, /an autonomous AI agent with a wallet/, + 'key mode must not claim a wallet it does not have'); + assert.doesNotMatch(keyPrompt, /wallet pays automatically/, + 'key mode must not say the wallet pays'); + assert.match(keyPrompt, /account credits/, 'key mode names the real funding source'); + // The spend-without-hesitating posture survives the branch. + assert.match(keyPrompt, /Don't hesitate on cents/); +}); + test('cleanup', () => { clean(); rmSync(TEST_HOME, { recursive: true, force: true });