Skip to content

feat(hub): provision the data-plane token, add ocx hub invite, and one hub block in ocx status - #4252

Merged
lidge-jun merged 12 commits into
devfrom
codex/260911-l4-hub-token-ux
Sep 11, 2026
Merged

feat(hub): provision the data-plane token, add ocx hub invite, and one hub block in ocx status#4252
lidge-jun merged 12 commits into
devfrom
codex/260911-l4-hub-token-ux

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

Nobody should have to export a token by hand to run a hub, and the fact that they did is what
produced the incident in #4236.

The refusal was right; the demand was the defect. ocx service install on a non-loopback
hostname threw unless OPENCODEX_API_AUTH_TOKEN was set, so the operator exported the token at
hand — the admin token. assertNotAdminToken correctly refused it, the hub crash-looped, and
ocx service repair then demanded the same environment variable again. The only remembered way
to make the command proceed was the exact thing that had broken it. Worse, the preflight threw
even when ~/.opencodex/service-api-token already held a perfectly good token, so a repair could
not succeed from a healthy on-disk state.

writeServiceApiTokenFile stays the single chokepoint every backend funnels through (launchd,
systemd, the Windows scheduler wrapper, WinSW native) and now resolves by precedence: the env
token, still refused outright when it is an admin token; else an existing owner-only
service-api-token; else 32 fresh random bytes, hex, written 0600 through the same hardened
writer. It returns the origin and logs the path, never the value. Reusing an existing token
rather than regenerating is what makes repair, reinstall and restart idempotent — a new hub secret
would silently invalidate every client key exchange already performed. A loopback install with no
env token still creates nothing: admission is not required there, and on a machine connected to a
hub that same file holds the hub's issued client key, which a local install must not clobber. The
preflight keeps exactly two refusals: the admin-token collision, and a token file that exists but
is unusable — reported where the operator can still act instead of failing mid-install. The
admin-token message now tells them to unset and rerun, where it previously offered "or set it to
a distinct data-plane key", which is how they got there.

The collision check is not only about the environment, and review caught that it had to run on
the reuse branch too.
A service-api-token that already holds the admin token — hand-pasted
pre-#2696, or written by this very incident — was silently accepted by the origin: "file" path:
ocx status said present (file) and the hub crash-looped at boot with nothing naming the cause.
assertNotAdminToken now takes the source, because the remedy differs (unset is meaningless
advice about a file; deleting it so a data token is generated is the fix), the writer and the
preflight both check the file, and both checks run ahead of the loopback short-circuit
buildServiceShellCommand cats that file into OPENCODEX_API_AUTH_TOKEN whenever it exists,
whatever the hostname, so a loopback install is fenced closed at boot just the same. On a
connected client the file holds the hub's issued per-client key, never a management token, so the
check is a no-op there. The reused file is also chmod 0600'd best-effort on the way through:
readServiceApiTokenState accepts any bounded regular file, so "owner-only" was a claim nobody
had checked.

A foreground ocx start was the mirror-image gap. assertServerAuthConfig reads the environment,
and under a service the environment always has the token because the plist and the unit cat the
file into it before exec — so ocx start refused to bind a non-loopback hostname the installed
service on the same machine was serving happily. startupDataPlaneToken applies the wrappers' own
precedence in one place (env, then OCX_API_TOKEN_FILE, then the installed token when admission
is required). assertServerAuthConfig itself is untouched: the fix belongs at the env-hydration
layer the wrappers already occupy, not in a per-request admission helper that would then read a
file on every request.

ocx hub invite replaces the remaining manual step. It prints the exact line to run on the
other machine:

# Run on the other machine:
echo '<code>' | ocx connect https://hub.tailnet.ts.net:8443 --management-url https://hub.tailnet.ts.net --pairing-code-stdin

