Skip to content

feat(link): connect a client to its hub through an SSH tunnel - #5801

Merged
lidge-jun merged 4 commits into
devfrom
codex/remote-link-3-client
Sep 25, 2026
Merged

lidge-jun merged 4 commits into
devfrom
codex/remote-link-3-client

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Third layer of the remote link stack. A child machine can now connect to its home (hub) through an SSH tunnel instead of a public HTTPS origin. Codex on the child keeps pointing at the child's own http://localhost:<port>, so its config does not change shape.

  • Config: OcxClientConnectionConfig gains transport?: "hub" | "link" and link?: { tunnelPort, linkId }. Existing configs are unaffected because an absent transport means hub. In link mode both origins must be http://127.0.0.1:<tunnelPort>, with the tunnel port restricted to 1024-65535.
  • ocx connect --link --key-stdin --tunnel-port P --link-id ID reuses connectClient and its journal. It reads the data key from at most 4 KiB of stdin and zeroes the source and copies on every path.
    • The key goes only into the service token file and is sent on readiness, catalog, hub-state and usage reads.
    • The command refuses when a service token already exists.
    • Rotation and revocation are refused in link mode because the hub owns the key.
  • Relay (src/client/link-relay.ts): the machine listener forwards exactly the route table in src/link/routes.ts, which the hub-link listener from L2 now shares.
    • It reuses the hub-relay header filtering, TE/CL checks and body caps, and adds no credential.
    • It rejects upgrades, streams SSE with caller-abort propagation and a 300-second idle limit, and answers 503 with Retry-After while the tunnel is down. The management relay is off in link mode.
  • Runtime: link mode binds the configured port or fails to start, so Codex's routing target can never drift to an ephemeral port. WebSockets are forced off for link targets by an explicit flag on the routing target; hub connections on localhost keep them.
  • Docs: structure/remote-link.md has a "Client link transport" section, and structure/runtime.md points to it.

Stack: #5788 (L1) ← #5798 (L2) ← this PR (L3) ← supervisor/API/CLI (L4) ← GUI (L5) ← child-initiated flow (L6). Base is codex/remote-link-2-hub-listener.

Security review requested (MAINTAINERS.md). This handles a data key on stdin and adds a credential-free relay into the tunnel.

Verification

  • Link, relay, runtime, injection, hub-link listener, boundary, existing client connect/machine-listener/hub-relay/runtime, cli-connect-readiness and test-layout suites: 212 pass, 0 fail.
  • All tests/codex-integration/injection-*.test.ts: 21 pass.
  • bun run typecheck, bun run structure:check, bun run privacy:scan, bun scripts/file-size-ratchet.ts: pass.
  • bun run test:changed selected 24,309 tests. I reran the 45 files with failures against the pre-change commit in a separate worktree.
  • The plan passed three rounds of independent audit. The implementation passed code review on the second round (two blockers found and fixed: hub-mode localhost WebSockets and stdin source-buffer zeroing).

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. (structure/remote-link.md, structure/runtime.md; user docs land with the GUI layer)
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. Maintainer security review is still pending.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 24, 2026 21:37
@coderabbitai

coderabbitai Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository: lidge-jun/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 2b362588-b827-4515-a1dd-62620fdc29a5

📥 Commits

Reviewing files that changed from the base of the PR and between 89aa880 and 767dcda.

