Skip to content

feat(server): one-port hub — same-port loopback companion listener, honest hub-gate messages - #4250

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

feat(server): one-port hub — same-port loopback companion listener, honest hub-gate messages#4250
lidge-jun merged 8 commits into
devfrom
codex/260911-l4-hub-loopback-companion

Conversation

@lidge-jun

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

A hub that binds its tailnet address has no 127.0.0.1:<proxy port>, and most local integrations
write exactly that. The issue's table lists eight of them — ocx claude (buildClaudeEnv,
fetchClaudeCodeState), Claude Desktop, the gateway cache, system-env and its shell variant,
the Cursor route, api-access, the vision helper — against four writers that do honor
unauthenticatedLoopbackListener. That asymmetry is the "Codex works but nothing else does"
report. It took 10100 for the tailnet bind, 10104 for the loopback listener, 10102 for the
management ingress, and a hand-made forwarder to paper over the rest.

unauthenticatedLoopbackListener.port becomes optional. { "enabled": true } is the
companion form: bind 127.0.0.1:<proxy port> with the existing loopback policy view. One port,
two sockets — remote clients dial hostname:port with a credential, local processes dial
127.0.0.1:port without one. None of the eight sites is edited; they already write the right
URL, and that is the point of fixing the socket rather than the callers.

The form is legal only when hostname is a specific non-loopback, non-wildcard address. On
127.0.0.1, localhost, ::1, 0.0.0.0 or :: the public listener already holds that loopback
address, so the pair is refused with one sentence that names the collision
(127.0.0.1:<proxy port>) and both fixes: set a distinct port, or drop the listener — a loopback
bind already admits local callers. It is refused at the write boundary over both keys, so
ocx config set hostname 127.0.0.1 on a companion host is caught there rather than breaking the
next start, and again in startServer before any bind, so a hand edit that skipped validation
reads the same diagnosis instead of EADDRINUSE from a rolled-back transaction. startServer stays
synchronous and the listener transaction, its rollback and the route allowlist are untouched: this
moves a bind address, not an admission decision.

effectiveLoopbackListenerPort(config, publicPort) joins isLoopbackHostname in
src/codex/loopback-target.ts as the one answer to "where do local callers dial", so no reader
repeats ?? port. chooseListenPort deliberately keeps reading .port: only an explicitly ported
listener reserves a port, because reserving the shared one would refuse every start on a one-port
hub. The startup line distinguishes the two forms; the ported form keeps today's
unauthenticated-surface warning verbatim.

Defect 4. The Grok fence drift check now compares against the set
{listen.port} ∪ {effective loopback port}. ocx sync writes the listener's port into the fence,
so comparing against the public port alone told every hub operator that their freshly synced,
working config pointed at a closed port — and to run the command that had just written it.

Follow-up 2 — the hub gate stops lying. localClientSyncAllowed refusing to rewrite a hub's
own clients is the right decision; reporting it as the user's toggle was not. A distinct
"hub-gated" reason now carries one sentence — "This machine is a hub; it does not rewrite its
own Codex/Grok/Claude configs unless unauthenticatedLoopbackListener is enabled."
— along the
existing linearized channel (CodexWriteLockSkipReasoncodexInjectLockOutcome
CodexInjectResultCodexSyncResult), so the reason cannot disagree with the write.

  • ocx restore back committed the toggle ON, got a gated skip, and told the operator to "retry
    after the competing integration change finishes"
    . There is no competing writer. It now names
    the gate, in both the human and --json paths.
  • ocx ensure stripped the managed Grok block as if Grok had been switched off. Only an
    explicit clientIntegrations.grok === false authorizes that strip now; a gated hub is told why
    nothing was written and ~/.grok/config.toml is left exactly as it is.
  • ocx sync, ocx sync-cache and the three "startup left Codex native" lines say the same honest
    thing. An explicit OFF keeps its existing wording byte for byte.

The first commit is a separate hub defect found on the way: ocx gui derived its URL from the
proxy bind, so a tailnet-bound hub opened a browser at the tailnet origin while the hub's own
loopback management ingress sat unused.