No new management route was needed and none was added. A "connect pairing code" is a GUI
pairing grant — ocx connect --pairing-code-stdin exchanges it at POST /opencodex-session — so
invite reuses requestBoundGuiPairingGrant, the attested local mint ocx gui pair already
drives, authorized by an HMAC over the running proxy's own attestation secret rather than by the
admin token. It therefore needs no admin token and nothing exported in the shell, which is
better than the admin-token path originally sketched, and src/server/* is untouched by this PR.

Everything that cannot work is refused before a single-use code is minted: a non-hub
runtimeRole, a missing hub.managementPublicOrigin, a non-loopback plaintext management origin,
a malformed --data-url, no running attested proxy, a hub whose allow-list admits no loopback
browser origin, and — from review — a data origin that would resolve to this machine's own
loopback. ocx connect sends Origin: http://localhost:<its own port>, so only
hub.managementPublicOrigin itself or a loopback corsAllowOrigins entry can ever match the
grant, and the refusal names the ocx config set line that fixes it. --management-url is a
confirmation rather than an override, because the grant is bound to the configured origin and
advertising anything else would hand out a code the hub then rejects.

The loopback-data-origin refusal is the one that mattered most. probeHostname spells 0.0.0.0,
:: and 127.0.0.1 all as loopback, so a hub bound to any of them with no hub.dataPublicOrigin
printed ocx connect http://localhost:10100 — an instruction for the other machine to dial
itself, paid for with a single-use code. resolveHubDataOrigin now returns either a usable
origin (flag / config / derived) or loopback-derived, and the latter is a refusal naming
which shape this hub has. A wildcard bind is refused rather than resolved on purpose: nothing in
this repo derives a tailnet or LAN address, and guessing one from os.networkInterfaces() would
advertise an interface the operator never chose. An explicit --data-url or
hub.dataPublicOrigin is never second-guessed — loopback is legitimate over an SSH tunnel.

The bound browser origin is now printed on every successful invite, on stderr beside the
single-use warning, and when it is not http://localhost:10100 the warning names the port the
connecting machine must be configured with. selectInviteBrowserOrigin silently falls back to the
first admitted loopback origin, the printed command carries no trace of which, and a grant bound
to anything the client does not send is refused at the exchange — burning the code with no hint.

Every ocx config set line the command surfaces is now one that actually runs.
ocx config set hub.managementPublicOrigin … exits config parent path not found: hub when the
hub object is absent — exactly the config being advised — so the parent-creating
ocx config set hub '{}' is prefixed, and only when it is genuinely missing.
ocx config set corsAllowOrigins '[…]' replaces the array, so the suggestion now carries the
entries the hub already has instead of telling the operator to delete them.

New optional hub.dataPublicOrigin carries the advertised data origin. It is its own field
rather than a derivation because on a real deployment the two planes are different sockets:
management is a loopback-only ingress published by Tailscale Serve on 443, data is the tailnet
bind fronted on its own port. Same canonical-origin transform as managementPublicOrigin, and
deliberately not .catched — silently dropping a typo would make invite fall back to
http://<hostname>:<port>, the value the field exists to replace.

ocx status gains one Hub block, so the question a hub operator actually asks is answered in one
place instead of four:

   Hub:
     Data origin: http://localhost:10100 (derived from the bind address)
     Loopback listener: ported on http://127.0.0.1:10104 — a second port local clients must be pointed at
     Management ingress: http://127.0.0.1:10102
     Management origin: https://hub.tailnet.ts.net
     Data token: present (file) at <home>/.opencodex/service-api-token
     Invite a machine: ocx hub invite

The listener line distinguishes the companion and ported forms from #4239's change through
effectiveLoopbackListenerPort. The token line reports the state of the file the service
reads
present (file) / unsafe (file) / admin-collision (file) / missing — and a test
asserts no token value appears in the JSON or the rendered lines.

Two review fixes live here. present (env) used to win whenever the calling shell exported
OPENCODEX_API_AUTH_TOKEN, but the plist and the unit overwrite that variable from the file
before exec, so the label described the operator's terminal rather than the hub; the shell's
variable is now its own field, rendered as "the installed service reads the file, not this", kept
because it does decide what a foreground ocx start in that shell would admit. And
admin-collision (file) is the incident shape made visible: a file holding the management token
used to read present (file) while the hub crash-looped, so the block now names the consequence
and the command that fixes it.

ocx help hub is the one place that states the topology end to end (one port; the companion
listener local processes dial without a credential; the separate loopback-only management plane;
the owner-only token file that must never be copied to another machine; how an invite's code is
bound). ocx service details gained the provisioning precedence and the admin-token refusal — the
absence of those sentences is what made this incident reachable from the documentation alone.

The guide still tells operators to export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"
before ocx service install. That step is now optional; rewriting guides/remote-hub.md (en + ko)
around ocx hub invite and documenting hub.dataPublicOrigin in
reference/configuration/server.md is the next PR in this stack.

This PR is stacked on #4239 and targets its head branch. Retarget to dev once that lands.

Verification

  • bun x tsc --noEmit clean. bun run privacy:scan passed. bun run skill:surface:check reports
    the committed map current (39 capabilities, 18 state-changing).
  • bun test tests/service/service.test.ts 211 pass; tests/service/service-secrets.test.ts 10
    pass; tests/service/winsw.test.ts 25 pass; tests/cli/hub-invite.test.ts 22 pass;
    tests/cli/cli-status-json.test.ts 54 pass; tests/gui/gui-pair-client.test.ts 4 pass;
    tests/gui/gui-pair-capability.test.ts 2 pass; tests/cli/cli-registry.test.ts 12 pass;
    tests/cli/cli-capabilities.test.ts 17 pass; tests/cli/cli-help.test.ts 17 pass;
    tests/cli/cli-dispatch.test.ts 39 pass; tests/cli/cli-transport-honesty.test.ts 22 pass;
    tests/ci-workflows/skill-ocx.test.ts 16 pass; tests/server/config.test.ts 196 pass;
    tests/test-layout.test.ts + tests/test-layout-tooling.test.ts 17 pass. Also green:
    tests/cli/cli-json-contract.test.ts 8, tests/cli/cli-start-journal-order.test.ts 3,
    tests/config/config-user-edits.test.ts 46, tests/providers/opencode-cli.test.ts 51.
  • tests/server/server-auth.test.ts is 111 pass / 1 fail on this branch and on the base with
    the working tree stashed
    : native passthrough upstream reset still logs 502 and penalizes the pool is pre-existing, not a regression.
  • The service and winsw files must be run one file per invocation. Passing winsw.test.ts in the
    same bun test call as cli-transport-honesty.test.ts trips the real-home guard through
    cross-file OPENCODEX_HOME leakage; that is true on the base too.
  • tests/cli/hub-invite.test.ts is new and registered in both scripts/test-layout/layout.json
    and tests/fixtures/test-layout-expected.json. It injects the mint, so no proxy, socket or real
    grant is involved, and it pins every refusal path — including that a --management-url differing
    from the configured origin is refused rather than printed. The hub status block, the startup
    token precedence and the hub.dataPublicOrigin schema went into the existing
    cli-status-json.test.ts, service-secrets.test.ts and server/config.test.ts.
  • The token-provisioning tests are behaviour tests, not source oracles: they assert 64 hex
    characters at mode 0600, that a second call returns origin: "file" with the same bytes, that
    a loopback install creates no file, and that the admin-token path writes nothing.
  • Review-round additions: the loopback/wildcard data-origin refusal and the --data-url /
    hub.dataPublicOrigin escapes, the bound-origin notes in both output modes, the
    ocx config set hub '{}' prefix when hub is absent, the appending corsAllowOrigins form,
    an admin token in the reused token FILE refused by both the preflight (non-loopback and
    loopback) and the writer with the file left untouched, the 0600 tightening of a reused
    world-readable file, and admin-collision (file) in the status block — including that it is
    reached through the same byte-equality comparison doctor uses, not only the minted prefix.
  • tests/service/service.test.ts is 209 on this branch with the working tree reverted on this
    machine, not the 212 reported in the first round; 211 is 209 plus the two new cases.
  • Two read-only commands were run against the maintainer's live hub in the first round. ocx status rendered the block quoted above. ocx hub invite --json exited 1 with No loopback browser origin is admitted for pairing…, minting nothing — that hub has hostname: 127.0.0.1
    and no corsAllowOrigins, so the refusal is correct, and it is also the exact shape the new
    loopback-data-origin refusal exists for. No live command was run in the review round, not
    even a read-only one: ocx status reaches diagnoseService()probeLaunchdLoadState()
    launchctl, and the instruction for this machine is to run no launchctl at all. The live
    config was not modified and no ocx service, ocx start, ocx ensure or ocx sync was run.
  • No repository-wide suite, by operator instruction. Hosted CI on the exact pushed head is the
    proof.

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.

Security-boundary notes for that last box. No admission path widened: assertServerAuthConfig,
configuredApiAuthToken, resolveDataPlaneAdmissionSecret and the loopback route allowlist are
untouched, and src/server/* has no diff. The generated token is randomBytes(32) through the
pre-existing hardened writer (mkdir 0700writeFileSync mode 0600chmod
hardenSecretPath on Windows), so no new file-permission code was written. The admin-token
chokepoint is preserved and now covered by a test that asserts nothing reaches disk when it
fires. Token values are never logged, never placed in a plist, unit file or argv, and the status
projection carries only which state the file is in — asserted, not merely intended; the
admin-token comparison added to ocx status derives a boolean and nothing else. The review round
tightened two things in this box's favour: the reuse branch can no longer accept a management
token from disk, and a reused token file is chmod 0600'd rather than assumed owner-only. The pairing code
ocx hub invite prints goes to stdout exactly as ocx gui pair already prints its grant, with
the single-use warning on stderr — and now the bound browser origin beside it, which is operator
advice rather than envelope contract, so --json still emits exactly
{ code, expiresAt, dataUrl, managementUrl, command }.

One reuse cleanup from review: canonicalHttpOrigin is exported once from
src/lib/gui-pair-capability.ts, beside canonicalGuiBrowserOrigin, and the private copies in
src/cli/hub.ts and src/cli/gui-pair-client.ts are gone. The copies in src/config.ts (zod
schema path) and src/server/gui-session.ts stay, so src/server/* keeps a zero diff; collapsing
those two is its own change.

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: 79cf7267-0ff1-471d-8295-c6cf052d9f89

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

리뷰 · 우선순위 75 / 80

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

고치는 사고는 본문이 아주 분명하게 말합니다. 비루프백 호스트에서 ocx service installOPENCODEX_API_AUTH_TOKEN을 강제했고, 운영자는 손에 있는 관리(admin) 토큰을 보내 넣었습니다. assertNotAdminToken 거부는 맞았는데, 요구 자체가 결함이었습니다. 게다가 ~/.opencodex/service-api-token에 이미 쓸 만한 값이 있어도 프리플라이트가 또 env를 요구해서, repair가 같은 함정으로 돌아갔습니다. 거부는 옳고 요구가 틀린 케이스를 이 PR이 닫습니다.

코드로 보면 세 축이 같이 움직입니다.

  1. src/service.tswriteServiceApiTokenFile — 여전히 launchd/systemd/WinSW가 모이는 단일 출구인데, 이제 우선순위가 env(관리 토큰이면 거부) → 기존 owner-only 파일 재사용 → 없으면 32바이트 hex를 0600으로 생성입니다. 재사용이 핵심입니다. 매 repair마다 새로 만들면 이미 교환된 클라이언트 키가 조용히 전부 깨집니다. 루프백 설치는 파일을 안 만듭니다(입장 불필요 + 허브에 붙은 클라이언트의 그 파일을 덮어쓰면 안 됨). 관리 토큰 거절 문구도 “다른 키를 넣으라”가 아니라 unset 후 재실행으로 바뀌었습니다.

  2. src/lib/service-secrets.tsstartupDataPlaneToken — 포그라운드 ocx start가 서비스와 같은 우선순위(env → OCX_API_TOKEN_FILE → 설치 토큰, 단 authRequired일 때만 파일)로 env를 채웁니다. assertServerAuthConfig는 그대로 두고, 래퍼들이 하던 env-hydration 층만 고칩니다. 요청마다 파일을 읽는 쪽으로 경계를 옮기지 않은 선택이 맞습니다.

  3. src/cli/hub.tsocx hub invite — 새 관리 라우트를 안 만들고, 이미 있는 attested requestBoundGuiPairingGrant(ocx gui pair와 동일, HMAC 증명)로 일회용 코드를 민트한 뒤, 다른 머신에서 그대로 돌릴 ocx connect … --pairing-code-stdin 한 줄을 인쇄합니다. 관리 토큰이 셸에 없어도 됩니다. --management-url은 덮어쓰기가 아니라 확인만 받고, 설정된 hub.managementPublicOrigin과 다르면 거절합니다. 민트 전에 runtimeRole/관리 origin/평문 HTTP/루프백 브라우저 origin/findLiveProxy를 전부 막습니다. 선택 설정 hub.dataPublicOrigin은 관리 origin과 다른 소켓(데이터면)을 광고하기 위한 별도 필드이고, 파싱 실패를 .catch로 삼키지 않습니다(오타면 잘못된 파생값으로 조용히 떨어지면 안 됨).

ocx status에는 허브 전용 블록 하나가 생깁니다(collectHubStatus / hubStatusLines). 데이터 origin, companion/ported/off 리스너, 관리 ingress/origin, 토큰 출처만(값 금지: present env/file, unsafe, missing), invite 힌트. 도움말·레지스트리·capabilities·skill surface(39 / 18 state-changing)까지 같이 올렸고, routes: []는 composition root에 있는 pairing-grant가 MANAGEMENT_ROUTES 밖이라 의도적으로 비운 것입니다(주석에 이유 있음). 가이드 guides/remote-hub.mdexport OPENCODEX_API_AUTH_TOKEN=… 문장은 아직 그대로이고, 본문이 다음 docs PR로 미룹니다.

검증은 서비스/시크릿/invite/status/config/레이아웃 등 파일 단위로 숫자가 적혀 있고, 라이브 허브에서는 ocx status 블록 확인 + invite는 cors 미설정으로 민트 없이 거절까지 확인했다고 합니다. 로컬 전체 스위트는 운영자 지시로 생략, 호스티드 CI가 증거입니다. 이 리뷰 시점 Cross-platform test 2/4tests/server/reserve-ingress.test.ts:187에서 Expected 404 / Received 429로 실패했습니다. 이 PR 파일 목록에 없는 서버 예약 테스트라 플레이크/교차 오염 가능성이 큽니다. 머지 전에 그린인지 한 번 더 보시면 됩니다.

라인 - 베이스 브랜치 codex/260911-l4-hub-local-clients - dev가 아님. #4249#4250→#4251이 안착·리타깃되기 전에는 이 PR을 dev에 머지할 수 없음.
경로/심볼 - devlog Base discrepancy - 작업 당시 ancestry에 PR1(#4249)이 없었고 src/service.ts 겹침을 “깨끗할 것”으로만 적음. 스택 전체 위에 다시 올린 뒤 충돌·행동 회귀를 실제로 확인해야 함.
경로/심볼 - CI test 2/4 - reserve-ingress.test.ts 404→429. 이 PR 범위 밖일 가능성이 크지만, 호스티드 증거의 한 조각이 빨갱이인 동안 머지하지 말 것.
경로/심볼 - guides/remote-hub.md - 여전히 설치 전 export OPENCODEX_API_AUTH_TOKEN을 가르침. 이 PR이 그 단계를 선택으로 만들었으니 docs 유닛(스택 5)에서 invite + hub.dataPublicOrigin으로 바꿔야 함.
경로/심볼 - src/cli/hub.ts hubInviteCommand - echo에 코드를 단일 인용으로 넣음. 현재 grant는 ocx_pair_ + base64url이라 안전하지만, 문자열이 바뀌면 셸이 깨질 수 있음. printf/quoting 헬퍼가 더 단단함.
경로/심볼 - src/service.ts writeServiceApiTokenFile env 분기 - 기존 파일이 있어도 env 토큰으로 persistServiceApiToken이 덮어씀. 의도(운영자 통제)지만, repair 중 실수로 다른 키를보내면 연결된 클라이언트가 한꺼번에 무효화됨. 지문 비교 후 동일하면 스킵하는 쪽이 더 안전할 수 있음.
경로/심볼 - selectInviteBrowserOrigin / 라이브 허브 - hostname: 127.0.0.1 + cors 없음이면 invite가 민트 전에 거절. 동작은 맞음. 문서에 “비루프백 허브는 corsAllowOrigins에 http://localhost:10100이 필요”를 세게 박아야 운영자가 또 막히지 않음.

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

너의 추천
#4249·#4250·#4251이 dev에 안착한 뒤 이 브랜치를 dev로 리타깃하고, Cross-platform CI(특히 깨진 test 2/4)가 그린인지 확인한 다음 머지하세요. 방향(요구 제거·파일 재사용·어드민 거부 메시지·assertServerAuthConfig 미이동·invite가 admin 없이 attested mint·status에 출처만)은 #4236 사고와 정확히 맞고 테스트도 두껍습니다. guides/remote-hub.md의 export 강제와 corsAllowOrigins 안내는 바로 다음 docs 유닛에 남기세요. 지금은 스택 대기 + CI 확인입니다.

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

lidge-jun and others added 12 commits September 11, 2026 12:43
…ing it

`assertServiceAuthEnvironment` threw for any non-loopback hostname with no
OPENCODEX_API_AUTH_TOKEN, even when ~/.opencodex/service-api-token already held a
perfectly good one. That demand is what produced the incident in #4236: the
operator exported the ADMIN token because `install` asked for a token, the hub
crash-looped on `assertNotAdminToken`, and `repair` asked for the same variable
again -- so the only remembered way to make the command proceed was the thing
that had broken it.

`writeServiceApiTokenFile` stays the single chokepoint every backend funnels
through, and now resolves by precedence: the env token (still refused when it is
an admin token), else an existing owner-only token file, else 32 fresh random
bytes written 0600 through the same hardened writer. It returns the origin and
logs the PATH, never the value. A loopback install with no env token still gets
nothing -- admission is not required there, and on a connected client that file
holds the hub's issued key.

The preflight keeps exactly two refusals: the admin-token collision, and a token
file that exists but cannot be used (reported where the operator can still act
rather than failing mid-install). The admin-token message now says to `unset` and
rerun, instead of offering "or set it to a distinct data-plane key".

A foreground `ocx start` had no wrapper to cat the file into its environment, so
`assertServerAuthConfig` refused to bind a non-loopback hostname the installed
service was serving happily. `startupDataPlaneToken` applies the wrappers' own
precedence in one place -- env, then OCX_API_TOKEN_FILE, then the installed token
when admission is required. `assertServerAuthConfig` itself is unchanged.

Refs #4236

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

The launchd plist and the systemd unit cat `service-api-token` into the
environment before exec, and WinSW names it through OCX_API_TOKEN_FILE, so under
a service the proxy has always seen OPENCODEX_API_AUTH_TOKEN regardless of the
calling shell. A foreground `ocx start` had neither source, so a non-loopback
hostname the installed service serves happily was refused at bind.

`handleStart` now hydrates the environment through `startupDataPlaneToken` with
the same precedence, gated on `isApiAuthRequired` so a loopback bind keeps
ignoring the file -- on a machine connected to a hub it holds that hub's issued
client key, not this proxy's admission secret.

Refs #4236

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

A hub's management and data planes are different sockets on a real deployment:
management is a loopback-only ingress published by Tailscale Serve on 443, while
the data listener is bound to the node's tailnet address and fronted on its own
port. Deriving one from the other produced an origin that answered /readyz and
nothing else, so the advertised data origin is its own field.

Same canonical-origin transform as managementPublicOrigin (http(s), no
credentials, path, query or fragment) and deliberately not `.catch`ed: silently
dropping a typo would make `ocx hub invite` fall back to
http://<hostname>:<port>, which is the value the field exists to replace. It is
advisory -- the origin the hub advertises, never a bind address.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ocx hub invite [--json] [--data-url <origin>] [--management-url <origin>]
[--clients codex,claude]` prints the exact line to run on the other machine:

    # Run on the other machine:
    echo '<code>' | ocx connect <data-origin> --management-url <mgmt-origin> --pairing-code-stdin

No new management route was needed. A "connect pairing code" IS a GUI pairing
grant -- `ocx connect --pairing-code-stdin` exchanges it at POST
/opencodex-session -- so invite reuses `requestBoundGuiPairingGrant`, the attested
local mint `ocx gui pair` already drives, authorized by an HMAC over the running
proxy's own attestation secret. It therefore needs no admin token and nothing
exported in the shell, and src/server/* is untouched.

Everything that cannot work is refused before a single-use code is minted: a
non-hub runtimeRole, a missing hub.managementPublicOrigin, a non-loopback
plaintext management origin, a malformed --data-url, no running attested proxy,
and a hub whose allow-list admits no loopback browser origin. That last one is
the non-obvious constraint: `ocx connect` sends Origin:
http://localhost:<its own port>, so only managementPublicOrigin itself or a
loopback corsAllowOrigins entry can match the grant, and the refusal names the
`ocx config set` line that fixes it. --management-url is a confirmation rather
than an override, because the grant is bound to the configured origin and
advertising anything else hands out a code the hub then rejects.

`ocx status` gains one Hub block on a hub: advertised data origin, the loopback
listener in its companion/ported/off forms, the management ingress and public
origin, the data-token SOURCE (present (env) / present (file) / unsafe (file) /
missing, env first because that is the service's own precedence), and the invite
hint. `collectHubStatus` and `hubStatusLines` live in status.ts so the sentences
are testable without spawning the CLI; no token value reaches either.

Refs #4236

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

`ocx hub` gets a registry entry whose details are the one place that states the
topology end to end: one port, the companion loopback listener local processes
dial without a credential, the separate loopback-only management plane, the
owner-only token file that must never be copied to another machine, and how an
invite's code is bound. `ocx help hub` renders it.

`ocx service` details gain the token-provisioning precedence and the admin-token
refusal, so an operator reading help before installing learns that nothing has to
be exported by hand -- the absence of that sentence is what made the incident in
#4236 reachable from the documentation alone.

`ocx hub invite` is a declared capability and `bun run skill:surface` regenerated
references/01_management_surface.md (39 capabilities, 18 state-changing). Its
`routes` are deliberately empty with the reason in a comment: the mint it drives
is answered in the composition root ahead of handleManagementAPI and so is absent
from MANAGEMENT_ROUTES, and declaring it would fail the capability/registry
reconciliation rather than inform anyone.

Refs #4236

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

Service: a non-loopback install no longer demands the env token; provisioning
generates 64 hex chars at mode 0600 and then REUSES the same value on repair and
reinstall (regenerating would invalidate every client key exchange already
performed); an env token still wins and a loopback install creates nothing; an
unusable token file is reported by the preflight rather than failing mid-install;
and the admin-token refusal tells the operator to unset, reaching the chokepoint
so nothing is written.

`startupDataPlaneToken`: the environment wins, OCX_API_TOKEN_FILE still beats the
installed path, the installed token is used only when admission is required, and
an absent or unusable file resolves to null instead of throwing at boot.

`ocx hub invite`: argument parsing, the origin helpers, the exact printed command,
the --json envelope, and every refusal path -- including that a --management-url
differing from the configured origin is refused rather than printed. The mint is
injected, so no proxy, socket or real grant is involved.

`ocx status` hub block: companion vs ported vs off, the configured vs derived data
origin, env-over-file token precedence, missing vs unusable, the invite hint, and
-- the assertion that matters for a security boundary -- that no token value
appears in the JSON or the rendered lines.

`hub.dataPublicOrigin` normalizes like the management origin and rejects the same
five malformed shapes at write time.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What shipped, why the admin-token demand was the defect rather than the refusal,
why no new management route was needed, the base discrepancy (PR1 is not in this
branch's ancestry despite the assignment), exact verification commands with
counts, the one pre-existing server-auth failure, and what the docs PR still owes.

Refs #4236

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

`derivedHubDataOrigin` sends 0.0.0.0, :: and 127.0.0.1 all through `probeHostname`,
which spells every one of them as loopback -- so a hub bound to loopback, or to a
wildcard with no `hub.dataPublicOrigin`, printed `ocx connect http://localhost:<port>`
under "# Run on the other machine". That tells the other machine to dial ITSELF, and
the pairing code is single-use, so it was spent on an exchange that cannot succeed.
The maintainer's live hub is exactly that shape.

`resolveHubDataOrigin` returns either a usable origin (tagged flag/config/derived) or
`loopback-derived`, and the latter is refused BEFORE the mint, naming which shape this
hub has. A wildcard bind is refused rather than resolved on purpose: nothing in this
repo derives a tailnet or LAN address (`probeHostname` deliberately collapses wildcards
to loopback, and there is no `networkInterfaces` caller anywhere), so deriving one here
would advertise an interface the operator never chose. An explicit `--data-url` or
`hub.dataPublicOrigin` is never second-guessed -- loopback is legitimate over an SSH
tunnel.

The bound browser origin is now printed on every successful invite, in both modes, on
stderr beside the single-use warning so the --json envelope is unchanged.
`selectInviteBrowserOrigin` silently falls back to the first admitted loopback origin,
and a remote `ocx connect` only ever sends `http://localhost:<its own configured port>`
-- so a grant bound to anything else was refused at the exchange with nothing printed
to explain it. When the bound origin is not the default, the warning names the port the
connecting machine must be configured with, and offers admitting the default instead.

Both surfaced `ocx config set` lines now actually run.
`ocx config set hub.managementPublicOrigin …` exits `config parent path not found: hub`
when the `hub` object is absent -- exactly the config being advised -- so
`configSetHubLines` prefixes `ocx config set hub '{}'`, as guides/remote-hub.md does,
and only when it is genuinely missing. `ocx config set corsAllowOrigins '[…]'` replaces
the array, so `appendCorsAllowOriginsCommand` emits the entries already configured plus
the new one instead of telling the operator to delete them.

Reuse: `canonicalHttpOrigin` is exported once from `src/lib/gui-pair-capability.ts`,
beside `canonicalGuiBrowserOrigin`, and the byte-identical private copies in `hub.ts`
and `gui-pair-client.ts` are gone. The copies in `src/config.ts` (zod schema path) and
`src/server/gui-session.ts` stay for now, so `src/server/*` keeps a zero diff.

Refs #4236

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

The `origin: "file"` branch of `writeServiceApiTokenFile` returned early without
calling `assertNotAdminToken`, so a `service-api-token` that already held the
MANAGEMENT token -- hand-pasted before #2696, or written by the very incident this
unit closes -- was silently accepted: `ocx status` said `present (file)` and the hub
crash-looped at boot with nothing naming the cause. That is the #4236 incident shape,
still reachable.

`assertNotAdminToken` takes the source now, because the remedy differs rather than the
rule: `unset OPENCODEX_API_AUTH_TOKEN` is meaningless advice about a file, so the file
message says the file holds the management token and to delete it and rerun
`ocx service repair` (or `install`). It deliberately does not call
`serviceRetryCommand()`, which would drag `diagnoseService()` -- and `launchctl` -- into
an error path that runs during install.

Both collision checks moved ahead of the loopback short-circuit in
`assertServiceAuthEnvironment`: `buildServiceShellCommand` cats the token file into
OPENCODEX_API_AUTH_TOKEN whenever the file exists, WHATEVER the hostname, so a loopback
install with an admin-token file fences its management plane closed at boot just the
same. On a machine connected to a hub that file holds the hub's issued per-client key,
never a management token, so the check is a no-op there.

The reused file is `chmod 0600`'d best-effort on the way through.
`readServiceApiTokenState` accepts any bounded regular file, so a reused token could be
group- or world-readable while install printed "owner-only"; best-effort because a
non-owner cannot chmod and failing the install over a loose mode would be worse.

`ocx status`: `HubDataTokenState` gains `admin-collision (file)` and the hub block adds
two lines naming the consequence and the fix, because that is what a crash-looping hub
looks like from status output and nothing else in the report said so. And the token
state is now always about the FILE: `present (env)` was reported whenever the calling
shell exported the variable, but the plist and the unit overwrite it from the file
before exec, so the label described the operator's terminal rather than the hub. The
shell's variable survives as its own field, `dataTokenEnvInShell`, rendered as a
sub-line -- it does decide what a foreground `ocx start` in that same shell admits.

Refs #4236

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

`hub-invite`: every loopback and wildcard spelling of the bind address resolves to
`loopback-derived` and is refused with nothing minted (the injected mint records no
call), while `--data-url` and `hub.dataPublicOrigin` each unblock it; the surfaced
config commands carry `ocx config set hub '{}'` only when `hub` is absent; the
corsAllowOrigins suggestion keeps the existing entries and never duplicates; the bound
browser origin reaches stderr in both human and --json mode, and a non-default one
names the port the client needs.

`service`: an admin token in the REUSED token file is refused by the preflight and by
the writer, the file is left untouched by the refusal, `unset` is NOT the advice given,
and a loopback install is refused too (the wrapper cats the file whatever the hostname).
A reused world-readable file comes back at mode 0600 with its bytes unchanged.

`cli-status-json`: the token state is about the file and never about the calling shell,
the shell's variable is reported separately, and a file holding the admin token reads
`admin-collision (file)` with the consequence and the fix in the lines -- reached
through the minted prefix and through byte-equality with the configured admin token,
the same comparison doctor and the service chokepoint use. Both cases still assert no
token value appears in the JSON or the rendered lines.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Six findings, all accepted, each with the reasoning that made it a fix rather than a
preference: why a wildcard bind is refused instead of resolved, why the admin-token
remedy differs by source, why both collision checks had to move ahead of the loopback
short-circuit, why `present (env)` was backwards, why the `--json` envelope did not
grow, and why two of the four `canonicalHttpOrigin` copies stay. The superseded
paragraph in the first round's section 5 is marked rather than rewritten.

Also records that `tests/service/service.test.ts` is 209 on this machine with the
working tree reverted, not the 212 the first round reported, and that no live-hub
command was run this round -- `ocx status` reaches `launchctl` through
`diagnoseService`.

Refs #4236

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lidge-jun
lidge-jun force-pushed the codex/260911-l4-hub-token-ux branch from 2e90005 to 902fc02 Compare September 11, 2026 03:43
@lidge-jun
lidge-jun force-pushed the codex/260911-l4-hub-local-clients branch from ea2b3a5 to 0fd205f Compare September 11, 2026 03:43
Base automatically changed from codex/260911-l4-hub-local-clients to dev September 11, 2026 04:15
@lidge-jun
lidge-jun changed the base branch from codex/260911-l4-hub-local-clients to dev September 11, 2026 04:15
@lidge-jun
lidge-jun merged commit 5557612 into dev Sep 11, 2026
36 of 41 checks passed
@lidge-jun
lidge-jun deleted the codex/260911-l4-hub-token-ux branch September 11, 2026 04:17
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