From 97a3c90494e4670e23605b55f8554fc5df600256 Mon Sep 17 00:00:00 2001 From: tsubasakong Date: Sun, 15 Feb 2026 01:20:59 -0800 Subject: [PATCH 1/4] feat(clawrouter): support claw.credit payment mode for BlockRun inference Add a claw.credit-backed payment path for OpenClaw BlockRun proxy requests, including payment mode selection, env/config wiring, health/reporting updates, and integration coverage. --- README.md | 12 +++ openclaw.plugin.json | 5 ++ openclaw.security.json | 8 ++ src/clawcredit.ts | 151 +++++++++++++++++++++++++++++++++++ src/cli.ts | 83 ++++++++++++++----- src/index.ts | 166 +++++++++++++++++++++++++------------- src/provider.ts | 9 ++- src/proxy.ts | 133 ++++++++++++++++++++++--------- test/clawcredit-mode.ts | 171 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 626 insertions(+), 112 deletions(-) create mode 100644 src/clawcredit.ts create mode 100644 test/clawcredit-mode.ts diff --git a/README.md b/README.md index e6988ff..5b0c80f 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,18 @@ For basic usage, no configuration needed. For advanced options: | `CLAWROUTER_DISABLED` | `false` | Disable smart routing | | `BLOCKRUN_PROXY_PORT` | `8402` | Proxy port | | `BLOCKRUN_WALLET_KEY` | auto | Wallet private key | +| `BLOCKRUN_PAYMENT_MODE` | `wallet` | Payment backend: `wallet` or `clawcredit` | +| `CLAWCREDIT_API_TOKEN` | - | Required when `BLOCKRUN_PAYMENT_MODE=clawcredit` | +| `CLAWCREDIT_BASE_URL` | `https://api.claw.credit` | Claw Credit API base URL | +| `CLAWCREDIT_PAYMENT_CHAIN` | `BASE` | Chain passed to claw.credit `transaction.chain` | +| `CLAWCREDIT_PAYMENT_ASSET` | Base USDC | Asset passed to claw.credit `transaction.asset` | + +To pay BlockRun inference via claw.credit instead of local wallet signing: + +```bash +export BLOCKRUN_PAYMENT_MODE=clawcredit +export CLAWCREDIT_API_TOKEN=claw_xxx +``` **Full reference:** [docs/configuration.md](docs/configuration.md) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 958e079..b3d9276 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -9,6 +9,11 @@ "type": "string", "description": "EVM wallet private key (0x...). Optional — auto-generated if not set." }, + "paymentMode": { + "type": "string", + "enum": ["wallet", "clawcredit"], + "description": "Payment backend. Defaults to wallet; set clawcredit to route payments via claw.credit." + }, "routing": { "type": "object", "description": "Override default routing configuration" diff --git a/openclaw.security.json b/openclaw.security.json index f0c25a3..e713fb6 100644 --- a/openclaw.security.json +++ b/openclaw.security.json @@ -11,6 +11,14 @@ "justification": "ClawRouter uses this wallet key to sign USDC payment transactions on Base L2. The key is used LOCALLY for cryptographic signing and is NEVER transmitted over the network. This is required for x402 protocol compliance.", "dataFlow": "local-only", "networkTransmission": false + }, + { + "type": "env-access", + "variable": "CLAWCREDIT_API_TOKEN", + "purpose": "authenticate claw.credit payment requests", + "justification": "When BLOCKRUN_PAYMENT_MODE=clawcredit is enabled, ClawRouter forwards payment authorization requests to claw.credit /v1/transaction/pay and must send this bearer token to authenticate the request.", + "dataFlow": "sent-to-claw-credit-api", + "networkTransmission": true } ], "securityNotes": [ diff --git a/src/clawcredit.ts b/src/clawcredit.ts new file mode 100644 index 0000000..c7c8379 --- /dev/null +++ b/src/clawcredit.ts @@ -0,0 +1,151 @@ +/** + * Claw Credit payment backend for ClawRouter. + * + * Converts a BlockRun upstream request into a claw.credit /v1/transaction/pay call + * and returns the merchant_response as a standard fetch Response. + */ + +import { VERSION } from "./version.js"; + +const DEFAULT_SERVICE_URL = "https://api.claw.credit"; + +export type ClawCreditConfig = { + baseUrl?: string; + apiToken: string; + chain: string; + asset: string; +}; + +export type PreAuthParams = { + estimatedAmount: string; +}; + +function headersToObject(headersInit?: HeadersInit): Record { + if (!headersInit) return {}; + const headers = new Headers(headersInit); + const out: Record = {}; + for (const [key, value] of headers.entries()) { + const lower = key.toLowerCase(); + if (lower === "host" || lower === "content-length" || lower === "connection") continue; + out[key] = value; + } + return out; +} + +function parseJsonBody(body: RequestInit["body"]): unknown { + if (body == null) return undefined; + + let raw = ""; + if (typeof body === "string") { + raw = body; + } else if (body instanceof Uint8Array) { + raw = Buffer.from(body).toString("utf-8"); + } else if (body instanceof ArrayBuffer) { + raw = Buffer.from(body).toString("utf-8"); + } else { + return undefined; + } + + if (!raw.trim()) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function microsToUsd(estimatedAmount?: string): number { + const micros = Number(estimatedAmount ?? ""); + if (!Number.isFinite(micros) || micros <= 0) return 0.01; + return Number((micros / 1_000_000).toFixed(6)); +} + +/** + * Create a fetch wrapper that pays through claw.credit instead of local x402 signing. + */ +export function createClawCreditFetch(config: ClawCreditConfig) { + const serviceUrl = (config.baseUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, ""); + const chain = config.chain.toUpperCase(); + const asset = config.asset; + const apiToken = config.apiToken.trim(); + + if (!apiToken) { + throw new Error("CLAWCREDIT_API_TOKEN is required for claw.credit payment mode"); + } + + return async ( + input: RequestInfo | URL, + init?: RequestInit, + preAuth?: PreAuthParams, + ): Promise => { + const upstreamUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method || "POST").toUpperCase(); + const headers = headersToObject(init?.headers); + const requestBody = parseJsonBody(init?.body); + const amountUsd = microsToUsd(preAuth?.estimatedAmount); + + const payload = { + transaction: { + recipient: upstreamUrl, + amount: amountUsd, + chain, + asset, + }, + request_body: { + http: { + url: upstreamUrl, + method, + headers, + }, + body: requestBody, + }, + audit_context: { + current_task: "blockrun_inference_via_clawrouter", + reasoning_process: "Proxying BlockRun inference payment through claw.credit", + timestamp: Date.now(), + }, + sdk_meta: { + sdk_name: "@blockrun/clawrouter", + sdk_version: VERSION, + }, + }; + + const payResponse = await fetch(`${serviceUrl}/v1/transaction/pay`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiToken}`, + }, + body: JSON.stringify(payload), + signal: init?.signal, + }); + + const text = await payResponse.text(); + const contentType = payResponse.headers.get("content-type") || "application/json"; + + if (!payResponse.ok) { + return new Response(text, { + status: payResponse.status, + headers: { "content-type": contentType }, + }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = { raw: text }; + } + + const merchantResponse = + parsed && typeof parsed === "object" && "merchant_response" in parsed + ? (parsed as { merchant_response: unknown }).merchant_response + : parsed; + + return new Response(JSON.stringify(merchantResponse ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; +} diff --git a/src/cli.ts b/src/cli.ts index 1a6cc9b..7e34260 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,10 @@ import { resolveOrGenerateWalletKey } from "./auth.js"; import { BalanceMonitor } from "./balance.js"; import { VERSION } from "./version.js"; +const CLAWCREDIT_DEFAULT_BASE_URL = "https://api.claw.credit"; +const CLAWCREDIT_DEFAULT_CHAIN = "BASE"; +const CLAWCREDIT_DEFAULT_ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + function printHelp(): void { console.log(` ClawRouter v${VERSION} - Smart LLM Router @@ -42,6 +46,11 @@ Examples: Environment Variables: BLOCKRUN_WALLET_KEY Private key for x402 payments (auto-generated if not set) + BLOCKRUN_PAYMENT_MODE wallet | clawcredit (default: wallet) + CLAWCREDIT_API_TOKEN Required when BLOCKRUN_PAYMENT_MODE=clawcredit + CLAWCREDIT_BASE_URL claw.credit API URL (default: https://api.claw.credit) + CLAWCREDIT_PAYMENT_CHAIN Chain for claw.credit transaction (default: BASE) + CLAWCREDIT_PAYMENT_ASSET Asset for claw.credit transaction (default: Base USDC) BLOCKRUN_PROXY_PORT Default proxy port (default: 8402) For more info: https://github.com/BlockRunAI/ClawRouter @@ -79,20 +88,50 @@ async function main(): Promise { process.exit(0); } - // Resolve wallet key - const { key: walletKey, address, source } = await resolveOrGenerateWalletKey(); + const paymentMode = (process.env.BLOCKRUN_PAYMENT_MODE || "wallet").trim().toLowerCase(); + const useClawCredit = paymentMode === "clawcredit"; + + let address = "clawcredit"; + let walletKey: string | undefined; + let clawCreditConfig: + | { baseUrl: string; apiToken: string; chain: string; asset: string } + | undefined; - if (source === "generated") { - console.log(`[ClawRouter] Generated new wallet: ${address}`); - } else if (source === "saved") { - console.log(`[ClawRouter] Using saved wallet: ${address}`); + if (useClawCredit) { + const apiToken = (process.env.CLAWCREDIT_API_TOKEN || "").trim(); + if (!apiToken) { + throw new Error("CLAWCREDIT_API_TOKEN is required when BLOCKRUN_PAYMENT_MODE=clawcredit"); + } + + clawCreditConfig = { + baseUrl: (process.env.CLAWCREDIT_BASE_URL || CLAWCREDIT_DEFAULT_BASE_URL).trim(), + apiToken, + chain: (process.env.CLAWCREDIT_PAYMENT_CHAIN || CLAWCREDIT_DEFAULT_CHAIN).trim().toUpperCase(), + asset: (process.env.CLAWCREDIT_PAYMENT_ASSET || CLAWCREDIT_DEFAULT_ASSET).trim(), + }; + console.log( + `[ClawRouter] Using claw.credit mode (${clawCreditConfig.baseUrl}, ${clawCreditConfig.chain})`, + ); } else { - console.log(`[ClawRouter] Using wallet from BLOCKRUN_WALLET_KEY: ${address}`); + // Resolve wallet key + const resolved = await resolveOrGenerateWalletKey(); + walletKey = resolved.key; + address = resolved.address; + + if (resolved.source === "generated") { + console.log(`[ClawRouter] Generated new wallet: ${resolved.address}`); + } else if (resolved.source === "saved") { + console.log(`[ClawRouter] Using saved wallet: ${resolved.address}`); + } else { + console.log(`[ClawRouter] Using wallet from BLOCKRUN_WALLET_KEY: ${resolved.address}`); + } } // Start the proxy const proxy = await startProxy({ + paymentMode: useClawCredit ? "clawcredit" : "wallet", walletKey, + clawCredit: clawCreditConfig, port: args.port, onReady: (port) => { console.log(`[ClawRouter] Proxy listening on http://127.0.0.1:${port}`); @@ -116,20 +155,24 @@ async function main(): Promise { }, }); - // Check balance - const monitor = new BalanceMonitor(address); - try { - const balance = await monitor.checkBalance(); - if (balance.isEmpty) { - console.log(`[ClawRouter] Wallet balance: $0.00 (using FREE model)`); - console.log(`[ClawRouter] Fund wallet for premium models: ${address}`); - } else if (balance.isLow) { - console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD} (low)`); - } else { - console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD}`); + if (!useClawCredit) { + // Check balance + const monitor = new BalanceMonitor(address); + try { + const balance = await monitor.checkBalance(); + if (balance.isEmpty) { + console.log(`[ClawRouter] Wallet balance: $0.00 (using FREE model)`); + console.log(`[ClawRouter] Fund wallet for premium models: ${address}`); + } else if (balance.isLow) { + console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD} (low)`); + } else { + console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD}`); + } + } catch { + console.log(`[ClawRouter] Wallet: ${address} (balance check pending)`); } - } catch { - console.log(`[ClawRouter] Wallet: ${address} (balance check pending)`); + } else { + console.log("[ClawRouter] Payments managed by claw.credit"); } console.log(`[ClawRouter] Ready - Ctrl+C to stop`); diff --git a/src/index.ts b/src/index.ts index aa8ca4c..c1fc834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,10 @@ import { VERSION } from "./version.js"; import { privateKeyToAccount } from "viem/accounts"; import { getStats, formatStatsAscii } from "./stats.js"; +const CLAWCREDIT_DEFAULT_BASE_URL = "https://api.claw.credit"; +const CLAWCREDIT_DEFAULT_CHAIN = "BASE"; +const CLAWCREDIT_DEFAULT_ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + /** * Detect if we're running in shell completion mode. * When `openclaw completion --shell zsh` runs, it loads plugins but only needs @@ -412,46 +416,94 @@ let activeProxyHandle: Awaited> | null = null; * treating activate() as an alias (def.register ?? def.activate). */ async function startProxyInBackground(api: OpenClawPluginApi): Promise { - // Resolve wallet key: saved file → env var → auto-generate - const { key: walletKey, address, source } = await resolveOrGenerateWalletKey(); - - // Log wallet source (brief - balance check happens after proxy starts) - if (source === "generated") { - api.logger.info(`Generated new wallet: ${address}`); - } else if (source === "saved") { - api.logger.info(`Using saved wallet: ${address}`); - } else { - api.logger.info(`Using wallet from BLOCKRUN_WALLET_KEY: ${address}`); - } + const paymentMode = (process.env.BLOCKRUN_PAYMENT_MODE || "wallet").trim().toLowerCase(); + const useClawCredit = paymentMode === "clawcredit"; - // Resolve routing config overrides from plugin config + // Resolve routing overrides from plugin config. const routingConfig = api.pluginConfig?.routing as Partial | undefined; - const proxy = await startProxy({ - walletKey, - routingConfig, - onReady: (port) => { - api.logger.info(`BlockRun x402 proxy listening on port ${port}`); - }, - onError: (error) => { - api.logger.error(`BlockRun proxy error: ${error.message}`); - }, - onRouted: (decision) => { - const cost = decision.costEstimate.toFixed(4); - const saved = (decision.savings * 100).toFixed(0); - api.logger.info( - `[${decision.tier}] ${decision.model} $${cost} (saved ${saved}%) | ${decision.reasoning}`, - ); - }, - onLowBalance: (info) => { - api.logger.warn(`[!] Low balance: ${info.balanceUSD}. Fund wallet: ${info.walletAddress}`); - }, - onInsufficientFunds: (info) => { - api.logger.error( - `[!] Insufficient funds. Balance: ${info.balanceUSD}, Needed: ${info.requiredUSD}. Fund wallet: ${info.walletAddress}`, - ); - }, - }); + let address = "clawcredit"; + let proxy: Awaited>; + if (useClawCredit) { + const apiToken = (process.env.CLAWCREDIT_API_TOKEN || "").trim(); + if (!apiToken) { + throw new Error("CLAWCREDIT_API_TOKEN is required when BLOCKRUN_PAYMENT_MODE=clawcredit"); + } + + const baseUrl = (process.env.CLAWCREDIT_BASE_URL || CLAWCREDIT_DEFAULT_BASE_URL).trim(); + const chain = (process.env.CLAWCREDIT_PAYMENT_CHAIN || CLAWCREDIT_DEFAULT_CHAIN) + .trim() + .toUpperCase(); + const asset = (process.env.CLAWCREDIT_PAYMENT_ASSET || CLAWCREDIT_DEFAULT_ASSET).trim(); + + api.logger.info( + `Using claw.credit payment mode (baseUrl=${baseUrl}, chain=${chain}, asset=${asset})`, + ); + + proxy = await startProxy({ + paymentMode: "clawcredit", + clawCredit: { + baseUrl, + apiToken, + chain, + asset, + }, + routingConfig, + onReady: (port) => { + api.logger.info(`BlockRun claw.credit proxy listening on port ${port}`); + }, + onError: (error) => { + api.logger.error(`BlockRun proxy error: ${error.message}`); + }, + onRouted: (decision) => { + const cost = decision.costEstimate.toFixed(4); + const saved = (decision.savings * 100).toFixed(0); + api.logger.info( + `[${decision.tier}] ${decision.model} $${cost} (saved ${saved}%) | ${decision.reasoning}`, + ); + }, + }); + } else { + // Resolve wallet key: saved file -> env var -> auto-generate. + const { key: walletKey, address: walletAddress, source } = await resolveOrGenerateWalletKey(); + address = walletAddress; + + // Log wallet source (brief - balance check happens after proxy starts) + if (source === "generated") { + api.logger.info(`Generated new wallet: ${walletAddress}`); + } else if (source === "saved") { + api.logger.info(`Using saved wallet: ${walletAddress}`); + } else { + api.logger.info(`Using wallet from BLOCKRUN_WALLET_KEY: ${walletAddress}`); + } + + proxy = await startProxy({ + paymentMode: "wallet", + walletKey, + routingConfig, + onReady: (port) => { + api.logger.info(`BlockRun x402 proxy listening on port ${port}`); + }, + onError: (error) => { + api.logger.error(`BlockRun proxy error: ${error.message}`); + }, + onRouted: (decision) => { + const cost = decision.costEstimate.toFixed(4); + const saved = (decision.savings * 100).toFixed(0); + api.logger.info( + `[${decision.tier}] ${decision.model} $${cost} (saved ${saved}%) | ${decision.reasoning}`, + ); + }, + onLowBalance: (info) => { + api.logger.warn(`[!] Low balance: ${info.balanceUSD}. Fund wallet: ${info.walletAddress}`); + }, + onInsufficientFunds: (info) => { + api.logger.error( + `[!] Insufficient funds. Balance: ${info.balanceUSD}, Needed: ${info.requiredUSD}. Fund wallet: ${info.walletAddress}`, + ); + }, + }); + } setActiveProxy(proxy); activeProxyHandle = proxy; @@ -459,24 +511,28 @@ async function startProxyInBackground(api: OpenClawPluginApi): Promise { api.logger.info(`ClawRouter ready — smart routing enabled`); api.logger.info(`Pricing: Simple ~$0.001 | Code ~$0.01 | Complex ~$0.05 | Free: $0`); - // Non-blocking balance check AFTER proxy is ready (won't hang startup) - const startupMonitor = new BalanceMonitor(address); - startupMonitor - .checkBalance() - .then((balance) => { - if (balance.isEmpty) { - api.logger.info(`Wallet: ${address} | Balance: $0.00`); - api.logger.info(`Using FREE model. Fund wallet for premium models.`); - } else if (balance.isLow) { - api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD} (low)`); - } else { - api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD}`); - } - }) - .catch(() => { - // Silently continue - balance will be checked per-request anyway - api.logger.info(`Wallet: ${address} | Balance: (checking...)`); - }); + if (!useClawCredit) { + // Non-blocking balance check AFTER proxy is ready (won't hang startup) + const startupMonitor = new BalanceMonitor(address); + startupMonitor + .checkBalance() + .then((balance) => { + if (balance.isEmpty) { + api.logger.info(`Wallet: ${address} | Balance: $0.00`); + api.logger.info(`Using FREE model. Fund wallet for premium models.`); + } else if (balance.isLow) { + api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD} (low)`); + } else { + api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD}`); + } + }) + .catch(() => { + // Silently continue - balance will be checked per-request anyway + api.logger.info(`Wallet: ${address} | Balance: (checking...)`); + }); + } else { + api.logger.info("Payments managed by claw.credit (local wallet not required)"); + } } /** diff --git a/src/provider.ts b/src/provider.ts index f66169d..bc172ac 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -34,7 +34,14 @@ export const blockrunProvider: ProviderPlugin = { label: "BlockRun", docsPath: "https://blockrun.ai/docs", aliases: ["br"], - envVars: ["BLOCKRUN_WALLET_KEY"], + envVars: [ + "BLOCKRUN_WALLET_KEY", + "BLOCKRUN_PAYMENT_MODE", + "CLAWCREDIT_API_TOKEN", + "CLAWCREDIT_BASE_URL", + "CLAWCREDIT_PAYMENT_CHAIN", + "CLAWCREDIT_PAYMENT_ASSET", + ], // Model definitions — dynamically set to proxy URL get models() { diff --git a/src/proxy.ts b/src/proxy.ts index 82c503b..2a9cc2e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -26,6 +26,7 @@ import { finished } from "node:stream"; import type { AddressInfo } from "node:net"; import { privateKeyToAccount } from "viem/accounts"; import { createPaymentFetch, type PreAuthParams } from "./x402.js"; +import { createClawCreditFetch, type ClawCreditConfig } from "./clawcredit.js"; import { route, getFallbackChain, @@ -80,6 +81,7 @@ const HEALTH_CHECK_TIMEOUT_MS = 2_000; // Timeout for checking existing proxy const RATE_LIMIT_COOLDOWN_MS = 60_000; // 60 seconds cooldown for rate-limited models const PORT_RETRY_ATTEMPTS = 5; // Max attempts to bind port (handles TIME_WAIT) const PORT_RETRY_DELAY_MS = 1_000; // Delay between retry attempts +const DUMMY_WALLET_ADDRESS = "0x0000000000000000000000000000000000000000"; /** * Transform upstream payment errors into user-friendly messages. @@ -254,9 +256,11 @@ export function getProxyPort(): number { /** * Check if a proxy is already running on the given port. - * Returns the wallet address if running, undefined otherwise. + * Returns identity and payment mode if running, undefined otherwise. */ -async function checkExistingProxy(port: number): Promise { +async function checkExistingProxy( + port: number, +): Promise<{ wallet: string; paymentMode: PaymentMode } | undefined> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); @@ -267,9 +271,16 @@ async function checkExistingProxy(port: number): Promise { clearTimeout(timeoutId); if (response.ok) { - const data = (await response.json()) as { status?: string; wallet?: string }; + const data = (await response.json()) as { + status?: string; + wallet?: string; + paymentMode?: PaymentMode; + }; if (data.status === "ok" && data.wallet) { - return data.wallet; + return { + wallet: data.wallet, + paymentMode: data.paymentMode || "wallet", + }; } } return undefined; @@ -666,8 +677,15 @@ export type InsufficientFundsInfo = { walletAddress: string; }; +export type PaymentMode = "wallet" | "clawcredit"; + export type ProxyOptions = { - walletKey: string; + /** Local wallet private key for direct x402 signing (required in wallet mode). */ + walletKey?: string; + /** Payment backend. Defaults to "wallet" for backwards compatibility. */ + paymentMode?: PaymentMode; + /** claw.credit payment configuration (required in clawcredit mode). */ + clawCredit?: ClawCreditConfig; apiBase?: string; /** Port to listen on (default: 8402) */ port?: number; @@ -781,22 +799,49 @@ function estimateAmount( */ export async function startProxy(options: ProxyOptions): Promise { const apiBase = options.apiBase ?? BLOCKRUN_API; + const paymentMode: PaymentMode = options.paymentMode ?? "wallet"; + const localBalanceEnabled = paymentMode === "wallet"; // Determine port: options.port > env var > default const listenPort = options.port ?? getProxyPort(); + let walletAddressForMode = "clawcredit"; + let payFetch: ( + input: RequestInfo | URL, + init?: RequestInit, + preAuth?: PreAuthParams, + ) => Promise; + let balanceMonitor: BalanceMonitor; + + if (paymentMode === "wallet") { + if (!options.walletKey) { + throw new Error("walletKey is required when paymentMode='wallet'"); + } + const account = privateKeyToAccount(options.walletKey as `0x${string}`); + walletAddressForMode = account.address; + payFetch = createPaymentFetch(options.walletKey as `0x${string}`).fetch; + balanceMonitor = new BalanceMonitor(account.address); + } else { + if (!options.clawCredit?.apiToken) { + throw new Error("clawCredit.apiToken is required when paymentMode='clawcredit'"); + } + payFetch = createClawCreditFetch(options.clawCredit); + balanceMonitor = new BalanceMonitor(DUMMY_WALLET_ADDRESS); + } + // Check if a proxy is already running on this port - const existingWallet = await checkExistingProxy(listenPort); - if (existingWallet) { + const existingProxy = await checkExistingProxy(listenPort); + if (existingProxy) { // Proxy already running — reuse it instead of failing with EADDRINUSE - const account = privateKeyToAccount(options.walletKey as `0x${string}`); - const balanceMonitor = new BalanceMonitor(account.address); const baseUrl = `http://127.0.0.1:${listenPort}`; - // Verify the existing proxy is using the same wallet (or warn if different) - if (existingWallet !== account.address) { + // Verify the existing proxy is using the same payment mode/identity. + if ( + existingProxy.wallet !== walletAddressForMode || + existingProxy.paymentMode !== paymentMode + ) { console.warn( - `[ClawRouter] Existing proxy on port ${listenPort} uses wallet ${existingWallet}, but current config uses ${account.address}. Reusing existing proxy.`, + `[ClawRouter] Existing proxy on port ${listenPort} uses mode=${existingProxy.paymentMode} identity=${existingProxy.wallet}, but current config uses mode=${paymentMode} identity=${walletAddressForMode}. Reusing existing proxy.`, ); } @@ -805,7 +850,7 @@ export async function startProxy(options: ProxyOptions): Promise { return { port: listenPort, baseUrl, - walletAddress: existingWallet, + walletAddress: existingProxy.wallet, balanceMonitor, close: async () => { // No-op: we didn't start this proxy, so we shouldn't close it @@ -813,13 +858,6 @@ export async function startProxy(options: ProxyOptions): Promise { }; } - // Create x402 payment-enabled fetch from wallet private key - const account = privateKeyToAccount(options.walletKey as `0x${string}`); - const { fetch: payFetch } = createPaymentFetch(options.walletKey as `0x${string}`); - - // Create balance monitor for pre-request checks - const balanceMonitor = new BalanceMonitor(account.address); - // Build router options (100% local — no external API calls for routing) const routingConfig = mergeRoutingConfig(options.routingConfig); const modelPricing = buildModelPricing(); @@ -875,17 +913,22 @@ export async function startProxy(options: ProxyOptions): Promise { const response: Record = { status: "ok", - wallet: account.address, + wallet: walletAddressForMode, + paymentMode, }; if (full) { - try { - const balanceInfo = await balanceMonitor.checkBalance(); - response.balance = balanceInfo.balanceUSD; - response.isLow = balanceInfo.isLow; - response.isEmpty = balanceInfo.isEmpty; - } catch { - response.balanceError = "Could not fetch balance"; + if (localBalanceEnabled) { + try { + const balanceInfo = await balanceMonitor.checkBalance(); + response.balance = balanceInfo.balanceUSD; + response.isLow = balanceInfo.isLow; + response.isEmpty = balanceInfo.isEmpty; + } catch { + response.balanceError = "Could not fetch balance"; + } + } else { + response.balance = "managed_by_clawcredit"; } } @@ -960,6 +1003,7 @@ export async function startProxy(options: ProxyOptions): Promise { balanceMonitor, sessionStore, responseCache, + localBalanceEnabled, ); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -993,11 +1037,15 @@ export async function startProxy(options: ProxyOptions): Promise { if (err.code === "EADDRINUSE") { // Port is in use - check if a proxy is actually running - const existingWallet = await checkExistingProxy(listenPort); - if (existingWallet) { + const existingProxy = await checkExistingProxy(listenPort); + if (existingProxy) { // Proxy is actually running - this is fine, reuse it console.log(`[ClawRouter] Existing proxy detected on port ${listenPort}, reusing`); - rejectAttempt({ code: "REUSE_EXISTING", wallet: existingWallet }); + rejectAttempt({ + code: "REUSE_EXISTING", + wallet: existingProxy.wallet, + paymentMode: existingProxy.paymentMode, + }); return; } @@ -1036,10 +1084,20 @@ export async function startProxy(options: ProxyOptions): Promise { await tryListen(attempt); break; // Success } catch (err: unknown) { - const error = err as { code?: string; wallet?: string; attempt?: number }; + const error = err as { + code?: string; + wallet?: string; + paymentMode?: PaymentMode; + attempt?: number; + }; if (error.code === "REUSE_EXISTING" && error.wallet) { // Proxy is running, reuse it + if (error.paymentMode && error.paymentMode !== paymentMode) { + console.warn( + `[ClawRouter] Existing proxy mode=${error.paymentMode} differs from requested mode=${paymentMode}. Reusing existing proxy.`, + ); + } const baseUrl = `http://127.0.0.1:${listenPort}`; options.onReady?.(listenPort); return { @@ -1124,7 +1182,7 @@ export async function startProxy(options: ProxyOptions): Promise { return { port, baseUrl, - walletAddress: account.address, + walletAddress: walletAddressForMode, balanceMonitor, close: () => new Promise((res, rej) => { @@ -1287,6 +1345,7 @@ async function proxyRequest( balanceMonitor: BalanceMonitor, sessionStore: SessionStore, responseCache: ResponseCache, + localBalanceEnabled: boolean, ): Promise { const startTime = Date.now(); @@ -1557,7 +1616,7 @@ async function proxyRequest( let estimatedCostMicros: bigint | undefined; const isFreeModel = modelId === FREE_MODEL; - if (modelId && !options.skipBalanceCheck && !isFreeModel) { + if (localBalanceEnabled && modelId && !options.skipBalanceCheck && !isFreeModel) { const estimated = estimateAmount(modelId, body.length, maxTokens); if (estimated) { estimatedCostMicros = BigInt(estimated); @@ -2055,7 +2114,7 @@ async function proxyRequest( } // --- Optimistic balance deduction after successful response --- - if (estimatedCostMicros !== undefined) { + if (localBalanceEnabled && estimatedCostMicros !== undefined) { balanceMonitor.deductEstimated(estimatedCostMicros); } @@ -2075,7 +2134,9 @@ async function proxyRequest( deduplicator.removeInflight(dedupKey); // Invalidate balance cache on payment failure (might be out of date) - balanceMonitor.invalidate(); + if (localBalanceEnabled) { + balanceMonitor.invalidate(); + } // Convert abort error to more descriptive timeout error if (err instanceof Error && err.name === "AbortError") { diff --git a/test/clawcredit-mode.ts b/test/clawcredit-mode.ts new file mode 100644 index 0000000..44f66dd --- /dev/null +++ b/test/clawcredit-mode.ts @@ -0,0 +1,171 @@ +/** + * Integration test for Claw Credit payment mode. + * + * Verifies the proxy can run without a local wallet key and route payment + * through claw.credit's /v1/transaction/pay endpoint. + * + * Usage: + * npx tsx test/clawcredit-mode.ts + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { startProxy } from "../src/proxy.js"; + +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, msg: string): void { + if (condition) { + console.log(` ✓ ${msg}`); + passed++; + } else { + console.error(` ✗ FAIL: ${msg}`); + failed++; + } +} + +async function startMockClawCreditServer() { + let lastHeaders: Record = {}; + let lastPayload: Record | null = null; + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.url !== "/v1/transaction/pay" || req.method !== "POST") { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not_found" })); + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks).toString("utf-8"); + const body = JSON.parse(raw) as Record; + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") headers[key.toLowerCase()] = value; + } + + lastHeaders = headers; + lastPayload = body; + + const merchantResponse = { + id: "chatcmpl-mock", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "openai/gpt-4o-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: "hello from mock claw.credit path" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "success", + tx_hash: "mock-tx-hash", + chain: "BASE", + amount_charged: 0.01, + remaining_balance: 10.0, + merchant_response: merchantResponse, + actual_amount_charged: 0.01, + remaining_balance_usd: 10.0, + }), + ); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const port = (server.address() as AddressInfo).port; + + return { + port, + getLastHeaders: () => lastHeaders, + getLastPayload: () => lastPayload, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function run(): Promise { + console.log("\n═══ Claw Credit Mode Test ═══\n"); + + const credit = await startMockClawCreditServer(); + const proxy = await startProxy({ + // Intentionally no walletKey: claw.credit mode should not require one. + paymentMode: "clawcredit", + clawCredit: { + baseUrl: `http://127.0.0.1:${credit.port}`, + apiToken: "claw_test_token", + chain: "BASE", + asset: BASE_USDC, + }, + port: 0, + }); + + try { + const response = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-4o", + messages: [{ role: "user", content: "Say hello" }], + max_tokens: 32, + }), + }); + + assert(response.ok, `proxy request succeeded (${response.status})`); + const json = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = json.choices?.[0]?.message?.content ?? ""; + assert(content.includes("mock claw.credit path"), "response came from claw.credit merchant_response"); + + const headers = credit.getLastHeaders(); + assert( + headers.authorization === "Bearer claw_test_token", + "claw.credit call included Authorization header", + ); + + const payload = credit.getLastPayload() as Record | null; + assert(payload != null, "claw.credit pay payload captured"); + if (payload) { + const tx = payload.transaction as Record; + const reqBody = payload.request_body as Record; + const http = reqBody.http as Record; + + assert(tx.chain === "BASE", "transaction.chain forwarded"); + assert(tx.asset === BASE_USDC, "transaction.asset forwarded"); + assert(typeof tx.amount === "number" && (tx.amount as number) > 0, "transaction.amount > 0"); + assert( + typeof tx.recipient === "string" && + (tx.recipient as string).includes("/v1/chat/completions"), + "transaction.recipient points to BlockRun chat endpoint", + ); + assert( + http.url === tx.recipient, + "request_body.http.url matches transaction.recipient", + ); + } + } finally { + await proxy.close(); + await credit.close(); + } + + console.log("\n═══════════════════════════════════"); + console.log(` ${passed} passed, ${failed} failed`); + console.log("═══════════════════════════════════\n"); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); From 01a0daef9534224f1e9ad387788cbf95fa9f3cd7 Mon Sep 17 00:00:00 2001 From: tsubasakong Date: Sun, 15 Feb 2026 01:50:16 -0800 Subject: [PATCH 2/4] feat(clawrouter): switch clawcredit mode to official SDK pay flow Replace custom claw.credit payload construction with @t54-labs/clawcredit-sdk pay() integration, add SDK dependency/types, and tighten integration tests to assert official sdk_meta identity and richer audit context capture. --- package-lock.json | 51 ++++++------ package.json | 1 + src/clawcredit.ts | 146 +++++++++++++++++++--------------- src/types/clawcredit-sdk.d.ts | 8 ++ test/clawcredit-mode.ts | 14 ++++ 5 files changed, 133 insertions(+), 87 deletions(-) create mode 100644 src/types/clawcredit-sdk.d.ts diff --git a/package-lock.json b/package-lock.json index 8ba3773..f2b13cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.9.12", "license": "MIT", "dependencies": { + "@t54-labs/clawcredit-sdk": "^0.2.40", "viem": "^2.39.3" }, "bin": { @@ -5550,6 +5551,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@t54-labs/clawcredit-sdk": { + "version": "0.2.40", + "resolved": "https://registry.npmjs.org/@t54-labs/clawcredit-sdk/-/clawcredit-sdk-0.2.40.tgz", + "integrity": "sha512-rhqStBLloliN387W0Y7kkrgkdLwTAwX0kRUkBjy/NtO/K6++0iaFORPU9x5wpo8GeoJTdI9bZDRBj9GFSkSTZw==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "uuid": "^9.0.0" + }, + "bin": { + "clawcredit": "bin/clawcredit.js", + "clawcredit-verify": "bin/verify.js" + } + }, "node_modules/@tinyhttp/content-disposition": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz", @@ -6490,7 +6505,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -6507,7 +6521,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", - "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -6706,7 +6719,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7146,7 +7158,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7416,7 +7427,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7546,7 +7556,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7626,7 +7635,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7636,7 +7644,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7653,7 +7660,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7666,7 +7672,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8422,7 +8427,6 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, "funding": [ { "type": "individual", @@ -8473,7 +8477,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8582,7 +8585,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8693,7 +8695,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8718,7 +8719,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8836,7 +8836,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8917,7 +8916,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8930,7 +8928,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8967,7 +8964,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9977,7 +9973,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10028,7 +10023,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10038,7 +10032,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -11526,7 +11519,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -13302,6 +13294,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-name": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", diff --git a/package.json b/package.json index 278e497..c883d9e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "url": "git+https://github.com/BlockRunAI/ClawRouter.git" }, "dependencies": { + "@t54-labs/clawcredit-sdk": "^0.2.40", "viem": "^2.39.3" }, "peerDependencies": { diff --git a/src/clawcredit.ts b/src/clawcredit.ts index c7c8379..855fb06 100644 --- a/src/clawcredit.ts +++ b/src/clawcredit.ts @@ -1,11 +1,11 @@ /** * Claw Credit payment backend for ClawRouter. * - * Converts a BlockRun upstream request into a claw.credit /v1/transaction/pay call - * and returns the merchant_response as a standard fetch Response. + * Uses the official @t54-labs/clawcredit-sdk payment path so requests include + * SDK-generated metadata and richer audit context. */ -import { VERSION } from "./version.js"; +import { ClawCredit, withTrace } from "@t54-labs/clawcredit-sdk"; const DEFAULT_SERVICE_URL = "https://api.claw.credit"; @@ -14,12 +14,32 @@ export type ClawCreditConfig = { apiToken: string; chain: string; asset: string; + agent?: string; + agentId?: string; }; export type PreAuthParams = { estimatedAmount: string; }; +type SdkClient = { + pay: (args: { + transaction: { + recipient: string; + amount: number; + chain: string; + asset: string; + amount_unit?: "human" | "atomic"; + }; + request_body: Record; + context?: { + reasoning_process?: string; + current_task?: string; + }; + idempotencyKey?: string; + }) => Promise<{ merchant_response?: unknown }>; +}; + function headersToObject(headersInit?: HeadersInit): Record { if (!headersInit) return {}; const headers = new Headers(headersInit); @@ -60,8 +80,18 @@ function microsToUsd(estimatedAmount?: string): number { return Number((micros / 1_000_000).toFixed(6)); } +function inferStatusCode(err: unknown): number { + const msg = err instanceof Error ? err.message : String(err); + const match = msg.match(/ClawCredit API Error:\s*(\d{3})\s*-/i); + if (match) return parseInt(match[1], 10); + if (/payment required/i.test(msg)) return 402; + if (/prequalification_pending/i.test(msg)) return 403; + if (/unauthorized/i.test(msg)) return 401; + return 502; +} + /** - * Create a fetch wrapper that pays through claw.credit instead of local x402 signing. + * Create a fetch wrapper that pays through claw.credit SDK instead of local x402 signing. */ export function createClawCreditFetch(config: ClawCreditConfig) { const serviceUrl = (config.baseUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, ""); @@ -73,6 +103,13 @@ export function createClawCreditFetch(config: ClawCreditConfig) { throw new Error("CLAWCREDIT_API_TOKEN is required for claw.credit payment mode"); } + const credit = new ClawCredit({ + serviceUrl, + apiToken, + agent: config.agent, + agentId: config.agentId, + }) as SdkClient; + return async ( input: RequestInfo | URL, init?: RequestInit, @@ -82,70 +119,51 @@ export function createClawCreditFetch(config: ClawCreditConfig) { typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const method = (init?.method || "POST").toUpperCase(); const headers = headersToObject(init?.headers); + const idempotencyKey = new Headers(init?.headers).get("idempotency-key") || undefined; const requestBody = parseJsonBody(init?.body); const amountUsd = microsToUsd(preAuth?.estimatedAmount); - const payload = { - transaction: { - recipient: upstreamUrl, - amount: amountUsd, - chain, - asset, - }, - request_body: { - http: { - url: upstreamUrl, - method, - headers, - }, - body: requestBody, - }, - audit_context: { - current_task: "blockrun_inference_via_clawrouter", - reasoning_process: "Proxying BlockRun inference payment through claw.credit", - timestamp: Date.now(), - }, - sdk_meta: { - sdk_name: "@blockrun/clawrouter", - sdk_version: VERSION, - }, - }; - - const payResponse = await fetch(`${serviceUrl}/v1/transaction/pay`, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${apiToken}`, - }, - body: JSON.stringify(payload), - signal: init?.signal, - }); - - const text = await payResponse.text(); - const contentType = payResponse.headers.get("content-type") || "application/json"; - - if (!payResponse.ok) { - return new Response(text, { - status: payResponse.status, - headers: { "content-type": contentType }, - }); - } - - let parsed: unknown; try { - parsed = JSON.parse(text); - } catch { - parsed = { raw: text }; + const result = await withTrace(async () => + credit.pay({ + transaction: { + recipient: upstreamUrl, + amount: amountUsd, + chain, + asset, + }, + request_body: { + http: { + url: upstreamUrl, + method, + headers, + }, + body: requestBody, + }, + context: { + current_task: "blockrun_inference_via_clawrouter", + reasoning_process: "Proxying BlockRun inference payment through claw.credit SDK", + }, + idempotencyKey, + }), + ); + + const merchantResponse = + result && typeof result === "object" && "merchant_response" in result + ? (result as { merchant_response?: unknown }).merchant_response + : result; + + return new Response(JSON.stringify(merchantResponse ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (err) { + const status = inferStatusCode(err); + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "content-type": "application/json" }, + }); } - - const merchantResponse = - parsed && typeof parsed === "object" && "merchant_response" in parsed - ? (parsed as { merchant_response: unknown }).merchant_response - : parsed; - - return new Response(JSON.stringify(merchantResponse ?? {}), { - status: 200, - headers: { "content-type": "application/json" }, - }); }; } diff --git a/src/types/clawcredit-sdk.d.ts b/src/types/clawcredit-sdk.d.ts new file mode 100644 index 0000000..6a85fed --- /dev/null +++ b/src/types/clawcredit-sdk.d.ts @@ -0,0 +1,8 @@ +declare module "@t54-labs/clawcredit-sdk" { + export class ClawCredit { + constructor(config?: Record); + pay(args: Record): Promise>; + } + + export function withTrace(fn: () => Promise): Promise; +} diff --git a/test/clawcredit-mode.ts b/test/clawcredit-mode.ts index 44f66dd..20875b1 100644 --- a/test/clawcredit-mode.ts +++ b/test/clawcredit-mode.ts @@ -140,6 +140,8 @@ async function run(): Promise { const tx = payload.transaction as Record; const reqBody = payload.request_body as Record; const http = reqBody.http as Record; + const audit = payload.audit_context as Record; + const sdkMeta = payload.sdk_meta as Record; assert(tx.chain === "BASE", "transaction.chain forwarded"); assert(tx.asset === BASE_USDC, "transaction.asset forwarded"); @@ -153,6 +155,18 @@ async function run(): Promise { http.url === tx.recipient, "request_body.http.url matches transaction.recipient", ); + assert(typeof sdkMeta?.sdk_name === "string", "sdk_meta.sdk_name present"); + assert( + sdkMeta?.sdk_name === "@t54-labs/clawcredit-sdk", + "sdk_meta.sdk_name uses official clawcredit sdk identity", + ); + assert(typeof sdkMeta?.sdk_version === "string", "sdk_meta.sdk_version present"); + assert( + Array.isArray(audit?.stack_code) && audit.stack_code.length > 0, + "audit_context.stack_code captured", + ); + assert(typeof audit?.current_task === "string", "audit_context.current_task present"); + assert(typeof audit?.reasoning_process === "string", "audit_context.reasoning_process present"); } } finally { await proxy.close(); From 3187aa327797cda24f986e0812710810f9d2bcae Mon Sep 17 00:00:00 2001 From: tsubasakong Date: Sun, 15 Feb 2026 17:59:58 -0800 Subject: [PATCH 3/4] feat(clawrouter): add openclaw clawcredit setup script --- README.md | 7 + docs/configuration.md | 7 + scripts/setup-clawcredit.sh | 305 ++++++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100755 scripts/setup-clawcredit.sh diff --git a/README.md b/README.md index 5b0c80f..def0bee 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,13 @@ export BLOCKRUN_PAYMENT_MODE=clawcredit export CLAWCREDIT_API_TOKEN=claw_xxx ``` +OpenClaw automatically loads `~/.openclaw/.env` on startup. If you want a one-command setup (no manual `export`), run: + +```bash +# Installs/enables the plugin (if needed), writes ~/.openclaw/.env, restarts gateway +bash ~/.openclaw/extensions/clawrouter/scripts/setup-clawcredit.sh +``` + **Full reference:** [docs/configuration.md](docs/configuration.md) --- diff --git a/docs/configuration.md b/docs/configuration.md index d7ff92f..312bcdc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,13 @@ Complete reference for ClawRouter configuration options. | --------------------- | ------- | ------------------------------------------------------------------------ | | `BLOCKRUN_WALLET_KEY` | - | Ethereum private key (hex, 0x-prefixed). Used if no saved wallet exists. | | `BLOCKRUN_PROXY_PORT` | `8402` | Port for the local x402 proxy server. | +| `BLOCKRUN_PAYMENT_MODE` | `wallet` | Payment backend: `wallet` (local x402 signing) or `clawcredit` (claw.credit) | +| `CLAWCREDIT_API_TOKEN` | - | Required when `BLOCKRUN_PAYMENT_MODE=clawcredit` | +| `CLAWCREDIT_BASE_URL` | `https://api.claw.credit` | claw.credit API base URL | +| `CLAWCREDIT_PAYMENT_CHAIN` | `BASE` | Chain passed to claw.credit `transaction.chain` | +| `CLAWCREDIT_PAYMENT_ASSET` | Base USDC | Asset passed to claw.credit `transaction.asset` | + +> **Tip:** OpenClaw loads environment variables from `~/.openclaw/.env` on startup. You can put the variables above there (or run `scripts/setup-clawcredit.sh`). ### BLOCKRUN_WALLET_KEY diff --git a/scripts/setup-clawcredit.sh b/scripts/setup-clawcredit.sh new file mode 100755 index 0000000..2fe87de --- /dev/null +++ b/scripts/setup-clawcredit.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +set -euo pipefail + +PLUGIN_PKG="@blockrun/clawrouter" +PLUGIN_ID="clawrouter" + +DEFAULT_CLAWCREDIT_BASE_URL="https://api.claw.credit" +DEFAULT_CHAIN="BASE" +DEFAULT_ASSET_BASE_USDC="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +usage() { + cat <<'EOF' +ClawRouter claw.credit Setup + +This script configures OpenClaw to pay BlockRun inference via claw.credit (SDK-backed). + +What it does: + - Installs/enables @blockrun/clawrouter (if needed) + - Writes/updates OpenClaw's global env file (~/.openclaw/.env) + - Restarts the OpenClaw gateway service + +Usage: + setup-clawcredit.sh [options] + +Options: + --token CLAWCREDIT_API_TOKEN (if omitted: tries to read from clawcredit.json; else prompts) + --agent OpenClaw agent id to read clawcredit.json from (default: main) + --chain Payment chain passed to claw.credit (default: BASE) + --asset Payment asset passed to claw.credit (default: Base USDC) + --base-url claw.credit API base URL (default: https://api.claw.credit) + --profile OpenClaw profile name (uses ~/.openclaw-) + --no-restart Do not restart the gateway + --dry-run Print actions without writing/exec'ing + -h, --help Show help + +Examples: + bash setup-clawcredit.sh + bash setup-clawcredit.sh --token claw_xxx + bash setup-clawcredit.sh --chain XRPL --asset USDC +EOF +} + +log() { printf '%s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +is_tty() { + [[ -t 0 && -t 1 ]] +} + +resolve_state_dir() { + local profile="${1:-}" + local home="${HOME}" + + if [[ -n "${OPENCLAW_STATE_DIR:-}" ]]; then + printf '%s' "${OPENCLAW_STATE_DIR}" + return 0 + fi + + if [[ -n "$profile" ]]; then + printf '%s' "${home}/.openclaw-${profile}" + return 0 + fi + + if [[ -d "${home}/.openclaw" ]]; then + printf '%s' "${home}/.openclaw" + return 0 + fi + + if [[ -d "${home}/.moltbot" ]]; then + printf '%s' "${home}/.moltbot" + return 0 + fi + + # Default fallback (OpenClaw will create it on demand). + printf '%s' "${home}/.openclaw" +} + +OPENCLAW_PROFILE="" +AGENT_ID="main" +CLAWCREDIT_API_TOKEN="${CLAWCREDIT_API_TOKEN:-}" +CLAWCREDIT_PAYMENT_CHAIN="${CLAWCREDIT_PAYMENT_CHAIN:-}" +CLAWCREDIT_PAYMENT_ASSET="${CLAWCREDIT_PAYMENT_ASSET:-}" +CLAWCREDIT_BASE_URL="${CLAWCREDIT_BASE_URL:-}" +NO_RESTART="0" +DRY_RUN="0" + +while [[ $# -gt 0 ]]; do + case "$1" in + --token) + [[ $# -ge 2 ]] || die "--token requires a value" + CLAWCREDIT_API_TOKEN="$2" + shift 2 + ;; + --agent) + [[ $# -ge 2 ]] || die "--agent requires a value" + AGENT_ID="$2" + shift 2 + ;; + --chain) + [[ $# -ge 2 ]] || die "--chain requires a value" + CLAWCREDIT_PAYMENT_CHAIN="$2" + shift 2 + ;; + --asset) + [[ $# -ge 2 ]] || die "--asset requires a value" + CLAWCREDIT_PAYMENT_ASSET="$2" + shift 2 + ;; + --base-url) + [[ $# -ge 2 ]] || die "--base-url requires a value" + CLAWCREDIT_BASE_URL="$2" + shift 2 + ;; + --profile) + [[ $# -ge 2 ]] || die "--profile requires a value" + OPENCLAW_PROFILE="$2" + shift 2 + ;; + --no-restart) + NO_RESTART="1" + shift + ;; + --dry-run) + DRY_RUN="1" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1 (use --help)" + ;; + esac +done + +need_cmd openclaw +need_cmd node + +STATE_DIR="$(resolve_state_dir "$OPENCLAW_PROFILE")" +ENV_FILE="${STATE_DIR}/.env" +EXT_DIR="${STATE_DIR}/extensions/${PLUGIN_ID}" +CLAWCREDIT_JSON="${STATE_DIR}/agents/${AGENT_ID}/agent/clawcredit.json" + +if [[ -z "$CLAWCREDIT_BASE_URL" ]]; then + CLAWCREDIT_BASE_URL="$DEFAULT_CLAWCREDIT_BASE_URL" +fi + +if [[ -z "$CLAWCREDIT_PAYMENT_CHAIN" ]]; then + CLAWCREDIT_PAYMENT_CHAIN="$DEFAULT_CHAIN" +fi + +CLAWCREDIT_PAYMENT_CHAIN="$(printf '%s' "$CLAWCREDIT_PAYMENT_CHAIN" | tr '[:lower:]' '[:upper:]')" + +if [[ -z "$CLAWCREDIT_PAYMENT_ASSET" ]]; then + if [[ "$CLAWCREDIT_PAYMENT_CHAIN" == "BASE" ]]; then + CLAWCREDIT_PAYMENT_ASSET="$DEFAULT_ASSET_BASE_USDC" + fi +fi + +if [[ -z "${CLAWCREDIT_API_TOKEN}" && -f "${CLAWCREDIT_JSON}" ]]; then + token_from_json="$(node -e " + const fs = require('fs'); + const p = process.argv[1]; + try { + const raw = fs.readFileSync(p, 'utf8'); + const j = JSON.parse(raw); + const t = typeof j.apiToken === 'string' ? j.apiToken.trim() : ''; + if (t) process.stdout.write(t); + } catch {} + " "${CLAWCREDIT_JSON}")" + if [[ -n "${token_from_json}" ]]; then + CLAWCREDIT_API_TOKEN="${token_from_json}" + log "→ Found claw.credit token in ${CLAWCREDIT_JSON}" + fi +fi + +if [[ -z "${CLAWCREDIT_API_TOKEN}" ]]; then + if ! is_tty; then + die "CLAWCREDIT_API_TOKEN not set and no token found at ${CLAWCREDIT_JSON}. Re-run with --token." + fi + printf "Enter CLAWCREDIT_API_TOKEN: " >&2 + read -r -s CLAWCREDIT_API_TOKEN + printf "\n" >&2 + if [[ -z "${CLAWCREDIT_API_TOKEN}" ]]; then + die "Empty token" + fi +fi + +if [[ -z "${CLAWCREDIT_PAYMENT_ASSET}" ]]; then + if ! is_tty; then + die "CLAWCREDIT_PAYMENT_ASSET is required for chain=${CLAWCREDIT_PAYMENT_CHAIN}. Re-run with --asset." + fi + printf "Enter CLAWCREDIT_PAYMENT_ASSET for chain=%s: " "${CLAWCREDIT_PAYMENT_CHAIN}" >&2 + read -r CLAWCREDIT_PAYMENT_ASSET + if [[ -z "${CLAWCREDIT_PAYMENT_ASSET}" ]]; then + die "Empty asset" + fi +fi + +log "" +log "ClawRouter claw.credit configuration" +log " OpenClaw state dir: ${STATE_DIR}" +log " Profile: ${OPENCLAW_PROFILE:-default}" +log " Agent: ${AGENT_ID}" +log " claw.credit baseUrl: ${CLAWCREDIT_BASE_URL}" +log " chain: ${CLAWCREDIT_PAYMENT_CHAIN}" +log " asset: ${CLAWCREDIT_PAYMENT_ASSET}" +log " env file: ${ENV_FILE}" +log "" + +if [[ "${DRY_RUN}" == "1" ]]; then + log "[dry-run] Would ensure plugin installed at: ${EXT_DIR}" + log "[dry-run] Would run: openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} plugins install ${PLUGIN_PKG}" + log "[dry-run] Would run: openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} plugins enable ${PLUGIN_ID}" + log "[dry-run] Would write env vars to: ${ENV_FILE}" + if [[ "${NO_RESTART}" != "1" ]]; then + log "[dry-run] Would run: openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway restart" + fi + exit 0 +fi + +log "→ Installing/enabling ${PLUGIN_PKG}..." +if [[ ! -d "${EXT_DIR}" ]]; then + openclaw ${OPENCLAW_PROFILE:+ --profile "${OPENCLAW_PROFILE}"} plugins install "${PLUGIN_PKG}" +else + log " Plugin already installed: ${EXT_DIR}" +fi + +openclaw ${OPENCLAW_PROFILE:+ --profile "${OPENCLAW_PROFILE}"} plugins enable "${PLUGIN_ID}" + +log "→ Writing ${ENV_FILE}..." +mkdir -p "${STATE_DIR}" + +BLOCKRUN_PAYMENT_MODE="clawcredit" \ +CLAWCREDIT_API_TOKEN="${CLAWCREDIT_API_TOKEN}" \ +CLAWCREDIT_BASE_URL="${CLAWCREDIT_BASE_URL}" \ +CLAWCREDIT_PAYMENT_CHAIN="${CLAWCREDIT_PAYMENT_CHAIN}" \ +CLAWCREDIT_PAYMENT_ASSET="${CLAWCREDIT_PAYMENT_ASSET}" \ +node -e " + const fs = require('fs'); + const path = require('path'); + + const envPath = process.argv[1]; + const pairs = { + BLOCKRUN_PAYMENT_MODE: process.env.BLOCKRUN_PAYMENT_MODE, + CLAWCREDIT_API_TOKEN: process.env.CLAWCREDIT_API_TOKEN, + CLAWCREDIT_BASE_URL: process.env.CLAWCREDIT_BASE_URL, + CLAWCREDIT_PAYMENT_CHAIN: process.env.CLAWCREDIT_PAYMENT_CHAIN, + CLAWCREDIT_PAYMENT_ASSET: process.env.CLAWCREDIT_PAYMENT_ASSET, + }; + + let lines = []; + if (fs.existsSync(envPath)) { + lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/); + } + + const setLine = (key, value) => { + const encoded = JSON.stringify(String(value)); + const next = key + '=' + encoded; + const re = new RegExp('^' + key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + '='); // key= + const idx = lines.findIndex((l) => re.test(l)); + if (idx >= 0) { + lines[idx] = next; + } else { + lines.push(next); + } + }; + + for (const [k, v] of Object.entries(pairs)) { + if (v == null || String(v).trim() === '') continue; + setLine(k, v); + } + + // Trim trailing empty lines, then ensure file ends with newline. + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + const out = lines.join('\\n') + '\\n'; + + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + fs.writeFileSync(envPath, out, 'utf8'); + try { fs.chmodSync(envPath, 0o600); } catch {} +" "${ENV_FILE}" + +if [[ "${NO_RESTART}" != "1" ]]; then + log "→ Restarting OpenClaw gateway..." + if ! openclaw ${OPENCLAW_PROFILE:+ --profile "${OPENCLAW_PROFILE}"} gateway restart; then + warn "Gateway restart failed. Try:" + warn " openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway install" + warn " openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway start" + fi +else + log "→ Skipping gateway restart (--no-restart)" +fi + +log "" +log "✓ claw.credit mode enabled for BlockRun inference" +log "" +log "Quick checks:" +log " openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway status" +log " curl -s \"http://127.0.0.1:8402/health?full=true\" | cat" From 230d7482d178dde9f9f613c53258759aa63900a1 Mon Sep 17 00:00:00 2001 From: tsubasakong Date: Sun, 15 Feb 2026 18:02:04 -0800 Subject: [PATCH 4/4] fix(scripts): make clawcredit setup script bash-safe --- scripts/setup-clawcredit.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/setup-clawcredit.sh b/scripts/setup-clawcredit.sh index 2fe87de..2c1d5a2 100755 --- a/scripts/setup-clawcredit.sh +++ b/scripts/setup-clawcredit.sh @@ -263,7 +263,8 @@ node -e " const setLine = (key, value) => { const encoded = JSON.stringify(String(value)); const next = key + '=' + encoded; - const re = new RegExp('^' + key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&') + '='); // key= + // Keys here are fixed env var names, so no regex escaping is needed. + const re = new RegExp('^' + key + '='); // key= const idx = lines.findIndex((l) => re.test(l)); if (idx >= 0) { lines[idx] = next;