From 8748366299c0217b938b17ee238761fa7b5c7970 Mon Sep 17 00:00:00 2001 From: TWZRD <33047129+twzrd-sol@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:02:55 +0000 Subject: [PATCH 1/8] fix(spend-control): one process-wide ledger shared by proxy, Polymarket, doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-surface SpendControl instances each loaded spending.json at construction and then enforced hourly/daily/session windows against diverging in-memory history — every aggregate window effectively doubled — while each instance's save() last-writer-won the file's history, dropping the other surface's records on restart. getSharedSpendControl() is now the single process-wide instance. startProxy, createDoctorX402Client, and the polymarket resolvers all default to it, and index.ts threads it explicitly through the startProxy options and the buildPolymarketTool deps seam. Injectable deps are unchanged for tests. New tests: LLM spend recorded through the wired instance blocks a Polymarket order placed via tool.execute with no per-call deps; tool functions called with no deps resolve the shared instance, not a private one; a restart over the same storage sees both surfaces' records instead of only the last writer's. Co-Authored-By: Claude Fable 5.1 --- src/doctor.ts | 4 +- src/index.ts | 19 +++----- src/polymarket/spend-policy.test.ts | 55 +++++++++++++++++++++- src/polymarket/spend-policy.ts | 7 +-- src/polymarket/tool.ts | 72 ++++++++++++++++++----------- src/proxy.ts | 9 +++- src/spend-control.test.ts | 33 +++++++++++++ src/spend-control.ts | 18 ++++++++ 8 files changed, 167 insertions(+), 50 deletions(-) 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.ts b/src/index.ts index 5ff23ebf..36c31d38 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; @@ -963,7 +955,10 @@ async function startProxyInBackground( 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: getSharedSpendControl(), onReady: (port) => { api.logger.info(`BlockRun ${apiKey ? "API-key" : "x402"} proxy listening on port ${port}`); }, @@ -2042,7 +2037,7 @@ 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. - api.registerTool(buildPolymarketTool()); + api.registerTool(buildPolymarketTool({ spendControl: getSharedSpendControl() })); if (partnerTools.length > 0 && shouldLogRegistration) { api.logger.info( `Registered ${partnerTools.length} partner tool(s): ${partnerTools.map((t) => t.name).join(", ")}, blockrun_polymarket`, @@ -2307,7 +2302,7 @@ const plugin: OpenClawPluginDefinition = { api.registerCommand(createExcludeCommand()); api.registerCommand( createPolicyCommand({ - liveControl: () => (activeProxyHandle ? (liveSpendControl ?? undefined) : undefined), + liveControl: () => (activeProxyHandle ? getSharedSpendControl() : undefined), }), ); if (shouldLogRegistration) { diff --git a/src/polymarket/spend-policy.test.ts b/src/polymarket/spend-policy.test.ts index d06f8a10..acb70707 100644 --- a/src/polymarket/spend-policy.test.ts +++ b/src/polymarket/spend-policy.test.ts @@ -81,6 +81,7 @@ vi.mock("./constants.js", async (importOriginal) => ({ 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 +91,11 @@ import { NEG_RISK_CTF_EXCHANGE_V2, PUSD_COLLATERAL, } from "./constants.js"; -import { InMemorySpendControlStorage, SpendControl } from "../spend-control.js"; +import { + InMemorySpendControlStorage, + setSharedSpendControl, + SpendControl, +} from "../spend-control.js"; function inMemoryControl(): SpendControl { return new SpendControl({ storage: new InMemorySpendControlStorage() }); @@ -316,3 +321,51 @@ 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(); + }); +}); 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..eefc9baf 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -13,6 +13,8 @@ import { formatDuration, registerSpendPolicyHook, assertSpendPolicyAllows, + getSharedSpendControl, + setSharedSpendControl, SpendPolicyError, CAIP2_BASE, CAIP2_SOLANA_MAINNET, @@ -841,3 +843,34 @@ describe("formatDuration", () => { expect(formatDuration(7200)).toBe("2h"); }); }); + +describe("process-wide shared instance", () => { + 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"); + }); +}); diff --git a/src/spend-control.ts b/src/spend-control.ts index b0f4cd37..3b09cf92 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -824,6 +824,24 @@ export class SpendControl { export type SpendPolicyAbort = { abort: true; reason: string }; +let sharedControl: SpendControl | undefined; + +/** + * 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 { + sharedControl ??= new SpendControl(); + return sharedControl; +} + +/** Replace the shared instance. Tests inject in-memory storage here. */ +export function setSharedSpendControl(control: SpendControl): void { + sharedControl = control; +} + /** * Thrown from the pre-sign hook when policy or an amount window refuses. * From 93d023976b1bc6ed8a1576b1ba19b13df8c643f0 Mon Sep 17 00:00:00 2001 From: TWZRD <33047129+twzrd-sol@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:51:20 +0000 Subject: [PATCH 2/8] fix(spend-control): restart re-reads limits, keeps the ledger; every surface proven on the singleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restart semantics for the process-wide SpendControl, stated rather than implied: an in-process proxy restart keeps history and rolling windows but re-reads limits from spending.json, so a hand-edit made while the proxy was up still applies — which is what a restart did when each surface built its own instance. SpendControl.reloadLimits() does the limits-only re-read and fails closed exactly like the constructor: a malformed file refuses every payment until repaired, and a repaired file clears the refusal. Tests pin the other two guarantees the singleton exists for: a /policy write on it is enforced by a Polymarket order placed with no injected deps, and Polymarket spend recorded on it refuses a proxy x402 payment over the hourly cap — the cross-surface direction the earlier test did not cover. Both test files now leave a fresh in-memory instance behind after each test so the singleton cannot leak between them. Co-Authored-By: Claude Fable 5.1 --- src/index.ts | 4 ++ src/polymarket/spend-policy.test.ts | 73 ++++++++++++++++++++++++++++- src/spend-control.test.ts | 50 ++++++++++++++++++++ src/spend-control.ts | 23 +++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 36c31d38..c193516c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -950,6 +950,10 @@ async function startProxyInBackground( ); } + // Restart semantics for the process-wide ledger: history and rolling + // windows survive an in-process proxy restart, but limits are re-read so a + // hand-edit to spending.json made while the proxy was up still applies. + getSharedSpendControl().reloadLimits(); const proxy = await startProxy({ ...(apiKey ? { apiKey } : { wallet: wallet! }), routingConfig, diff --git a/src/polymarket/spend-policy.test.ts b/src/polymarket/spend-policy.test.ts index acb70707..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,6 +78,8 @@ 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"; @@ -93,7 +95,10 @@ import { } from "./constants.js"; import { InMemorySpendControlStorage, + getSharedSpendControl, + registerSpendPolicyHook, setSharedSpendControl, + CAIP2_BASE, SpendControl, } from "../spend-control.js"; @@ -369,3 +374,69 @@ describe("every surface shares ONE ledger at runtime", () => { 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/spend-control.test.ts b/src/spend-control.test.ts index eefc9baf..71d6081c 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -14,6 +14,7 @@ import { registerSpendPolicyHook, assertSpendPolicyAllows, getSharedSpendControl, + MalformedSpendPolicyError, setSharedSpendControl, SpendPolicyError, CAIP2_BASE, @@ -874,3 +875,52 @@ describe("process-wide shared instance", () => { 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("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.ts b/src/spend-control.ts index 3b09cf92..e02d3ba4 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -458,6 +458,29 @@ 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 MalformedSpendPolicyError)) throw err; + this.policyFileBroken = err.message; + console.error(`[ClawRouter] ${err.message}`); + return; + } + this.policyFileBroken = undefined; + this.limits = data ? cloneLimits(data.limits) : {}; + this.limitsDirty = false; + } + check(estimatedCost: number, counterparty?: CounterpartyInfo): CheckResult { if (this.policyFileBroken !== undefined) { return { From 5b94ffec51ed723e7ac1c384ee9137f1d6e6a1b8 Mon Sep 17 00:00:00 2001 From: TWZRD <33047129+twzrd-sol@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:22:26 +0000 Subject: [PATCH 3/8] fix(spend-control): reloadLimits refreshes the compare-and-swap baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reloadLimits() adopted the on-disk limits but left diskLimits — the value a later saveLimits() compares storage against — at whatever the instance had last read or written. The first write after an in-process restart that had picked up an external edit was therefore refused as a conflict with the very state it had just reloaded. The baseline now follows what was just read. Test goes red with the one line removed. Co-Authored-By: Claude Fable 5.1 --- src/spend-control.test.ts | 12 ++++++++++++ src/spend-control.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/src/spend-control.test.ts b/src/spend-control.test.ts index 71d6081c..5d9ae765 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -900,6 +900,18 @@ describe("reloadLimits (in-process proxy restart)", () => { 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("fails closed on a malformed file and recovers once it is repaired", () => { let broken = false; class FlakyStorage extends InMemorySpendControlStorage { diff --git a/src/spend-control.ts b/src/spend-control.ts index e02d3ba4..a2fccb50 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -478,6 +478,7 @@ export class SpendControl { } 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; } From a472ae7edc28041f5b01963cfccbe9b423f658ee Mon Sep 17 00:00:00 2001 From: TWZRD <33047129+twzrd-sol@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:21:38 +0000 Subject: [PATCH 4/8] fix(spend-control): a torn spending.json must not widen what the agent may pay CodeRabbit caught a fail-open this PR introduced. `reloadLimits()` is new here, so the bug is new here too. `FileSpendControlStorage.load()` has two failure modes. A malformed policy list throws `MalformedSpendPolicyError`, which the reload already handled by refusing every payment. A torn or unreadable file took the other path: it logged and returned `null`, which is indistinguishable from "no file yet". The reload then cleared `policyFileBroken` and set `limits = {}`, so a `spending.json` truncated by a crash plus one in-process proxy restart left the proxy running with no caps and no allow/deny lists. The constructor can treat an unreadable file as an empty start because it has nothing to lose. A reload does. Guessing at the call site is not possible while both cases return `null`, so the distinction now lives at the storage boundary: `load()` throws `UnreadableSpendPolicyError` for a read or parse failure, and `null` again means only "nothing stored". - constructor: catches it, logs the same "starting fresh" warning, and begins with no limits. Startup behaviour is unchanged. - reload: catches it, logs, and returns without touching limits or any refusal state. The last known-good policy stays in force. Refusing outright on a torn file would be stricter, but that is a change to startup semantics beyond this PR; not widening is the part that belongs here. Test added red first: set a daily cap, make `load()` fail, reload, and assert the over-cap payment is still refused. Before the fix the reload reset limits to `{}` and it was allowed. 942 tests, typecheck, lint and prettier clean. Co-Authored-By: Claude Fable 5.1 --- src/index.ts | 1 + src/spend-control.test.ts | 23 +++++++++++++++++++++++ src/spend-control.ts | 37 ++++++++++++++++++++++++++++++++----- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index c193516c..41a659e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2614,6 +2614,7 @@ export { registerSpendPolicyHook, SpendPolicyError, MalformedSpendPolicyError, + UnreadableSpendPolicyError, CAIP2_BASE, CAIP2_SOLANA_MAINNET, PAYABLE_NETWORKS, diff --git a/src/spend-control.test.ts b/src/spend-control.test.ts index 5d9ae765..1043926d 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -15,6 +15,7 @@ import { assertSpendPolicyAllows, getSharedSpendControl, MalformedSpendPolicyError, + UnreadableSpendPolicyError, setSharedSpendControl, SpendPolicyError, CAIP2_BASE, @@ -912,6 +913,28 @@ describe("reloadLimits (in-process proxy restart)", () => { 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 { diff --git a/src/spend-control.ts b/src/spend-control.ts index a2fccb50..d1a2a12a 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; } @@ -471,6 +482,14 @@ export class SpendControl { 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}`); @@ -825,6 +844,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 From 2b41343458ee8fc34859d575530bf585d705996d Mon Sep 17 00:00:00 2001 From: TWZRD <33047129+twzrd-sol@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:28:45 +0000 Subject: [PATCH 5/8] fix(spend-control): keep the shared ledger on process, not in a module variable Second CodeRabbit finding on this PR, and a fair one: the accessor promised a process-wide ledger while holding it in a module variable, so the claim only held for a single module instance. That is not a safe assumption in this plugin. A global install (~/.openclaw/extensions/clawrouter) and an npm-projects install (~/.openclaw/npm/projects/blockrun-clawrouter-*) can both be resolved in one gateway, and two module copies would each hold their own ledger -- every window enforced once per copy, which is the per-surface split this singleton exists to end. index.ts already defends against exactly this for startup state (`__clawrouterProxyStarted`, `__clawrouterStartupGeneration` and the rest live on `process`). The ledger now follows the same convention under `__clawrouterSharedSpendControl`, so `getSharedSpendControl()` and `setSharedSpendControl()` resolve through one process slot. The test pins where the instance lives rather than simulating a dual load; loading two isolated copies of the module under vitest would test the loader more than the ledger. 943 tests, typecheck, lint and prettier clean. Co-Authored-By: Claude Fable 5.1 --- src/spend-control.test.ts | 13 +++++++++++++ src/spend-control.ts | 22 ++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/spend-control.test.ts b/src/spend-control.test.ts index 1043926d..aaef4c1e 100644 --- a/src/spend-control.test.ts +++ b/src/spend-control.test.ts @@ -847,6 +847,19 @@ describe("formatDuration", () => { }); 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); diff --git a/src/spend-control.ts b/src/spend-control.ts index d1a2a12a..1e7d7d8b 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -875,7 +875,20 @@ export class SpendControl { export type SpendPolicyAbort = { abort: true; reason: string }; -let sharedControl: SpendControl | undefined; +/** + * 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 @@ -884,13 +897,14 @@ let sharedControl: SpendControl | undefined; * history — aggregate caps only hold against a single shared instance. */ export function getSharedSpendControl(): SpendControl { - sharedControl ??= new SpendControl(); - return sharedControl; + 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 { - sharedControl = control; + sharedControlHost().__clawrouterSharedSpendControl = control; } /** From 33c31ee9b5f666da6acba875de70783f186246a8 Mon Sep 17 00:00:00 2001 From: TWZRD <33047129+twzrd-sol@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:43:34 +0000 Subject: [PATCH 6/8] test(spend-control): a real truncated spending.json, not a storage stub The unit tests for the torn-file path use an in-memory stub that throws UnreadableSpendPolicyError on demand. That proves the branch is handled; it does not prove a genuinely truncated file on disk reaches it, and "the file is not what the parser expected" is precisely the thing a stub cannot reproduce. These drive the real FileSpendControlStorage against a real half-written file, with a temp HOME set before import so WALLET_DIR lands in the sandbox. Three of the four go red against the pre-fix code, and one of them documents a defect worse than the one this PR set out to fix: - reload: the daily cap survives the torn read (the fail-open this PR fixes) - history save: the damaged file is left byte-identical, where before it was rewritten from limits that had just failed to load - policy write: `policy limit daily 2` now reports isError and leaves the file alone. Before, `result.isError` was undefined -- the command reported SUCCESS and overwrote the damaged file, discarding whatever payee or asset lists sat in the unreadable part. An operator repairing a corrupted policy file was the most likely person to hit that. The fourth pins startup behaviour as deliberately unchanged: a torn file must not take the constructor down, and still begins with no limits. 947 tests, typecheck, lint and prettier clean. Co-Authored-By: Claude Opus 5 --- src/spend-control.torn-file.test.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/spend-control.torn-file.test.ts 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); + }); +}); From 4482f5746516a509ff02f2e525a33686062bd78e Mon Sep 17 00:00:00 2001 From: TWZRD <33047129+twzrd-sol@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:20:51 +0000 Subject: [PATCH 7/8] fix(policy): find the live ledger across duplicate modules --- src/index.lifecycle.test.ts | 46 +++++++++++++++++++++++++++++++++++++ src/index.ts | 5 +++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/index.lifecycle.test.ts b/src/index.lifecycle.test.ts index fda313b9..eb727c17 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(); diff --git a/src/index.ts b/src/index.ts index 41a659e9..4c6c34c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2306,7 +2306,10 @@ const plugin: OpenClawPluginDefinition = { api.registerCommand(createExcludeCommand()); api.registerCommand( createPolicyCommand({ - liveControl: () => (activeProxyHandle ? getSharedSpendControl() : undefined), + liveControl: () => + (process as ProcessWithClawRouterState).__clawrouterProxyStarted + ? getSharedSpendControl() + : undefined, }), ); if (shouldLogRegistration) { From a34c4924d402d0b020676226f88df24024993dcc Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Thu, 3 Sep 2026 21:10:05 -0500 Subject: [PATCH 8/8] fix(spend-control): reset the session window on an in-process proxy restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-wide ledger fixed the per-surface split, but it also took over a reset that used to happen by accident. `sessionSpent`/`sessionCalls` are instance state that is never persisted, so every `startProxy()` building its own SpendControl was what made `session` reset on a proxy restart — docs/configuration.md and the /policy help both state that as the contract. One ledger for the whole process quietly redefined `session` as "since the gateway booted", and left it asymmetric: a gateway restart still reset it, an in-process restart no longer did. `supersedeEmptyConfigStartup` puts ordinary boots through two starts, so this was not a corner case. The restart path now resets it on purpose. History and the rolling hourly and daily windows still survive, which is the point of sharing the ledger. The regression test pins the wiring rather than the method: it asserts the same instance reaches both `startProxy` calls, then that `session` is back to 0 while `daily` still counts the payment recorded before the restart. Removing the reset call turns it red. Also: - `buildPolymarketTool()` takes no deps at the registration site. It was passed `getSharedSpendControl()`, which is what `resolveSpendControl` already falls back to, so the argument bought nothing and cost a synchronous read of spending.json on every plugin registration — including the installs that never place a bet, and nine `register()` call sites in the lifecycle tests that would reach the developer's real home directory. - Guard the conflict-recovery re-read in `save()`. `load()` now throws where it returned null, so a file that goes unreadable between the conflict check and the re-read would clear the limits and surface the wrong error class to /policy, which branches on `SpendPolicyConflictError`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015UgUAoQS97qEgVSu1qphFb --- node_modules | 1 + src/index.lifecycle.test.ts | 152 ++++++++++++++++++++++++++++++++++++ src/index.ts | 25 ++++-- src/spend-control.ts | 27 ++++++- 4 files changed, 195 insertions(+), 10 deletions(-) create mode 120000 node_modules 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/index.lifecycle.test.ts b/src/index.lifecycle.test.ts index eb727c17..e18e3064 100644 --- a/src/index.lifecycle.test.ts +++ b/src/index.lifecycle.test.ts @@ -271,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 4c6c34c1..b824c04d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -950,10 +950,19 @@ async function startProxyInBackground( ); } - // Restart semantics for the process-wide ledger: history and rolling - // windows survive an in-process proxy restart, but limits are re-read so a - // hand-edit to spending.json made while the proxy was up still applies. - getSharedSpendControl().reloadLimits(); + // 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, @@ -962,7 +971,7 @@ async function startProxyInBackground( // 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: getSharedSpendControl(), + spendControl: sharedControl, onReady: (port) => { api.logger.info(`BlockRun ${apiKey ? "API-key" : "x402"} proxy listening on port ${port}`); }, @@ -2041,7 +2050,11 @@ 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. - api.registerTool(buildPolymarketTool({ spendControl: getSharedSpendControl() })); + // 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( `Registered ${partnerTools.length} partner tool(s): ${partnerTools.map((t) => t.name).join(", ")}, blockrun_polymarket`, diff --git a/src/spend-control.ts b/src/spend-control.ts index 1e7d7d8b..276a1ddc 100644 --- a/src/spend-control.ts +++ b/src/spend-control.ts @@ -794,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; @@ -822,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; }