Fix upstream url forming on forwardRequest - #188
Conversation
There was a problem hiding this comment.
Thanks for catching this! The bug is real and I reproduced it locally to confirm.
Empirical verification
Current behavior on main (your case 2):
new URL("/v1/messages", "https://corp-proxy.example.net/anthropic-mirror")
→ "https://corp-proxy.example.net/v1/messages" ❌ "/anthropic-mirror" dropped
With your fix:
"https://corp-proxy.example.net/anthropic-mirror" + "/v1/messages"
→ "https://corp-proxy.example.net/anthropic-mirror/v1/messages" ✓
The trailing-slash handling on the base + leading-slash on the relative is the right shape. Query strings flow through cleanly — verified ?beta=true&x=y etc. round-trip via url.search.
What the fix is doing correctly
- Idempotent on whether
config.upstreamhas a trailing/(both"https://x.net/d"and"https://x.net/d/"produce the same final URL) - Preserves query strings unchanged (the
URLconstructor parses them on the final concatenated string, soclientReq.url = "/v1/messages?beta=true"round-trips correctly) - Handles both no-path and with-path
config.upstreamvalues
One subtle behavior change worth noting (probably not a regression in practice)
The original new URL(clientReq.url, config.upstream) is RFC 3986 relative-resolution: if clientReq.url is an absolute URL (https://api.anthropic.com/v1/messages), the base is ignored and the absolute URL wins. The new string-concat path produces https://corp-proxy.example.net/mirror/https://api.anthropic.com/... instead.
In practice this won't surface for cache-fix because Node's HTTP server populates req.url with the path-only form (/v1/messages) for normal proxy use — but worth flagging in case anyone embeds this proxy in a forward-proxy scenario where clients send absolute URLs.
Could you add a regression test?
test/proxy-upstream-corp-proxy.test.mjs already has the CACHE_FIX_PROXY_UPSTREAM env-var scaffolding; a test there with CACHE_FIX_PROXY_UPSTREAM=http://127.0.0.1:<port>/mirror and an upstream that asserts req.url === "/mirror/v1/messages" would pin this against future regression. Happy to walk through the test shape if it'd help, or I can push the test directly to your branch if you'd prefer (you have maintainer_can_modify enabled which makes that clean).
Disposition
The fix itself is good and the bug is real. Holding off on a formal gh pr review --approve until either:
- You add the regression test, OR
- You give me a thumbs-up to push the test to your branch myself, OR
- Project lead waves the test requirement (this is a small targeted fix; reasonable to ship without if speed matters)
— Proxy Builder
There was a problem hiding this comment.
Codex review:
I reproduced the upstream path-prefix bug and verified this patch fixes the supported proxy flow: a CACHE_FIX_PROXY_UPSTREAM such as https://corp-proxy.example.net/anthropic-mirror now correctly forwards /v1/messages as /anthropic-mirror/v1/messages. I also checked the authority edge cases most likely to matter operationally (IPv6 host, explicit port, userinfo, request query string) and did not find a correctness issue that warrants blocking this first community fix.
The one real gap is regression coverage: the current upstream tests do not yet pin the /mirror/v1/messages case. I recommend backfilling that test, but I do not consider it a blocker here. Review artifact is committed at docs/code-reviews/pr-188-upstream-url-composition-codex-review-2026-06-04.md on branch consult/pr-188-codex-review.
— Codex review
Extract the URL-construction logic into a pure `buildUpstreamUrl(base, clientUrl)` helper exported from proxy/upstream.mjs, then table-test it in test/proxy-upstream-corp-proxy.test.mjs with 8 cases covering: - No-path base (preserves the pre-fix behavior for default https://api.anthropic.com upstream) - Trailing-slash base normalization - Mirror / corp-proxy base with one-segment path (the bug @nisqatsi caught — `new URL("/v1/messages", base)` drops the base path) - Trailing-slash idempotence on a base-path - Multi-segment base path - Query string flow-through for the no-path case - Query string flow-through with the base-path fix - http + non-standard port + base-path Matches the pure-function table-test pattern already established for `selectProxyUrl` in the same file. forwardRequest now calls buildUpstreamUrl(config.upstream, clientReq.url) for the same result. 1005/1005 tests pass. Refs PR cnighswonger#188 / @nisqatsi. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3c3fd17
2942aa8 to
3c3fd17
Compare
|
Per Chris's call, I pushed the regression test to your branch (`fix/nested-upstream-url-processing` on your fork). HEAD is now `3c3fd17`. What landedTwo commits on the branch:
Matches the pure-function table-test pattern already established for `selectProxyUrl` in the same file. `forwardRequest` now calls `buildUpstreamUrl(config.upstream, clientReq.url)` — same end-to-end behavior, just testable in isolation. 1005/1005 tests passing locally. Why the force-pushYour branch was 5+ commits behind main; a rebase was the cleanest way to avoid the test landing on a stale base that would have masked unrelated post-v3.9.0 regressions. Sorry for the force-push without a heads-up — let me know if you'd prefer I always ask before that, or if the maintainer-can-modify lane is the standard you want me to use for these. Holding off on `approved-by-code-agent` and `ready-for-merge` for now since Chris is the merge gate. Codex's `reviewed-by-codex-agent` from 2026-06-04 was on `2942aa8`; technically that approval is stale relative to the new HEAD, but the only delta is the test addition + the function extraction (no behavior change in the fix itself). I'll let Chris decide whether a Codex re-review is warranted before merge. — Proxy Builder |
There was a problem hiding this comment.
Review: PR #188 Fix upstream url forming on forwardRequest
Date: 2026-06-07
Reviewed: PR #188 at bb7164e
Round: 2
Label applied: approved-by-codex-agent
What Is Correct
buildUpstreamUrlinproxy/upstream.mjs:194is a faithful extraction of the already-reviewed2bead94URL-building logic.forwardRequestnow delegates to that helper atproxy/upstream.mjs:202; the only substantive implementation change remains the original base-path preservation fix.- The regression table in
test/proxy-upstream-corp-proxy.test.mjs:68covers the real failure mode: RFC 3986 relative resolution dropping the configured base path for path-absolute request URLs. The cases also pin no-path upstreams, trailing-slash normalization, multi-segment mirror paths, preserved query strings, and non-defaulthttpports. - Verification passed on the refreshed head.
node --test test/proxy-upstream-corp-proxy.test.mjspassed, and the fullnpm testsuite passed 1005/1005 on the implementation head before the docs-only review-artifact commit. This review artifact is committed atdocs/code-reviews/pr-188-round-2-codex.mdon the PR branch.
Blockers
None.
What Needs Attention
None.
Bloat / Non-Functional
None.
Recommendations
- Merge on the refreshed approval state; I found no new regression risk in the post-2026-06-04 delta.
Bottom Line
Approve. The post-approval delta is a behavior-preserving helper extraction plus the missing regression coverage for the exact corp-proxy/base-path failure mode, and the PR remains green after full-suite verification.
— Codex review
A proxy-configured client does not always tunnel: axios's built-in proxy mode (which the Claude Code CLI's auto-updater and telemetry paths use) sends `GET https://host/path HTTP/1.1` on the plain proxy connection instead of issuing CONNECT. buildUpstreamUrl() treated that absolute URI as an origin-form path and concatenated it onto the configured upstream (`api.anthropic.com/https://downloads.claude.ai/...`), misrouting every such request to the upstream host. Cloudflare answers 404, the CLI's version check fails 3.5s after every session start, and the user gets a permanent "✘ Auto-update failed" banner (1P event export and Datadog flush 404 the same way). - upstream.mjs: parseAbsoluteForm() + honor the absolute-form authority in buildUpstreamUrl(); origin-form concatenation (PR cnighswonger#188 contract) unchanged. - server.mjs: forward mode normalizes absolute-form targets — upstream- origin targets reduce to origin-form so the normal routing (incl. the /v1/messages transform) applies; downloads.claude.ai routes through the existing rewrite; foreign hosts relay via handlePassthrough. Reverse mode keeps its 404 contract. - forward-proxy.mjs: handleDownloadsAbsolute() adapter so both arrival styles (CONNECT-MITM and absolute-form) get the download rewrite. Verified live A/B on macOS (CC 2.1.214): an agents view through the buggy proxy renders the banner with two 404s in the debug log; the same launch through the fixed proxy renders no banner and zero 404s, and the /v1/messages cache transform still applies. Full suite: 1379 tests, 1377 pass; the one failure (proxy-server.test.mjs "routes to upstream") reproduces identically on the unmodified base — corp-network artifact (direct egress 403s), same hygiene class as cnighswonger#253 item 3. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
axios's built-in proxy mode — which the CC CLI uses for its auto-updater, 1P event export, and Datadog flush — skips CONNECT and sends absolute-form targets on the plain proxy connection. buildUpstreamUrl() treated the absolute URI as an origin-form path and concatenated it onto the upstream, so every session start 404'd its version check and pinned a permanent 'Auto-update failed' banner. parseAbsoluteForm() in upstream.mjs returns the authority when present. Forward mode normalizes before route dispatch: upstream-origin targets reduce to origin-form (so /v1/messages still gets the cache transform), downloads.claude.ai routes through the download rewrite, foreign hosts relay to their real destination. Reverse mode keeps its 404 contract, test-pinned. Security posture is unchanged in kind: forward mode already blind-tunnels arbitrary CONNECT targets, and the proxy binds 127.0.0.1 by default. This is parity for clients that skip CONNECT. Verified locally merged onto main: full suite 1440/0. The #188 base-path contract is intact. Closes #261
Upstream URL path after top-level domain was cut off by forwardRequest() on URL combining, e.g.: