From 5a853edc079f1888a836dc026627ca83ae0c9141 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/7] 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 5a9d561a7b431ecb83413ccfa9ca99b8d5c7f08a 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/7] 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 5dd8b3ccbd6d71bb404fff59c74bceb18e9c0c62 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/7] 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 61fc425245b2306cf4133895ff215bfda1bc3a51 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/7] 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 acf3e30fd0baa5964b949f5c59f46c99066bf791 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/7] 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 3811435d7fe44323c2fc68d092a6ceefa698ca75 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/7] 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 d4056035fbe0de43ee20ade2a35068a3b066c29e 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/7] 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) {