diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..cedad0dd --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/vickyfu/Documents/blockrun-web/ClawRouter/node_modules \ No newline at end of file diff --git a/src/doctor.ts b/src/doctor.ts index 43804efe..47677cbf 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -31,7 +31,7 @@ import { import { getSolanaAddress } from "./wallet.js"; import { getStats } from "./stats.js"; import { getProxyPort } from "./proxy.js"; -import { registerSpendPolicyHook, SpendControl } from "./spend-control.js"; +import { getSharedSpendControl, registerSpendPolicyHook, SpendControl } from "./spend-control.js"; import { VERSION } from "./version.js"; // Types @@ -517,7 +517,7 @@ export function createDoctorX402Client(opts: { const publicClient = createPublicClient({ chain: base, transport: http() }); const evmSigner = toClientEvmSigner(account, publicClient); const x402 = new x402Client(); - registerSpendPolicyHook(x402, opts.spendControl ?? new SpendControl()); + registerSpendPolicyHook(x402, opts.spendControl ?? getSharedSpendControl()); registerExactEvmScheme(x402, { signer: evmSigner }); return x402; } diff --git a/src/index.lifecycle.test.ts b/src/index.lifecycle.test.ts index fda313b9..e18e3064 100644 --- a/src/index.lifecycle.test.ts +++ b/src/index.lifecycle.test.ts @@ -49,12 +49,14 @@ describe("plugin lifecycle", () => { __clawrouterStartupGeneration?: number; __clawrouterStartedWithEmptyConfig?: boolean; __clawrouterStartupPhase?: "idle" | "probing" | "starting" | "running"; + __clawrouterSharedSpendControl?: unknown; }; proc.__clawrouterProxyStarted = undefined; proc.__clawrouterDeferredStartTimer = undefined; proc.__clawrouterStartupGeneration = undefined; proc.__clawrouterStartedWithEmptyConfig = undefined; proc.__clawrouterStartupPhase = undefined; + proc.__clawrouterSharedSpendControl = undefined; }); it("clears deferred proxy startup state during deactivate", async () => { @@ -87,6 +89,50 @@ describe("plugin lifecycle", () => { expect(proc.__clawrouterDeferredStartTimer).toBeUndefined(); }); + it("connects /policy to the process-wide ledger when another module owns the proxy", async () => { + const { InMemorySpendControlStorage, SpendControl, setSharedSpendControl } = + await import("./spend-control.js"); + const shared = new SpendControl({ storage: new InMemorySpendControlStorage() }); + shared.setLimit("daily", 7); + setSharedSpendControl(shared); + + const proc = process as NodeJS.Process & { __clawrouterProxyStarted?: boolean }; + proc.__clawrouterProxyStarted = true; + const commands: import("./types.js").OpenClawPluginCommandDefinition[] = []; + const api = { + id: "duplicate-module", + name: "duplicate-module", + source: "local", + config: {}, + pluginConfig: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerProvider: vi.fn(), + registerImageGenerationProvider: vi.fn(), + registerMusicGenerationProvider: vi.fn(), + registerWebSearchProvider: vi.fn(), + registerTool: vi.fn(), + registerHook: vi.fn(), + registerHttpRoute: vi.fn(), + registerService: vi.fn(), + registerCommand: vi.fn((command) => commands.push(command)), + resolvePath: vi.fn((input: string) => input), + on: vi.fn(), + } as unknown as import("./types.js").OpenClawPluginApi; + + const { default: plugin } = await import("./index.js"); + plugin.register?.(api); + const policy = commands.find((command) => command.name === "policy"); + expect(policy).toBeDefined(); + const result = await policy!.handler({ + channel: "test", + isAuthorizedSender: true, + commandBody: "", + args: "", + config: {}, + }); + expect(result.text).toContain("daily: $7"); + }); + it("restarts a provisional default-config proxy when populated pluginConfig arrives later", async () => { vi.useFakeTimers(); @@ -225,6 +271,158 @@ describe("plugin lifecycle", () => { } }); + it("resets the session window across an in-process proxy restart, keeping the daily one", async () => { + vi.useFakeTimers(); + + const firstClose = vi.fn(async () => {}); + const secondClose = vi.fn(async () => {}); + const startProxy = vi + .fn() + .mockResolvedValueOnce({ + close: firstClose, + balanceMonitor: { + checkBalance: vi.fn(async () => ({ isEmpty: true, isLow: false, balanceUSD: "0.00" })), + }, + }) + .mockResolvedValueOnce({ + close: secondClose, + balanceMonitor: { + checkBalance: vi.fn(async () => ({ isEmpty: true, isLow: false, balanceUSD: "0.00" })), + }, + }); + + vi.doMock("./proxy.js", () => ({ + getProxyPort: () => 8402, + startProxy, + })); + vi.doMock("./api-key.js", () => ({ + resolveApiKey: vi.fn(async () => undefined), + isValidApiKey: (v: unknown) => typeof v === "string" && v.startsWith("brk_"), + maskApiKey: (v: string) => v, + PORTAL_CREDITS_URL: "https://user.blockrun.ai/dashboard/credits", + PORTAL_KEYS_URL: "https://user.blockrun.ai/dashboard/keys", + })); + vi.doMock("./auth.js", () => ({ + resolveOrGenerateWalletKey: vi.fn(async () => ({ + key: "0x1234567890123456789012345678901234567890123456789012345678901234", + address: "0x1111111111111111111111111111111111111111", + source: "saved", + })), + setupSolana: vi.fn(), + savePaymentChain: vi.fn(), + resolvePaymentChain: vi.fn(async () => "base"), + WALLET_FILE: "/tmp/wallet", + MNEMONIC_FILE: "/tmp/mnemonic", + })); + vi.doMock("./provider.js", () => ({ + blockrunProvider: { id: "blockrun" }, + setActiveProxy: vi.fn(), + })); + vi.doMock("./models.js", () => ({ + OPENCLAW_MODELS: [], + VISIBLE_OPENCLAW_MODELS: [], + })); + vi.doMock("./web-search-provider.js", () => ({ + BLOCKRUN_EXA_PROVIDER_ID: "blockrun-exa", + blockrunExaWebSearchProvider: { id: "blockrun-exa" }, + })); + vi.doMock("./partners/index.js", () => ({ + buildPartnerTools: vi.fn(() => []), + PARTNER_SERVICES: [], + })); + vi.doMock("./commands/stats.js", () => ({ + createStatsCommand: vi.fn(() => ({ name: "stats", handler: vi.fn() })), + })); + vi.doMock("./commands/exclude.js", () => ({ + createExcludeCommand: vi.fn(() => ({ name: "exclude", handler: vi.fn() })), + })); + vi.doMock("./mcp-config.js", () => ({ + BLOCKRUN_MCP_SERVER_NAME: "blockrun", + createBlockrunMcpServerDefinition: vi.fn(() => ({ command: "npx", args: [] })), + ensureBlockrunMcpServerConfig: vi.fn(() => ({ changed: false, status: "preserved" })), + removeManagedBlockrunMcpServerConfig: vi.fn(), + })); + vi.doMock("./version.js", () => ({ + VERSION: "test", + })); + vi.doMock("./exclude-models.js", () => ({ + loadExcludeList: vi.fn(() => new Set()), + })); + + const createApi = (pluginConfig: Record) => + ({ + id: "test", + name: "test", + source: "local", + config: {}, + pluginConfig, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + registerProvider: vi.fn(), + registerImageGenerationProvider: vi.fn(), + registerMusicGenerationProvider: vi.fn(), + registerWebSearchProvider: vi.fn(), + registerTool: vi.fn(), + registerHook: vi.fn(), + registerHttpRoute: vi.fn(), + registerService: vi.fn(), + registerCommand: vi.fn(), + resolvePath: vi.fn((input: string) => input), + on: vi.fn(), + }) as unknown as import("./types.js").OpenClawPluginApi; + + const originalArgv = process.argv; + process.argv = [...originalArgv, "gateway"]; + + try { + const { default: plugin } = await import("./index.js"); + const emptyApi = createApi({}); + const configuredRouting = { + tiers: { + SIMPLE: { primary: "configured-model", fallback: [] }, + }, + }; + const configuredApi = createApi({ + routing: configuredRouting, + }); + + const { InMemorySpendControlStorage, SpendControl, setSharedSpendControl } = + await import("./spend-control.js"); + const shared = new SpendControl({ storage: new InMemorySpendControlStorage() }); + setSharedSpendControl(shared); + + plugin.register?.(emptyApi); + await vi.advanceTimersByTimeAsync(300); + await flush(); + + expect(startProxy).toHaveBeenCalledTimes(1); + expect(startProxy.mock.calls[0]?.[0]?.spendControl).toBe(shared); + + // Spend on the ledger the running proxy signs against. + shared.record(3, { model: "some/model" }); + expect(shared.getSpending("session")).toBe(3); + expect(shared.getSpending("daily")).toBe(3); + + plugin.register?.(configuredApi); + await vi.runAllTimersAsync(); + await flush(); + + expect(firstClose).toHaveBeenCalledTimes(1); + expect(startProxy).toHaveBeenCalledTimes(2); + // Same ledger across the restart — that is the point of the singleton... + expect(startProxy.mock.calls[1]?.[0]?.spendControl).toBe(shared); + // ...but `session` is documented as resetting on restart, so it does, + // while the rolling daily window keeps counting the same payment. + expect(shared.getSpending("session")).toBe(0); + expect(shared.getSpending("daily")).toBe(3); + } finally { + process.argv = originalArgv; + } + }); + it("restarts the queued configured proxy when provisional startup rejects after being superseded", async () => { vi.useFakeTimers(); diff --git a/src/index.ts b/src/index.ts index 5ff23ebf..b824c04d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,10 +96,10 @@ import { privateKeyToAccount } from "viem/accounts"; import { getStats } from "./stats.js"; import { buildPartnerTools, PARTNER_SERVICES } from "./partners/index.js"; import { buildPolymarketTool } from "./polymarket/tool.js"; +import { getSharedSpendControl } from "./spend-control.js"; import { createStatsCommand } from "./commands/stats.js"; import { createExcludeCommand } from "./commands/exclude.js"; import { createPolicyCommand } from "./commands/policy.js"; -import { SpendControl } from "./spend-control.js"; import { BLOCKRUN_MCP_SERVER_NAME, removeManagedBlockrunMcpServerConfig } from "./mcp-config.js"; import { BLOCKRUN_PLUGIN_ID, prepareBlockRunPluginConfig } from "./openclaw-plugin-config.js"; @@ -752,14 +752,6 @@ function removeInjectedAuthPlaceholder( // Store active proxy handle for cleanup on gateway_stop let activeProxyHandle: Awaited> | null = null; -/** - * The SpendControl handed to the most recent startProxy(). Only meaningful - * while activeProxyHandle is set: it is created before startup so it can be - * passed in, so a failed or superseded start, or a stopped proxy, must not - * let /policy claim "applied to the running proxy" — the getter below gates - * on the handle, which every stop/reset path already clears. - */ -let liveSpendControl: SpendControl | null = null; let pendingConfiguredStartupApi: OpenClawPluginApi | null = null; type ProcessWithClawRouterState = NodeJS.Process & { __clawrouterProxyStarted?: boolean; @@ -958,12 +950,28 @@ async function startProxyInBackground( ); } + // Restart semantics for the process-wide ledger. The rolling hourly/daily + // windows and the history behind them survive an in-process proxy restart — + // that is the point of a shared ledger. Two things must NOT survive it: + // - limits, which are re-read so a hand-edit to spending.json made while + // the proxy was up still applies + // - the session window, which docs/configuration.md and the /policy help + // both define as resetting on restart. It used to reset by accident + // (every startProxy built its own SpendControl); with one ledger for the + // whole process it has to be reset on purpose, or `session` quietly + // becomes "since the gateway booted". + const sharedControl = getSharedSpendControl(); + sharedControl.reloadLimits(); + sharedControl.resetSession(); const proxy = await startProxy({ ...(apiKey ? { apiKey } : { wallet: wallet! }), routingConfig, maxCostPerRunUsd, maxCostPerRunMode, - spendControl: (liveSpendControl = new SpendControl()), + // The process-wide ledger: the polymarket tool and the /policy command + // registered below use the same instance, so hourly/daily/session windows + // cover every surface and a /policy write reaches the live signer. + spendControl: sharedControl, onReady: (port) => { api.logger.info(`BlockRun ${apiKey ? "API-key" : "x402"} proxy listening on port ${port}`); }, @@ -2042,6 +2050,10 @@ const plugin: OpenClawPluginDefinition = { // blockrun_polymarket is a LOCAL trading tool (signs CLOB orders with the // ClawRouter wallet key), not an HTTP-proxy partner tool — register it // separately so real-money betting works out of the box. + // No deps: the tool's signing paths resolve the same process-wide ledger + // themselves, at call time. Passing it here instead would construct the + // SpendControl — and read spending.json off disk — on every plugin + // registration, including for the many installs that never place a bet. api.registerTool(buildPolymarketTool()); if (partnerTools.length > 0 && shouldLogRegistration) { api.logger.info( @@ -2307,7 +2319,10 @@ const plugin: OpenClawPluginDefinition = { api.registerCommand(createExcludeCommand()); api.registerCommand( createPolicyCommand({ - liveControl: () => (activeProxyHandle ? (liveSpendControl ?? undefined) : undefined), + liveControl: () => + (process as ProcessWithClawRouterState).__clawrouterProxyStarted + ? getSharedSpendControl() + : undefined, }), ); if (shouldLogRegistration) { @@ -2615,6 +2630,7 @@ export { registerSpendPolicyHook, SpendPolicyError, MalformedSpendPolicyError, + UnreadableSpendPolicyError, CAIP2_BASE, CAIP2_SOLANA_MAINNET, PAYABLE_NETWORKS, diff --git a/src/polymarket/spend-policy.test.ts b/src/polymarket/spend-policy.test.ts index d06f8a10..ae9e4e8d 100644 --- a/src/polymarket/spend-policy.test.ts +++ b/src/polymarket/spend-policy.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; /** * Wiring tests for the Polymarket signing paths. Each one builds an in-memory @@ -78,9 +78,12 @@ vi.mock("./constants.js", async (importOriginal) => ({ getSigType: () => h.sigType, })); +import { x402Client } from "@x402/fetch"; +import { runPolicyCommand } from "../commands/policy.js"; import { fundVault } from "./fund.js"; import { executeTrade, getSessionLedger } from "./orders.js"; import { redeemPosition } from "./redeem.js"; +import { buildPolymarketTool } from "./tool.js"; import { withdrawFunds } from "./withdraw.js"; import { BASE_USDC, @@ -90,7 +93,14 @@ import { NEG_RISK_CTF_EXCHANGE_V2, PUSD_COLLATERAL, } from "./constants.js"; -import { InMemorySpendControlStorage, SpendControl } from "../spend-control.js"; +import { + InMemorySpendControlStorage, + getSharedSpendControl, + registerSpendPolicyHook, + setSharedSpendControl, + CAIP2_BASE, + SpendControl, +} from "../spend-control.js"; function inMemoryControl(): SpendControl { return new SpendControl({ storage: new InMemorySpendControlStorage() }); @@ -316,3 +326,117 @@ describe("redeemPosition confirms the claim before reporting success", () => { expect(h.waitForReceipt).not.toHaveBeenCalled(); }); }); + +describe("every surface shares ONE ledger at runtime", () => { + // LLM spend recorded exactly as the proxy's x402 hook settles it. + function recordLlmSpend(control: SpendControl, usd: number): void { + control.settleReservation(control.reserve(usd), { action: "x402 payment" }); + } + + // Limit buy 10 @ 0.50 → $5 notional. + const limitBuy = { action: "buy" as const, token_id: "123", price: 0.5, size: 10, confirm: true }; + + beforeEach(() => { + h.negRisk = false; + }); + + it("LLM spend recorded through the wired instance blocks a Polymarket order placed via tool.execute with no per-call deps", async () => { + const control = inMemoryControl(); + control.setLimit("hourly", 1); + recordLlmSpend(control, 2); + + const tool = buildPolymarketTool({ spendControl: control }); + const r = (await tool.execute("t1", { + action: "buy", + token_id: "123", + price: 0.5, + size: 10, + confirm: true, + })) as { content: { text: string }[] }; + + expect(r.content[0].text).toMatch(/Hourly limit exceeded/i); + expect(h.clob.createAndPostOrder).not.toHaveBeenCalled(); + expect(h.clob.createAndPostMarketOrder).not.toHaveBeenCalled(); + }); + + it("with no deps at all, the tool functions resolve the shared instance, not a private one", async () => { + const shared = inMemoryControl(); + shared.setLimit("hourly", 1); + recordLlmSpend(shared, 2); + setSharedSpendControl(shared); + + // No deps: before the shared instance this constructed a fresh polymarket + // ledger that had never seen the LLM spend above, and the order went out. + const r = await executeTrade(limitBuy); + + expect(r.isError).toBe(true); + expect(r.text).toMatch(/Hourly limit exceeded/i); + expect(h.clob.createAndPostOrder).not.toHaveBeenCalled(); + }); +}); + +afterEach(() => { + setSharedSpendControl(new SpendControl({ storage: new InMemorySpendControlStorage() })); +}); + +describe("the singleton is the one ledger every surface reads and writes", () => { + const limitBuy = { action: "buy" as const, token_id: "123", price: 0.5, size: 10, confirm: true }; + + beforeEach(() => { + h.negRisk = false; + }); + + it("a /policy write on the singleton is enforced by a Polymarket order placed with no deps", async () => { + const storage = new InMemorySpendControlStorage(); + setSharedSpendControl(new SpendControl({ storage })); + + const res = runPolicyCommand(["set", "blockedPayees", CTF_EXCHANGE_V2], { + liveControl: getSharedSpendControl, + openControl: () => new SpendControl({ storage }), + }); + expect(res.isError).toBeFalsy(); + expect(res.text).toContain("Applied to the running proxy"); + + const r = await executeTrade(limitBuy); + expect(r.isError).toBe(true); + expect(r.text).toMatch(/blocked by policy/i); + expect(h.clob.createAndPostOrder).not.toHaveBeenCalled(); + }); + + it("Polymarket spend recorded on the singleton refuses a proxy payment over the cap", async () => { + const shared = new SpendControl({ storage: new InMemorySpendControlStorage() }); + shared.setLimit("hourly", 6); + setSharedSpendControl(shared); + + expect((await executeTrade(limitBuy)).isError).toBeFalsy(); // $5 notional, allowed + + let signerCalls = 0; + const client = new x402Client(); + registerSpendPolicyHook(client, getSharedSpendControl()); + client.register(CAIP2_BASE, { + scheme: "exact", + async createPaymentPayload() { + signerCalls += 1; + return { x402Version: 2, payload: {} }; + }, + }); + await expect( + client.createPaymentPayload({ + x402Version: 2, + resource: { url: "https://example.invalid/pay" }, + accepts: [ + { + scheme: "exact", + network: CAIP2_BASE, + amount: "2000000", // $2: 5 + 2 > 6 + asset: "USDC", + payTo: h.ATTACKER, + maxTimeoutSeconds: 60, + extra: {}, + }, + ], + }), + ).rejects.toThrow(/hourly limit/i); + expect(signerCalls).toBe(0); + }); +}); diff --git a/src/polymarket/spend-policy.ts b/src/polymarket/spend-policy.ts index 9354fbeb..d287e513 100644 --- a/src/polymarket/spend-policy.ts +++ b/src/polymarket/spend-policy.ts @@ -22,6 +22,7 @@ // and the batch is `confirm`-gated behind an explicit preview. import { assertSpendPolicyAllows, + getSharedSpendControl, SpendControl, type QuotedRequirements, } from "../spend-control.js"; @@ -31,13 +32,9 @@ export interface PolymarketSpendDeps { spendControl?: SpendControl; } -let defaultControl: SpendControl | undefined; - /** One instance per process so session windows and reservations span tool calls, as in the proxy. */ function resolveSpendControl(deps?: PolymarketSpendDeps): SpendControl { - if (deps?.spendControl) return deps.spendControl; - defaultControl ??= new SpendControl(); - return defaultControl; + return deps?.spendControl ?? getSharedSpendControl(); } /** diff --git a/src/polymarket/tool.ts b/src/polymarket/tool.ts index c7793035..a9e4c1c6 100644 --- a/src/polymarket/tool.ts +++ b/src/polymarket/tool.ts @@ -11,6 +11,7 @@ // engine's guardrails (confirm:true hard gate, POLYMARKET_MAX_BET_USD per-order // cap, optional session cap) are unchanged from the source. import type { PartnerToolDefinition } from "../partners/tools.js"; +import type { PolymarketSpendDeps } from "./spend-policy.js"; import { executeTrade, listOpenOrders, @@ -42,9 +43,12 @@ Prices are probabilities 0–1 on the market's tick grid. token_id = clobTokenId /** * Build the blockrun_polymarket tool. Registered alongside the partner tools in - * src/index.ts via api.registerTool(). + * src/index.ts via api.registerTool(). `deps` carries the process-wide + * SpendControl from the wiring site so the tool's signing paths enforce the + * SAME amount-window ledger the proxy records x402 payments against — omit it + * and the tool functions resolve the same shared instance themselves. */ -export function buildPolymarketTool(): PartnerToolDefinition { +export function buildPolymarketTool(deps?: PolymarketSpendDeps): PartnerToolDefinition { return { name: "blockrun_polymarket", description: DESCRIPTION, @@ -134,26 +138,32 @@ export function buildPolymarketTool(): PartnerToolDefinition { result = await runSetup({ confirm }); break; case "fund": - result = await fundVault({ - amount_usd: params.amount_usd as number | undefined, - confirm, - }); + result = await fundVault( + { + amount_usd: params.amount_usd as number | undefined, + confirm, + }, + deps, + ); break; case "buy": case "sell": - result = await executeTrade({ - action, - token_id: params.token_id as string | undefined, - condition_id: params.condition_id as string | undefined, - outcome: params.outcome as string | undefined, - price: params.price as number | undefined, - size: params.size as number | undefined, - amount_usd: params.amount_usd as number | undefined, - order_type: params.order_type as "GTC" | "GTD" | "FOK" | "FAK" | undefined, - expires_at: params.expires_at as number | undefined, - post_only: params.post_only as boolean | undefined, - confirm, - }); + result = await executeTrade( + { + action, + token_id: params.token_id as string | undefined, + condition_id: params.condition_id as string | undefined, + outcome: params.outcome as string | undefined, + price: params.price as number | undefined, + size: params.size as number | undefined, + amount_usd: params.amount_usd as number | undefined, + order_type: params.order_type as "GTC" | "GTD" | "FOK" | "FAK" | undefined, + expires_at: params.expires_at as number | undefined, + post_only: params.post_only as boolean | undefined, + confirm, + }, + deps, + ); break; case "orders": result = await listOpenOrders({ @@ -170,17 +180,23 @@ export function buildPolymarketTool(): PartnerToolDefinition { result = await listPositions(); break; case "redeem": - result = await redeemPosition({ - condition_id: params.condition_id as string | undefined, - confirm, - }); + result = await redeemPosition( + { + condition_id: params.condition_id as string | undefined, + confirm, + }, + deps, + ); break; case "withdraw": - result = await withdrawFunds({ - amount_usd: params.amount_usd as number | undefined, - to_address: params.to_address as string | undefined, - confirm, - }); + result = await withdrawFunds( + { + amount_usd: params.amount_usd as number | undefined, + to_address: params.to_address as string | undefined, + confirm, + }, + deps, + ); break; default: throw new Error(`Unknown blockrun_polymarket action: ${action}`); diff --git a/src/proxy.ts b/src/proxy.ts index ca80afeb..e9a372b0 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -87,7 +87,12 @@ import { isValidApiKey, maskApiKey, } from "./api-key.js"; -import { registerSpendPolicyHook, SpendControl, SpendPolicyError } from "./spend-control.js"; +import { + getSharedSpendControl, + registerSpendPolicyHook, + SpendControl, + SpendPolicyError, +} from "./spend-control.js"; import { compressContext, shouldCompress, type NormalizedMessage } from "./compression/index.js"; // Error classes available for programmatic use but not used in proxy // (universal free fallback means we don't throw balance errors anymore) @@ -2493,7 +2498,7 @@ export async function startProxy(options: ProxyOptions): Promise { if (x402 && account) { const evmPublicClient = createPublicClient({ chain: base, transport: http() }); const evmSigner = toClientEvmSigner(account, evmPublicClient); - const spendControl = options.spendControl ?? new SpendControl(); + const spendControl = options.spendControl ?? getSharedSpendControl(); registerSpendPolicyHook(x402, spendControl); registerExactEvmScheme(x402, { signer: evmSigner }); } diff --git a/src/spend-control.test.ts b/src/spend-control.test.ts index aa44b71a..aaef4c1e 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -13,6 +13,10 @@ import { formatDuration, registerSpendPolicyHook, assertSpendPolicyAllows, + getSharedSpendControl, + MalformedSpendPolicyError, + UnreadableSpendPolicyError, + setSharedSpendControl, SpendPolicyError, CAIP2_BASE, CAIP2_SOLANA_MAINNET, @@ -841,3 +845,130 @@ describe("formatDuration", () => { expect(formatDuration(7200)).toBe("2h"); }); }); + +describe("process-wide shared instance", () => { + it("keeps the ledger on process, so a second module copy resolves the same one", () => { + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + setSharedSpendControl(control); + // Pins WHERE the instance lives rather than simulating a dual load: a + // second copy of this module reads the same process slot, which is what + // makes the ledger process-wide instead of module-wide. + const host = process as NodeJS.Process & { + __clawrouterSharedSpendControl?: SpendControl; + }; + expect(host.__clawrouterSharedSpendControl).toBe(control); + expect(getSharedSpendControl()).toBe(control); + }); + + it("hands every surface the same instance", () => { + const control = new SpendControl({ storage: new InMemorySpendControlStorage() }); + setSharedSpendControl(control); + + expect(getSharedSpendControl()).toBe(control); + expect(getSharedSpendControl()).toBe(getSharedSpendControl()); + }); + + it("a restart sees history from every surface because both recorded on ONE instance", () => { + const storage = new InMemorySpendControlStorage(); + const clock = Date.now(); + setSharedSpendControl(new SpendControl({ storage, now: () => clock })); + + // The proxy's x402 hook settles with "x402 payment"; signUnderSpendPolicy + // with "polymarket order". Both must land on the same instance. + const control = getSharedSpendControl(); + control.settleReservation(control.reserve(2), { action: "x402 payment" }); + control.settleReservation(control.reserve(25), { action: "polymarket order" }); + expect(control.getSpending("hourly")).toBeCloseTo(27); + + // Restart: a fresh instance must see BOTH records — the old per-surface + // shape last-writer-won the file and dropped the other surface's history. + const restarted = new SpendControl({ storage, now: () => clock }); + expect(restarted.getSpending("hourly")).toBeCloseTo(27); + const actions = restarted.getHistory().map((r) => r.action); + expect(actions).toContain("x402 payment"); + expect(actions).toContain("polymarket order"); + }); +}); + +// The singleton is process state; leave a fresh in-memory one behind so no +// test in this file can see another's instance. +afterEach(() => { + setSharedSpendControl(new SpendControl({ storage: new InMemorySpendControlStorage() })); +}); + +describe("reloadLimits (in-process proxy restart)", () => { + it("adopts an on-disk edit and keeps this instance's history and windows", () => { + const storage = new InMemorySpendControlStorage(); + const clock = Date.now(); + const live = new SpendControl({ storage, now: () => clock }); + live.record(2, { action: "x402 payment" }); + + // A CLI in another process (or a hand-edit) lands a new cap on disk. + new SpendControl({ storage, now: () => clock }).setLimit("hourly", 5); + expect(live.getLimits().hourly).toBeUndefined(); + + live.reloadLimits(); + + expect(live.getLimits().hourly).toBe(5); + expect(live.getSpending("hourly")).toBeCloseTo(2); + expect(live.check(4).allowed).toBe(false); // 2 recorded + 4 > 5: the window survived + }); + + it("a write after reloadLimits uses the reloaded state as its compare-and-swap baseline", () => { + const storage = new InMemorySpendControlStorage(); + const live = new SpendControl({ storage }); + new SpendControl({ storage }).setLimit("hourly", 5); // lands behind live's back + live.reloadLimits(); + + // Without refreshing the baseline this would be refused as a conflict: + // live's last-read limits would still be {} while disk holds {hourly:5}. + expect(() => live.setLimit("daily", 2)).not.toThrow(); + expect(storage.load()?.limits).toEqual({ hourly: 5, daily: 2 }); + }); + + it("keeps enforcing limits when an unreadable file makes load() return null", () => { + let torn = false; + class TornStorage extends InMemorySpendControlStorage { + override load() { + // What FileSpendControlStorage does for a JSON-parse or read error. + if (torn) throw new UnreadableSpendPolicyError(new Error("Unexpected end of JSON input")); + return super.load(); + } + } + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const live = new SpendControl({ storage: new TornStorage() }); + live.setLimit("daily", 1); + expect(live.check(5).allowed).toBe(false); + + torn = true; + live.reloadLimits(); + // A torn file must not widen what the agent may pay. Before this fix the + // reload reset limits to {} and the over-cap payment was allowed. + expect(live.check(5).allowed).toBe(false); + errors.mockRestore(); + }); + + it("fails closed on a malformed file and recovers once it is repaired", () => { + let broken = false; + class FlakyStorage extends InMemorySpendControlStorage { + override load() { + if (broken) throw new MalformedSpendPolicyError("blockedPayees"); + return super.load(); + } + } + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const live = new SpendControl({ storage: new FlakyStorage() }); + expect(live.check(0.01).allowed).toBe(true); + + broken = true; + live.reloadLimits(); + expect(live.check(0.01).allowed).toBe(false); + expect(live.getPolicyFileError()).toMatch(/blockedPayees/); + + broken = false; + live.reloadLimits(); + expect(live.check(0.01).allowed).toBe(true); + expect(live.getPolicyFileError()).toBeUndefined(); + errors.mockRestore(); + }); +}); diff --git a/src/spend-control.torn-file.test.ts b/src/spend-control.torn-file.test.ts new file mode 100644 index 00000000..b28974a1 --- /dev/null +++ b/src/spend-control.torn-file.test.ts @@ -0,0 +1,67 @@ +/** + * A torn spending.json against the REAL FileSpendControlStorage. + * + * The unit tests for this path use an in-memory storage stub that throws + * UnreadableSpendPolicyError on demand. That proves the branch, not that a + * genuinely truncated file on disk reaches it — and the whole failure mode is + * "the file is not what the parser expected", which a stub cannot reproduce. + * + * Uses a temp HOME set BEFORE importing spend-control.js: WALLET_DIR is + * computed from homedir() at module load (same pattern as + * auth.payment-chain-default.test.ts). + */ +import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const TEMP_HOME = mkdtempSync(join(tmpdir(), "clawrouter-torn-")); +process.env.HOME = TEMP_HOME; +process.env.USERPROFILE = TEMP_HOME; + +const { SpendControl } = await import("./spend-control.js"); +const { runPolicyCommand } = await import("./commands/policy.js"); + +const WALLET_DIR = join(TEMP_HOME, ".openclaw", "blockrun"); +const SPENDING = join(WALLET_DIR, "spending.json"); +const TORN = '{"limits":{"daily":1},"history":[{"timestamp":1,"amo'; + +beforeEach(() => { + mkdirSync(WALLET_DIR, { recursive: true }); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterAll(() => rmSync(TEMP_HOME, { recursive: true, force: true })); + +describe("a truncated spending.json on disk", () => { + it("does not take the constructor down, and starts with no limits", () => { + writeFileSync(SPENDING, TORN); + const control = new SpendControl(); + expect(control.check(5).allowed).toBe(true); // unchanged startup behaviour + }); + + it("does not widen a running instance's limits on reload", () => { + writeFileSync(SPENDING, JSON.stringify({ limits: { daily: 1 }, history: [] })); + const control = new SpendControl(); + expect(control.check(5).allowed).toBe(false); + + writeFileSync(SPENDING, TORN); + control.reloadLimits(); + expect(control.check(5).allowed).toBe(false); // the cap survives the torn read + }); + + it("is never overwritten by a history save", () => { + writeFileSync(SPENDING, JSON.stringify({ limits: { daily: 1 }, history: [] })); + const control = new SpendControl(); + writeFileSync(SPENDING, TORN); + control.record(0.01, { model: "some/model" }); + // Writing history would have rewritten limits from an unreadable read. + expect(readFileSync(SPENDING, "utf8")).toBe(TORN); + }); + + it("makes a policy write report failure rather than a false success", () => { + writeFileSync(SPENDING, TORN); + const result = runPolicyCommand(["limit", "daily", "2"]); + expect(result.isError).toBe(true); + expect(readFileSync(SPENDING, "utf8")).toBe(TORN); + }); +}); diff --git a/src/spend-control.ts b/src/spend-control.ts index b0f4cd37..276a1ddc 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -94,6 +94,18 @@ function isPolicyList(value: string): value is PolicyList { * pay, which is the one direction this file must never fail in. Callers * classify on `instanceof`, not on the message text. */ +/** + * spending.json exists but could not be read or parsed. Distinct from "no file + * yet" (load() returns null) so a reload can tell a failed read from an empty + * store and refuse to widen what the agent may pay. + */ +export class UnreadableSpendPolicyError extends Error { + constructor(cause: unknown) { + super(`[ClawRouter] Failed to load spending data: ${cause}`); + this.name = "UnreadableSpendPolicyError"; + } +} + export class MalformedSpendPolicyError extends Error { constructor(key: string) { super( @@ -273,11 +285,10 @@ export class FileSpendControlStorage implements SpendControlStorage { throw err; } // A torn or unparseable file loses history, which is safe. It must not - // also silently drop configured policy lists — but at this point we - // cannot tell whether any were configured, so say so loudly. - console.error( - `[ClawRouter] Failed to load spending data, starting fresh (any configured spend policy is NOT in effect until this file is repaired): ${err}`, - ); + // also silently drop configured policy lists. Callers decide: the + // constructor starts fresh and says so loudly, a reload keeps enforcing + // what it already has. + throw new UnreadableSpendPolicyError(err); } return null; } @@ -458,6 +469,38 @@ export class SpendControl { return this.policyFileBroken; } + /** + * Re-read limits from storage, keeping this instance's history and open + * reservations. Used on an in-process proxy restart so a hand-edit to + * spending.json made while the process was running still applies, without + * resetting the rolling windows. Fails closed exactly like the constructor: + * a malformed file refuses every payment until repaired, and a repaired + * file clears that refusal. + */ + reloadLimits(): void { + let data: { limits: SpendLimits; history: SpendRecord[] } | null; + try { + data = this.storage.load(); + } catch (err) { + if (err instanceof UnreadableSpendPolicyError) { + // The constructor can start fresh on an unreadable file because it has + // nothing to lose. A reload does: dropping the live limits here would + // widen what the agent may pay because a read failed. Keep enforcing + // what is already loaded and leave any refusal state alone. + console.error(`${err.message} — keeping the limits already in effect`); + return; + } + if (!(err instanceof MalformedSpendPolicyError)) throw err; + this.policyFileBroken = err.message; + console.error(`[ClawRouter] ${err.message}`); + return; + } + this.policyFileBroken = undefined; + this.limits = data ? cloneLimits(data.limits) : {}; + this.diskLimits = cloneLimits(this.limits); // the CAS baseline follows what was just read + this.limitsDirty = false; + } + check(estimatedCost: number, counterparty?: CounterpartyInfo): CheckResult { if (this.policyFileBroken !== undefined) { return { @@ -751,6 +794,16 @@ export class SpendControl { return limit ? records.slice(0, limit) : records; } + /** + * Reset the session window. `sessionSpent`/`sessionCalls` are instance state + * that is never persisted, so this used to happen implicitly: every + * startProxy() built a fresh SpendControl. The process-wide ledger outlives + * an in-process proxy restart, which would silently redefine `session` as + * "since the gateway booted" — docs/configuration.md and the /policy help + * both promise "session resets on restart". The restart path in index.ts + * calls this to keep that promise. History and the rolling hourly/daily + * windows are deliberately untouched. + */ resetSession(): void { this.sessionSpent = 0; this.sessionCalls = 0; @@ -779,10 +832,19 @@ export class SpendControl { this.storage.saveLimits(cloneLimits(this.limits), cloneLimits(this.diskLimits)); } catch (err) { if (err instanceof SpendPolicyConflictError) { - const current = this.storage.load(); - this.limits = cloneLimits(current?.limits ?? {}); - this.diskLimits = cloneLimits(this.limits); - this.limitsDirty = false; + // Adopt what actually landed. load() can now throw (the file went + // unreadable between the conflict check and here), and reconciling + // against nothing would clear the limits and report the wrong error + // to /policy. Leave this instance as it is and let the conflict + // surface — the caller re-reads and retries either way. + try { + const current = this.storage.load(); + this.limits = cloneLimits(current?.limits ?? {}); + this.diskLimits = cloneLimits(this.limits); + this.limitsDirty = false; + } catch { + /* keep the in-memory limits; the conflict below is still the right answer */ + } } throw err; } @@ -801,6 +863,14 @@ export class SpendControl { try { data = this.storage.load(); } catch (err) { + if (err instanceof UnreadableSpendPolicyError) { + // Unchanged startup behaviour: begin with no limits, and say loudly + // that whatever the file configured is not in effect. + console.error( + `${err.message} — starting fresh (any configured spend policy is NOT in effect until this file is repaired)`, + ); + return; + } if (!(err instanceof MalformedSpendPolicyError)) throw err; // Refuse every paid request rather than either (a) running with the // policy silently dropped, or (b) throwing out of the constructor and @@ -824,6 +894,38 @@ export class SpendControl { export type SpendPolicyAbort = { abort: true; reason: string }; +/** + * The ledger lives on `process`, not in a module variable, for the same reason + * `__clawrouterProxyStarted` and the other startup flags in index.ts do: a + * global install and an npm-projects install can both be resolved in one + * gateway, and two module copies each holding their own `sharedControl` would + * enforce every window twice over -- once per copy -- which is exactly the + * per-surface split this singleton exists to end. + */ +type ProcessWithSharedSpendControl = NodeJS.Process & { + __clawrouterSharedSpendControl?: SpendControl; +}; + +const sharedControlHost = (): ProcessWithSharedSpendControl => + process as ProcessWithSharedSpendControl; + +/** + * The process-wide SpendControl instance: ONE ledger for every signing surface + * (the proxy's x402 hook, the Polymarket tools, doctor). Per-surface instances + * enforced each window once per surface and last-writer-won spending.json + * history — aggregate caps only hold against a single shared instance. + */ +export function getSharedSpendControl(): SpendControl { + const host = sharedControlHost(); + host.__clawrouterSharedSpendControl ??= new SpendControl(); + return host.__clawrouterSharedSpendControl; +} + +/** Replace the shared instance. Tests inject in-memory storage here. */ +export function setSharedSpendControl(control: SpendControl): void { + sharedControlHost().__clawrouterSharedSpendControl = control; +} + /** * Thrown from the pre-sign hook when policy or an amount window refuses. *