Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand All @@ -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.)

---

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 8 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
121 changes: 121 additions & 0 deletions scripts/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,127 @@ 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 = [
{ 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({
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 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;
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 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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!"
Expand Down
11 changes: 7 additions & 4 deletions src/proxy.reasoning-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +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(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);
Expand All @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion src/upstream-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,15 @@ async function applyEnvProxyFallback(
env: Record<string, string | undefined>,
overrides?: UpstreamProxyOverrides,
): Promise<string | undefined> {
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;
Expand Down
20 changes: 15 additions & 5 deletions test/upstream-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});

Expand Down
Loading