Skip to content

feat(link): turn a Child on from its own dashboard and keep Codex on 127.0.0.1 - #5970

Closed
lidge-jun wants to merge 1 commit into
claude/remote-link-ssh-hostsfrom
claude/child-link-turn-on
Closed

lidge-jun wants to merge 1 commit into
claude/remote-link-ssh-hostsfrom
claude/child-link-turn-on

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

Stacked on #5928 (claude/remote-link-ssh-hosts). Retarget to dev after #5928 lands.

A computer could not become a Child from its own dashboard. POST /api/link/join admitted only a paired session, and a standalone never issues one. Even a CLI join left the Child broken for a GUI-launched Codex. Link mode re-pointed Codex at an env_key provider table (http://localhost:<port>/v1 + OPENCODEX_API_AUTH_TOKEN) that a GUI app cannot satisfy. The relay also kept the management-relay bounds (4 MiB buffered, 15 s headers), forwarded caller credentials to the Home, relayed /readyz without a key, answered 404 to the Responses WebSocket probe and ran on Bun's 10 s idle default. After the restart the dashboard waited forever on a 404 /api/link/status.

Turn on from the Child's dashboard

  • POST /api/link/join admits the same session as the Home-side routes: a paired session, or the current standalone loopback session on trusted loopback ingress.
  • Join answers 409 join_port_mismatch before any SSH when the live port is not the configured port.
  • joinAvailable follows the same gates, and the Child card is selectable again.

127.0.0.1:<port>/v1 passes through as-is

  • A link Child keeps the standalone root form openai_base_url = "http://127.0.0.1:<port>/v1", with no provider table and no env_key. For the same port the join writes the bytes the standalone already wrote.
  • The Child's listener (src/client/link-ingress.ts) applies the standalone Host/Origin gate before any upstream fetch.
  • It answers 426 to a /v1/responses upgrade (the same HTTP fallback the standalone uses) and serves /readyz locally.
  • It returns 503 link_credential_unavailable without the committed key.

Credentials

  • The relay drops Authorization, x-api-key, x-opencodex-api-key, chatgpt-account-id and cookie.
  • It sends the link key as a Bearer (x-opencodex-api-key for GET /v1/usage), so the Home serves with its own accounts and the Child's own tokens never cross the tunnel.

After the restart

  • The Child listener serves a read-only GET/HEAD /api/link/status and advertises its own origin as the shared plane.
  • The dashboard shows a pre-join notice and polls /healthz after the 202. It reloads only when a Child with a new pid answers.
  • The wait never gives up: past the server's handoff budget it shows a slow notice and keeps polling.

Docs and i18n: childJoinUnavailable removed in all 10 locales; join_port_mismatch and restart.slow added. Structure docs, route registry and guides/remote-link.md (8 locales) updated.

Performance

  • The standalone and hub-transport paths are unchanged. Hub keeps its 4 MiB bound and default idle limit.
  • In link mode the request body streams chunk by chunk with the caller's Content-Length and a byte-counting cap at the inbound limit (256 MiB default), instead of being buffered up to 4 MiB.
  • The header deadline is 300 s with caller-abort.
  • The link key is read once and cached, so no relayed request touches the disk.
  • Both the Child listener and the Home hub-link listener bind with idleTimeout: 255. Each relayed request lifts its idle timer with one O(1) call, as a standalone data route does.
  • No new server timers.

Security review requested

  • The Child's 127.0.0.1:<port> becomes a keyless local path to the Home's providers. That is the same trust a standalone loopback bind gives, with browsers held off by the Host/Origin gate.
  • The loopback dashboard session can now join. This is the same casual-path trade the Home-side apply already makes: key-only SSH, a confirmed host key, a 5-minute TTL and CSRF.
  • The key is never logged or returned.

Role select: Child is selectable
Child → Find Home loads SSH hosts

(Screenshots: this branch's built dashboard, served by a proxy with a temporary HOME/OPENCODEX_HOME/CODEX_HOME and demo SSH aliases.)

Verification

  • bun run typecheck, bun run structure:check, bun run privacy:scan, (cd gui && bunx tsc -b), bun run lint:gui, (cd gui && bun run lint:i18n): pass.
  • Exact changed test files only, by the maintainer's no-local-suite rule. CI covers the rest.
    • The server/client link tests (client-link-relay, client-machine-listener, client-link-status, client-link-connect, injection-link-websocket, link-join-route, link-management-routes, link-listener-lifecycle): 89 pass, then 37 pass on the files re-touched by the review fixes.
    • gui/tests/remote-link.test.tsx plus locale parity: 47 pass.
    • file-size-ratchet and the test-layout guards: pass.
  • Every behavior test was reverted against its hunk and failed:
    • credential replacement, streaming, 413;
    • Host/Origin, 426, local /readyz, idleTimeout, /api/link/status;
    • the shared plane and root-form routing;
    • join admission and join_port_mismatch;
    • the GUI reload flow;
    • chunked admission;
    • the idle-timer lift.
  • Real-socket probe: a 5 MiB POST was relayed with its original Content-Length and the Bearer link key in 10 ms. An SSE response with a 12 s gap streamed fully (ECONNRESET without the idle fix).
  • Not done: a manual end-to-end join between two real machines, and the docs-site build (left to CI).

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.

🤖 Generated with Claude Code

…127.0.0.1

Root cause: POST /api/link/join admitted only a paired session, which a
standalone never issues, so no computer could become a Child from its own
dashboard. A link Child also routed Codex through an env_key provider table
(http://localhost:<port>/v1 + OPENCODEX_API_AUTH_TOKEN) that a GUI-launched
Codex cannot satisfy, and its relay kept the 4 MiB / 15 s management-relay
bounds, forwarded caller credentials, relayed /readyz without a key, 404ed
the Responses WebSocket probe, refused chunked uploads, and ran on Bun's
10 s idle default with the per-request idle timer still armed.

Fix:
- Join admits the same dashboard session as the Home-side routes (paired, or
  the current standalone loopback session on trusted loopback ingress) and
  answers 409 join_port_mismatch before any SSH when the live port is not the
  configured port. joinAvailable follows the same gates.
- A link Child keeps the standalone root form openai_base_url =
  http://127.0.0.1:<port>/v1 (no provider table, no env_key); for the same
  port the join writes the bytes the standalone wrote.
- The link data plane (src/client/link-ingress.ts): standalone Host/Origin
  gate before any upstream fetch, 426 for a /v1/responses upgrade, local
  /readyz, 503 link_credential_unavailable without the committed key. A
  relayed request lifts its own idle timer (server.timeout(req, 0)), as a
  standalone data route does, so a quiet stretch longer than 255 s inside a
  long generation is not cut.
- The relay drops Authorization, x-api-key, x-opencodex-api-key,
  chatgpt-account-id and cookie and sends the link key as a Bearer
  (x-opencodex-api-key for GET /v1/usage), so the Home serves with its own
  accounts. A lone Transfer-Encoding: chunked without Content-Length is
  admitted (the listener already de-chunked it); any other framing
  ambiguity still answers 400.
- The Child listener serves its own read-only GET/HEAD /api/link/status
  (src/client/link-status.ts) and advertises its origin as the shared plane.
- Dashboard: pre-join notice, /healthz pid read, 1 s /healthz poll after the
  202, reload only onto role client with a new pid. The wait never gives up:
  past the server's handoff budget (60 s drain + 70 s replacement + 15 s) it
  shows a slow notice and keeps polling every 5 s, so a late Child still
  reloads the page. childJoinUnavailable removed in all 10 locales,
  join_port_mismatch and restart.slow added. Structure docs, route registry
  and docs-site guides (8 locales) updated.

Performance: standalone and hub transport paths are unchanged (hub keeps the
4 MiB bound and default idle limit). In link mode the request body streams
chunk by chunk with the caller's Content-Length and a byte-counting cap at the
inbound limit (256 MiB default) instead of buffering up to 4 MiB; the header
deadline is 300 s with caller abort; the link key is read once and cached, so
no relayed request touches the disk; both the Child machine listener and the
Home hub-link listener bind with idleTimeout 255, and each relayed request
lifts its idle timer with one O(1) call (a probe cut a 5 s SSE gap at
idleTimeout 1 without it). The idle-timer helper is local, so the Child does
not load the Responses WebSocket upstream modules. No new server timers; the
only new poll is the browser's /healthz read while a join restart is pending.

Security: auth-boundary change. The Child's 127.0.0.1:<port> becomes a keyless
local path to the Home's providers, the same trust a standalone loopback bind
gives, with browsers held off by the Host/Origin gate; the Child's own
credentials no longer cross the tunnel. The loopback dashboard session can now
join, the same casual-path trade apply already makes (key-only SSH, confirmed
host key, 5-minute TTL, CSRF). The key is never logged or returned. Chunked
admission is limited to the link data plane; the hub management relay stays
strict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 26, 2026 15:55
@coderabbitai

coderabbitai Bot commented Sep 26, 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: Repository: lidge-jun/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 8fb00c66-c620-4ca9-b4d0-de3edad2db2b

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 26, 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-26T16:00:57.346297Z 12fc878 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.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 63 / 80

이 PR은 내 컴퓨터의 대시보드에서 이 컴퓨터를 자식으로 켜게 합니다. 지금까지 POST /api/link/join은 페어링된 세션만 받았습니다. 혼자 도는 컴퓨터는 그 세션을 만들지 않아서, 대시보드의 자식 카드가 눌리지 않았습니다. 이제는 홈 쪽과 같은 대시보드 세션이면 가입할 수 있습니다. 지금 듣고 있는 포트가 설정 포트와 같을 때만 진행하고, 다르면 SSH를 열기 전에 409 join_port_mismatch를 돌려줍니다.

가입 뒤 Codex 주소는 http://127.0.0.1:<포트>/v1 그대로입니다. 설정에는 루트 openai_base_url만 남고, 공급자 표와 env_key는 없습니다. GUI로 켠 Codex가 키 환경 변수를 몰라도 같은 주소로 붙습니다. 자식 리스너가 요청을 받아, 호출자의 Authorization, API 키, 쿠키를 지우고, 저장된 링크 키만 홈에 보냅니다. 홈은 자기 계정으로 응답합니다. /readyz는 자식이 직접 답하고, Responses 웹소켓은 426으로 거절합니다. 재시작 뒤 대시보드는 /healthz를 보다가, 새 프로세스 번호의 자식이 대답하면 페이지를 새로 고칩니다.

기준 브랜치는 #5928 claude/remote-link-ssh-hosts입니다. #5928은 아직 dev에 들어가지 않았고, 열려 있습니다.

gui/src/pages/RemoteLink.tsx:382 - joinAvailable이 거짓이면 항상 포트 불일치 문장을 보여 줍니다. 서버의 그 값은 포트와 함께, 이 세션이 현재 루프백 대시보드 세션인지도 봅니다. 포트는 맞는데 세션만 어긋나도 사용자는 포트를 다시 맞추라는 안내를 봅니다.

gui/src/remote-link-api.ts waitForChildRuntime - 가입이 202로 끝난 뒤, /healthz가 role: client가 될 때까지 확인을 멈추지 않습니다. 재시작이 끝나지 않고 서버가 독립형으로 그대로 대답하면, 이 화면에서 역할 선택으로 돌아가는 버튼이 없습니다. 사용자는 페이지를 직접 새로 고쳐야 합니다.

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

자식의 127.0.0.1은 키 없이 홈 공급자로 나가는 통로입니다. 그 컴퓨터의 다른 프로그램도 이 포트로 홈 계정을 쓸 수 있습니다. 다른 사이트의 브라우저는 Host와 Origin 검사로 막습니다. 가입은 홈을 추가할 때와 같은 대시보드 세션만 받고, 다른 웹사이트가 몰래 호출하지 못하게 출처와 세션 토큰을 확인합니다. 그 결과로 이 컴퓨터의 OpenCodex가 다시 시작됩니다. 이 거래를 이 릴리스에 둘지 정해 주십시오.

#5928이 dev에 들어간 뒤 이 PR의 기준을 dev로 옮기면 됩니다. #5928을 닫는다면 이 PR도 함께 닫으면 됩니다. 두 대의 실제 컴퓨터로 가입해 본 기록은 이 PR에 없습니다.

너의 추천

방향은 맞습니다. 포트 안내를 세션 실패와 나누고, 독립형으로 남아 있으면 기다림에서 나오게 하는 쪽이 안전합니다. #5928이 dev에 들어간 뒤 기준을 dev로 옮겨 머지하면 됩니다. 키 없는 로컬 통로는 그 결정을 한 뒤에 넣는 변경입니다.

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

lidge-jun added a commit that referenced this pull request Sep 27, 2026
…ch 9E) (#5998)

Carry the owner's Remote Link, restart, desktop supervision and Codex routing fixes onto dev in their original order, then carry RHODIZSECURITY's combo reasoning fix as one attributed commit.

| PR | Change | Author |
| --- | --- | --- |
| #5970 | Turn a standalone computer into a Child from its dashboard; keep Codex on its local loopback URL and protect the linked data plane. | lidge-jun |
| #5973 | Reconnect the Child's SSH tunnel after sleep, outages and crashes. | lidge-jun |
| #5972 | Heal opencodex-owned Codex routing left on a dead loopback endpoint, with ownership and race gates. | lidge-jun |
| #5971 | Keep a proxy on the configured port through a Child restart. | lidge-jun |
| #5974 | Let the desktop app supervise runtime restarts and unexpected exits. | lidge-jun |
| #5990 | Apply forced combo defaults over declared none/minimal reasoning sentinels. | RHODIZSECURITY |

The carry keeps the 9D one-use sibling handoff and passes link status, cached key and tunnel gate through the Child listener. Separate integration commits bound the port-conflict regression test and keep carried files below the file-size guard, including newer dev's layout entries. The five owner commits retain JUN's authorship; the #5990 squash retains RHODIZSECURITY's commit identity and noreply co-author trailer.

An independent review found four integration defects. Each repair is a separate Codex-authored commit:

| Finding | Commit | Repair |
| --- | --- | --- |
| Linked requests could fetch without a connected tunnel. | 6a29f7b | Require a positive supervisor connected verdict before every fetch; missing, failed and stopped supervision return 503 without forwarding key or body. |
| IPv4 and IPv6 destinations on one port shared one probe/streak key. | 8903998 | Probe and track each hostname and port separately; a live endpoint blocks healing and an address change starts a fresh dead-probe streak. |
| A same-port route change could pass the locked write guard. | f62e43b | Abort when admitted config bytes or the complete destination set changes under the lock, then require fresh probes. |
| Orphan reaping could KILL a reused PID. | e0ef426 | Record the process start identity and revalidate argv, start time and orphan status before TERM and before KILL; legacy records lacking start identity never authorize a signal. |

A second review confirmed those four repairs and found two remaining blockers:

| Finding | Commit | Repair |
| --- | --- | --- |
| A competing local listener received the readiness key and private relay traffic before SSH bound the tunnel port. | 39f246a | Require an exclusive local LISTEN socket owner PID matching the SSH child before every keyed probe and relay admission; adopted processes also need matching pidfile argv and start time. Unknown scans fail closed. |
| Newer dev mappings made the merge result exceed the layout file-size guard. | 3bb2ab6, 46ee24f | Merge origin/dev at a91568e, then compact formatting while retaining every explicit mapping. |

A third review confirmed the competing-listener and layout repairs, then found three lookup defects:

| Finding | Commit | Repair |
| --- | --- | --- |
| Minimal Linux lacks lsof/netstat and never proves the SSH listener. | 9c66251 | Use tool-independent async /proc/net/tcp{,6} inode lookup, checking the expected SSH PID's fd symlinks first. |
| A foreign ::1 listener shares the numeric port with the owned IPv4 forward. | 9c66251 | Match only the exact 127.0.0.1 address and port on Linux, macOS and Windows. |
| Synchronous owner scans block Bun on every relayed fetch. | b5e565d | Use bounded async lookups and a one-second positive proof keyed by port, SSH PID, start identity and tunnel generation; re-prove after restart. |

The branch also merged current dev at 35f267d in 51747c9. The merged test registries retain both lanes' mappings and the management contract retains Kiro's account projection and Child join.

Current dev through `2a3cfa5abe` was merged again in `5856179cd1` without conflicts. It brings #6012's macOS plugin ACL fix and dev's Kiro projection test clock correction; `scripts/test-layout/layout.json` stays at 1,997 lines. The merge changes no link/relay or server-management-auth files.

A fourth review found that a one-second proof cache could survive a local port takeover, and that an adopted PID's start identity was only checked at adoption:

| Finding | Commit | Repair |
| --- | --- | --- |
| Cached ownership authorized the next keyed probe or relay after a port takeover. | b8142ad | Every keyed probe and every relayed fetch now obtains a fresh bounded asynchronous socket-owner proof; concurrent admissions do not share a cached success. |
| A reused adopted PID retained its old trusted start identity. | b8142ad | Re-read current argv and start time on every adopted admission, invalidate trust and mark the link failed on mismatch. |
| The TCP listener can change after the check and before connect. | bee1613 | Record this pre-existing residual race and a private Unix-domain SSH forward as future hardening in the link structure contract. |

A fifth review found that transient unreadable adopted identity was treated like a confirmed replacement:

| Finding | Commit | Repair |
| --- | --- | --- |
| One null or timed-out identity read permanently disabled a live adopted link. | 0eefebf | Return an explicit unknown verdict; deny only the current keyed admission and retry on the next check without discarding the adopted record. |
| A confirmed changed identity left the old adopted PID blocking recovery. | 0eefebf | Release the stale adoption and pidfile without signalling that PID; the next supervisor tick starts its own SSH tunnel. |

Windows CI follow-up: `88fbb539af` samples the relay hold clock once per attempt. The initial reconnect wait now receives the full 15-second budget even when the wall clock ticks during admission; later retries still subtract elapsed time.

Windows teardown follow-up: `0171b4856c` makes `server.stop(true)` await any timed-out `icacls.exe` child still reaping after config-directory hardening settles. The stop promise now marks the actual handle-release boundary before a caller removes the home.

Security review: Child join still refuses Tailscale identity, a non-standalone role and a mismatched live port before SSH. Linked data routes retain the Host/Origin gate, committed-key fingerprint, caller-credential stripping and inbound byte cap. The relay now sends no key or request without positive tunnel supervision, and the supervisor obtains a fresh bounded asynchronous exact-IPv4 owner proof before every keyed probe and relay fetch; adopted processes also have their current argv and start time checked each time. An unknown read refuses only that admission; a confirmed mismatch releases the adopted PID without signalling it. Desktop supervision stays bound to its live parent, and dashboard Stop is refused before teardown while CLI/tray Stop remains available. Routing self-heal writes only owned loopback routing after all distinct endpoints were proven dead and the locked bytes were rechecked. The existing home-bound stop proof and sibling Desktop-write gate remain intact.

![Child role selectable](https://github.com/lidge-jun/opencodex/blob/3aa948da9e5b1c6dc47c9c45ab08e47fa5b95ece/260927-child-link-turn-on/05-role-select-child-enabled.png?raw=true)
![Find Home sheet](https://github.com/lidge-jun/opencodex/blob/3aa948da9e5b1c6dc47c9c45ab08e47fa5b95ece/260927-child-link-turn-on/06-find-home-sheet.png?raw=true)

Co-authored-by: RHODIZSECURITY <180237049+RHODIZSECURITY@users.noreply.github.com>
@lidge-jun

Copy link
Copy Markdown
Owner Author

Landed on dev through merge train batch 9E, #5998 (merge 5744e7a), as the original commit on top of batch 9D. Integration and review follow-ups on top, each with a regression: restart issues the one-use sibling handoff; the Child listener forwards the link status, key and tunnel gate; the relay sends the link key only after a fresh proof that the tunnel port's listener is the SSH child, and only while the tunnel is connected; routing self-heal probes host and port separately and aborts on any config change under the lock; the tunnel reaper revalidates process identity before each signal. Closing since the content is now on dev.

@lidge-jun lidge-jun closed this Sep 27, 2026
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