Wrapper cmd double-escapes env values: Ec() escapes for unquoted set, but ce() emits quoted set "KEY=..." so carets/%% survive into the child env (model names with () arrive as ^(self hosted^))
Wrapper cmd double-escapes env values: Ec() escapes for unquoted set, but ce() wraps in quotes, so quoted set "KEY=..." never unescapes → model names with () arrive as ^(self hosted^)
Environment
- Claude Code Router Desktop v3.1.0 (Windows,
C:\Users\<user>\AppData\Local\Programs\Claude Code Router\Claude Code Router.exe) - Windows 10 19045 x64
- Claude Code CLI 2.1.270
- Profile default model:
Habilynx/glm-5.3 (self hosted)(a provider model label containing parentheses — e.g. self-hosted provider model names)
Summary
The per-profile wrapper bin/ccr-claude-code-wrapper-<profile>.cmd (regenerated on every ccr-app "<Profile>" launch) escapes env values with Ec(), which encodes for unquoted set syntax — but the line is emitted by ce() as a quoted set "KEY=<escaped>". A quoted set never interprets caret/percent escapes, so the child process env literally contains ^ and %%.
For the profile default model this means:
set "ANTHROPIC_MODEL=Habilynx/glm-5.3 ^(self hosted^)"
set "CCR_CLAUDE_CODE_MODEL=Habilynx/glm-5.3 ^(self hosted^)"
set "CODEXL_CLAUDE_CODE_MODEL=Habilynx/glm-5.3 ^(self hosted^)"So Claude Code receives ANTHROPIC_MODEL='Habilynx/glm-5.3 ^(self hosted^)' and prints on startup (for every session, query_source: generate_session_title):
[claude-code:unrecognized_model] {"model":"Habilynx/glm-5.3 ^(self hosted^)","query_source":"generate_session_title"}Root cause (bundled cli.js, minified names from the shipped app.asar)
function ce(e, t = "") { return `${r}set "${e}=${Ec(t)}"` } // quoted set
function Ec(e) {
return e.replace(/\r?\n/g, " ")
.replace(/\^/g, "^^")
.replace(/%/g, "%%")
.replace(/"/g, '^"')
.replace(/[&|<>()]/g, "^$&"); // unquoted-set escaping
}ce() uses Ec() (escapes for set KEY=value without quotes) inside a quoted set "KEY=...". Inside quotes, ^( / %% / ^" are never unescaped by cmd, so the value that reaches the child env still contains the escape characters.
Impact beyond the banner: any env value routed through the wrapper (model names with (), strings with %, values containing &|<>()) is corrupted the same way. In Claude Code, process env wins over settings.json env, so the clean settings.json value cannot recover it.
Minimal reproduction
# 1. Launch the profile
ccr-app "Claude Code"
# 2. Inspect the generated wrapper
type "%APPDATA%\claude-code-router\bin\ccr-claude-code-wrapper-<profile>.cmd"
# → set "ANTHROPIC_MODEL=<provider>/<model> ^(self hosted^)" (model name containing parens)
# 3. Confirm the child env literally contains carets
echo 'Reply with one word: PING' | claude --print "test" # with ANTHROPIC_BASE_URL pointed at CCR
# → [claude-code:unrecognized_model] {"model":"Habilynx/glm-5.3 ^(self hosted^)","query_source":"generate_session_title"}Direct proof that the quoted set keeps the carets (cmd does not unescape inside quotes):
set "V=a ^(b^)"
echo %V% → a ^(b^) (carets preserved, not a(b)Suggested fix
Either:
- Drop
Ec()for quotedset:set "KEY=<value>"inside quotes only needs"doubling — a plain quoted set needs no caret/percent escaping at all (keep the\r?\n→ space normalization), or - Emit unquoted
set KEY=<Ec(value)>lines, where the currentEc()escaping is actually correct, or - Un-escape in the middleware before spawning the child (least desirable — every wrapper variant would need it).
Option 1 is the smallest change: inside set "...", only " needs handling ("" or ^"), and %/()/&|<> are literal.
Additional notes
- Editing the wrapper by hand does not help:
ccr-app "<Profile>"PREPARE regenerates wrapper/settings/api-key files from the CCR config store on every launch. - The
^(...)corruption is invisible in the UI (the profile model shows correctly) and only surfaces in the child process env. - Same generation path is used for the Codex stdio wrapper (
ccr-codex-cli-stdio-<profile>.cmd), soCCR_CODEX_MODELvalues with special characters are affected too.
Happy to provide the full reproduction transcript or a PR if the escaping change direction is agreed.
Source: musistudio/claude-code-router