Skip to content

feat(client): a connected client reports the hub's state, not its own - #4255

Merged
lidge-jun merged 10 commits into
devfrom
codex/260911-l4-client-hub-state
Sep 11, 2026
Merged

feat(client): a connected client reports the hub's state, not its own#4255
lidge-jun merged 10 commits into
devfrom
codex/260911-l4-client-hub-state

Conversation

@lidge-jun

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

Copy link
Copy Markdown
Owner

Stack (hub single-port, #4236): 1 #4249 → 2 #4250 → 3 #4251 → 4 #4252 → 6 this PR (sibling of the docs PR 5, both on #4252's branch). Retarget to dev as the stack lands. Local suite deliberately not run (operator instruction); hosted CI on the pushed head is the proof.

Summary

An agent running on a connected client machine read that machine's local ~/.opencodex/config.json and ocx status, saw xai ✗ not logged in, no grok provider and only five delegable models, and concluded the hub could not serve grok — while the hub has xAI logged in and serves grok.

Nothing malfunctioned. Every number it read was correct about the client, and a client stores no provider credentials and no featured roster by design. The defect is attribution: three surfaces reported local facts in the voice of the system, and a locally sourced report is byte-indistinguishable from a hub-sourced one.

The hub now has something to ask. GET|HEAD /v1/hub-state is a least-privilege data-plane read in the /v1/catalog (#809) tradition: the same resolveApiAuth admission set and the same origin check, because nothing here forwards a caller credential upstream; no query parameters, Cache-Control: no-store, no validator, a fixed bounded body. It answers with booleans and model ids — hasCredential is the !!p.apiKey presence projection GET /api/providers already ships, loggedIn is oauthLoginSummary's boolean with the email and account id dropped rather than masked — plus the hub's subagentModels roster, its version and runtimeRole. buildHubState builds every row field by field and never spreads a provider or a login record, which is what makes "no keys, no emails, no account ids" checkable by reading one function; a spread would silently begin exporting whatever field is added to those records next. The route 404s with its own hub_state_not_a_hub code unless runtimeRole === "hub", so a standalone install gains no surface at all, and that gate runs after admission on purpose — answering an anonymous caller would turn the route into a free "is that host a hub?" probe. A provider the operator marked disabled is not exported at all, so the disclosure delta over /v1/catalog and /v1/models is exactly: hasCredential, loggedIn, authMode, the roster, and the name and adapter of an enabled provider the catalog omits for want of a usable credential — which is the point of the route. truncated reports a cap being hit rather than serving a prefix silently. It is deliberately absent from loopbackRouteAllowed (pinned by a test on a real listener, see the review round), and per the reviewer note on #4236 nothing was added to /api/* and no admin credential goes anywhere near a client.

ocx status on a connected client reports the hub and labels what is local. The report leads with State from hub <origin>: provider credentials, logins and delegable models below are the HUB's, not this machine's., prints the hub's OAuth logins, providers and delegable models above the local block, labels that block Local-only (not used for routing while connected), and tags the lines that really describe this machine — proxy, health, dashboard, config, PID file, runtime, service, shim, Codex runtime/version/source/home — (local). The tag appears only while connected: on a standalone install every line is local and tagging them all would train the reader to skip the tag. Providers carry authMode, which is what stops "no API key" from reading as "not configured" for an OAuth provider — the precise inference that went wrong.

Failure is reported, never substituted. stateSource is three-valued: hub (live), cache (the last good read from this connection, still the hub's state), unavailable (nothing true is known). An older hub that 404s the route produces ⚠️ Hub <origin>: state unavailable (this hub is too old to report its state; upgrade the hub) — provider and login lines below are LOCAL and do not describe the hub. Every other failure — 401, unreachable, non-JSON, malformed, foreign schema, oversized — lands the same way. A two-valued ok/failed flag would have invited the same silent local fallback at the next call site. The last good response is cached 0600 at <OPENCODEX_HOME>/hub-state.json, stamped with the (serverUrl, apiKeyId, connectedAt) triple and compared with sameClientConnectionOwner: a stale cache is still the hub's state, while an unstamped one would be a different hub's after a disconnect and reconnect, which is not staleness but a lie.

The spawn surface. cmdClaude gated the roster writer on typeof route === "number", false on a connected client (route is a ClaudeRoutingTarget), so ~/.claude/agents/ocx-*.md stayed whatever a previous standalone run had left — and when it did run it built from local config.subagentModels. That is the operator's "only 5 delegable models". The gate is gone; buildClaudeAgentDefs and injectClaudeAgentDefs take an explicit roster argument defaulting to today's behaviour (including "unset means the defaults, an explicit [] means none"), and on a client the hub's roster is passed in. The five-row cap stays — it is a Claude Code picker constraint, not the bug; sourcing the five from the wrong machine was, and the hub can now change which five without touching the client. An unreadable hub falls back to the local list and prints a warning, because an unannounced fallback is exactly how this stayed invisible. entryParts already kept the raw id when a provider is absent locally, which is what lets xai/grok-4.6 yield ocx-grok-4-6.md on a credential-less client instead of throwing and aborting the sync for every other model too; that was latent and untested and now has a test. The generated-by: opencodex ownership marker is untouched. syncClaudeAgentDefsAtProxyStartup uses the same roster from the cache only — startup makes no hub round trip, so an offline hub cannot stand between an operator and a local proxy start.

Behaviour change worth naming: the first ocx claude after this lands rewrites a previously frozen roster on a client.

ocx config show puts a synthetic _remoteHub note first on a client — { connected, origin, note: "provider credentials and model availability live on the hub; run ocx status" } — because it has to be read before the empty providers map, not after it. connected is observed from collectClientConnectionStatus() (a settled connection record and the token file whose fingerprint matches it), never inferred from the presence of a client block; when either half fails the note names what is wrong instead. client.priorCatalog, a base64 catalog snapshot up to 64 MB, prints as <omitted: N bytes>, mirroring sanitizeModelCostsForDisplay. Both are display-only: config export emits the real config untouched so round trips still validate, and a persisted client.note was rejected because clientConnectionSchema is .strict() and persisted prose drifts.

--json gains runtimeRole and an always-present remoteHub block. schemaVersion stays 1 (additive, the same rule as versionSkew), and connection is untouched: it describes the link, remoteHub describes what is on the other end of it.

Design and decision record: devlog/_plan/260911_hub_single_port/060_client_hub_state.md. The en remote-hub guide gains a "What a connected client shows" section; the ko copy is named as undone there, with three other follow-ups (GET /api/machine/hub-state for the client's own dashboard, a configured boolean to distinguish a never-configured OAuth provider from a logged-out one, and ocx doctor, which still reports local state on a client).

Verification

bun x tsc --noEmit                                                      # clean
bun run privacy:scan                                                    # Privacy scan passed
bun test tests/server/v1-hub-state.test.ts                              #  8 pass 0 fail
bun test tests/server/api-key-attribution.test.ts                       # 25 pass 0 fail
bun test tests/clients/client-hub-state.test.ts                         # 20 pass 0 fail
bun test tests/cli/cli-status-hub-state.test.ts                         # 12 pass 0 fail
bun test tests/cli/cli-status-json.test.ts                              # 53 pass 0 fail
bun test tests/cli/cli-config-show-client.test.ts                       #  6 pass 0 fail
bun test tests/cli/cli-config-command.test.ts                           #  2 pass 0 fail
bun test tests/cli/cli-transport-honesty.test.ts                        # 22 pass 0 fail
bun test tests/claude-integration/claude-agents-inject-client.test.ts   # 14 pass 0 fail
bun test tests/claude-integration/claude-agents-inject.test.ts          # 20 pass 0 fail
bun test tests/claude-integration/claude-agent-startup-sync.test.ts     #  9 pass 0 fail
bun test tests/claude-integration/claude-cli.test.ts                    # 51 pass 0 fail
bun test tests/server/management-route-registry.test.ts                 # 13 pass 0 fail
bun test tests/ci-workflows/docs-remote-hub-claims.test.ts              #  7 pass 0 fail
bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts    # 17 pass 0 fail
  • bun test tests/server/server-auth.test.ts is 111 pass / 1 fail on this branch and, re-run at the base commit 8c277294c, 111 pass / 1 fail there toonative passthrough upstream reset still logs 502 and penalizes the pool, the same pre-existing failure PR4's devlog recorded. Not a regression.
  • No repository-wide suite, by operator instruction. Hosted CI on the pushed head is the proof.
  • Four new test files, registered in scripts/test-layout/layout.json (explicit) and tests/fixtures/test-layout-expected.json. The one that cannot be satisfied by reading the projection is tests/server/v1-hub-state.test.ts: it configures a real-looking provider key and a real-looking OAuth credential (access token, refresh token, email) and asserts none of those bytes, and none of the field names that carry them, appear in the serialized response. It also pins 401-before-the-role-is-disclosed, the cross-origin 403, HEAD, the role gate's own 404 on standalone and on an absent role, and the content-length/size ceiling. The admission-matrix proof — that this 404 is the role gate and not a vanished route, so no accepted cell passes vacuously — is driven against a real request in tests/server/api-key-attribution.test.ts, which asserts the hub_state_not_a_hub code; that is where the standalone 404 is pinned as admission evidence.
  • tests/clients/client-hub-state.test.ts enumerates every failure mode of the fetch and asserts each lands on cache or unavailable with a reason — the invariant being defended is that none of them reaches back into local state. It also covers owner mismatch, a rotated apiKeyId, and malformed and symlinked cache files.
  • tests/cli/cli-status-hub-state.test.ts spawns ocx status three times: against a local fake hub (which refuses any request not carrying the client's own data key, so the credential path is proven), against an unreachable hub, and on a standalone machine whose output gains no banner and no (local).
  • This machine is a live OpenCodex hub. ocx service …, ocx start/stop/ensure/sync/restore/connect/disconnect and launchctl were not run, and the real ~/.opencodex, ~/.codex, ~/.claude/agents and ~/Library/LaunchAgents were not touched. Every test points OPENCODEX_HOME at a mkdtemp directory, and tests/preload.ts arms OCX_TEST_HOME_GUARD=1 for every invocation including a bare bun test <file>.

Review round

Seven findings, all accepted. The two that mattered are the same mistake this PR is about,
committed by this PR: a boundary comment claiming less disclosure than the code performed, and a
connected: true inferred from configuration rather than observed.

  1. /v1/hub-state exported every provider row, disabled: true included (should-fix) — while /v1/catalog and /v1/models both filter a disabled provider out, making this the only data-plane surface that named one. Three comments and the devlog claimed the delta was "only the two booleans". buildHubState now drops a disabled provider entirely (a client cannot route to it, and authMode already explains a keyless row), and all four places state the delta exactly. HubStateProvider.disabled stays in the contract, always false from a hub of this version: an older hub still sends true, and a client reading one must label that row rather than present it as routable.
  2. hub_state_content_type_invalid and hub_state_http_<status> printed as bare codes (should-fix) — the banner could read state unavailable (hub_state_http_507), which sends an operator hunting for a client bug when the hub has answered (507 is the hub's own hub_state_too_large). Both render as sentences now: "the hub's state response was not JSON" and "the hub answered HTTP N to the state request", with the numeric suffix validated so a non-numeric code still falls back to the code.
  3. _remoteHub.connected was hardcoded true (should-fix) — including on a machine whose key was revoked at the hub, rotated away, or whose token file was deleted. Derived from collectClientConnectionStatus() now, requiring both the connection record and the matching token file; the status arrives as a thunk so a standalone or hub install returns before the probe, and ./connect is imported lazily so ocx config get/set does not drag the client lifecycle in.
  4. .slice(0, MAX_HUB_STATE_PROVIDERS) truncated silently (nit) — a hub with 240 providers served 200 and said nothing, so the client told its reader the other 40 do not exist. truncated: boolean is now in the contract and the parser (absent reads as false, for an older hub; a present non-boolean is still refused), and ocx status appends "(the hub truncated this state to fit its response caps; some rows are not listed)".
  5. The hub-state cache outlived the connection (nit) — disconnectClient now unlinks <OPENCODEX_HOME>/hub-state.json, best effort, after the connection is cleared.
  6. The loopback 404 was asserted nowhere (nit) — a real hub with the unauthenticated loopback listener bound is now asked for GET /v1/hub-state with no credential and must answer 404 with code not_found (the listener's refusal), not hub_state_not_a_hub (which would mean the handler was reached); the same run then reads the route successfully on the public listener with a data key, so a deleted route cannot pass vacuously. The verification bullet above is also corrected: the standalone 404 is pinned as admission evidence in api-key-attribution.test.ts, not v1-hub-state.test.ts.
  7. structure/01_runtime.md (nit) — "Remote Hub hardening ownership" now names src/remote/hub-state.ts and src/client/hub-state.ts, including the rule that a failed read reports "unavailable" rather than degrading to local state.

Verification (review round)

bun run typecheck                                           # clean
bun run privacy:scan                                        # Privacy scan passed
bun test tests/server/v1-hub-state.test.ts                  #  9 pass 0 fail (was 8)
bun test tests/clients/client-hub-state.test.ts             # 26 pass 0 fail (was 20)
bun test tests/cli/cli-config-show-client.test.ts           #  9 pass 0 fail (was 6)
bun test tests/cli/cli-status-hub-state.test.ts             # 13 pass 0 fail (was 12)
bun test tests/cli/cli-status-json.test.ts                  # 54 pass 0 fail
bun test tests/server/api-key-attribution.test.ts           # 25 pass 0 fail
bun test tests/server/loopback-listener-admission.test.ts   # 31 pass 0 fail
bun test tests/server/loopback-listener-integration.test.ts # 36 pass 0 fail (was 35)
bun test tests/clients/client-connect.test.ts               # 49 pass 0 fail (cache-removal assertion)
bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts   # 17 pass 0 fail

No new test files, so layout.json and tests/fixtures/test-layout-expected.json are unchanged. No repository-wide suite (operator instruction); hosted CI on the pushed head is the proof. No ocx service …, ocx start/stop/ensure/sync/connect/disconnect/status or launchctl was run on this live hub, and every test kept OPENCODEX_HOME in a mkdtemp directory.

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

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 11, 2026 03:18
@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: b4025577-6fdc-4ee4-b327-f05f3d62bd2c

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 enhancement New feature or request label Sep 11, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 76 / 80

이 PR은 #4236 허브 단일 포트 스택의 6번째 조각입니다. 형제인 5번 docs(#4254)와 같은 베이스(codex/260911-l4-hub-token-ux = #4252) 위에 올라 있습니다. 지금 CURRENT dev HEAD는 babb76449(#4240 L4 client-catalog, 패키지 2.51.0)이고, 이 PR의 베이스는 dev가 아닙니다. 스택 순서 1 #4249(repair) → 2 #4250(companion) → 3 #4251(local-clients) → 4 #4252(token/invite) → 5 #4254(docs, 형제) → 6 이 PR 입니다. 아래가 안착·리타깃되기 전에는 dev에 바로 못 붙습니다.

고치는 사고는 본문이 한 줄로 말합니다. 연결된 클라이언트 머신에서 에이전트가 로컬 ~/.opencodex/config.jsonocx status만 보고 xai ✗ not logged in, grok 없음, 위임 모델 다섯 개만 보고 허브가 grok을 못 준다고 결론 냈습니다. 허브에는 xAI가 로그인되어 있고 grok을 실제로 서빙 중이었습니다. 숫자 하나하나가 클라이언트에 대해서는 맞았고, 클라이언트는 원래 자격 증명·로스터를 안 갖습니다. 결함은 출처 표시입니다. 로컬 사실이 시스템 목소리로 나왔고, 허브에서 온 보고서와 바이트가 구분이 안 됐습니다.

지금 devsrc/cli/status.ts collectStatus는 로컬 진단·프로브만 보고, OAuth/프로바이더 줄은 이 머신의 빈 저장소를 그대로 찍습니다. src/cli/claude.tscmdClaudetypeof route === "number"일 때만 에이전트 정의를 다시 써서, 연결된 클라이언트(routeClaudeRoutingTarget)에서는 ~/.claude/agents/ocx-*.md가 예전 단독 실행 때 남은 스냅샷으로 굳습니다. ocx config showruntimeRole: "client"와 빈 providers를 라벨 없이 보여 줍니다. 이 세 표면이 같은 착각을 만듭니다.

이 PR이 넣는 답은 세 층입니다.

  1. 데이터 플레인 GET|HEAD /v1/hub-state. src/remote/hub-state.ts(계약·캡·파서), src/server/hub-state.ts(buildHubState, 순수), src/server/index.ts에서 /v1/catalog 바로 뒤, AUTH_MATRIX 한 줄. 입장은 resolveApiAuth + isAllowedRequestOrigin으로 [Feature]: add least-privilege GET /v1/catalog for remote Codex clients #809 카탈로그와 같고, 쿼리 없음, Cache-Control: no-store, 검증자 없음. 본문은 boolean과 모델 id뿐입니다. hasCredential은 이미 GET /api/providers가 쓰는 !!p.apiKey 투영이고, loggedIn은 이메일·계정 id를 마스킹이 아니라 삭제합니다. buildHubState는 프로바이더/로그인 레코드를 spread하지 않고 필드마다 집어 넣어서, 나중에 그 레코드에 필드가 생겨도 이 경로로 새지 않게 합니다. runtimeRole !== "hub"hub_state_not_a_hub 404인데, 역할 게이트는 입장 뒤에 둡니다. 익명 호출에 답하면 “저 호스트가 허브냐” 무료 프로브가 됩니다. loopbackRouteAllowed에는 넣지 않았고, /api/*도 안 넓혔습니다. macOS: ocx service repair unconditionally evicts the running LaunchAgent, accepts a no-op launchctl load as success, and leaves the hub down with no log line #4236 리뷰 노트와 맞습니다.

  2. 클라이언트 fetchHubState / resolveHubState + 0600 캐시. stateSourcehub / cache / unavailable 세 값입니다. 실패할 때마다 로컬 config로 돌아가지 않습니다. 그게 이번 사고의 핵심입니다. 캐시는 <OPENCODEX_HOME>/hub-state.jsonatomicWriteFile(0600)로 쓰고, (serverUrl, apiKeyId, connectedAt) 스탬프를 sameClientConnectionOwner로 맞춥니다. 심링크·과대 파일은 거절합니다. 오래된 허브의 404는 hub_state_unsupported → “허브를 업그레이드하라” 문장으로 풀립니다.

  3. 표시·스폰. ocx status는 맨 위에 State from hub … 배너를 두고 허브 OAuth/프로바이더/위임 모델을 그 위에 찍고, 로컬 블록은 Local-only (not used for routing while connected)로 묶으며 프록시·서비스·Codex 줄에만 (local)을 붙입니다(단독 설치에서는 태그 없음 — 전부 로컬이라 태그를 달면 사람들이 태그를 무시하게 됨). --jsonruntimeRole과 항상 있는 remoteHub 블록을 추가하고 schemaVersion은 1(가산)로 둡니다. connection은 링크, remoteHub는 맞은편입니다. cmdClaude의 number 게이트는 제거하고, buildClaudeAgentDefs/injectClaudeAgentDefsrosterOverride를 넣습니다. 허브를 못 읽으면 로컬로 떨어지되 경고를 찍습니다. 다섯 줄 상한은 Claude Code 피커 제약으로 남기고, 잘못된 머신에서 다섯을 고르던 것만 고칩니다. syncClaudeAgentDefsAtProxyStartup은 캐시만 봅니다(시작 경로에 허브 RTT를 넣지 않음). ocx config show는 클라이언트에서 _remoteHub 노트를 맨 앞에 두고, client.priorCatalog<omitted: N bytes>로 줄입니다. export는 그대로라 왕복 검증이 깨지지 않습니다.

설계 메모는 devlog/_plan/260911_hub_single_port/060_client_hub_state.md에 있고, en guides/remote-hub.md에 “What a connected client shows” 절이 추가됩니다. 테스트는 v1-hub-state(비밀 스캔 포함)·client-hub-state·cli-status-hub-state·claude-agents-inject-client 등이 새로 등록됐고, 레이아웃 JSON도 맞춰 두었습니다. 로컬 전체 스위트는 운영자 지시로 생략, 호스티드 CI가 증거입니다. 이 리뷰 시각 hygiene/label/changes/react-doctor 등은 통과했고 Cross-platform test shard·gates·docker smoke·keyring·npm-global 다수는 아직 pending이며 mergeStateStatus는 UNSTABLE입니다.

src/server/index.ts /v1/hub-state 라우트 - 역할 게이트를 입장 뒤에 둔 것과 hub_state_not_a_hubformatErrorResponse가 아닌 자체 JSON으로 만든 선택은 맞고, attribution 테스트가 빈 통과하지 않게 합니다. 이 경로는 유지하세요.

src/server/hub-state.ts buildHubState - 프로바이더를 Object.entries(...).slice(0, 200)로 자릅니다. 삽입 순이라 200을 넘는 기이한 허브에서는 뒤쪽 이름이 조용히 빠집니다. 현실에선 드물지만, 잘렸다는 신호를 DTO/로그에 남기면 운영 디버깅이 쉬워집니다.

src/cli/claude-agent-startup-sync.ts cachedHubRoster - 캐시가 비어 있으면 경고 없이 undefined → 로컬 로스터입니다. ocx claude 경로의 “발표된 폴백”과 결이 다릅니다. 연결 직후 첫 프록시 기동·아직 ocx status/ocx claude를 안 돌린 창에서 예전 로컬 다섯이 다시 쓰일 수 있습니다.

src/client/hub-state.ts 캐시 - disconnect가 hub-state.json을 지우지 않습니다. 스탬프 비교로 다른 허브 재연결은 막히지만, 디스크에 이전 허브 투영이 남습니다. 비밀은 아니지만 disconnect 시 unlink가 더 깔끔합니다.

src/cli/status.ts stateSource === "unavailable" - 배너는 확실한데, 그 아래 로컬 OAuth/프로바이더 줄은 그대로 나옵니다. 배너를 건너뛰는 에이전트/스크래퍼는 같은 착각을 다시 할 수 있습니다. JSON의 remoteHub.stateSource를 읽으라는 계약이 문서·스키마에 더 세게 박혀야 합니다.

경로/심볼 - ocx doctor - 본문이 인정한 대로 같은 등급의 출처 결함이 아직 로컬을 보고합니다. 이 PR 범위 밖이지만 #4236 사고를 끝까지 닫으려면 follow-up이 필요합니다.

경로/심볼 - ko remote-hub - en만 갱신. 형제 #4254가 레시피/invite를 다루더라도, 이 PR이 넣는 “클라이언트가 무엇을 보여 주는지” 절의 한국어는 아직 비어 있습니다.

경로/심볼 - 캐시 TTL 없음 - 의도(나이를 보여 주고 사람이 판단). 허브에서 로그인/로스터가 바뀐 뒤 클라이언트가 오래 오프라인이면 cache가 옛 허브 사실을 계속 말합니다. TTL을 넣을지는 메인테이너 판단입니다.

경로/심볼 - #4246(src/cli/status.ts readiness 표시, 베이스 dev) - 이 스택이 dev로 리타깃될 때 status 충돌·의미 겹침을 한 번 맞춰야 합니다. 카탈로그 readiness와 hub-state는 다른 축이지만 같은 파일입니다.

경로/심볼 - CI - 호스티드 Cross-platform이 아직 대부분 pending/UNSTABLE. 머지 증거로 쓰려면 초록을 기다리세요.

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

  • 스택 순서 1→2→3→4→(5 docs 형제)→6을 유지한 채 순차 랜딩할지, status 충돌 때문에 6을 4 직후로 당길지.
  • 프록시 기동 시 캐시 미스에 경고를 넣을지, 시작 경로 침묵을 유지할지.
  • disconnect 때 hub-state.json unlink 여부.
  • 캐시에 TTL(또는 최대 age 경고 강화)을 넣을지, 나이 표시만으로 둘지.
  • ocx doctor 출처 수정을 이 스택 follow-up으로 묶을지 별 이슈로 뺄지.
  • ko 절을 #4254에 얹을지, 이 PR에 작은 커밋으로 넣을지.
  • #4246과 status.ts 합류 순서.

너의 추천
#4249#4250#4251→#4252가 dev에 안착한 뒤 이 브랜치를 dev로 리타깃하고, Cross-platform CI가 초록인지 확인한 다음 머지하세요. #4254(docs)와는 형제이니 둘 다 4 위에 두고 순서만 정하면 됩니다. 방향(데이터 키만의 boolean 투영·세 값 stateSource·로컬 폴백 금지·발표된 Claude 폴백·0600 owner 스탬프 캐시·/api/* 미확대)은 #4236 사고와 정확히 맞고 테스트도 두껍습니다. 기동 캐시 미스 경고·disconnect unlink·doctor·ko는 랜딩 직후 follow-up으로 남겨도 이번 핵심(출처 거짓말 제거)은 닫힙니다. types/config 분할에 치일 범위가 아니니 닫지 말고 스택 대기 후 착지시키세요. 지금은 스택 대기 + CI 확인입니다.

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

@lidge-jun
lidge-jun force-pushed the codex/260911-l4-hub-token-ux branch from b26eee3 to 2aa572d Compare September 11, 2026 03:24
@lidge-jun
lidge-jun force-pushed the codex/260911-l4-client-hub-state branch 2 times, most recently from e016704 to 7e18fc9 Compare September 11, 2026 03:30
@lidge-jun
lidge-jun force-pushed the codex/260911-l4-hub-token-ux branch 2 times, most recently from 2e90005 to 902fc02 Compare September 11, 2026 03:43
lidge-jun and others added 10 commits September 11, 2026 12:55
… data plane

A connected client had no way to ask the hub what it can actually serve, so
`ocx status` on the client reported the client's own empty credential store as
if it were the truth: `xai ✗ not logged in` on a machine whose hub has xAI
logged in and is serving grok. An agent working on such a client read that
output and concluded the hub could not serve grok.

`GET|HEAD /v1/hub-state` is the least-privilege read that fixes it, built in the
`/v1/catalog` (#809) tradition: the same `resolveApiAuth` admission set, the same
origin check, no parameters, `no-store`, no validator, and a fixed bounded body
of booleans plus model ids. The alternative operators reach for — handing the
client an admin token so it can call `GET /api/providers` — is exactly the trade
#809 already refused, and widening `/api/*` to the data plane would be worse.

What crosses the boundary is deliberately narrow. `hasCredential` is the same
`!!p.apiKey` presence projection `GET /api/providers` ships; `loggedIn` is
`oauthLoginSummary`'s boolean with the email and account id dropped rather than
masked. Provider names already leak through `/v1/catalog` slugs, so the delta is
those two booleans. The projection builds every row field by field for that
reason: a spread would silently start exporting whatever field is added to a
provider or a login record next.

The route 404s with its own `hub_state_not_a_hub` code unless
`runtimeRole === "hub"`, so a standalone install gains no surface at all, and
that gate runs AFTER admission on purpose — answering an anonymous caller would
turn the route into a free "is that host a hub?" probe.

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

`fetchHubState` sits beside `downloadClientCatalog` because it is the same kind
of call: one bounded, schema-validated, unconditional GET with the per-client
data key. There is deliberately no management variant — the client holds no hub
management credential, and minting one to read a list of booleans is the trade
#809 already refused.

`resolveHubState` is where the actual fix lives. Every failure path — a 404 from
a hub too old to serve the route, a 401, an unreachable host, a non-JSON or
malformed or foreign-schema body, an oversized one — lands on `cache` or
`unavailable` with a reason, and none of them reaches back into the client's own
providers and logins. That fallback is invisible in output, because local state
renders exactly like hub state, which is how a client came to report
`xai ✗ not logged in` about a hub that had xAI logged in.

The last good response is cached 0600 at `<OPENCODEX_HOME>/hub-state.json`,
stamped with the (serverUrl, apiKeyId, connectedAt) triple the rest of the client
lifecycle compares on. A stale cache is still the HUB's state; an unstamped one
would be a different hub's, which after a disconnect and reconnect is not
staleness but a lie.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ls what is local

The report was not incomplete, it was misattributed. On a connected client
`ocx status` printed `OAuth logins:` from this machine's own credential store —
empty by design — plus local providers and a local five-model roster, with one
buried `Remote hub: connected (<url>)` line as the only hint that none of it
described the machine doing the work. An agent read `xai ✗ not logged in` off
such a client and concluded the hub could not serve grok.

Now a connected client leads with a banner naming the hub origin, prints the
hub's OAuth logins, providers and delegable models above the local block, labels
that block "not used for routing while connected", and tags the lines that really
are about this machine — proxy, health, dashboard, config, service, shim, Codex
runtime/version/source/home — `(local)`. The tag appears only while connected:
on a standalone install every line is local and tagging them all would teach the
reader to ignore the tag.

`--json` gains `runtimeRole` and a `remoteHub` block with the same
`stateSource: "hub" | "cache" | "unavailable"` honesty. `schemaVersion` stays 1
(additive, same rule as `versionSkew`), and `connection` is untouched: it
describes the LINK, `remoteHub` describes what is on the other end of it. When
the hub cannot be read the block is empty with a reason and the banner says so,
because the one thing this must never do is answer from local state.

Providers carry `authMode`, which is what stops "no API key" from reading as
"not configured" for an `oauth` provider — the precise inference that went wrong.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`cmdClaude` gated the roster writer on `typeof route === "number"`, which is
false on a connected client — `route` is a `ClaudeRoutingTarget` there. So
`~/.claude/agents/ocx-*.md` on a client was whatever a previous standalone run
had left, indefinitely. And when it did run, it built the roster from local
`config.subagentModels`: the list this machine had before it joined the hub. The
operator saw five delegable native models on a client whose hub serves grok, with
nothing in the output admitting the list described a different machine.

The gate is gone and `buildClaudeAgentDefs` takes an explicit roster argument,
defaulting to today's behaviour including "unset means the defaults, an explicit
`[]` means none". On a connected client `ocx claude` passes the hub's featured
roster; when the hub cannot be read it falls back to the local list and SAYS so,
because an unannounced fallback is indistinguishable from a correct answer, which
is how this defect stayed invisible.

The five-row cap stays. It is a Claude Code picker constraint, not the bug —
sourcing the five from the wrong machine was. `entryParts` already kept the raw
id when a provider is absent from local config, which is what lets `xai/grok-4.6`
produce `ocx-grok-4-6.md` on a credential-less client instead of throwing and
aborting the sync for every other model too; there is now a test holding that.

`syncClaudeAgentDefsAtProxyStartup` uses the same roster from the on-disk cache
only. Startup makes no hub round trip for it: the live read belongs on the
`ocx claude` path, and an offline hub must not stand between an operator and a
local proxy start.

Behaviour change worth naming: the first `ocx claude` after this lands rewrites a
previously frozen roster on a client. The `generated-by: opencodex` ownership
marker is untouched, so a user-authored `ocx-*.md` is still never overwritten or
pruned.

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

`runtimeRole: "client"` and the `client` block were already printed and were
already missed. An agent read a client's `config.json`, saw `providers: {}` and no
grok provider, and concluded the hub could not serve grok — two floors below the
fact that this machine stores no provider credentials on purpose.

Two changes, both about where a reader's eye lands. `_remoteHub` is now the FIRST
key on a client, naming the hub origin and pointing at `ocx status`, which is the
command that has the facts. And `client.priorCatalog` — the base64 snapshot
connect takes before overwriting the local catalog, up to 64 MB of it — prints as
`<omitted: N bytes>`, mirroring `sanitizeModelCostsForDisplay`, instead of
burying every other field under a wall of base64.

`_remoteHub` is synthetic and never persisted: `clientConnectionSchema` is
`.strict()` so a real `client.note` would not validate, and persisted prose drifts
from the behaviour it describes. `config export` emits the real config untouched —
annotation and omission marker both absent — so round trips still validate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Includes the en remote-hub guide paragraph on what a connected client shows: the
`State from hub <origin>` banner, the hub-sourced OAuth/provider/model lines, the
`(local)` tags, `runtimeRole` and the `remoteHub` block with its three-valued
`stateSource`, and that the read uses the per-client data key only. The ko copy is
left for the docs lane and is named as undone in the devlog.

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

`buildHubState` mapped every `config.providers` row, `disabled: true` included, while
`/v1/catalog` and `/v1/models` both filter a disabled provider out — so this route was the
only data-plane surface that named one, and three comments plus the devlog claimed the
delta over `/v1/catalog` was "only the two booleans". A boundary comment that understates
what the code discloses is worse than none: it is what the next reviewer checks against.

A disabled provider is now dropped entirely (a client cannot route to it, and `authMode`
already explains a keyless row), and the comments state the delta exactly, down to the
name of an enabled provider the catalog omits for want of a credential. `disabled` stays
in the contract because an older hub still sends `true` and a client must label that row.

`truncated` joins the body so a cap is reported rather than silently clipping the lists,
and `hubStateFailureReason` grows a sentence for `hub_state_content_type_invalid` and for
the `hub_state_http_<status>` family — the `ocx status` banner printed `state unavailable
(hub_state_http_507)`, which reads like a client bug when the hub has in fact answered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e cache on disconnect

`_remoteHub.connected` was hardcoded `true` for any config carrying a `client` block, so a
machine whose data key was revoked at the hub, rotated away, or whose token file was gone
still printed `connected: true` in `ocx config show`. Configuration is not evidence that a
connection works, which is the defect this unit exists to fix, in miniature.

`remoteHubConfigNote` now reads `collectClientConnectionStatus()` and requires both halves —
a settled connection record and the token file whose fingerprint matches it — and names what
is wrong when either fails. The status is passed as a thunk so a standalone or hub install
returns before the probe, and `./connect` is imported lazily so `ocx config get/set` does
not drag the client lifecycle in.

`disconnectClient` also unlinks `<OPENCODEX_HOME>/hub-state.json`. It is owner-stamped, so a
reader would reject it, but a disconnected machine should not keep a file naming the former
hub's providers and logins. Best effort, after the connection is cleared: the disconnect has
already succeeded by then and a stubborn cache file must not fail it.

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

The decision not to add the route to `loopbackRouteAllowed` had no test behind it. This one
starts a real hub with the loopback listener bound, asks it for `GET /v1/hub-state` with no
credential, and asserts a 404 whose code is `not_found` — the listener's refusal, not the
route's own `hub_state_not_a_hub`, which would have meant the request reached the handler.
The same run then reads the route successfully on the public listener with a data key, so a
route that disappeared entirely cannot make the first assertion pass vacuously.

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

Seven findings, all accepted, with the corrected disclosure delta for `/v1/hub-state` written
down in the place the wrong one was: "only the two booleans" became the exact list, and the
note that a disabled provider is not exported at all. `structure/01_runtime.md` now names
`src/remote/hub-state.ts` and `src/client/hub-state.ts` under remote-hub ownership, including
the rule that a failed read reports "unavailable" rather than degrading to local state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lidge-jun
lidge-jun force-pushed the codex/260911-l4-client-hub-state branch from 4ccf177 to af7c114 Compare September 11, 2026 03:55
Base automatically changed from codex/260911-l4-hub-token-ux to dev September 11, 2026 04:17
@lidge-jun
lidge-jun merged commit 42184ea into dev Sep 11, 2026
34 checks passed
@lidge-jun
lidge-jun deleted the codex/260911-l4-client-hub-state branch September 11, 2026 04:19
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