📒 Files selected for processing (55)
  • devlog/_plan/260925_remote_home_child_link/000_prd.md
  • devlog/_plan/260925_remote_home_child_link/001_stack_plan.md
  • devlog/_plan/260925_remote_home_child_link/002_arch_plan.md
  • devlog/_plan/260925_remote_home_child_link/003_decisions.md
  • devlog/_plan/260925_remote_home_child_link/010_wp1_link_core.md
  • devlog/_plan/260925_remote_home_child_link/020_wp2_hub_link_listener.md
  • devlog/_plan/260925_remote_home_child_link/030_wp3_client_link_transport.md
  • devlog/_plan/260925_remote_home_child_link/040_wp4_supervisor_api_cli.md
  • devlog/_plan/260925_remote_home_child_link/050_wp5_remote_gui.md
  • devlog/_plan/260925_remote_home_child_link/060_wp6_client_initiated.md
  • scripts/test-layout/layout.json
  • src/cli/connect.ts
  • src/cli/runtime-api.ts
  • src/client/connect.ts
  • src/client/hub-relay.ts
  • src/client/link-relay.ts
  • src/client/machine-listener.ts
  • src/client/runtime.ts
  • src/client/state.ts
  • src/codex/inject/plan.ts
  • src/config/schema/leaf-validators.ts
  • src/link/paths.ts
  • src/link/routes.ts
  • src/link/ssh-argv.ts
  • src/link/ssh-config.ts
  • src/link/store.ts
  • src/link/tunnel-state.ts
  • src/server/audio-client.ts
  • src/server/audio-upstream.ts
  • src/server/auth-cors.ts
  • src/server/hub-usage.ts
  • src/server/index.ts
  • src/server/index/link-listener.ts
  • src/server/index/optional-listeners.ts
  • src/server/index/serve-options.ts
  • src/types/config.ts
  • structure/INDEX.md
  • structure/manifest.json
  • structure/remote-link.md
  • structure/runtime.md
  • tests/clients/client-link-connect.test.ts
  • tests/clients/client-link-relay.test.ts
  • tests/clients/client-link-runtime.test.ts
  • tests/clients/link-boundary.test.ts
  • tests/clients/link-routes.test.ts
  • tests/clients/link-ssh-argv.test.ts
  • tests/clients/link-ssh-config.test.ts
  • tests/clients/link-store.test.ts
  • tests/clients/link-tunnel-state.test.ts
  • tests/codex-integration/injection-link-websocket.test.ts
  • tests/fixtures/test-layout-expected.json
  • tests/lab/core-lab-boundary.test.ts
  • tests/lab/core-link-boundary.test.ts
  • tests/server/link-listener-admission.test.ts
  • tests/server/link-listener-lifecycle.test.ts
 ________________________________________________________
< A spoonful of AI helps the bitter code review go down. >
 --------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-24T21:43:17.133762Z c61ce2e PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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 24, 2026
@lidge-jun
lidge-jun force-pushed the codex/remote-link-2-hub-listener branch from 443b8b1 to 093e1aa Compare September 24, 2026 21:38
@lidge-jun
lidge-jun force-pushed the codex/remote-link-3-client branch from c61ce2e to 767dcda Compare September 24, 2026 21:38

@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: c61ce2ecf1

ℹ️ 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
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
throw new Error("link mode requires a valid local config port");
}
return `http://localhost:${localPort}/v1`;

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 Route linked Codex to the IPv4 listener

On Windows, localhost commonly resolves to ::1 first, but the linked client listener is explicitly bound only to 127.0.0.1 in src/client/machine-listener.ts. The repository's existing providerBaseHost helper already uses 127.0.0.1 to avoid this exact Windows failure; using localhost here makes every Codex request fail with connection refused on affected systems. Generate the link target with 127.0.0.1 as well.

Useful? React with 👍 / 👎.

Comment on lines +95 to +96
if (linkMode && linkRouteAllowed(url, req)) {
return relayLinkDataRequest(req, { tunnelPort: connection.link!.tunnelPort }, { fetchImpl: deps.fetchImpl });

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 Keep local readiness ahead of the link relay

In link mode, GET /readyz satisfies linkRouteAllowed, so this branch runs before the listener's local readiness response. ocx ready probes /readyz without a data key, while the hub-link ingress requires one, causing every linked runtime to appear unreachable; even a keyed request returns the hub's PID and port and fails the local identity check. Handle the machine listener's own /readyz before relaying link routes, or exclude readiness from this local relay path.

Useful? React with 👍 / 👎.

Comment thread src/client/runtime.ts
if (linkMode && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535)) {
throw new Error("link mode requires a valid local config port");
}
const preferred = linkMode ? config.port : options.port ?? config.port ?? 10100;

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 Reconcile Codex routing when the link port changes

If config.port changes after ocx connect --link—including when an operator follows the new error's advice to “change port”—startup binds the new value here, but the injected Codex configuration still targets the port captured during connect, and the connected-client startup path performs no reinjection before blocking. The runtime therefore starts successfully while all Codex requests continue hitting the old dead port. Reinject or validate the saved routing target at startup, or reject port drift until an explicit sync updates it.

Useful? React with 👍 / 👎.

Comment thread src/cli/connect.ts
Comment on lines +58 to +59
ocx connect --link --key-stdin --tunnel-port <port> --link-id <id>
[--clients codex,claude] [--catalog-timeout <seconds>] [--no-sync]

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 link credential workflow

