From d8bdce019bc6c987ba70f48793713be4011e1f0d Mon Sep 17 00:00:00 2001 From: TheCheetah Date: Tue, 16 Jun 2026 11:16:59 +0100 Subject: [PATCH 1/4] fix(update): clear stale OpenClaw install metadata --- scripts/update.sh | 92 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/scripts/update.sh b/scripts/update.sh index 87e7c769..c145cdb9 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -906,6 +906,98 @@ try { } catch (e) { console.log(' Skipped: ' + e.message); } " +# OpenClaw 2026.6 migrates the legacy plugin install index into shared SQLite. +# If the old JSON index still contains a stale ClawRouter record, OpenClaw keeps +# warning about conflicting install metadata on every doctor/restart. Clean only +# ClawRouter's legacy record after this updater has verified the current install. +echo "→ Cleaning stale ClawRouter install metadata..." +node -e " +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const home = os.homedir(); +const legacyPath = path.join(home, '.openclaw', 'plugins', 'installs.json'); + +function currentPackageCandidates() { + const candidates = [ + path.join(home, '.openclaw', 'extensions', 'clawrouter', 'package.json'), + path.join(home, '.openclaw', 'npm', 'node_modules', '@blockrun', 'clawrouter', 'package.json'), + ]; + const projectsDir = path.join(home, '.openclaw', 'npm', 'projects'); + try { + for (const name of fs.readdirSync(projectsDir)) { + candidates.push(path.join(projectsDir, name, 'node_modules', '@blockrun', 'clawrouter', 'package.json')); + } + } catch {} + return candidates; +} + +function newestCurrentPackage() { + let best = null; + for (const packagePath of currentPackageCandidates()) { + try { + const stat = fs.statSync(packagePath); + const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + if (pkg?.name !== '@blockrun/clawrouter') continue; + if (!best || stat.mtimeMs > best.mtimeMs) { + best = { packagePath, pkg, mtimeMs: stat.mtimeMs }; + } + } catch {} + } + return best; +} + +function atomicWrite(filePath, data) { + const tmp = filePath + '.tmp.' + process.pid; + fs.writeFileSync(tmp, data); + fs.renameSync(tmp, filePath); +} + +try { + if (!fs.existsSync(legacyPath)) { + console.log(' ✓ No legacy install index found'); + process.exit(0); + } + const current = newestCurrentPackage(); + if (!current) { + console.log(' Skipped: current ClawRouter package.json not found'); + process.exit(0); + } + + const index = JSON.parse(fs.readFileSync(legacyPath, 'utf8')); + const currentPkg = current.pkg; + const legacyRecord = index?.installRecords?.clawrouter; + + if (!legacyRecord) { + console.log(' ✓ Legacy install index has no ClawRouter record'); + process.exit(0); + } + + const currentVersion = String(currentPkg.version || ''); + const legacyVersion = String(legacyRecord.version || legacyRecord.resolvedVersion || ''); + const legacyPathValue = String(legacyRecord.installPath || ''); + const currentPath = path.dirname(current.packagePath); + const stale = legacyVersion !== currentVersion || path.resolve(legacyPathValue) !== path.resolve(currentPath); + + if (!stale) { + console.log(' ✓ Legacy ClawRouter install metadata already matches current install'); + process.exit(0); + } + + delete index.installRecords.clawrouter; + if (Array.isArray(index.plugins)) { + index.plugins = index.plugins.filter((plugin) => plugin?.pluginId !== 'clawrouter'); + } + index.refreshReason = 'clawrouter-stale-metadata-cleanup'; + index.generatedAtMs = Date.now(); + atomicWrite(legacyPath, JSON.stringify(index, null, 2)); + console.log(' ✓ Removed stale legacy ClawRouter install metadata (' + (legacyVersion || 'unknown') + ' -> ' + currentVersion + ')'); +} catch (e) { + console.log(' Skipped: ' + e.message); +} +" + # ── Summary ───────────────────────────────────────────────────── echo "" echo "✓ ClawRouter updated successfully!" From 09c65bf63c593a8cf68a376409566cfc28830900 Mon Sep 17 00:00:00 2001 From: TheCheetah Date: Tue, 16 Jun 2026 11:21:41 +0100 Subject: [PATCH 2/4] chore: apply prettier formatting --- CHANGELOG.md | 4 ++-- README.md | 12 ++++++------ docs/configuration.md | 16 ++++++++-------- src/proxy.reasoning-header.test.ts | 9 ++++++--- src/upstream-proxy.ts | 10 +++++++++- test/upstream-proxy.test.ts | 20 +++++++++++++++----- 6 files changed, 46 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2494b35b..dbe21aee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Registry realignment with the 2026-06-13 → 06-14 BlockRun catalog sweep: Fable Stop non-English prompts from crashing paid responses via the `x-clawrouter-reasoning` header. -- **Non-ASCII routing reasoning crashed `res.writeHead` after settlement** (`src/proxy.ts`). The routing reasoning string embeds matched keywords from the multilingual lists in `src/router/config.ts` (Cyrillic, CJK, …). Debug headers are on by default, so for e.g. Russian prompts the raw Cyrillic keywords landed in the `x-clawrouter-reasoning` response header and Node rejected the write with `ERR_INVALID_CHAR` — *after* the upstream call had completed and the x402 payment had settled. The client never received the body and retried, signing a fresh payment each round: a paid retry loop. Header values are now percent-encoded outside printable ASCII (`sanitizeHeaderValue`, reversible via `decodeURIComponent`), and as defense in depth a rejected `writeHead` sanitizes all header values and still delivers the paid body instead of throwing. (Test: `proxy.reasoning-header.test.ts`, 13 cases, including a live Russian-prompt repro through `classifyByRules` validated against Node's `validateHeaderValue`.) +- **Non-ASCII routing reasoning crashed `res.writeHead` after settlement** (`src/proxy.ts`). The routing reasoning string embeds matched keywords from the multilingual lists in `src/router/config.ts` (Cyrillic, CJK, …). Debug headers are on by default, so for e.g. Russian prompts the raw Cyrillic keywords landed in the `x-clawrouter-reasoning` response header and Node rejected the write with `ERR_INVALID_CHAR` — _after_ the upstream call had completed and the x402 payment had settled. The client never received the body and retried, signing a fresh payment each round: a paid retry loop. Header values are now percent-encoded outside printable ASCII (`sanitizeHeaderValue`, reversible via `decodeURIComponent`), and as defense in depth a rejected `writeHead` sanitizes all header values and still delivers the paid body instead of throwing. (Test: `proxy.reasoning-header.test.ts`, 13 cases, including a live Russian-prompt repro through `classifyByRules` validated against Node's `validateHeaderValue`.) - **`CLAWROUTER_DEBUG_HEADERS=off|false|0`** — new env kill switch for the `x-clawrouter-*` debug headers, for clients that can't set the per-request `x-clawrouter-debug: false` header. Comments claiming the headers were "opt-in" corrected (they are on by default). --- @@ -36,7 +36,7 @@ Stop non-English prompts from crashing paid responses via the `x-clawrouter-reas Honor standard proxy environment variables for upstream traffic. -- **`HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` fallback** (`src/upstream-proxy.ts`). Node's fetch (undici) ignores the standard proxy env vars, so users whose system traffic is meant to flow through a local proxy (mihomo/clash without TUN mode, corporate proxies) had ClawRouter connecting *directly* while their `curl` tests went through the proxy — on throttled routes (e.g. RU → Google-hosted gateway) this surfaced as instant 500s on large request bodies, `Premature close`, and per-model timeouts, while small requests slipped through. When `BLOCKRUN_UPSTREAM_PROXY` is unset, ClawRouter now applies the standard env vars via undici's `EnvHttpProxyAgent`. `NO_PROXY` is honored, and loopback hosts (`localhost`, `127.0.0.1`, `::1`) are always excluded so local health checks and sibling proxies stay direct. `BLOCKRUN_UPSTREAM_PROXY` still wins when set; SOCKS URLs in the standard env vars are *not* auto-applied (a warning points to `BLOCKRUN_UPSTREAM_PROXY=socks5://…`). (Test: `test/upstream-proxy.test.ts`, 9 cases, dependency-injected — no process-global mutation in tests.) +- **`HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` fallback** (`src/upstream-proxy.ts`). Node's fetch (undici) ignores the standard proxy env vars, so users whose system traffic is meant to flow through a local proxy (mihomo/clash without TUN mode, corporate proxies) had ClawRouter connecting _directly_ while their `curl` tests went through the proxy — on throttled routes (e.g. RU → Google-hosted gateway) this surfaced as instant 500s on large request bodies, `Premature close`, and per-model timeouts, while small requests slipped through. When `BLOCKRUN_UPSTREAM_PROXY` is unset, ClawRouter now applies the standard env vars via undici's `EnvHttpProxyAgent`. `NO_PROXY` is honored, and loopback hosts (`localhost`, `127.0.0.1`, `::1`) are always excluded so local health checks and sibling proxies stay direct. `BLOCKRUN_UPSTREAM_PROXY` still wins when set; SOCKS URLs in the standard env vars are _not_ auto-applied (a warning points to `BLOCKRUN_UPSTREAM_PROXY=socks5://…`). (Test: `test/upstream-proxy.test.ts`, 9 cases, dependency-injected — no process-global mutation in tests.) --- diff --git a/README.md b/README.md index 24d609e8..d8a6d699 100644 --- a/README.md +++ b/README.md @@ -519,13 +519,13 @@ USDC stays in your wallet until spent — non-custodial. Price is visible in the For basic usage, no configuration needed. For advanced options: -| Variable | Default | Description | -| --------------------------- | ------------------------------------- | ----------------------- | -| `BLOCKRUN_WALLET_KEY` | auto-generated | Your wallet private key | -| `BLOCKRUN_PROXY_PORT` | `8402` | Local proxy port | -| `CLAWROUTER_DISABLED` | `false` | Disable smart routing | +| Variable | Default | Description | +| --------------------------- | ------------------------------------- | ---------------------------------------------------------------- | +| `BLOCKRUN_WALLET_KEY` | auto-generated | Your wallet private key | +| `BLOCKRUN_PROXY_PORT` | `8402` | Local proxy port | +| `CLAWROUTER_DISABLED` | `false` | Disable smart routing | | `CLAWROUTER_DEBUG_HEADERS` | `on` | Set to `off` to suppress `x-clawrouter-*` debug response headers | -| `CLAWROUTER_SOLANA_RPC_URL` | `https://api.mainnet-beta.solana.com` | Solana RPC endpoint | +| `CLAWROUTER_SOLANA_RPC_URL` | `https://api.mainnet-beta.solana.com` | Solana RPC endpoint | **Full reference:** [docs/configuration.md](docs/configuration.md) diff --git a/docs/configuration.md b/docs/configuration.md index 3badac37..09e3f5b2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,15 +18,15 @@ Complete reference for ClawRouter configuration options. ## Environment Variables -| Variable | Default | Description | -| --------------------------- | ------------------------------------- | ------------------------------------------------------------------------ | -| `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. | -| `CLAWROUTER_SOLANA_RPC_URL` | `https://api.mainnet-beta.solana.com` | Solana RPC endpoint for USDC balance checks. | -| `CLAWROUTER_DISABLED` | `false` | Set to `true` to disable smart routing (pass requests through as-is). | -| `CLAWROUTER_WORKER` | - | Set to `1` to enable Worker Mode (earn USDC by running health checks). | +| Variable | Default | Description | +| --------------------------- | ------------------------------------- | --------------------------------------------------------------------------------- | +| `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. | +| `CLAWROUTER_SOLANA_RPC_URL` | `https://api.mainnet-beta.solana.com` | Solana RPC endpoint for USDC balance checks. | +| `CLAWROUTER_DISABLED` | `false` | Set to `true` to disable smart routing (pass requests through as-is). | +| `CLAWROUTER_WORKER` | - | Set to `1` to enable Worker Mode (earn USDC by running health checks). | | `CLAWROUTER_DEBUG_HEADERS` | (on) | Set to `off`/`false`/`0` to suppress the `x-clawrouter-*` debug response headers. | -| `BLOCKRUN_WEB_SEARCH` | (auto-enabled) | Set to `off` to disable BlockRun's Exa web search provider registration. | +| `BLOCKRUN_WEB_SEARCH` | (auto-enabled) | Set to `off` to disable BlockRun's Exa web search provider registration. | ### BLOCKRUN_WALLET_KEY diff --git a/src/proxy.reasoning-header.test.ts b/src/proxy.reasoning-header.test.ts index fb90a77f..f0c38629 100644 --- a/src/proxy.reasoning-header.test.ts +++ b/src/proxy.reasoning-header.test.ts @@ -64,9 +64,12 @@ describe("debugHeadersEnabledFromEnv", () => { expect(debugHeadersEnabledFromEnv({})).toBe(true); }); - it.each(["0", "false", "off", "FALSE", "Off"])("is disabled by CLAWROUTER_DEBUG_HEADERS=%s", (v) => { - expect(debugHeadersEnabledFromEnv({ CLAWROUTER_DEBUG_HEADERS: v })).toBe(false); - }); + it.each(["0", "false", "off", "FALSE", "Off"])( + "is disabled by CLAWROUTER_DEBUG_HEADERS=%s", + (v) => { + expect(debugHeadersEnabledFromEnv({ CLAWROUTER_DEBUG_HEADERS: v })).toBe(false); + }, + ); it("treats other values as enabled", () => { expect(debugHeadersEnabledFromEnv({ CLAWROUTER_DEBUG_HEADERS: "1" })).toBe(true); diff --git a/src/upstream-proxy.ts b/src/upstream-proxy.ts index b90714e2..1d58813d 100644 --- a/src/upstream-proxy.ts +++ b/src/upstream-proxy.ts @@ -90,7 +90,15 @@ async function applyEnvProxyFallback( env: Record, overrides?: UpstreamProxyOverrides, ): Promise { - const url = firstEnv(env, "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"); + const url = firstEnv( + env, + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", + ); if (!url) return undefined; let parsed: URL; diff --git a/test/upstream-proxy.test.ts b/test/upstream-proxy.test.ts index 3fabf0d5..367536ee 100644 --- a/test/upstream-proxy.test.ts +++ b/test/upstream-proxy.test.ts @@ -18,7 +18,9 @@ describe("applyUpstreamProxy", () => { it("uses BLOCKRUN_UPSTREAM_PROXY when set", async () => { const setDispatcher = vi.fn(); const env = { BLOCKRUN_UPSTREAM_PROXY: "http://127.0.0.1:8080" }; - expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe("http://127.0.0.1:8080"); + expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe( + "http://127.0.0.1:8080", + ); expect(setDispatcher).toHaveBeenCalledTimes(1); expect(setDispatcher.mock.calls[0][0]).toBeInstanceOf(ProxyAgent); }); @@ -29,28 +31,36 @@ describe("applyUpstreamProxy", () => { BLOCKRUN_UPSTREAM_PROXY: "http://127.0.0.1:8080", HTTPS_PROXY: "http://127.0.0.1:7890", }; - expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe("http://127.0.0.1:8080"); + expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe( + "http://127.0.0.1:8080", + ); expect(setDispatcher.mock.calls[0][0]).toBeInstanceOf(ProxyAgent); }); it("falls back to HTTPS_PROXY when BLOCKRUN_UPSTREAM_PROXY is unset", async () => { const setDispatcher = vi.fn(); const env = { HTTPS_PROXY: "http://127.0.0.1:7890" }; - expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe("http://127.0.0.1:7890"); + expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe( + "http://127.0.0.1:7890", + ); expect(setDispatcher.mock.calls[0][0]).toBeInstanceOf(EnvHttpProxyAgent); }); it("falls back to lowercase https_proxy", async () => { const setDispatcher = vi.fn(); const env = { https_proxy: "http://127.0.0.1:7890" }; - expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe("http://127.0.0.1:7890"); + expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe( + "http://127.0.0.1:7890", + ); expect(setDispatcher.mock.calls[0][0]).toBeInstanceOf(EnvHttpProxyAgent); }); it("falls back to ALL_PROXY when no scheme-specific vars are set", async () => { const setDispatcher = vi.fn(); const env = { ALL_PROXY: "http://127.0.0.1:7890" }; - expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe("http://127.0.0.1:7890"); + expect(await applyUpstreamProxy(undefined, { env, setDispatcher })).toBe( + "http://127.0.0.1:7890", + ); expect(setDispatcher.mock.calls[0][0]).toBeInstanceOf(EnvHttpProxyAgent); }); From 9f0374d62bd0d22269fbbcaedf40a38fe4738bff Mon Sep 17 00:00:00 2001 From: TheCheetah Date: Tue, 16 Jun 2026 11:30:28 +0100 Subject: [PATCH 3/4] fix(update): prefer current ClawRouter install metadata --- scripts/update.sh | 41 +++++++++++++++++++++++++----- src/proxy.reasoning-header.test.ts | 4 ++- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/scripts/update.sh b/scripts/update.sh index c145cdb9..8f668c5c 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -921,27 +921,56 @@ const legacyPath = path.join(home, '.openclaw', 'plugins', 'installs.json'); function currentPackageCandidates() { const candidates = [ - path.join(home, '.openclaw', 'extensions', 'clawrouter', 'package.json'), - path.join(home, '.openclaw', 'npm', 'node_modules', '@blockrun', 'clawrouter', 'package.json'), + { packagePath: path.join(home, '.openclaw', 'extensions', 'clawrouter', 'package.json'), priority: 0 }, + { + packagePath: path.join(home, '.openclaw', 'npm', 'node_modules', '@blockrun', 'clawrouter', 'package.json'), + priority: 2, + }, ]; const projectsDir = path.join(home, '.openclaw', 'npm', 'projects'); try { for (const name of fs.readdirSync(projectsDir)) { - candidates.push(path.join(projectsDir, name, 'node_modules', '@blockrun', 'clawrouter', 'package.json')); + candidates.push({ + packagePath: path.join(projectsDir, name, 'node_modules', '@blockrun', 'clawrouter', 'package.json'), + priority: 1, + }); } } catch {} return candidates; } +function versionParts(version) { + return String(version || '') + .split(/[.-]/) + .slice(0, 3) + .map((part) => Number.parseInt(part, 10) || 0); +} + +function compareVersions(a, b) { + const left = versionParts(a); + const right = versionParts(b); + for (let i = 0; i < 3; i++) { + if (left[i] !== right[i]) return left[i] - right[i]; + } + return 0; +} + function newestCurrentPackage() { let best = null; - for (const packagePath of currentPackageCandidates()) { + for (const candidate of currentPackageCandidates()) { try { + const { packagePath, priority } = candidate; const stat = fs.statSync(packagePath); const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); if (pkg?.name !== '@blockrun/clawrouter') continue; - if (!best || stat.mtimeMs > best.mtimeMs) { - best = { packagePath, pkg, mtimeMs: stat.mtimeMs }; + const versionOrder = best ? compareVersions(pkg.version, best.pkg.version) : 1; + if ( + !best || + versionOrder > 0 || + (versionOrder === 0 && + (priority < best.priority || (priority === best.priority && stat.mtimeMs > best.mtimeMs))) + ) { + best = { packagePath, pkg, mtimeMs: stat.mtimeMs, priority }; } } catch {} } diff --git a/src/proxy.reasoning-header.test.ts b/src/proxy.reasoning-header.test.ts index f0c38629..c76a559e 100644 --- a/src/proxy.reasoning-header.test.ts +++ b/src/proxy.reasoning-header.test.ts @@ -51,7 +51,9 @@ describe("sanitizeHeaderValue", () => { const result = classifyByRules(prompt, undefined, 64, DEFAULT_ROUTING_CONFIG.scoring); const reasoning = `score=${result.score.toFixed(2)} | ${result.signals.join(", ")}`; // Precondition: the repro really does produce non-ASCII reasoning - expect(reasoning).toMatch(/[^\x00-\x7F]/); + expect( + Array.from(reasoning).some((ch) => (ch.codePointAt(0) ?? 0) > 0x7f), + ).toBe(true); expect(() => validateHeaderValue("x-clawrouter-reasoning", reasoning)).toThrow(); const sanitized = sanitizeHeaderValue(reasoning); From ea885238a75b9f09dbdf1c0069bb5419f3975b67 Mon Sep 17 00:00:00 2001 From: TheCheetah Date: Tue, 16 Jun 2026 11:35:30 +0100 Subject: [PATCH 4/4] chore: format reasoning header test --- src/proxy.reasoning-header.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/proxy.reasoning-header.test.ts b/src/proxy.reasoning-header.test.ts index c76a559e..e73f6c14 100644 --- a/src/proxy.reasoning-header.test.ts +++ b/src/proxy.reasoning-header.test.ts @@ -51,9 +51,7 @@ describe("sanitizeHeaderValue", () => { const result = classifyByRules(prompt, undefined, 64, DEFAULT_ROUTING_CONFIG.scoring); const reasoning = `score=${result.score.toFixed(2)} | ${result.signals.join(", ")}`; // Precondition: the repro really does produce non-ASCII reasoning - expect( - Array.from(reasoning).some((ch) => (ch.codePointAt(0) ?? 0) > 0x7f), - ).toBe(true); + expect(Array.from(reasoning).some((ch) => (ch.codePointAt(0) ?? 0) > 0x7f)).toBe(true); expect(() => validateHeaderValue("x-clawrouter-reasoning", reasoning)).toThrow(); const sanitized = sanitizeHeaderValue(reasoning);