Skip to content
Merged
1 change: 1 addition & 0 deletions node_modules
4 changes: 2 additions & 2 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
198 changes: 198 additions & 0 deletions src/index.lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -225,6 +271,158 @@ describe("plugin lifecycle", () => {
}
});

it("resets the session window across an in-process proxy restart, keeping the daily one", async () => {
vi.useFakeTimers();

const firstClose = vi.fn(async () => {});
const secondClose = vi.fn(async () => {});
const startProxy = vi
.fn()
.mockResolvedValueOnce({
close: firstClose,
balanceMonitor: {
checkBalance: vi.fn(async () => ({ isEmpty: true, isLow: false, balanceUSD: "0.00" })),
},
})
.mockResolvedValueOnce({
close: secondClose,
balanceMonitor: {
checkBalance: vi.fn(async () => ({ isEmpty: true, isLow: false, balanceUSD: "0.00" })),
},
});

vi.doMock("./proxy.js", () => ({
getProxyPort: () => 8402,
startProxy,
}));
vi.doMock("./api-key.js", () => ({
resolveApiKey: vi.fn(async () => undefined),
isValidApiKey: (v: unknown) => typeof v === "string" && v.startsWith("brk_"),
maskApiKey: (v: string) => v,
PORTAL_CREDITS_URL: "https://user.blockrun.ai/dashboard/credits",
PORTAL_KEYS_URL: "https://user.blockrun.ai/dashboard/keys",
}));
vi.doMock("./auth.js", () => ({
resolveOrGenerateWalletKey: vi.fn(async () => ({
key: "0x1234567890123456789012345678901234567890123456789012345678901234",
address: "0x1111111111111111111111111111111111111111",
source: "saved",
})),
setupSolana: vi.fn(),
savePaymentChain: vi.fn(),
resolvePaymentChain: vi.fn(async () => "base"),
WALLET_FILE: "/tmp/wallet",
MNEMONIC_FILE: "/tmp/mnemonic",
}));
vi.doMock("./provider.js", () => ({
blockrunProvider: { id: "blockrun" },
setActiveProxy: vi.fn(),
}));
vi.doMock("./models.js", () => ({
OPENCLAW_MODELS: [],
VISIBLE_OPENCLAW_MODELS: [],
}));
vi.doMock("./web-search-provider.js", () => ({
BLOCKRUN_EXA_PROVIDER_ID: "blockrun-exa",
blockrunExaWebSearchProvider: { id: "blockrun-exa" },
}));
vi.doMock("./partners/index.js", () => ({
buildPartnerTools: vi.fn(() => []),
PARTNER_SERVICES: [],
}));
vi.doMock("./commands/stats.js", () => ({
createStatsCommand: vi.fn(() => ({ name: "stats", handler: vi.fn() })),
}));
vi.doMock("./commands/exclude.js", () => ({
createExcludeCommand: vi.fn(() => ({ name: "exclude", handler: vi.fn() })),
}));
vi.doMock("./mcp-config.js", () => ({
BLOCKRUN_MCP_SERVER_NAME: "blockrun",
createBlockrunMcpServerDefinition: vi.fn(() => ({ command: "npx", args: [] })),
ensureBlockrunMcpServerConfig: vi.fn(() => ({ changed: false, status: "preserved" })),
removeManagedBlockrunMcpServerConfig: vi.fn(),
}));
vi.doMock("./version.js", () => ({
VERSION: "test",
}));
vi.doMock("./exclude-models.js", () => ({
loadExcludeList: vi.fn(() => new Set()),
}));

const createApi = (pluginConfig: Record<string, unknown>) =>
({
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();

Expand Down
38 changes: 27 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -752,14 +752,6 @@ function removeInjectedAuthPlaceholder(

// Store active proxy handle for cleanup on gateway_stop
let activeProxyHandle: Awaited<ReturnType<typeof startProxy>> | 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;
Expand Down Expand Up @@ -958,12 +950,28 @@ async function startProxyInBackground(
);
}

// Restart semantics for the process-wide ledger. The rolling hourly/daily
// windows and the history behind them survive an in-process proxy restart —
// that is the point of a shared ledger. Two things must NOT survive it:
// - limits, which are re-read so a hand-edit to spending.json made while
// the proxy was up still applies
// - the session window, which docs/configuration.md and the /policy help
// both define as resetting on restart. It used to reset by accident
// (every startProxy built its own SpendControl); with one ledger for the
// whole process it has to be reset on purpose, or `session` quietly
// becomes "since the gateway booted".
const sharedControl = getSharedSpendControl();
sharedControl.reloadLimits();
sharedControl.resetSession();
const proxy = await startProxy({
...(apiKey ? { apiKey } : { wallet: wallet! }),
routingConfig,
maxCostPerRunUsd,
maxCostPerRunMode,
spendControl: (liveSpendControl = new SpendControl()),
// The process-wide ledger: the polymarket tool and the /policy command
// registered below use the same instance, so hourly/daily/session windows
// cover every surface and a /policy write reaches the live signer.
spendControl: sharedControl,
onReady: (port) => {
api.logger.info(`BlockRun ${apiKey ? "API-key" : "x402"} proxy listening on port ${port}`);
},
Expand Down Expand Up @@ -2042,6 +2050,10 @@ const plugin: OpenClawPluginDefinition = {
// blockrun_polymarket is a LOCAL trading tool (signs CLOB orders with the
// ClawRouter wallet key), not an HTTP-proxy partner tool — register it
// separately so real-money betting works out of the box.
// No deps: the tool's signing paths resolve the same process-wide ledger
// themselves, at call time. Passing it here instead would construct the
// SpendControl — and read spending.json off disk — on every plugin
// registration, including for the many installs that never place a bet.
api.registerTool(buildPolymarketTool());
if (partnerTools.length > 0 && shouldLogRegistration) {
api.logger.info(
Expand Down Expand Up @@ -2307,7 +2319,10 @@ const plugin: OpenClawPluginDefinition = {
api.registerCommand(createExcludeCommand());
api.registerCommand(
createPolicyCommand({
liveControl: () => (activeProxyHandle ? (liveSpendControl ?? undefined) : undefined),
liveControl: () =>
(process as ProcessWithClawRouterState).__clawrouterProxyStarted
? getSharedSpendControl()
: undefined,
}),
);
if (shouldLogRegistration) {
Expand Down Expand Up @@ -2615,6 +2630,7 @@ export {
registerSpendPolicyHook,
SpendPolicyError,
MalformedSpendPolicyError,
UnreadableSpendPolicyError,
CAIP2_BASE,
CAIP2_SOLANA_MAINNET,
PAYABLE_NETWORKS,
Expand Down
Loading
Loading