This exposes a new user-facing command whose stdin must be an exact JSON object containing apiKeyId and a patterned key, but only the internal error usage was updated: ocx help connect still reads the old hub-only entry from src/cli/registry.ts, and the public remote-hub documentation contains no link-mode syntax or credential format. Operators cannot construct this command from published help, so update the command registry and docs-site/ alongside the implementation.

AGENTS.md reference: AGENTS.md:L452-L453

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 72 / 80

이 PR은 원격 링크의 세 번째 층입니다. 자식 컴퓨터가 공개 HTTPS 주소 대신 SSH 터널로 집(허브)에 붙습니다. 기준 브랜치는 codex/remote-link-2-hub-listener(#5798)입니다. #5788과 #5798이 dev에 들어간 뒤에 이 PR의 base를 dev로 바꿔야 합니다. 같은 일을 하는 다른 열린 PR은 없습니다.

Codex 설정 모양은 그대로입니다. Codex는 자식의 http://localhost:<설정 포트>/v1만 봅니다. 터널 주소는 http://127.0.0.1:<tunnelPort>이고, 데이터 열쇠는 ocx connect --link --key-stdin의 표준 입력으로 받습니다. 입력은 4KiB를 넘기면 거절하고, 받은 바이트는 성공·실패·초과에서 0으로 지웁니다. 열쇠는 서비스 토큰 파일에만 두고, 준비 상태·카탈로그·허브 상태·사용량 읽기에 보냅니다. 이미 토큰이 있으면 연결을 거절합니다. 링크 모드에서는 열쇠 회전과 폐기를 거절합니다. 열쇠 주인은 허브입니다.

src/link/routes.ts로 옮긴 경로 표를 허브 링크 리스너와 자식 릴레이가 같이 씁니다. 릴레이는 업그레이드를 거절하고, 본문 크기 상한을 지키며, SSE는 호출자가 끊으면 같이 끊고 300초 동안 데이터가 없으면 멈춥니다. 터널이 내려가 있으면 503과 Retry-After를 줍니다. 관리 릴레이는 링크 모드에서 꺼집니다. 설정한 포트를 못 잡으면 임시 포트로 넘어가지 않고 시작이 실패합니다. 웹소켓은 링크 대상에서만 강제로 끄고, 로컬호스트 허브 연결은 웹소켓을 유지합니다.

라인 - src/client/machine-listener.ts 95행, src/link/routes.ts 18행 — 링크 모드에서 GET /readyz가 로컬 준비 응답보다 먼저 터널로 넘어갑니다. 로컬 /readyz는 role: "client"와 이 프로세스의 pid·포트를 돌려줘야 합니다. ocx ready는 validateReadyzBody에서 그 포트가 방금 두드린 포트와 같은지 봅니다. 릴레이는 열쇠를 붙이지 않으므로, 열쇠 없는 준비 확인은 허브에서 거절됩니다. 열쇠를 붙여 보내도 허브의 포트가 자식 포트와 달라 로컬 계약은 실패합니다. /healthz는 경로 표에 없어서 로컬에 남습니다.

라인 - src/client/connect.ts 177행 — Codex base_url은 http://localhost:<포트>/v1입니다. 리스너와 런타임 기록은 127.0.0.1에만 붙습니다 (src/client/runtime.ts 95행, 112행). localhost가 ::1로 먼저 풀리면 Codex가 그 포트에 닿지 못합니다. 터널 목적지는 이미 127.0.0.1입니다. 주입 테스트는 localhost 문자열을 고정해 두었습니다.

라인 - src/client/link-relay.ts 55행, 61행 — 관리 릴레이는 허용한 헤더만 넘깁니다. 링크 릴레이는 filterRelayHeaders에 허용 목록을 넘기지 않습니다. hop-by-hop과 host, content-length를 뺀 요청·응답 헤더가 터널을 그대로 오갑니다. 자식의 로컬 프로세스가 Cookie 같은 헤더를 허브 링크 입구에 실을 수 있습니다. PR 설명은 허브 릴레이의 헤더 여과를 재사용한다고 되어 있습니다.

라인 - src/client/runtime.ts 102행 — 포트가 바쁘면 포트를 비우거나 바꾸라고 합니다. 포트를 바꾸면 리스너는 새 포트에 붙지만, Codex에 이미 심은 base_url은 다음 동기화 전까지 옛 포트를 가리킵니다.

라인 - src/cli/connect.ts 58행, 246행 — 사용법에는 플래그만 있습니다. 표준 입력은 apiKeyId와 ocx_data_ 뒤에 16진수 40자가 오는 key만 있는 JSON이어야 합니다. 형식이 틀리면 invalid link credential과 그 사용법만 보여 줍니다.

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

GET /readyz를 터널 경로 표에 둘지 정해 주세요. 허브 준비 상태에 열쇠를 붙여 묻는 일은 연결 코드가 터널 주소로 직접 합니다 (src/client/connect.ts 591행). 자식 리스너의 /readyz는 로컬 계약으로 두는 쪽이 ocx ready와 맞습니다.

Codex 주소를 127.0.0.1로 맞출지, localhost를 유지하고 리스너가 IPv6 루프백도 받을지 정해 주세요. Codex가 보내는 authorization은 관리 릴레이 허용 목록에 없습니다. 데이터용 허용 목록을 따로 둘지 봐 주세요.

체크리스트의 보안 리뷰는 아직 비어 있습니다. 표준 입력 열쇠와, 자격 증명을 더하지 않는 릴레이가 이 층의 경계입니다. 받은 바이트는 0으로 지우지만, JSON으로 읽은 열쇠 문자열은 releaseConnectCredential(src/client/connect.ts 201행)이 링크 종류를 건너뛰어 메모리에 남습니다. 토큰 파일만 비밀의 저장 위치로 볼지 확인해 주세요.

너의 추천

방향은 맞습니다. 터널 주소만 허용하고, 회전과 폐기는 허브에 맡기며, 포트를 못 잡으면 임시 포트로 넘어가지 않습니다. 닫을 중복 PR은 없습니다. #5798이 dev에 머지된 뒤 base를 dev로 바꾸세요. 머지 전에는 자식 /readyz를 로컬 응답으로 되돌리고, Codex가 붙는 주소를 리스너와 맞춘 다음, 보안 리뷰 체크를 채우는 쪽을 권합니다.

이 댓글은 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.

Reviewed exact head 767dcda. Blocking credential-boundary issue: forwardLinkRequestHeaders calls filterRelayHeaders without an allowlist and omits only Host, Content-Length, hop-by-hop, and Connection-nominated fields. Requests reaching the child machine listener can therefore forward child-local management secrets (x-opencodex-machine-session, x-opencodex-machine-csrf-token, x-opencodex-machine-gui-origin) and cookies through the tunnel to the hub data listener. A direct probe of the current helper returns all three of machine-session, machine-CSRF, and Cookie as present. The response path likewise forwards unrestricted end-to-end headers, including Set-Cookie, back into the child localhost origin. Please use an explicit data-plane request/response allowlist (or at minimum strip every machine/management credential and Cookie/Set-Cookie), and add regression tests proving local management/session headers cannot cross either direction while required Codex/Anthropic data-plane headers still do. Focused connection, relay, runtime, injection, listener, and boundary suites otherwise passed 143/143 under CPUQuota=200%, MemoryMax=4G, MemorySwapMax=0, TasksMax=128. The stack also remains blocked on #5788.

@lidge-jun
lidge-jun force-pushed the codex/remote-link-2-hub-listener branch from 093e1aa to 6914338 Compare September 25, 2026 05:01
Base automatically changed from codex/remote-link-2-hub-listener to dev September 25, 2026 05:01
A client with transport link keeps Codex on its own localhost port, accepts its data key on bounded stdin, and relays exactly the hub-link route table through the tunnel with the hub-relay header and body bounds. It binds its configured port or fails, keeps WebSockets off, and refuses key rotation and revocation, which the hub owns.
Link mode is now an explicit flag on the Codex routing target instead of an inference from localhost plus admission, so hub connections on localhost keep WebSockets. The link credential reader zeroes every source chunk and copy on success, error and overflow. Hub-mode connect again reads config after the catalog download, keeping its stderr order.
@lidge-jun
lidge-jun force-pushed the codex/remote-link-3-client branch from 767dcda to e346ece Compare September 25, 2026 05:02
@lidge-jun
lidge-jun merged commit 20c8922 into dev Sep 25, 2026
5 checks passed
@lidge-jun
lidge-jun deleted the codex/remote-link-3-client branch September 25, 2026 05:02
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