Skip to content

The gateway serves absolute-form requests as a third front door (LLP 0246/0247) - #797

Merged
philcunliffe merged 4 commits into
masterfrom
fix/absolute-form-third-front-door
Aug 17, 2026
Merged

The gateway serves absolute-form requests as a third front door (LLP 0246/0247)#797
philcunliffe merged 4 commits into
masterfrom
fix/absolute-form-third-front-door

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Problem

On proxy-mode installs (post #782/#794), claude Remote Control fails with "Remote Control environments are not available for your account". The account message is false: with NO_PROXY=api.anthropic.com it works immediately.

The RC bridge's first call is POST https://api.anthropic.com/v1/environments/bridge. Unlike Claude Code's main API client, the bridge does not open a CONNECT tunnel through HTTPS_PROXY: it sends the request to the gateway port as absolute-form plaintext (POST https://api.anthropic.com/... HTTP/1.1, verified on the wire). The gateway served only two request shapes (LLP 0233), routed the request by pathname alone (the host on the request line was parsed but never read), answered its own local 404 no upstream matches path, and Claude Code misread that 404 as a missing account feature. The same request through a proper CONNECT tunnel returns 200, so forwarding, auth, and TLS trust were all fine.

Fix

A third front door (decision LLP 0247, extending LLP 0233; issue LLP 0246):

  • An absolute-form request-target routes by the authority its request line names, through the existing matchUpstreamByHost forwarding path, exactly like a terminated tunnel.
  • Recording follows the same per-path opt-in as proxy mode (LLP 0234), and the recorded path is normalized to origin-form so projectors see one shape from all three front doors. RC's /v1/environments/bridge sits outside the Claude anchor, so it forwards unrecorded.
  • Containment: a host/port no upstream names is refused with 403 (never forwarded, so the listener is not a general absolute-form relay); non-loopback peers are refused under the same rule as CONNECT; the /_hypaware/ control surface never answers an absolute-form target (it is addressed to a third party).

Tests

test/plugins/ai-gateway-absolute-form.test.js replays the exact on-the-wire shape (raw absolute-form plaintext POST to the listener port):

  • the RC registration path is forwarded and unrecorded (the regression)
  • an anchored path is recorded, origin-form
  • an unregistered host is refused, never forwarded
  • an absolute-form /_hypaware/ path is not answered locally
  • origin-form routing is unchanged (unmatched path still 404s)

Verified: 89 gateway tests green, npm run typecheck clean, smokes gateway_claude_capture, gateway_codex_capture, hypignore_capture_drop, otel_loopback_capture, client_attach_idempotent all green. The full npm test has 21 failures that are pre-existing on master (parquet pushdown and gateway-source reload areas, confirmed identical with this change stashed).

Upstream

Separately worth filing with Anthropic: the RC bridge client ignores standard HTTPS_PROXY CONNECT semantics for an https:// target and sends its bearer token in plaintext to the proxy port. Noted in LLP 0246/0247.

🤖 Generated with Claude Code

philcunliffeand others added 3 commits August 17, 2026 13:45
…0246/0247)
Claude Code's Remote Control bridge registers via POST
https://api.anthropic.com/v1/environments/bridge sent as absolute-form
plaintext to the proxy port instead of a CONNECT tunnel. The gateway
routed non-CONNECT requests by pathname alone, discarded the host the
request line named, and answered a local 404 that Claude Code misread
as "Remote Control environments are not available for your account".
An absolute-form request-target now routes by its named authority
through matchUpstreamByHost, with the same per-path recording opt-in
as proxy mode and the recorded path normalized to origin-form. Hosts
no upstream names are refused with 403, as are non-loopback peers
(the CONNECT rule), and the /_hypaware/ control surface never answers
an absolute-form target.
Issue LLP 0246, decision LLP 0247 extending LLP 0233; regression
tests replay the exact on-the-wire request shape.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dual-review round 1 findings on PR #797:
- The absolute-form branch was live on every listener flavor, including
pure reverse-proxy installs whose behavior LLP 0233
#proxy-mode-is-explicit promises stays unchanged. The door now opens
only where the CONNECT front door does (interception or tunnelOnly),
which still fixes the Remote Control bridge: it only sends
absolute-form when HTTPS_PROXY is set. LLP 0247 gains the
#only-forward-proxy-listeners-serve-it section settling this.
- The loopback-peer 403 (the containment that stops the listener
relaying for the network) had no test. Added one that injects a
socket with a LAN peer address into the real listener, plus a test
that a reverse-proxy-only listener leaves absolute-form to path
routing. StartedProxy exposes the server as the test seam.
- Comment honesty: matchUpstreamByHost's "a miss is impossible" JSDoc
now covers the absolute-form caller where a miss means refusal; the
connect.js "two front doors" docblocks now describe the three-shape
split; LLP 0232 carries an Extended-by forward-ref to 0247 since the
gateway now serves a shape 0232's rationale said it does not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both round-2 reviewers converged on the same gap from different sides:
tunnelOnly marks the degraded states (stale CA with proxy mode off, CA
prep failure, nothing to intercept), and two of those three keep a
populated routing table, so the gate let a degraded listener record
absolute-form traffic inside a path anchor while its CONNECT door was
blind, and 0247's consequence bullet wrongly claimed tunnel-only
listeners have empty routing tables and refuse the shape.
Recording now additionally requires live interception: a degraded
listener forwards absolute-form to registered hosts (the stranded
client's Remote Control keeps working) but records nothing, matching
the unrecorded-but-working degrade contract and keeping capture behind
the explicit proxy-mode consent. LLP 0247 gains the
#degraded-listeners-forward-it-blind section and a corrected
consequence bullet; the test rig now boots the three listener shapes
(intercepting, degraded, reverse-proxy) and a new test proves the
degraded listener forwards an anchored path unrecorded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 17, 2026
PR #797 (`fix/absolute-form-third-front-door`) claimed 0247 for
`llp/0247-absolute-form-third-front-door.decision.md` about a minute
before this branch's commit, so both PRs introduce an LLP 0247 and both
merge cleanly (different filenames). The result on master would be two
documents claiming one number, an ambiguous `@ref LLP 0247#the-bit`, and
a red `test/core/llp-ref-hygiene.test.js` ('no LLP number is claimed by
two documents').
LLP 0156#renumber settles this: the later claimant moves above the
highest number claimed anywhere. 0245-0249 are all claimed on unmerged
branches, so this doc takes 0250. Mechanical rename plus the reference
sweep in the same commit (the doc title, the two `Extended-by:`
forward-refs on LLP 0138/0186, four `@ref`s and two prose mentions in
`action_reconciler.js`, two in `types.d.ts`, and the test file's `@ref`
and two test names). Nothing the document decided changes.
Round-3 review nit: the consequence bullet tied the empty-routing-table
tunnel-only state to "proxy mode on, no upstream naming a host", but the
same state is reachable with proxy mode off (zero compiled upstreams
plus a CA on disk). Describe the state, not the branch that produced it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Dual-agent review — approve

  • Verdict:approve
  • Risk class:low
  • Auto-merge advisory: 👎 thumbs down — requires human merge gate

Advisory only: no merge was attempted.

Review history (3 rounds, findings folded back into the PR head)

  • Round 1 (head 4120e77): 2 majors — the absolute-form door was live on every listener flavor (contradicting LLP 0233 #proxy-mode-is-explicit), and the loopback-peer 403 had no test — plus 3 comment/doc-honesty minors. Fixed in e763aed: the door is gated on the forward-proxy front door (interception or tunnelOnly), tests for the peer refusal and the reverse-proxy fallback, JSDoc/comment/forward-ref fixes.
  • Round 2 (head e763aed): both reviewers converged on one gap — a degraded tunnelOnly listener could record absolute-form inside a path anchor while its CONNECT door was blind, and 0247 wrongly claimed tunnel-only listeners have empty routing tables. Fixed in 25208e0: recording additionally requires live interception (degraded listeners forward blind), 0247 gains #degraded-listeners-forward-it-blind, and the rig boots all three listener shapes.
  • Round 3 (head 25208e0): Codex clean across all 11 categories; Claude verifier confirmed the recording conditional for all four front-door combinations and left 1 nit (0247 wording), fixed in b63235f.

Risk capstone

Cross-reference: reviewer findings vs high-risk surfaces

SourceFinding (severity, evidence)Intersects
Claude0247 consequence bullet mislabeled the empty-table degrade state (nit, llp/0247:110; fixed in b63235f)Targets (LLP doc set)
Codex review (round 3, head 25208e0/b63235f)

Fix Validations

Absolute-form Remote Control requests were handled as local path-routed requests

  • Status: correct
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:310, hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:357, hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:454, test/plugins/ai-gateway-absolute-form.test.js:139
  • Assessment: The new path classifies absolute-form requests only on forward-proxy listeners, resolves by request-line host/port, and forwards the origin-form path upstream. The existing CONNECT handler would not have fixed this because it only runs on connect events, while this traffic enters handleRequest as a normal HTTP request.

Absolute-form recording follows proxy-mode path opt-in

  • Status: correct
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:405, hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:424, test/plugins/ai-gateway-absolute-form.test.js:160, test/plugins/ai-gateway-absolute-form.test.js:310
  • Assessment: Anchored paths record with normalized origin-form paths, while Remote Control and degraded tunnel-only traffic stay unrecorded as described.

Absolute-form containment prevents open relay behavior

  • Status: correct
  • Evidence: hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:318, hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:381, hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:342, test/plugins/ai-gateway-absolute-form.test.js:179, test/plugins/ai-gateway-absolute-form.test.js:237
  • Assessment: Non-loopback peers and unregistered hosts are refused, and /_hypaware/ is not served locally for absolute-form targets.

Findings

No new issues found.

No Finding

  1. Behavioral Correctness
  2. Contract & Interface Fidelity
  3. Change Impact / Blast Radius
  4. Concurrency, Ordering & State Safety
  5. Error Handling & Resilience
  6. Security Surface
  7. Resource Lifecycle & Cleanup
  8. Release Safety
  9. Test Evidence Quality
  10. Architectural Consistency
  11. Debuggability & Operability

Evidence Bundle

  • Changed hot paths:startProxy return shape, handleRequest front-door classification, host/port upstream matching, recording decision, local control-path guard.
  • Impacted callers: hypaware-core/plugins-workspace/ai-gateway/src/source.js:370, hypaware-core/plugins-workspace/ai-gateway/src/source.js:397, hypaware-core/plugins-workspace/ai-gateway/src/source.js:538, test/plugins/ai-gateway-absolute-form.test.js:54, test/plugins/ai-gateway-proxy-mode.test.js:62, test/plugins/ai-gateway-proxy-routing.test.js:201.
  • Impacted tests: test/plugins/ai-gateway-absolute-form.test.js:139, test/plugins/ai-gateway-absolute-form.test.js:160, test/plugins/ai-gateway-absolute-form.test.js:179, test/plugins/ai-gateway-absolute-form.test.js:198, test/plugins/ai-gateway-absolute-form.test.js:237, test/plugins/ai-gateway-absolute-form.test.js:287, test/plugins/ai-gateway-absolute-form.test.js:310.
  • Unresolved uncertainty: Full npm test was not rerun here. I ran node --test test/plugins/ai-gateway-absolute-form.test.js; it passed after escalating past sandbox loopback-bind restrictions.
Claude review (round 3, head 25208e0/b63235f)

Claude review

Round 3 of 3 (two earlier rounds' findings were fixed on the PR head; see the
review-history section of the summary comment). The round-2 fix was verified
sound: the recording conditional traces correctly for all four front-door and
listener combinations, opts.interception and opts.tunnelOnly are mutually
exclusive in production, all @ref anchors resolve, and no guidance violations
(em dashes, semicolons) exist in the changed files.

0247 consequence bullet mislabeled the empty-table degrade state

  • Severity: nit
  • Confidence: 85
  • Evidence: llp/0247-absolute-form-third-front-door.decision.md:110
  • Why it matters: The bullet tied the empty-routing-table tunnel-only state to "proxy mode on, no upstream naming a host", but the same state is reachable with proxy mode off (zero compiled upstreams plus a CA on disk falls through the idle check into the stale-CA branch), and the doc is headed for Accepted.
  • Suggested fix: Describe the state, not one arrival route. (Fixed on the PR head in b63235f.)

Reports: .git/dual-review/pr-797

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

🧭 Decision map — where to spend your attention

Companion to the dual-review verdict. This casts no verdict — it points at the 6 forks where the author made a real choice, so you can skim the rest.

Scanned: 18 hunks across 8 files. Most is mechanical or explanatory: the two new LLP docs (0246/0247) restating the settled rationale, comment and JSDoc honesty updates in connect.js/proxy.js, the types.d.ts field addition, and raw-socket test plumbing. The decisions worth your eyes, in order:

1. Degraded listeners forward absolute-form blind · unhappy-path policy

hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:405

constabsoluteFormBlind=absoluteForm&&!opts.interceptionconstrecording=!absoluteFormBlind&&((!proxyMode&&!absoluteForm)||shouldRecordProxyExchange(upstream,parsedUrl.pathname))
  • Decision: a tunnelOnly listener (stale CA, CA failure) forwards absolute-form to registered hosts but records nothing, even inside a path anchor.
  • Alternative not taken: capture-where-possible — record anchored paths wherever routing succeeds. That would capture proxy-shaped traffic on a listener whose proxy_mode is explicitly off.
  • Check: agree that "unrecorded-but-working" (LLP 0233 #degrade-to-blind-tunnels) is the right contract for this door too, and that recording keying on live interception is the right proxy for consent.

2. The door opens only beside the CONNECT front door · contract

hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:310

constforwardProxyDoor=Boolean(opts.interception)||Boolean(opts.tunnelOnly)constabsoluteForm=!proxyMode&&forwardProxyDoor&&/^https?:\/\//i.test(requestUrl)
  • Decision: absolute-form is served only on listeners that also serve CONNECT (interception or tunnelOnly); pure reverse-proxy installs keep their exact pre-PR behavior.
  • Alternative not taken: serve the shape unconditionally on every listener (simpler, RFC-friendlier) — at the cost of changing behavior on installs LLP 0233 #proxy-mode-is-explicit promises stay unchanged.
  • Check: the gate condition matches the condition that installs the CONNECT front door in startProxy, and the RC bridge (which only sends absolute-form under HTTPS_PROXY) always lands on a gated-open listener.

3. Unregistered hosts are refused, never relayed · contract / security

hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:387

sendJson(res,403,{error: 'no upstream matches absolute-form host',
  • Decision: an absolute-form miss is a 403 refusal; forwarding only ever reaches hosts the routing table names.
  • Alternative not taken: degrade symmetrically with CONNECT (relay to arbitrary hosts the way blind tunnels do) — which would make the listener a general plaintext relay.
  • Check: the asymmetry argument in LLP 0247 #refuse-hosts-nobody-registered — a blind tunnel protects a client that points ALL egress here, while an absolute-form miss is one request from a client that demonstrably tunnels elsewhere.

4. The control surface never answers absolute-form · security

hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:342

if(!proxyMode&&!absoluteForm&&isControlPath(parsedUrl.pathname)){
  • Decision:https://host/_hypaware/... in absolute-form routes like any other third-party-addressed path instead of hitting the local, unauthenticated control surface.
  • Alternative not taken: answer the control prefix locally regardless of request form — which would let anything that can make a proxy-pointed client fetch a URL reach the control surface.
  • Check: one !absoluteForm token carries the whole guarantee; the test "an absolute-form control path is forwarded, not answered locally" pins it.

5. Absolute-form routes by the named authority · contract

hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:360

: absoluteForm
? matchUpstreamByHost(upstreams,parsedUrl.hostname,absoluteFormPort)
: matchUpstream(upstreams,req.method??'GET',parsedUrl.pathname,req.headers)
  • Decision: the request line's authority routes through matchUpstreamByHost, exactly like a terminated tunnel; the PR's headline choice.
  • Alternative not taken: keep path routing (the pre-PR 404 that Claude Code misread as an account limitation), or refuse the shape with a 4xx and only file the upstream client bug.
  • Check: RFC 9112 3.2.2 requires proxies to accept absolute-form, and host+port matching agrees with interceptsHost so the two routing tables cannot diverge.

6. The recorded path is origin-form · contract / shape

hypaware-core/plugins-workspace/ai-gateway/src/proxy.js:424

path: absoluteForm ? parsedUrl.pathname+parsedUrl.search : requestUrl,
  • Decision: recorded exchanges carry the origin-form remainder, so projectors see one path shape from all three front doors.
  • Alternative not taken: record the raw absolute-form request-target and teach every projector both shapes.
  • Check: nothing downstream wants the authority half — it is already carried by the upstream name/provider on the exchange.

Honorable mentions (real but lower-stakes): proxy.js:319 loopback-peers-only 403, mirrors the settled CONNECT rule (peer, not bind); types.d.ts:184 StartedProxy exposes server as a test seam rather than leaving the peer branch untested; the raw-socket tests deliberately pin the exact wire shape (request-line verbatim) as the contract.

Generated by /decision-map. Advisory — directs attention, casts no verdict.

@philcunliffe
philcunliffe merged commit ece46cb into masterAug 17, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/absolute-form-third-front-door branch August 17, 2026 21:20
philcunliffe pushed a commit that referenced this pull request Aug 17, 2026
…he proxy_mode_error read path
- The CA on disk is `tls/ca-cert.pem`, not `tls/ca.crt`: LLP 0245 section 1
and the new claude_proxy_capture step 2 both named a path that does not
exist, so the procedure's settings check could only fail.
- LLP 0246 now names the Remote Control absolute-form issue on master; the
proxy-mode-default design renumbered itself to LLP 0251. Both references
updated.
- proxy_mode_error is published in the gateway source's status details, which
`hyp status --json`'s sources array does not carry; the failure step now
reads it from `hyp daemon status --json`.
- Note the absolute-form third front door (LLP 0246/0247, #797) in the design
and in step 4's diagnosis, since it is what makes Remote Control pass.
- Correct the launchd env status line and two test paths in the audit.
philcunliffe added a commit that referenced this pull request Aug 18, 2026
… 2) (#799)
* A terminal marker rewrite records the effect it overwrites (LLP 0247)
The reconciler's reverse gap dropped an assetless failed/refused marker
for a request key the config stops naming, reading "no installed_assets"
as "this key never applied anything". That is only true for the half of
an attach that copies files. An attach that reached `done`, wrote the
client's settings and copied nothing (openclaw, routinely), then
re-performed into failed/refused, produced a marker indistinguishable
from one whose attach never touched the disk - and the drop stranded the
settings write with nothing naming it.
Record a `prior_done` bit on the rewrite and read it in the drop
condition, so such a marker is handed to reverse() instead.
Item 2 of #780, deferred from PR #630's review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Review fix: renumber the new decision to LLP 0250, the number is free
PR #797 (`fix/absolute-form-third-front-door`) claimed 0247 for
`llp/0247-absolute-form-third-front-door.decision.md` about a minute
before this branch's commit, so both PRs introduce an LLP 0247 and both
merge cleanly (different filenames). The result on master would be two
documents claiming one number, an ambiguous `@ref LLP 0247#the-bit`, and
a red `test/core/llp-ref-hygiene.test.js` ('no LLP number is claimed by
two documents').
LLP 0156#renumber settles this: the later claimant moves above the
highest number claimed anywhere. 0245-0249 are all claimed on unmerged
branches, so this doc takes 0250. Mechanical rename plus the reference
sweep in the same commit (the doc title, the two `Extended-by:`
forward-refs on LLP 0138/0186, four `@ref`s and two prose mentions in
`action_reconciler.js`, two in `types.d.ts`, and the test file's `@ref`
and two test names). Nothing the document decided changes.
* Review fix: the re-arm's assetless drop states why it is safe, not a premise LLP 0250 retired
---------
Co-authored-by: test <test@example.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: test <test@test.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@philcunliffe