Skip to content

fix(claude): route the hub's own local clients through their two destinations (management ingress + loopback inference) - #4251

Merged
lidge-jun merged 8 commits into
devfrom
codex/260911-l4-hub-local-clients
Sep 11, 2026
Merged

fix(claude): route the hub's own local clients through their two destinations (management ingress + loopback inference)#4251
lidge-jun merged 8 commits into
devfrom
codex/260911-l4-hub-local-clients

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 11, 2026

Copy link
Copy Markdown
Owner

Stack (hub single-port, #4236): 1 codex/260911-l4-launchd-repair → 2 codex/260911-l4-hub-loopback-companion → 3 codex/260911-l4-hub-local-clients → 4 codex/260911-l4-hub-token-ux → 5 docs (next). Each PR targets the previous branch; retarget to dev as the one below lands. Local suite deliberately not run (operator instruction); hosted CI on the pushed head is the proof.

Summary

The local-client follow-up on #4236 is two defects, and the reviewer comment on that issue is the design: two destination contracts, not one base URL substituted everywhere. This PR ships both halves separately. A review round (second revision, commits added on top — nothing rewritten, PRs 4/5/6 are stacked above) found that the first revision got the shape of one of those contracts wrong; that is described in "Review round" below and is the largest part of the current diff.

The hub's own clients had no local socket for their wire. The unauthenticated loopback listener served only the Codex data plane, so on a hub bound to a tailnet address POST /v1/messages (the Anthropic wire ocx claude, Claude Desktop and the system-env injection speak) and POST /v1/chat/completions (the OpenAI chat wire Cursor Private Inference, the routed vision helper and aside/opencode speak) both returned 404 there. loopbackRouteAllowed now admits those two POST routes plus POST /v1/messages/count_tokens, which completes the Anthropic wire. Both handlers already resolve admission from the receiving listener's RequestPolicyView — the same resolver and the same loopback short-circuit /v1/responses uses — so this adds a wire, not a trust level. /api/*, /healthz, /readyz and the GUI still 404: local management discovery belongs to the authenticated surface, and the review condition against widening this listener to /api/* is honored literally. One consistency fix rides along — the chat-completions branch finished its CORS with config instead of the request's policy; on the public listener those are the same object (no-op), on the loopback listener it is the difference between CORS headers that match the admission decision above them and headers derived from a bind address that did not receive the request.

Eight call sites composed http://127.0.0.1:<public port> by hand, an address that does not exist on such a hub — the "Codex works but nothing else does" symptom, because ocx sync honored the listener and nobody else did. src/lib/local-destinations.ts now answers the two questions separately, reusing PR2's effectiveLoopbackListenerPort, isWildcardHostname and shouldInjectApiAuthHeader plus the existing probeHostname:

  • localInferenceDestination{ origin, port, requiresAdmissionToken } — the unauthenticated loopback listener's effective port when enabled (no credential), else 127.0.0.1:<public port> on a loopback bind (no credential), else probeHostname(hostname):<public port> with a data-plane credential required. Used by buildClaudeEnv, writeDesktop3pConfig, refreshGatewayModelCacheFromProxy, injectSystemEnv + the shell env file, the Cursor gateway card, routedDescribeBaseUrl, and resolveApiAccessBaseUrl's last-resort loopback branch only — every branch above it still describes the address the client actually reached, so a remote caller is never handed a port that exists only on the hub's own 127.0.0.1.
  • localManagementOrigin — a hub's loopback hub.managementIngress when enabled, otherwise the public bind address, with the admin token still attached. Management authentication has no loopback bypass and the unauthenticated listener serves no /api/*. fetchClaudeCodeState is its only caller today; its enabled: false answer is what makes ocx claude launch natively, so a wrong destination there silently downgrades every launch on the machine. No exported client configuration gained a credential.

Three details worth naming. targetsLocalClaudeProxy takes a set of ports plus the resolved destination origin, because more than one local address can be ours — treating one a launch did not pick as a foreign proxy would strip the admission token minted for it. The gateway-model cache had to move with buildClaudeEnv, since Claude Code honors that file only while its baseUrl equals ANTHROPIC_BASE_URL. And the system-env tracking record gained bindHost and clientBaseUrl: ownership on revert is proven against what was injected, while liveness probes the bind host on the public port, because the listener serves no /healthz and a tailnet-bound hub has no loopback socket to probe at all.

A plain loopback or standalone install resolves to the byte-identical string it wrote before; a companion hub ({enabled:true}, PR2) is unchanged because the effective port is the public port.

This PR is stacked on codex/260911-l4-hub-loopback-companion and targets its head branch. Retarget to dev once that lands.

Review round (second revision)

Six findings, all about the shape of the resolution rather than the split itself. Four new commits, none rewriting history.

1 — should-fix: localInferenceOrigin had no bind-address fallback. With the listener OFF and a non-loopback hostname, all eight sites still got http://127.0.0.1:<public port> — a dead socket — and tests/lib/local-destinations.test.ts pinned that as intended. So the first revision fixed the ported listener form and left the reported topology broken, which is the case #4236 is actually about.

Inference now mirrors localManagementOrigin's shape and returns a struct, because a string cannot say whether the destination will admit you: a wildcard bind answers on 127.0.0.1 and demands a data-plane credential. requiresAdmissionToken is shouldInjectApiAuthHeader, the predicate that already encoded exactly this question, so the two cannot drift. Each of the eight sites then either attaches the credential or degrades out loud:

Site Credential-demanding destination
buildClaudeEnv fills the ANTHROPIC_AUTH_TOKEN slot it already uses for ownAdmissionTokens; on a subscription launch it cannot (asserting a host token logs a claude.ai subscriber out, #253) so it warns, naming the origin and the two fixes
injectSystemEnv / writeShellEnvFile injects it, or returns {injected:false, reason} — the launchd domain is machine-wide, so a base URL that 401s every plain claude is worse than no injection
writeDesktop3pConfig writes it as the gateway api key the generator already takes
refreshGatewayModelCacheFromProxy, routed vision self-fetch already sent x-opencodex-api-key; now share one ladder to resolve it
Cursor gateway card apiKeyMode keys on the destination it just resolved, so the card cannot hand an operator a URL and say no key is needed for it
resolveApiAccessBaseUrl wildcard-only branch, so its string is unchanged

The credential is the data-plane one — OPENCODEX_API_AUTH_TOKEN, the hardened service token file, then a configured apiKeys entry, the same ladder standaloneCodexRoutingTarget and the Codex provider table use — shared as localAdmissionToken. Never the admin token. One hardening rides along: that helper shape-checks the token it reads from the file before sending it as a credential, because a path can be pointed at something that is not a credential and putting that in a header leaks file contents; the env var and configured keys pass through verbatim, so no existing key can break.

Inside ocx claude, the "ours" port set became localLoopbackInferencePorts — the ports that actually answer on 127.0.0.1 — which is empty on a tailnet bind with no listener, so a leftover http://127.0.0.1:10100 from a previous loopback-bound install is correctly rewritten instead of preserved. buildNativeClaudeEnv keeps a wider set on purpose: shedding asks "could we have written this?", and leaving such a URL behind with its token stripped points a native launch at a dead socket.

2 — should-fix: cleanStaleSystemEnv probed an address that does not exist. It dialed 127.0.0.1:<public port>, so on a tailnet-bound hub every liveness probe failed, the record was reverted on every start, and the "another instance owns env" guard could never fire — while the new comment and test asserted the opposite. The tracking record now carries bindHost (shape-validated on read; it reaches a fetch URL) and clientBaseUrl in place of the first revision's clientPort, which shipped only on this branch. Both fields are omitted when they add nothing to port, so a plain loopback install writes a byte-identical record and older records are read unchanged.

3 — probeHostname and api-access.ts's isWildcardBindHost each knew three wildcard spellings while isWildcardHostname (PR2) knew every all-zero form, so 0.0.0.0., ::0 and * were composed into literal URLs that resolve to nothing for a config the server itself treats as a wildcard bind. Both call the shared predicate now; the tests enumerate nine spellings.

4 — nit: Number(configuredPort()) is 0 when _corsOrigin carries no explicit port, so routedDescribeBaseUrl could compose http://127.0.0.1:0. Guarded.

5 — POST /v1/messages/count_tokens is admitted. The first revision withheld it on a scope argument and pinned the 404. Scope is not a confinement argument here: it spends no provider quota, reaches no stored credential, and returns a count from a body the caller already holds — and the same caller may POST the whole conversation to /v1/messages on that socket. Withholding it only cost Claude Code its server-side count.

Widening the allowlist also turned a weaker assertion into a real one. tests/server/reserve-ingress.test.ts asserted a local 404 for both translated wires and said outright that it "does NOT prove admission propagation inside the translated handler" — the 404 came from the allowlist. Those requests now reach the handler and give the answer the 404 stood in for: loopback admission makes Reserve eligible, so the turn is refused 429 behind a WHAM probe with nothing reaching the upstream, while the public listener's dedicated admission is not eligible and forwards the caller's own credential. The file's invariant — eligibility is decided by the receiving listener, not the dial address — is now proven on four transports instead of two.

6 — docs. structure/01_runtime.md's three-socket paragraph was one run-on block; split into four, with count_tokens named in the allowlist. structure/09_client-integrations.md records that fetchClaudeCodeState sends the admin token to the management ingress or, without one, to the bind address — host-local, never exported — plus the inference resolver's credential contract.

Also: localInferencePort was removed rather than kept as a wrapper. A bare port cannot express a bind-address destination, so an export that returns one is a trap.

Verification

  • bun run typecheck clean; bun run privacy:scanPrivacy scan passed.
  • bun test tests/lib/local-destinations.test.ts tests/server/loopback-listener-admission.test.ts tests/server/loopback-listener-integration.test.ts tests/server/system-env.test.ts tests/claude-integration/claude-cli.test.ts tests/clients/desktop-3p.test.ts tests/server/api-access-endpoints.test.ts tests/providers/cursor/cursor-integration-status.test.ts tests/vision/vision-routed.test.ts tests/server/loopback-companion-client-targets.test.ts241 pass.
  • bun test tests/server/reserve-ingress.test.ts32 pass (the CI failure above, fixed).
  • bun test tests/claude-integration/claude-gateway-cache.test.ts tests/claude-integration/claude-system-env-auto.test.ts tests/claude-integration/claude-shell-hook.test.ts tests/claude-integration/claude-management-api.test.ts tests/clients/desktop-3p-guard.test.ts tests/clients/desktop-remote-store.test.ts tests/clients/sync-client-integrations.test.ts tests/codex-integration/native-claude-desktop-toggle.test.ts122 pass.
  • bun test tests/server/proxy-liveness.test.ts tests/codex-integration/codex-inject.test.ts tests/codex-integration/codex-inject-integration.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts215 pass.
  • Every other file that mentions the loopback listener or the reserve fixture: bun test tests/cli/cli-export-command.test.ts tests/cli/hub-gated-local-clients.test.ts tests/clients/integrations-writer.test.ts tests/codex-integration/codex-desired-state.test.ts tests/codex-integration/reserve-auth-context.test.ts tests/codex-integration/reserve-catalog.test.ts tests/codex-integration/reserve-dispatch.test.ts tests/codex-integration/reserve-helper-boundary.test.ts tests/providers/xai/grok-sync.test.ts tests/server/management-client-config-route.test.ts tests/server/reserve-claude-policy.test.ts245 pass.
  • bun test tests/cli/cli-management-auth.test.ts tests/claude-integration/claude-auth-detect.test.ts tests/claude-integration/claude-auth-mode.test.ts tests/server/api-keys-routes.test.ts tests/providers/cursor/cursor-effort-rows.test.ts tests/ci-workflows/docs-remote-hub-claims.test.ts125 pass.
  • tests/lib/local-destinations.test.ts enumerates the review's six configurations — standalone loopback, companion hub, ported hub, listener-off + non-loopback bind, wildcard bind, client role — as one table driving both the destination and the loopback-port-set assertions, so a branch that forgets requiresAdmissionToken fails there rather than in production. The integration file starts real servers: the three wires are reachable on the loopback listener and 401 on the public one, while /api/claude-code and /api/config still 404 on the listener with an admin token in the header. Assertions are not 404 and not 401, because either alone stays green if a route is silently dropped from the allowlist.
  • No repository-wide suite, per operator instruction. Hosted CI at the exact pushed head is the proof.
  • Live acceptance on the reporter's host, read-only (first revision, unchanged by this round): with runtimeRole: hub, hostname: 127.0.0.1, port: 10100, listener 10104, ingress 10102, a scratchpad script resolved management → http://127.0.0.1:10102, inference → http://127.0.0.1:10104, and fetchClaudeCodeState returned enabled: true with 264 context windows. No config write, no restart, no repair/ensure/sync/start. The same probe confirms the restart requirement: the still-running pre-PR proxy answers 404 for POST /v1/messages on 10104 while GET /v1/models is 200, so operators on the ported form must restart before ocx claude uses the listener — recorded for the docs unit.
  • Both of the first revision's recorded follow-ups are closed by this round: count_tokens is admitted and Cursor's apiKeyMode keys on the resolved destination. What remains is PR4's: the GUI API-access panel's copy when the resolved destination demands a credential.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed (reference/configuration/server.md, structure/01_runtime.md, structure/09_client-integrations.md, devlog/_plan/260911_hub_single_port/030_hub_local_clients.md).
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults: no new admission bypass reachable from the public listener, no /api/* on the unauthenticated listener, no admin credential in any exported client configuration, the data-plane credential ladder never reads the admin variable, a file-sourced token is shape-checked before it becomes a header, startServer still synchronous.

Refs #4236

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 11, 2026 02:50
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^dev$
  • ^preview$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 0016d3b7-c2d4-4169-b648-24b9d1b06828

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 11, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 74 / 80

이 PR은 #4236 허브 단일 포트 스택의 3번째 조각(로컬 클라이언트 목적지)입니다. 지금 dev HEAD는 babb76449(#4240 L4 client-catalog, 패키지 2.51.0)이고, 이 PR의 베이스는 dev가 아니라 codex/260911-l4-hub-loopback-companion(#4250)입니다. 그 아래에는 다시 codex/260911-l4-launchd-repair(#4249, 베이스 dev)가 있습니다. 스택은 1 repair → 2 companion → 3 이 PR(local-clients) → 4 token-ux(#4252) → 5 docs 순서입니다. 아래가 안착하기 전에는 dev에 바로 못 붙고, 부모 랜딩 후 리타깃이 필요합니다.

고치는 증상은 한 줄로 이렇게 읽힙니다. 테일넷 주소에만 공개 리스너를 묶은 허브에서 Codex는 되는데, ocx claude / Claude Desktop / Cursor Private Inference / vision routed-describe / aside·opencode는 안 됩니다. 이유는 단순합니다. 공개 포트 127.0.0.1:<public> 소켓이 원래 없고, 인증 없는 루프백 리스너는 예전에 Codex 데이터면(/v1/responses 등)만 받았기 때문입니다. Anthropic 와이어 POST /v1/messages와 OpenAI chat 와이어 POST /v1/chat/completions는 거기서 404였고, 여덟 군데 호출부가 손으로 http://127.0.0.1:<공개포트>를 조립해서 “없는 주소”로 갔습니다.

이 PR의 설계 핵심은 베이스 URL 하나를 Everywhere에 바꿔 넣는 게 아니라, 목적지 계약을 둘로 나눈 것입니다.

  1. 추론(localInferenceOrigin / localInferencePort) — unauthenticatedLoopbackListener가 켜져 있으면 그 유효 포트, 아니면 공개 포트. 자격 증명 없는 로컬 호출자가 쓰는 데이터면입니다.
  2. 관리(localManagementOrigin) — 허브의 hub.managementIngress가 켜져 있으면 127.0.0.1:<ingress>, 아니면 공개 바인드(+ admin 토큰). /api/*는 루프백 리스너에 절대 안 올리고, 관리 인증의 루프백 우회도 없습니다.

새 모듈 src/lib/local-destinations.ts가 그 두 질문을 한곳에서 답하고, PR2의 effectiveLoopbackListenerPort와 기존 probeHostname을 재사용합니다. 서버 쪽에서는 loopbackRouteAllowed에 위 두 POST만 추가하고, chat-completions CORS는 config가 아니라 수신 리스너의 policy를 쓰도록 고칩니다(공개 리스너에선 사실상 no-op, 루프백에선 입장 결정과 헤더가 맞습니다). buildClaudeEnv / Desktop 3P / gateway cache / system-env(+shell) / Cursor gateway 카드 / routed-describe / resolveApiAccessBaseUrl최후 루프백 분기만 추론 쪽을 타고, fetchClaudeCodeState만 관리 쪽을 탑니다. targetsLocalClaudeProxy는 포트 집합을 보고, system-env 추적 파일은 clientPort로 “주입한 포트”와 “/healthz를 찌를 공개 포트”를 갈라 둡니다. 일반 루프백·스탠드얼론·companion(enabled:true만, 유효 포트=공개 포트)은 바이트가 예전과 같고, 포트형(enabled:true, port:10104)만 이동합니다.

검증 서술은 모듈별로 세 토폴로지를 고정했고, 통합 테스트는 루프백에서 두 와이어가 404/401이 아니며 /api/*는 토큰을 줘도 404라고 못 박았습니다. 로컬 전체 스위트는 운영자 지시로 생략, 호스티드 CI가 증거입니다. 라이브 수락도 읽기 전용으로 management→10102, inference→10104, fetchClaudeCodeState enabled:true까지 확인했다고 적혀 있습니다.

라인 - 베이스 브랜치 codex/260911-l4-hub-loopback-companion - dev가 아님. #4249→#4250이 안착·리타깃되기 전에는 이 PR을 dev에 머지할 수 없음.
라인 - POST /v1/messages/count_tokens - 루프백 리스너에 의도적으로 404로 남김. Claude Code는 로컬 추정으로 떨어짐. 문서 유닛 후속.
경로/심볼 - Cursor gateway apiKeyMode - 게이트웨이 URL은 로컬 리스너 포트를 쓰지만, apiKeyMode는 여전히 공개 바인드의 입장 규칙을 설명함. 리스너에 키를 붙여도 해롭지 않고, 키가 필요한 바인드에서 빼면 깨짐. 문서에 남길 말.
경로/심볼 - 포트형 리스너 업그레이드 - 이미 떠 있는 프리-PR 프록시는 10104에서 POST /v1/messages가 여전히 404. 운영자는 리스너 와이어가 바뀌므로 재시작이 필요함(본문·devlog에 기록됨).
경로/심볼 - CI mergeStateStatus UNSTABLE - hygiene/label 등은 통과, cross-platform test/gates/macos 등이 이 리뷰 시점에 아직 돌거나 대기 중. 그린 확인 전 머지 금지.
경로/심볼 - 스택 아래 #4249 BLOCKED - repair PR이 dev에서 아직 막혀 있으면 companion(#4250)과 이 PR도 연쇄로 대기. 독립 머지 대상이 아님.

메인테이너의 판단이 필요한 지점

너의 추천
#4249와 #4250이 dev에 안착하면 이 브랜치를 dev로 리타깃하고, 호스티드 CI가 전부 그린인지 확인한 뒤 머지하세요. 코드 방향(두 목적지 계약, /api/* 비확대, 관리 자격 증명을 클라이언트 설정에 안 씀, chat CORS를 policy로)은 #4236 리뷰 조건과 맞고 테스트 범위도 충분합니다. count_tokens·apiKeyMode·재시작 요구는 다음 docs 유닛(#4236 스택 5번)에 명시적으로 남기세요. 지금은 스택 대기 + CI 대기입니다.

이 댓글은 grok-bot이 작성했습니다

lidge-jun and others added 8 commits September 11, 2026 12:43
The unauthenticated loopback listener served only the Codex data plane, so on a
hub whose public listener binds a tailnet address the machine's other local
clients had no socket at all: `ocx claude`, Claude Desktop and the system-env
injection speak `POST /v1/messages`, while Cursor Private Inference, the routed
vision helper and aside/opencode speak `POST /v1/chat/completions`. Both 404'd
there, which is the other half of the "Codex works but nothing else does"
report in #4236.

Admit exactly those two POST routes. Both handlers already resolve admission
from the receiving listener's policy view — the same resolver and the same
loopback short-circuit `/v1/responses` uses — so this adds a wire, not a trust
level. `/api/*`, `/healthz`, `/readyz`, the GUI and `count_tokens` still 404:
local management discovery belongs to the authenticated surface, and the
reviewer condition on #4236 was explicit about not widening this listener to
`/api/*`.

The chat-completions branch also finished its CORS with `config` rather than the
request's `policy`. On the public listener those are the same object, so that is
a no-op there; on the loopback listener it is the difference between CORS
headers that match the admission decision above them and headers derived from a
bind address that did not receive the request.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… port

Eight sites composed `http://127.0.0.1:<public port>` by hand (#4236). On a hub
bound to a tailnet address that socket does not exist, so `ocx claude`, Claude
Desktop, Cursor, the system-env injection, the gateway-model cache and the
routed vision helper all pointed at a closed port while Codex — which already
honored `unauthenticatedLoopbackListener` — worked.

They are two contracts, not one substituted base URL, and `src/lib/local-
destinations.ts` keeps them apart:

- `localInferenceOrigin` / `localInferencePort` — the unauthenticated loopback
  listener's effective port when it is enabled, else the public port. That
  listener admits local callers with no credential.
- `localManagementOrigin` — a hub's loopback management ingress when enabled,
  else the public bind address. The caller still sends the admin token:
  management auth has no loopback bypass and the unauthenticated listener serves
  no `/api/*`. No exported client configuration gains a credential.

`fetchClaudeCodeState` uses the management resolver (its `enabled: false` answer
is what makes `ocx claude` fall back to a native launch); every inference site
uses the other one. A loopback or standalone install resolves to the same string
it wrote before, so nothing changes there.

Three details worth naming. `targetsLocalClaudeProxy` now takes a SET of ports,
because the public port and the listener's port are both ours — rewriting one
into the other would strip the admission token minted for it. The gateway-model
cache moved with `buildClaudeEnv`, since Claude Code honors that file only while
its `baseUrl` equals `ANTHROPIC_BASE_URL`. And the system-env tracking record
gained an optional `clientPort`: ownership on revert is proven against the port
that was injected, while liveness keeps probing the public port, because the
listener serves no `/healthz` and probing it would revert a live proxy's
environment.

`resolveApiAccessBaseUrl` changed only in its last-resort loopback branch; every
branch above it still describes the address the client actually reached.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One case per touched module, each proving the same three topologies: a ported
listener moves the destination, a companion listener keeps the public port, and a
plain loopback or standalone install is byte-identical to before.

`tests/lib/local-destinations.test.ts` (new, registered in the layout manifests)
holds the resolvers apart: the inference origin is always loopback and never the
bind address, and the management origin never returns the listener's port even
when both are configured.

The per-module cases cover what a shared resolver cannot: the gateway cache's
`baseUrl` must equal what `buildClaudeEnv` writes or Claude Code ignores the
file; `writeDesktop3pConfig` must resolve from the config it re-reads under the
mutation lock; system-env must inject the listener port while still probing
`/healthz` on the public one; the vision planner must carry the listener field
into the narrowed config it hands the self-fetch; and `resolveApiAccessBaseUrl`
must NOT move when a real request host is available.

`ocx claude` also gets the two ports treated as one family: neither of our own
local ports is replaced as stale, and a native launch sheds the managed
destination on either of them.

PR2's "the ported form still splits the two" witness was the record of the gap
this unit closes, so it now asserts the agreement.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What shipped, the two-contract decision and why the allowlist stopped where it
did, the three live-config verifications, and the follow-ups left for the docs
unit (restart requirement, `count_tokens`, Cursor's apiKeyMode wording).

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…l inference

Review on #4236, should-fix: `localInferenceOrigin` returned
`http://127.0.0.1:<public port>` whenever `unauthenticatedLoopbackListener` was
off, with no bind-address fallback — and `tests/lib/local-destinations.test.ts`
pinned that as intended. So the first revision of this unit closed the PORTED
listener form and left the reported topology (listener off, `hostname` a tailnet
address) pointing all eight call sites at a dead socket, which is the case the
issue is actually about.

Inference now mirrors `localManagementOrigin`'s shape and returns a struct,
`localInferenceDestination` → `{ origin, port, requiresAdmissionToken }`:

  listener enabled    → `127.0.0.1:<effective port>`, no credential
  loopback `hostname` → `127.0.0.1:<public port>`, no credential
  anything else       → `<probeHostname(hostname)>:<public port>`, CREDENTIAL

A wildcard bind keeps a loopback origin — it does answer there — but lands in the
credential bucket, because the public listener demands admission regardless of
which address received the request. A string cannot express that, which is why
the resolver stopped returning one: a caller that cannot see
`requiresAdmissionToken` cannot tell a free socket from one that will 401.
`requiresAdmissionToken` IS `shouldInjectApiAuthHeader`, the predicate that
already encoded this question, so the two cannot drift.

Each of the eight sites then either attaches the credential or degrades out loud:

- `buildClaudeEnv` puts it in the `ANTHROPIC_AUTH_TOKEN` slot it already uses for
  `ownAdmissionTokens`, and warns when a subscription launch cannot carry one —
  asserting a host token there logs a claude.ai subscriber out (#253), so the
  honest outcome is a warning naming the two fixes, not a silent 401.
- `injectSystemEnv` / `writeShellEnvFile` inject it, or refuse the whole
  injection with a reason: the launchd domain is machine-wide, so a base URL that
  401s every plain `claude` on the box is worse than no injection.
- `writeDesktop3pConfig` writes it as the gateway api key it already accepts.
- `refreshGatewayModelCacheFromProxy` and the routed vision self-fetch already
  sent `x-opencodex-api-key`; they now share one ladder for resolving it.
- the Cursor card's `apiKeyMode` keys on the destination it just resolved, so it
  cannot hand the operator a URL and tell them no key is needed for it.
- `resolveApiAccessBaseUrl`'s last-resort branch is wildcard-only, so its string
  is unchanged.

The credential is the DATA-plane one — `OPENCODEX_API_AUTH_TOKEN`, the hardened
service token file, then a configured `apiKeys` entry, the same ladder
`standaloneCodexRoutingTarget` and the Codex provider table use — shared as
`localAdmissionToken`. Never the admin token: no exported client configuration
may carry management authority (reviewer constraint on #4236). That helper
shape-checks the token it reads from the FILE before sending it as a credential,
because a path can be pointed at something that is not a credential at all and
putting that in a header leaks file contents; the env var and configured keys
pass through verbatim.

Two consequences inside `ocx claude`. `targetsLocalClaudeProxy` gained the
resolved destination origin as a second way to be ours, or the launch would write
a base URL and refuse to recognize it one line later. And the port set became
`localLoopbackInferencePorts` — the ports that actually answer on 127.0.0.1 —
which is EMPTY on a tailnet bind with no listener, so a leftover
`http://127.0.0.1:10100` from a previous loopback-bound install is correctly
rewritten instead of preserved as ours. `buildNativeClaudeEnv` keeps a wider set
on purpose: shedding asks "could we have written this?", and leaving such a URL
behind with its token stripped points a native launch at a dead socket.

Two more review findings ride here because they are the same composition bug:

- `cleanStaleSystemEnv` probed `127.0.0.1:<public port>`, which does not exist on
  a tailnet-bound hub, so the record was reverted on every start and the "another
  instance owns env" guard could never fire — while the comment and test asserted
  the opposite. The tracking record now carries `bindHost` (validated on read, it
  reaches a fetch URL) and `clientBaseUrl` in place of `clientPort`; liveness
  probes the bind host and the public port, ownership is proven against what was
  injected. Both fields are omitted when they add nothing to `port`, so a plain
  loopback install writes a byte-identical record.
- `probeHostname` and `api-access.ts`'s `isWildcardBindHost` each knew three
  wildcard spellings while `isWildcardHostname` (PR2) knew every all-zero form,
  so `0.0.0.0.`, `::0` and `*` were composed into literal URLs that resolve to
  nothing. Both call the shared predicate now.
- nit: `Number(configuredPort())` is `0` when `_corsOrigin` carries no explicit
  port, so `routedDescribeBaseUrl` could compose `http://127.0.0.1:0`.

`localInferencePort` was removed rather than kept as a wrapper: a bare port
cannot express a bind-address destination, so an export that returns one is a
trap. `planVisionSidecar`'s narrowed config gained `hostname` for the same
reason the listener field was added to it.

The test table is the review's six configurations — standalone loopback,
companion hub, ported hub, listener-off + non-loopback bind, wildcard bind,
client role — driving both the destination and the port-set assertions, so a new
branch that forgets the flag fails there rather than in production.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stener

The first revision withheld it on a scope argument and pinned the 404 so widening
it would be deliberate. Review took that option: scope is not a confinement
argument here.

`count_tokens` spends no provider quota, reaches no stored credential, and returns
a count computed from the request body the caller already holds. Withholding it
bought nothing — the same caller may POST the entire conversation to
`/v1/messages` on this socket — while costing Claude Code its server-side count,
which it silently replaces with a local estimate. So the Anthropic wire is
complete on the listener now.

`/api/*`, `/healthz`, `/readyz` and the GUI stay 404, which is the boundary that
actually matters and the one the reviewer conditioned on. The pinned 404 test
became a pinned reachability test in the same loop as the other two wires —
`not 404` and `not 401`, because either alone stays green if the route is silently
dropped from the allowlist — and the `GET` form is still in the denied list, so
"admit the path" cannot quietly become "admit the path for any method".

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`structure/01_runtime.md`: the three-socket paragraph was one run-on block; split
into four, and the data-loopback allowlist now names `count_tokens` alongside the
two other wires.

`structure/09_client-integrations.md`: states that `fetchClaudeCodeState` is the
management resolver's caller — it sends the admin token to the hub's management
ingress, or without one to the bind address, both host-local and never exported —
and records the inference resolver's credential contract, including that a
wildcard or tailnet bind demands the data-plane ladder and never the admin token.

Devlog 030 gains a "Review round" section: the six findings, why the missing
bind-address fallback left the reported topology broken while the ported form
worked, what each of the eight call sites now does with `requiresAdmissionToken`,
the tracking-record change, the `count_tokens` reversal, and the exact commands
and counts at the pushed head. The two former follow-ups are closed; the GUI
API-access copy on a credential-demanding destination is recorded as PR4's.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… listener

CI caught the two translated-wire cases in `tests/server/reserve-ingress.test.ts`
asserting a local 404 for `POST /v1/messages` and `POST /v1/chat/completions`.
That 404 came from the loopback listener's allowlist, and the test said so: "this
local 404 does NOT prove admission propagation inside the translated handler."

Both wires are served there now, so the request reaches the handler and gives the
answer the 404 was standing in for — the same one the Responses transport already
gives on that listener. Loopback admission makes Reserve eligible, so the turn is
refused `429` behind a WHAM probe with nothing reaching the upstream, while the
public listener's `dedicated` admission is not eligible and forwards the caller's
own credential with no probe at all.

The describe block's invariant is unchanged and now better covered: eligibility is
decided by the RECEIVING listener's admission, not by the dial address — both
requests leave from 127.0.0.1 with the same credential and only the socket differs.

One stale comment corrected in the same file: chat IS served by the secondary
listener now, so the `primaryLoopback` fixture is used because it proves the
terminal refusal inside the handler, not because chat is unreachable elsewhere.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lidge-jun
lidge-jun force-pushed the codex/260911-l4-hub-local-clients branch from ea2b3a5 to 0fd205f Compare September 11, 2026 03:43
@lidge-jun
lidge-jun force-pushed the codex/260911-l4-hub-loopback-companion branch from 28c2b79 to 216032d Compare September 11, 2026 03:43
Base automatically changed from codex/260911-l4-hub-loopback-companion to dev September 11, 2026 04:14
@lidge-jun
lidge-jun merged commit c3419e5 into dev Sep 11, 2026
35 of 38 checks passed
@lidge-jun
lidge-jun deleted the codex/260911-l4-hub-local-clients branch September 11, 2026 04:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant