Skip to content

feat(connect): phase 3 — ocx connect client core, per-client keys, mode-aware sync - #2777

Merged
lidge-jun merged 14 commits into
devfrom
codex/remote-hub-p3
Sep 1, 2026
Merged

feat(connect): phase 3 — ocx connect client core, per-client keys, mode-aware sync#2777
lidge-jun merged 14 commits into
devfrom
codex/remote-hub-p3

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

  • Phase 3 of the remote-hub stack (plan: devlog/_plan/260827_remote_hub/050_phase3_connect.md).
  • Adds ocx connect <url> / ocx disconnect / ocx connect status / ocx connect revoke and the client runtime under src/client/ (state, hub-client, connect).
  • Connect is a transaction: URL validation → /readyz protocol compatibility (p2/min1 accepted; explicit too-new/too-old errors) → one-time authority via --pairing-code-stdin or --admin-token-stdin (held as bytes, retained until commit/rollback, then released) → per-client key auto-issued through the hub key API → key written only through the service-secrets owner to the owner-only token file the shim already reads (never config.toml) → catalog downloaded atomically → injector preflight → inject → client state persisted (including apiKeyId for connected-only revoke). Any pre-commit failure leaves the machine untouched.
  • Generalizes the Codex injector to an explicit routing target while keeping standalone output byte-compatible; ocx sync becomes mode-aware with no local-provider fallback in client mode; the Claude launcher gains a hub target (launcher-scope env only); disconnect restores from the injector journal fully offline.

Verification

Run on lidge-ai (remote CI host) at aa26159, per the no-local-suite policy:

  • bun run typecheck — clean.
  • bun test tests/client-connect.test.ts tests/codex-inject.test.ts tests/codex-inject-integration.test.ts tests/service-secrets.test.ts tests/cli-headless-parity.test.ts tests/cli-start-journal-order.test.ts tests/core-lab-boundary.test.ts — 140 pass, 0 fail (P3-A1..A15: transaction rollback at every step, key-file permissions, zeroization, revoke-when-disconnected refusal, sync fallback refusal, standalone byte compatibility, offline disconnect).
  • Implementation-side full-suite run on a separate lidge clone: 15,410 pass / 16 skip / 0 fail.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed (operator docs land in the deployment phase).
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults (stdin-only credentials, owner-only token file, no secret in config/argv/logs).

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 27, 2026 18:22
@coderabbitai

coderabbitai Bot commented Aug 27, 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: Team

Run ID: 1db03730-9e49-47a3-a05a-6b7295904823

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 github-actions Bot added the enhancement New feature or request label Aug 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa26159532

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/connect.ts
Comment on lines +225 to +229
if (!options.noSync && options.selectedClients.includes("codex")) {
const injected = await injectCodexConfig(config.port, injectConfig, {
routingTarget: target,
catalogPath: DEFAULT_CATALOG_PATH,
journalOwner: { kind: "client", apiKeyId: issued.id },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-own existing Codex journals before connecting

When Codex is already routed through the local OpenCodex proxy, as it normally is after ocx start, injectCodexConfig() reaches writeJournal(), which returns early for already-injected content and leaves the existing process-owned journal unchanged. The connection is nevertheless committed, so disconnectClient() later rejects the process-owned journal as conflicting; if final commit fails, rollback can also be partial and leave Codex pointing at the hub after the new token is removed. Before injection, either safely transfer the existing journal/preimage to the new client owner or refuse/reconcile this state before issuing a key.

Useful? React with 👍 / 👎.

Comment thread src/client/hub-client.ts
Comment on lines +83 to +85
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxBytes) {
throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound response reads before buffering

For a hub response without a trustworthy Content-Length—for example, a chunked or deliberately misreported /readyz, /api/keys, or /v1/catalog response—response.arrayBuffer() buffers the entire body before the size check runs. A misconfigured or compromised remote hub can therefore consume unbounded client memory despite these APIs advertising bounded bodies. Read the response stream incrementally, cancel it as soon as maxBytes is exceeded, and only then decode the accumulated bytes.

Useful? React with 👍 / 👎.

Comment thread src/cli/help.ts
Comment on lines +34 to +35
ocx connect <url> Connect this machine to a remote OpenCodex hub (credential via stdin)
ocx disconnect Restore local state and clear the hub connection

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the remote-connect workflow

The CLI now exposes a substantial user-facing connect/disconnect workflow, including stdin-only credentials, client selection, HTTP opt-ins, synchronization behavior, persisted client state, and the manual post-disconnect key-revocation requirement, but this commit adds no docs-site/ documentation. Add an English source page and keep translated locales consistent so users can safely configure and recover this feature rather than relying on the terse command help.

AGENTS.md reference: src/AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 70 / 80

이 PR은 원격 허브 스택 3단계입니다. 베이스는 codex/remote-hub-p2(#2776)이지, 지금 체크아웃한 dev(50e9556, 패키지 2.35.0, 마지막 머지 #2767 prompt_cache_options)가 아닙니다. 스택은 #2771(설계 문서) → #2772(phase 1, 런타임 역할·/readyz·/v1/catalog) → #2776(phase 2 원격 GUI·페어링, Draft) → 이 #2777 순서입니다. 지금 dev에는 src/remote/src/server/catalog-download.ts도 없습니다. 그래서 이 diff만 dev에 얹으면 바로 깨집니다.

하는 일은 한 문장으로 말하면 ocx connect <url>으로 이 기계를 허브의 클라이언트로 바꾸는 것입니다. 새 경로 src/client/{state,hub-client,connect}.ts와 CLI src/cli/connect.ts가 생기고, ocx disconnect / connect status / connect revoke이 붙습니다. 연결은 트랜잭션입니다. URL·/readyz 프로토콜 검사 → stdin 일회성 자격(--pairing-code-stdin 또는 --admin-token-stdin) → 허브에서 클라이언트 전용 API 키 발급 → service-api-token 파일에만 저장(config.toml·argv·로그에 비밀 없음) → 카탈로그 원자 기록 → Codex injector 프리플라이트·주입 → config.jsonruntimeRole=client + client 블록 커밋. 중간에 실패하면 저널·카탈로그·토큰 파일·원격 키를 되돌리려 합니다. 이건 phase 1/2가 열어 둔 허브 표면을 실제 운영자가 쓰는 손잡이로 만드는 단계라서, 스택 안에서 우선순위는 높습니다.

Codex 쪽은 src/codex/inject.tsCodexRoutingTarget(절대 HTTP(S) /v1, OPENCODEX_API_AUTH_TOKEN)을 넣고, 숫자 포트 오버로드를 유지해 standalone 출력이 바이트 호환이라고 합니다. 저널 owner에 client+apiKeyId가 생겨 disconnect가 오프라인으로 복구할 수 있습니다. Claude 런처(src/cli/claude.ts)는 connected면 로컬 프록시를 안 띄우고 허브 origin + admission token으로 ANTHROPIC_BASE_URL을 맞춥니다. ocx sync는 client 모드에서 로컬 provider 폴백 없이 syncConnectedClient만 탑니다. 타입은 src/types/config.tsOcxClientConnectionConfig이고 src/types.ts는 re-export만 합니다. src/config.ts에 zod 스키마·malformed fail-closed·쓰기 거절이 약 +107줄 붙습니다. types/config 분할 캠페인 방향과 맞고, close-don't-rebase 대상은 아닙니다.

보안 설계는 대체로 단단합니다. 자격은 stdin만, Uint8Array.fill(0)으로 해제하고, 토큰 파일은 atomicWriteFile(mode 0o600)만 쓰고 기존 파일이 있으면 교체를 거절합니다. 허브 요청은 redirect 거절·본문 바이트 한도·프로토콜 too-new/too-old를 봅니다. 다만 CI는 지금 빨갛습니다. test 2/4·macos의 release version line은 스택 트리 package.json이 2.34.0인데 이미 공개된 태그와 충돌합니다(dev는 이미 2.35.0). gates GUI는 window.prompt is not a function으로 부트스트랩 세션 테스트가 깨집니다(phase 2 표면 쪽 냄새). 작성자 검증(lidge-ai, 관련 스위트 140 pass / 전체 15410 pass)과 GitHub Actions 결과가 갈라져 있으니, 머지 전에 Actions를 초록으로 맞추는 게 맞습니다.

라인 src/cli/connect.ts --management-transport - CLI는 relay를 받지만 connectClient는 Phase 4 이전이라 바로 throw합니다. 도움말에 받아 놓고 런타임에서만 막는 발판입니다. 지금은 거절하거나 도움말에서 빼는 편이 덜 헷갈립니다.

라인 src/lib/service-secrets.ts writeServiceApiTokenFile - 이미 service-api-token이 있으면 connect 전체가 거절됩니다. standalone으로 쓰던 기계는 파일을 직접 치운 뒤에야 붙습니다. 의도는 fail-closed가 맞지만, 에러 메시지에 “로컬 프록시 토큰을 먼저 제거/이전하라”는 한 줄이 있으면 운영이 쉽습니다.
라인 src/client/connect.ts issued.key - 발급 키는 JS string이라 fill(0)이 안 됩니다. stdin Uint8Array만 지웁니다. 파일·허브 쪽으로는 맞고, 프로세스 메모리에 문자열이 남는 한계는 문서/계획에 한 줄 적어 두는 정도가 현실적입니다.
라인 src/cli/claude.ts refreshGatewayModelCacheFromProxy 호출 - typeof route === "number" 분기가 양쪽 같은 호출입니다. 동작은 오버로드로 되지만 죽은 분기입니다. 하나로 줄이세요.
라인 src/cli/claude.ts ClaudeRoutingTarget.baseUrl - Codex는 serverUrl+/v1이고 Claude는 origin만 넣습니다. Anthropic ANTHROPIC_BASE_URL 관례와 맞지만, 허브가 origin 루트에서 Claude 호환 /v1/messages를 실제로 받는지 phase 1 계약과 한 번 더 맞춰 보세요.
경로 src/config.ts clientConnectionSchema - malformed client는 catch로 빼고 경고만 남긴 뒤 src/client/state.ts가 invalid로 막습니다. 쓰기 API도 수리/명시적 clear 전엔 거절합니다. 이 fail-closed는 유지하세요.
경로 CI package.json 2.34.0 - 스택이 dev 2.35.0보다 뒤처져 release gate가 붉습니다. phase PR마다 버전을 올리지 말고, 랜딩 직전에 dev에 rebase/retarget하면서 맞추세요.
경로 #2771/#2772/#2776 - 이 PR만 Ready여도 dev에 단독 머지할 수 없습니다. 아래 스택이 먼저입니다.

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

너의 추천
지금 dev에 머지하지 마세요. #2771과 #2772를 먼저 랜딩하고, #2776 보안 리뷰·Ready 전환 뒤에 이 PR을 retarget하세요. 머지 직전에는 Actions(release version line, GUI window.prompt)를 초록으로 맞추고, relay CLI 발판과 Claude cache 호출의 죽은 분기는 정리하세요. 트랜잭션·토큰 파일 소유권·client fail-closed·Codex 바이트 호환 방향은 유지하는 게 맞습니다. preview deploy는 필요 없습니다.

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

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head aa26159532d4da2002f1d0aed897620788a1c6e0. It is 203 commits behind current dev@ae356a3cf, and exact-head CI has three concrete assertion failures: the GUI gate fails in gui/tests/api-auth-memory.test.ts:23, and tests/release-version-line.test.ts:108 fails on Linux shard 2 and macOS. This is a 34-file client/key/sync phase, so those failures plus the large integration drift make the current head unsafe to approve. Please rebuild it on current dev, preserve the current dashboard auth-memory boundary, fix the release-line contract rather than weakening it, and return with a fully green exact-head matrix.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head ad1ab25d8f2e1e44ca8433152e26247dc7d0d20d.

This phase is not merely waiting on the parent stack: its client contract is still the pre-correction contract and is incompatible with the exact Phase-1/Phase-2 bases it targets.

  1. Phase 1 now serves /v1/catalog unconditionally with Cache-Control: no-store, no ETag, and no 304. Phase 3 still requires a fresh ETag during connect (src/client/connect.ts:207-210), persists catalogEtag, sends If-None-Match, accepts 304 as not-modified, and uses the ETag as local catalog ownership (src/client/hub-client.ts:280-300, src/client/connect.ts:303-319,343-350). Against the exact 07d7f1006 parent, every real connect reaches a valid 200 catalog with no ETag and then throws initial hub catalog did not include a fresh ETag. Replace this with unconditional bounded fetches and a locally computed catalog fingerprint for ownership/rollback. Treat any unsolicited 304 as a protocol error that preserves the LKG. Update the stale 200/304 tests accordingly.
  2. --allow-insecure-http is still public CLI/config behavior (src/cli/connect.ts:25-27,122-145, src/client/connect.ts:50-58, src/client/hub-client.ts:169-184). The corrected Phase-2 server rejects non-loopback HTTP regardless of opt-in, so this option is both unsafe guidance and nonfunctional against the exact parent. Remove it completely; reject legacy argv rather than silently suggesting the operator can make reusable pairing safe over plaintext.
  3. Exact-head CI is red on two concrete assertions. The GUI gate inherits the unresolved Phase-2 gui/tests/api-auth-memory.test.ts:23 failure, and Linux shard 3 fails tests/cli-transport-honesty.test.ts:107 because the new sync dispatch body matches the forbidden handler-result-discard shape. Keep the connected no-local-fallback behavior, but make the runner satisfy the existing exit-code honesty invariant instead of weakening the guard.

The parent chain #2771#2772#2776 is itself under CHANGES_REQUESTED, and #2776 has a credential-transport proof bypass. This PR targets the stacked codex/remote-hub-p2 branch, not an integration branch. Fix the contract against corrected parents, rebase the actual Phase-3 delta onto the resulting current dev, and require a fully green exact-head matrix before another review. The transactional rollback, owner-only token file, offline disconnect, and client-mode no-local-provider-fallback direction should be preserved.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on exact head 38c361362142f49f5cd1b8f800b076e188a034f7.

The old catalog-contract blocker remains unchanged. Phase 1 serves /v1/catalog with Cache-Control: no-store, no ETag, and no 304. This client still requires a fresh ETag during initial connect, persists catalogEtag, sends If-None-Match, accepts 304, and uses the ETag as local catalog ownership. Against the exact parent 07d7f1006, a valid 200 catalog has no ETag and connect throws initial hub catalog did not include a fresh ETag.

Remove the validator/304 contract from the client and bind local ownership to the already-computed catalog body fingerprint. Sync should accept every bounded 200 body, and disconnect should compare the stored fingerprint rather than an HTTP validator.

Two current-head boundaries also remain valid:

  • boundedText() calls response.arrayBuffer() before checking the size, so a chunked/misreported hub response can consume unbounded client memory. Read and cancel incrementally at the limit.
  • Treating every process-owned Codex journal as safe to unwind does not establish ownership transfer. A live process can still own and mutate that journal. Re-own/fence the exact preimage before issuing the remote key, or refuse the transition until the process journal is reconciled; cover final-commit rollback and disconnect.

Add the missing connect/operator documentation in this phase or before any phase exposing the CLI can land. Exact-head CI being green does not make the client compatible with its exact parent while these contracts remain.

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing exact head c5420db.

The new prior-catalog restoration and profile-unlink completion checks are useful, but they do not resolve any of the three current blockers from the previous exact-head review.

  1. connectClient still rejects every valid Phase-1 catalog without an ETag, persists catalogEtag, and syncConnectedClient still sends the validator and accepts not-modified. The exact parent serves unconditional no-store 200 bodies without ETag or 304. Replace this protocol dependency with a local body fingerprint before extending the rollback payload.
  2. The bounded catalog reader still materializes response.arrayBuffer before enforcing its byte ceiling. Incrementally read and cancel at the limit so a chunked or dishonest response cannot allocate without bound.
  3. disconnectClient still explicitly treats every process-owned journal as safe to unwind. The new restore completeness check does not establish ownership transfer or fence a live process that can still mutate the same Codex state. Re-own/fence the exact journal preimage during connect, or refuse while a live process owns it, and cover connect final-commit rollback plus disconnect.

Keep the prior-catalog restoration direction, but bind its ownership to the local catalog fingerprint that replaces ETag. The parent stack remains blocked, so this phase is not a merge candidate on this head.

jun and others added 14 commits September 1, 2026 22:42
Connecting after `ocx start` is the ordinary path: routing is already injected
and the Codex journal is owned by the proxy process. Ownership never transfers
during connect, because writeJournal() refuses to overwrite a journal whose
config is already injected — so the process owner survives into the connected
state.

disconnectClient() read any non-matching owner as a conflict and refused. That
stranded the connection: the operator could not disconnect, and no action
available to them would make the check pass. The artifacts were preserved, so
nothing was lost, but the connected state had no exit.

A process-owned journal records the pre-injection baseline this same tool
wrote, so restoring it is exactly the right unwind. The genuine conflict is a
journal owned by a DIFFERENT client key, where restoring would tear down
another key's routing; that case still refuses, and its existing test still
passes.

Injected routing with no journal at all now gets its own message. Previously it
fell into the ownership error, which named the wrong cause: there is no
recorded baseline to restore, so unwinding would be guessing at the original
config rather than reading it.

The regression test drives the real shape — injected config plus a
process-owned journal — and fails against the previous refusal.
…anch

tests/cli-transport-honesty.test.ts flags any runner that awaits a handler and
then returns a literal 0, because that erases a failure the handler recorded in
process.exitCode. The exemption list requires a verified reason rather than a
name, and the connected sync branch has none: handleConnectedSyncCatalogWrite
drives app-server restarts, so a failure there must survive.

Returns process.exitCode like every other runner. Node types it as
number | string; only a numeric code is meaningful to the dispatcher.
…uck profile a clean restore

Two rollback defects. Both let disconnect report that native Codex state was
restored while leaving the user worse off than before they connected.

Connect overwrites whatever catalog is already at DEFAULT_CATALOG_PATH. The
pre-connect bytes were snapshotted only into an in-memory `priorCatalog`, which
covers a connect that fails and rolls back in the same run — not a disconnect,
which is a different process on a different day. Durable state recorded only the
remote catalog's fingerprint, so disconnect deleted the remote catalog and left
the user with none. That is the one artifact a rollback cannot reconstruct from
anywhere else: the token can be reissued and the config is journaled, but a
catalog the user brought with them is simply gone.

The snapshot is now persisted on the connection as `priorCatalog` (base64, or
"" for "there genuinely was none") and disconnect writes it back. An older
connection with the field absent keeps the previous removal behavior, since
nothing recorded what to restore. Ownership is still checked first — a catalog
edited since connect belongs to the user and `changed` refuses rather than
overwriting it. The result gains `catalogRestored` so the two outcomes are
distinguishable instead of both reading as `catalogRemoved`.

restoreJournalState set profileRestored = true after a swallowed unlink. When
the original profile was absent, "delete the one we generated" failing meant the
function still reported complete, which deletes the journal — the only record
that the leftover profile is ours. The user is told native state was restored
while our profile stays on disk with nothing left pointing at it. Now only a
verified removal counts, with ENOENT treated as success because the file being
already gone is the outcome the removal wanted.

The catalog fix carries a runtime regression driven red against the previous
behavior. The profile fix is asserted source-level, and the test says why: making
unlink fail requires denying writes on the Codex home, which denies the atomic
config write earlier in the same function, so the branch is unreachable from a
test process. Asserting a fabricated runtime failure would prove less than
asserting the shape.
Base automatically changed from codex/remote-hub-p2 to dev September 1, 2026 14:31
@lidge-jun
lidge-jun merged commit fd8b6b8 into dev Sep 1, 2026
46 of 48 checks passed
@lidge-jun
lidge-jun deleted the codex/remote-hub-p3 branch September 1, 2026 14:32
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.

2 participants