fix(claude): route the hub's own local clients through their two destinations (management ingress + loopback inference) - #4251
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
✅ Deterministic PR hygiene checks passed. |
리뷰 · 우선순위 74 / 80이 PR은 #4236 허브 단일 포트 스택의 3번째 조각(로컬 클라이언트 목적지)입니다. 지금 고치는 증상은 한 줄로 이렇게 읽힙니다. 테일넷 주소에만 공개 리스너를 묶은 허브에서 Codex는 되는데, 이 PR의 설계 핵심은 베이스 URL 하나를 Everywhere에 바꿔 넣는 게 아니라, 목적지 계약을 둘로 나눈 것입니다.
새 모듈 검증 서술은 모듈별로 세 토폴로지를 고정했고, 통합 테스트는 루프백에서 두 와이어가 404/401이 아니며 라인 - 메인테이너의 판단이 필요한 지점
너의 추천 이 댓글은 grok-bot이 작성했습니다 |
8440b9d to
dc01427
Compare
07cdc54 to
5ea847d
Compare
1218e08 to
ea2b3a5
Compare
3e9aab9 to
28c2b79
Compare
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>
ea2b3a5 to
0fd205f
Compare
28c2b79 to
216032d
Compare
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 wireocx claude, Claude Desktop and thesystem-envinjection speak) andPOST /v1/chat/completions(the OpenAI chat wire Cursor Private Inference, the routed vision helper and aside/opencode speak) both returned404there.loopbackRouteAllowednow admits those two POST routes plusPOST /v1/messages/count_tokens, which completes the Anthropic wire. Both handlers already resolve admission from the receiving listener'sRequestPolicyView— the same resolver and the same loopback short-circuit/v1/responsesuses — so this adds a wire, not a trust level./api/*,/healthz,/readyzand the GUI still404: 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 withconfiginstead of the request'spolicy; 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, becauseocx synchonored the listener and nobody else did.src/lib/local-destinations.tsnow answers the two questions separately, reusing PR2'seffectiveLoopbackListenerPort,isWildcardHostnameandshouldInjectApiAuthHeaderplus the existingprobeHostname:localInferenceDestination→{ origin, port, requiresAdmissionToken }— the unauthenticated loopback listener's effective port when enabled (no credential), else127.0.0.1:<public port>on a loopback bind (no credential), elseprobeHostname(hostname):<public port>with a data-plane credential required. Used bybuildClaudeEnv,writeDesktop3pConfig,refreshGatewayModelCacheFromProxy,injectSystemEnv+ the shell env file, the Cursor gateway card,routedDescribeBaseUrl, andresolveApiAccessBaseUrl'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 loopbackhub.managementIngresswhen 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/*.fetchClaudeCodeStateis its only caller today; itsenabled: falseanswer is what makesocx claudelaunch natively, so a wrong destination there silently downgrades every launch on the machine. No exported client configuration gained a credential.Three details worth naming.
targetsLocalClaudeProxytakes 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 withbuildClaudeEnv, since Claude Code honors that file only while itsbaseUrlequalsANTHROPIC_BASE_URL. And thesystem-envtracking record gainedbindHostandclientBaseUrl: ownership on revert is proven against what was injected, while liveness probes the bind host on the public port, because the listener serves no/healthzand 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-companionand targets its head branch. Retarget todevonce 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:
localInferenceOriginhad no bind-address fallback. With the listener OFF and a non-loopbackhostname, all eight sites still gothttp://127.0.0.1:<public port>— a dead socket — andtests/lib/local-destinations.test.tspinned 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.requiresAdmissionTokenisshouldInjectApiAuthHeader, 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:buildClaudeEnvANTHROPIC_AUTH_TOKENslot it already uses forownAdmissionTokens; 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 fixesinjectSystemEnv/writeShellEnvFile{injected:false, reason}— the launchd domain is machine-wide, so a base URL that 401s every plainclaudeis worse than no injectionwriteDesktop3pConfigrefreshGatewayModelCacheFromProxy, routed vision self-fetchx-opencodex-api-key; now share one ladder to resolve itapiKeyModekeys on the destination it just resolved, so the card cannot hand an operator a URL and say no key is needed for itresolveApiAccessBaseUrlThe credential is the data-plane one —
OPENCODEX_API_AUTH_TOKEN, the hardened service token file, then a configuredapiKeysentry, the same ladderstandaloneCodexRoutingTargetand the Codex provider table use — shared aslocalAdmissionToken. 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 becamelocalLoopbackInferencePorts— the ports that actually answer on 127.0.0.1 — which is empty on a tailnet bind with no listener, so a leftoverhttp://127.0.0.1:10100from a previous loopback-bound install is correctly rewritten instead of preserved.buildNativeClaudeEnvkeeps 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:
cleanStaleSystemEnvprobed an address that does not exist. It dialed127.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 carriesbindHost(shape-validated on read; it reaches a fetch URL) andclientBaseUrlin place of the first revision'sclientPort, which shipped only on this branch. Both fields are omitted when they add nothing toport, so a plain loopback install writes a byte-identical record and older records are read unchanged.3 —
probeHostnameandapi-access.ts'sisWildcardBindHosteach knew three wildcard spellings whileisWildcardHostname(PR2) knew every all-zero form, so0.0.0.0.,::0and*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())is0when_corsOrigincarries no explicit port, soroutedDescribeBaseUrlcould composehttp://127.0.0.1:0. Guarded.5 —
POST /v1/messages/count_tokensis 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/messageson 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.tsasserted a local404for 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 refused429behind a WHAM probe with nothing reaching the upstream, while the public listener'sdedicatedadmission 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, withcount_tokensnamed in the allowlist.structure/09_client-integrations.mdrecords thatfetchClaudeCodeStatesends 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:
localInferencePortwas 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 typecheckclean;bun run privacy:scan→Privacy 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.ts→ 241 pass.bun test tests/server/reserve-ingress.test.ts→ 32 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.ts→ 122 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.ts→ 215 pass.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.ts→ 245 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.ts→ 125 pass.tests/lib/local-destinations.test.tsenumerates 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 forgetsrequiresAdmissionTokenfails there rather than in production. The integration file starts real servers: the three wires are reachable on the loopback listener and401on the public one, while/api/claude-codeand/api/configstill404on the listener with an admin token in the header. Assertions arenot 404andnot 401, because either alone stays green if a route is silently dropped from the allowlist.runtimeRole: hub,hostname: 127.0.0.1,port: 10100, listener10104, ingress10102, a scratchpad script resolvedmanagement → http://127.0.0.1:10102,inference → http://127.0.0.1:10104, andfetchClaudeCodeStatereturnedenabled: truewith 264 context windows. No config write, no restart, norepair/ensure/sync/start. The same probe confirms the restart requirement: the still-running pre-PR proxy answers404forPOST /v1/messageson10104whileGET /v1/modelsis200, so operators on the ported form must restart beforeocx claudeuses the listener — recorded for the docs unit.count_tokensis admitted and Cursor'sapiKeyModekeys on the resolved destination. What remains is PR4's: the GUI API-access panel's copy when the resolved destination demands a credential.Checklist
reference/configuration/server.md,structure/01_runtime.md,structure/09_client-integrations.md,devlog/_plan/260911_hub_single_port/030_hub_local_clients.md)./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,startServerstill synchronous.Refs #4236