Deliberately not done. loopbackRouteAllowed is unchanged, so on a companion hub
http://127.0.0.1:<port>/v1/messages and /api/* still return 404 — a companion is a bind-address
change, never an admission change (per the review note on #4236: do not add /api/* to the
unauthenticated listener). A test pins that. The consequence is recorded rather than hidden:
ocx claude and fetchClaudeCodeState reach a live socket on such a hub but not a wire that
serves them, and closing that needs the two destination contracts the reviewer described
(authenticated local management discovery vs. per-wire inference), which is its own change.

Only the English reference/configuration/server.md paragraph is corrected here — it claimed the
port was required, which is now false. The remote-hub guide, the translated copies and
skills/ocx are PR4 of this stack.

Stacked on the macOS launchd repair PR and targets its head branch; retarget to dev once that
lands. Devlog: devlog/_plan/260911_hub_single_port/020_loopback_companion.md.

Verification

  • bun run typecheck — clean. bun run privacy:scan — passed (the startup log line changed).
  • bun test tests/server/loopback-listener-admission.test.ts tests/server/loopback-companion-client-targets.test.ts tests/server/server-loopback-host-gate.test.ts — 44 pass.
  • bun test tests/server/loopback-listener-integration.test.ts — 34 pass. A real companion start
    proves one port answers twice (200 with no credential on 127.0.0.1, 401 on the tailnet address),
    that the startup line says "companion" rather than the ported warning, that /api/*, /healthz,
    / and POST /v1/messages still 404 on that socket, and that an impossible companion throws
    before the public listener opens, leaving the port bindable. The same-port case needs a
    non-loopback IPv4 interface to tell the two sockets apart and warns rather than passing silently
    without one, matching the existing bind-scope case in that file.
  • bun test tests/cli/hub-gated-local-clients.test.ts tests/cli/cli-dispatch.test.ts — 50 pass.
    New file holds restore back to the gate's sentence instead of the phantom-conflict text
    (human and --json), holds ocx ensure to leaving a Grok block it did not own the decision to
    delete while still stripping on an explicit OFF, and pins the skip reason/message every other
    caller prints.
  • bun test tests/providers/xai/grok-status.test.ts tests/providers/xai/grok-sync.test.ts tests/providers/xai/grok-lifecycle.test.ts tests/codex-integration/codex-desired-state.test.ts tests/codex-integration/codex-inject.test.ts tests/codex-integration/codex-sync-api.test.ts tests/cli/ensure-desired-integrations-race.test.ts — 149 pass.
  • bun test tests/config/config-user-edits.test.ts tests/config/config-load-degrade.test.ts tests/cli/cli-json-contract.test.ts tests/cli/cli-restore-back.test.ts tests/clients/integrations-writer.test.ts tests/clients/sync-client-integrations.test.ts tests/server/startup-prompt.test.ts — 177 pass.
  • bun test tests/cli/cli-transport-honesty.test.ts tests/cli/cli-status-json.test.ts tests/cli/cli-config-command.test.ts tests/cli/cli-start-journal-order.test.ts tests/cli/cli-capabilities.test.ts tests/cli/cli-help.test.ts — 108 pass.
  • bun test tests/codex-integration/codex-write-lock.test.ts tests/codex-integration/codex-inject-write-lock.test.ts tests/codex-integration/codex-composed-acceptance.test.ts tests/codex-integration/codex-history-lock.test.ts tests/lab/lab-activation.test.ts tests/lab/core-lab-boundary.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts — 98 pass.
    core-lab-boundary is the guard that startServer stays synchronous and that nothing awaits in
    its activation window.
  • bun test tests/update/update-stop-first.test.ts — 23 pass (the other source-oracle harness that
    evaluates a CLI body).
  • Both new test files are registered in scripts/test-layout/layout.json and
    tests/fixtures/test-layout-expected.json. One pre-existing harness needed a line:
    tests/clients/sync-client-integrations.test.ts transpiles the real handleEnsure and injects
    every free identifier, so the new startup-line helper had to join that map.
  • No repository-wide suite, by operator instruction. Hosted CI on the exact pushed head is the
    proof.
  • No GUI change: grep -rni loopback gui/src finds only unrelated copy, so this field is not
    rendered and lint:gui was not required.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Refs #4236 — this PR closes follow-ups 1 and 2 and defect 4. Defects 1 and 2 (the macOS launchd
repair outage) are the parent PR in this stack, so the issue stays open until that lands.

@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: 3b53b5fc-ce86-4997-b44b-606a4a93958b

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.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 71 / 80

이 PR는 지금 dev(HEAD babb76449, #4240 client-catalog 직후) 위에서 돌아가는 허브 한 포트 스택의 2번 칸이다. 베이스는 dev가 아니라 부모 PR #4249의 브랜치 codex/260911-l4-launchd-repair이고, 그 위에 #4251(로컬 클라 두 목적지), #4252(허브 토큰 UX)가 또 쌓여 있다. 이슈 #4236이 적어 둔 표 — 허브가 테일넷 주소에만 바인드하면 127.0.0.1:<proxy port>를 쓰는 로컬 통합 여덟 곳이 죽고, Codex만 산다 — 를 호출부를 고치지 않고 소켓 쪽을 고쳐서 푸는 단위다.

핵심은 unauthenticatedLoopbackListener.port를 선택으로 만든 것이다. { "enabled": true }만 쓰면 companion 형식: 공개 리스너가 테일넷/LAN IP에 붙어 있을 때 127.0.0.1:<proxy port>에 같은 포트로 한 번 더 듣는다. 원격은 hostname:port+자격증명, 로컬은 127.0.0.1:port+무자격. effectiveLoopbackListenerPortsrc/codex/loopback-target.ts에 들어가서 standaloneCodexRoutingTarget·Grok fence drift·서버 리스너가 같은 답을 쓰고, chooseListenPort만 일부러 .port를 직독한다(companion을 예약 포트로 치면 one-port 허브가 매번 ephemeral로 튄다). 루프백/와일드카드/::0 계열에 companion을 올리면 쓰기 경계와 startServer 바인드 전에 같은 문장으로 거절한다. Defect 4(Grok fence가 listener 포트를 drift로 오인)와 follow-up 2(허브 게이트를 "토글 OFF/경쟁 writer"로 거짓말하던 ocx restore back·ocx ensure의 Grok strip)도 같이 닫는다. ocx gui는 허브에서 management ingress 루프백을 연다. 라우트 allowlist는 일부러 안 넓혔고(/v1/messages·/api/*는 companion에서도 404) — 그건 #4251 몫이라고 본문·devlog가 분명히 적었다. 영어 server.md만 고쳤고 로케일·remote-hub·skills는 스택 PR4다. 로컬 전수 스위트는 운영자 지시에 따라 안 돌렸고, 호스티드 CI가 증거다.

라인 src/types/config.ts · src/config.ts - port? companion 스키마는 types/config 스플릿 캠페인과 겹치는 파일이지만, 이번엔 "스플릿에 깔리는 잔여 PR"이 아니라 #4236 허브 한 포트의 정식 스키마 확장이다. 닫지 말고 이 스택으로 가져가라.

라인 베이스 codex/260911-l4-launchd-repair - 지금 상태로는 dev에 직접 머지할 칸이 아니다. #4249가 dev에 먼저 들어간 뒤 이 PR를 dev로 리타깃(또는 스택 랜딩 PR)해야 한다. 아래 #4251/#4252도 같은 기차다.

라인 src/cli/dispatch.ts selectDefaultGuiUrl - ingress가 enabled면 곧바로 http://localhost:${ingress.port}다. live probe/헬스 없이 config 포트만 연다. ingress가 안 떠 있거나 포트가 바뀐 직후면 브라우저가 빈 페이지로 간다. 의도된 단순화면 괜찮지만, "허브인데 ingress가 죽은 경우" 한 줄 fallback(공개 바인드/에러)이 있는편이 운영자 친화적이다.

라인 src/config.ts 주석 assertLoopbackListenerBindable - 실제 export/호출은 loopbackCompanionBindError다. 주석 이름만 남은 드리프트. 나중에 검색하는 사람 헷갈린다.

라인 src/codex/loopback-target.ts isWildcardHostname - ::/::0/0.0.0.0 철자는 잘 막았다. ::ffff:0.0.0.0 같은 IPv4-mapped unspecified는 아직 companion-allowed로 남을 수 있다. 실사용 허브 호스트네임은 드물지만, admission 테스트에 한 케이스 더 넣으면 표가 닫힌다.

라인 src/server/index.ts companion 로그 - 포트형은 네 줄 console.warn 보안 경고를 유지하고, companion은 한 줄 console.log로 부드럽게 바꿨다. one-port 허브가 의도 토폴로지라는 점은 맞다. 다만 companion도 무자격 루프백 데이터면이라는 사실은 같다. warn 한 줄이라도 남길지, 아니면 status/docs에만 둘지 메인테이너가 한 번 고르면 된다.

라인 loopbackRouteAllowed 유지(의도) - companion으로 소켓은 살아도 ocx claude/fetchClaudeCodeState의 Anthropic 와이어와 /api/*는 여전히 404다. PR이 숨기지 않고 #4251로 넘긴 건 옳다. 다만 리뷰/릴리즈 노트에 "PR2만 랜딩해도 Claude Desktop·claude CLI는 아직 안 산다"를 한 문장 더 박아 두면 기여자 기대가 안 빗나간다.

라인 검증 - 타깃 테스트 숫자는 본문에 잘 적혀 있다. 호스티드 CI는 이 리뷰 시점에 아직 pending이 많다. 머지 전에 gates/test 샤드 green을 보고, 특히 companion 통합 테스트가 비루프백 IPv4 인터페이스 없으면 warn-and-skip 하는 경로가 CI 러너에서 실제로 돌았는지 확인하라.

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

너의 추천
호스티드 CI green을 기다리고, #4249가 dev에 머지된 뒤 이 PR를 dev로 리타깃한 다음 approve·merge 하라. companion allowlist를 여기서 억지로 넓히지 말고 #4251에 맡기고, 머지 코멘트에 "소켓은 살아도 Claude/Anthropic·/api#4251 전"을 한 줄 남겨라. types/config 변경은 스플릿 잔여로 닫지 않는다.

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

lidge-jun and others added 8 commits September 11, 2026 12:43
…ailnet-bound proxy origin

`ocx gui` derived the URL from the proxy bind, so a hub whose `hostname` is a
Tailscale address opened a browser at that tailnet origin — a page the local
browser cannot authenticate against the management plane, while the hub's own
loopback management ingress was sitting there unused. Prefer the ingress when
`runtimeRole: "hub"` has it enabled; every other topology keeps the previous
URL derivation exactly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`unauthenticatedLoopbackListener.port` becomes optional. `{ "enabled": true }` with no port is
the companion form: bind `127.0.0.1:<proxy port>` with the existing loopback policy view. That
is the one-port hub topology — remote clients dial `hostname:port` with a credential, local
processes dial `127.0.0.1:port` without one — and it is what lets the integrations that hardcode
`http://127.0.0.1:<proxy port>` keep working on a tailnet-bound hub whose public address they
cannot reach (#4236).

The form is legal only when `hostname` is a specific non-loopback, non-wildcard address; on
`127.0.0.1`/`localhost`/`0.0.0.0`/`::` the public listener already holds that loopback address.
`loopbackCompanionBindError` is the one sentence for that collision, naming the port and both
fixes (set a distinct `port`, or drop the listener because a loopback bind already admits local
callers). It runs at the write boundary over both `hostname` and the listener — so `ocx config
set hostname 127.0.0.1` on a companion host is refused there rather than breaking the next
start — and again in `startServer` before any bind, so a hand edit that skipped validation reads
the same diagnosis instead of EADDRINUSE from a rolled-back transaction.

`effectiveLoopbackListenerPort` joins `isLoopbackHostname` as the single answer to "where do
local callers dial", so no reader repeats `?? port`. The startup line distinguishes the two
forms: the companion states where local processes go, the ported form keeps today's
unauthenticated-surface warning verbatim.

The listener transaction, its rollback, the route allowlist and `startServer`'s synchronous
window are unchanged: this moves a bind address, not an admission decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e instead of the toggle

Three consequences of the companion form, all in the readers (#4236):

1. `standaloneCodexRoutingTarget` resolves the listener through
   `effectiveLoopbackListenerPort`, so an omitted port means the public port. Every writer that
   already goes through it — the Codex provider block, `opencodeProxyBaseUrl`, `syncGrokConfig`,
   the integrations exporter — lands on `127.0.0.1:<proxy port>` with no admission header, which
   is the same origin the hardcoded local integrations write. `chooseListenPort` deliberately
   keeps reading `.port` directly: only an explicitly ported listener reserves a port, because
   reserving the shared one would refuse every start on a one-port hub.

2. The Grok fence drift check compares against a SET — `{listen.port} ∪ {effective loopback
   port}`. `ocx sync` writes the listener's port into the fence, so comparing against the public
   port alone told every hub operator that their freshly synced, working config pointed at a
   closed port, and to run the command that had just written it (issue defect 4). A third port
   still warns, still against the public listener.

3. The hub-role gate gets its own skip reason (`"hub-gated"`) and its own sentence: "This machine
   is a hub; it does not rewrite its own Codex/Grok/Claude configs unless
   unauthenticatedLoopbackListener is enabled." It travels the existing linearized channel
   (`CodexWriteLockSkipReason` → `codexInjectLockOutcome` → `CodexInjectResult` →
   `CodexSyncResult`) so the reason cannot disagree with the write.

   `ocx restore back` is the worst of the old reports: it committed the toggle ON, got a gated
   skip, and told the operator to "retry after the competing integration change finishes" — there
   is no competing writer. `ocx ensure` was the most destructive: it stripped the managed Grok
   block as if Grok had been switched off. Only an explicit `clientIntegrations.grok === false`
   authorizes that strip now; a gated hub is told why nothing was written and
   `~/.grok/config.toml` is left exactly as it is. `ocx sync`, `ocx sync-cache` and the three
   "startup left Codex native" lines say the same honest thing, and an explicit OFF keeps its
   existing wording byte for byte.

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

Config boundary: the port-less form is accepted on a specific non-loopback bind and the absent
port SURVIVES the parse (a schema that helpfully filled it in would make the pair look like the
#1102 collision on the next write); it is refused on loopback, localhost, ::1 and the wildcards,
with a message that names `127.0.0.1:<configured port>` and both fixes; and reverting `hostname`
to loopback is refused by that same check, which is the `ocx config set hostname` path.

Listener: a real companion start proves one port answers twice — 200 without a credential on
127.0.0.1, 401 on the tailnet address — and that the startup line says "companion" rather than
the ported form's warning. A second case pins what did NOT change: `/api/*`, `/healthz`, `/` and
`POST /v1/messages` still 404 on that socket, so sharing a port widens no surface. A third proves
an impossible companion throws before the public listener opens, leaving the port bindable.

Clients: `tests/server/loopback-companion-client-targets.test.ts` is the claim the whole PR rests
on — `standaloneCodexRoutingTarget` and the untouched `buildClaudeEnv` resolve to the SAME origin
on a companion hub, with no admission header — and keeps the ported form's split as a regression
witness for the issue's table.

Hub gate: `tests/cli/hub-gated-local-clients.test.ts` holds `restore back` to the gate's sentence
instead of the phantom-conflict text (human and `--json`), holds `ocx ensure` to leaving a Grok
block it did not own the decision to delete while still stripping on an explicit OFF, and pins
the skip reason/message that every other caller prints.

Both new files are registered in `scripts/test-layout/layout.json` and
`tests/fixtures/test-layout-expected.json`.

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

`already-running ensure leaves Raycast untouched…` transpiles the real `handleEnsure` body and
evaluates it with every free identifier injected. Reporting a skipped sync now goes through
`startupLeftCodexNativeLine` — the hub gate and the Codex toggle must not print the same
sentence — so the harness has to supply that name too, or the case fails with a ReferenceError
that says nothing about Raycast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What shipped, the decisions behind it — including the one this PR deliberately did NOT make,
widening the loopback route allowlist — and the exact verification commands with their counts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e every wildcard spelling

`localClientSkipReason` named the hub gate even when `clientIntegrations.codex` was false,
pointing that operator at a listener that would not make the sync happen. It is now the
conjunction the Grok path already used (toggle on AND gate closed), per client id.

`isWildcardHostname` accepted `::0`, `[::0]`, `0::`, `0:0:0:0:0:0:0:0`, bare `0` and padded
IPv4 zeros as specific addresses, so a port-less companion on those binds passed validation and
then rolled back with EADDRINUSE. Every all-zero spelling is refused up front now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The oracle pinned the pre-#4236 line; the hub-aware selector keeps the bind-host branch,
so pin both the call and the retained branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@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-launchd-repair to dev September 11, 2026 04:07
@lidge-jun
lidge-jun changed the base branch from codex/260911-l4-launchd-repair to dev September 11, 2026 04:07
@lidge-jun
lidge-jun merged commit 24ef1ec into dev Sep 11, 2026
35 of 37 checks passed
@lidge-jun
lidge-jun deleted the codex/260911-l4-hub-loopback-companion branch September 11, 2026 04:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant