Skip to content

feat(link): add /api/link routes, the tunnel supervisor and ocx link - #5803

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

lidge-jun merged 8 commits into
devfrom
codex/remote-link-4-api

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fourth layer of the remote link stack. It turns the building blocks into a working flow a home machine can drive: it keeps SSH tunnels alive, exposes /api/link/* for the dashboard, and adds ocx link for the CLI and for the child-initiated flow in L6.

  • Supervisor (src/link/supervisor.ts): one ssh -R 127.0.0.1:P:127.0.0.1:L child per hub-initiated link, driven by the L1 reducer.

    • It reloads when links change, respawns a tunnel whose record changed, and stops before the hub-link listener on shutdown.
    • A leftover tunnel is killed only when Linux /proc/<pid>/cmdline matches the recorded argv exactly; elsewhere it is reported, never killed.
  • Runner: src/link/ssh-runner.ts runs every ssh and ssh-keygen argv without a shell and caps captured output. Fingerprints come from ssh-keygen -lf on a temporary known_hosts file.

  • Routes (src/server/management/link-routes.ts, loaded lazily):

    Route Allowed principals
    GET /api/link/candidates, POST /api/link/probe, POST /api/link/confirm-host, POST /api/link/apply paired dashboard session
    GET /api/link/status, DELETE /api/link/{id} paired dashboard session, or the admin token on a trusted loopback ingress
    POST /api/link/issue the admin token on a trusted loopback ingress only

    Tailscale-identity sessions are refused on every link route. "Trusted loopback" comes from the listener the request arrived on, not from Host, so the Tailscale Serve management ingress and the hub-link ingress never qualify.

  • Apply: probe the host key → operator confirms the fingerprint → issue a data key → record the link → ensure the listener is listening → reload the supervisor → run ocx connect --link --key-stdin on the client over SSH with the key on stdin. Success needs exit 0 and the first key-authenticated /v1/catalog read within 15 seconds. A failed step revokes the new key before removing its record. If revocation fails, the record stays with a compensation_failed status, and revocation is idempotent so a retried removal completes.

  • Remove: stop the tunnel → ocx disconnect on the client → revoke → delete the record → close the listener after the last link. A failed remote disconnect keeps everything unless force is set.

  • CLI: ocx link port|issue|revoke|status (src/cli/link.ts), registered in capabilities, the usage banner and the generated skill surface. The key is printed only on issue's stdout.

  • Docs: structure/remote-link.md and structure/gui-and-management-api.md.

Stack: #5788 (L1) ← #5798 (L2) ← #5801 (L3) ← this PR (L4) ← GUI (L5) ← child-initiated flow (L6). Base is codex/remote-link-3-client.

Security review requested (MAINTAINERS.md). This covers key issuance, admin-token routes, SSH execution and host-key trust.

Verification

  • Link core, supervisor, projection, fingerprint, admission-wait, client link, CLI, management routes, hub-link listener, boundary, injection, skill-surface, existing /api/keys suites and test-layout: 431 pass, 0 fail.
  • Route tests cover every principal × ingress × route combination, the pure trusted-loopback derivation for all four ingresses, and compensation at each failure point (including a failed revoke and a failed store write). They also cover listener-not-listening refusal, the partial-compensation retry and apply ordering (reload before remote connect).
  • bun run typecheck, bun run structure:check, bun run privacy:scan, bun run skill:surface:check, bun scripts/file-size-ratchet.ts: pass. src/server/index.ts is 891/893 lines.
  • bun run test:changed (24,403 tests): I compared the failing files against the pre-change commit in a separate worktree.
    • One real regression turned up (ocx link missing from the usage banner) and is fixed here.
    • The remaining failures are identical in both trees, apart from one test that flips between runs in both.
  • The plan passed three rounds of independent audit. The implementation passed code review on the third round (blockers fixed: orphaned keys on compensation, issue while the listener failed, stale tunnel after a record change, stuck cleanup retry).
  • The real SSH round trip between two machines is not exercised by automated tests; that check follows the GUI layer.

Checklist

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

Review follow-up (a0a5a24)

This addresses the review comment on this PR:

  • issue, apply and remove reread links.json right before each write and change only their own record. Links written by another request during the SSH wait survive (race tests for all three).
  • A failed remote disconnect restarts the tunnel before returning remote_disconnect_failed.
  • compensation_failed persists in a private link/compensation.json sidecar, so status shows it after a restart. At startup the supervisor revokes any link: key that no record references.
  • Supervisor reloads are coalesced, so a reload requested mid-run triggers one more run instead of being dropped (two near-simultaneous applies both get tunnels).
  • A tunnel reports connected only after the hub-link listener sees an authenticated request with its key, or after its ssh child survives a 5-second grace period. An early exit goes to reconnecting or failed.
  • issue rejects a duplicate client-initiated alias with 409 link_exists, the same as apply.
  • The empty catch {} in the route tests is gone. The repository hygiene rules run locally against this diff return no findings.

Verification at a0a5a24: 433 pass / 0 fail on the link, route, listener, boundary, CLI, registry, /api/keys and layout suites, plus typecheck, structure, privacy and file-size gates.

Still for maintainers: the security review, and the loopback-trust question (the admin token on a public listener bound to a loopback name is accepted for the CLI path). The PR stays in draft until the security review.

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 25, 2026 00:32
@coderabbitai

coderabbitai Bot commented Sep 25, 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: 39864226-c8d4-48a1-9e6d-21fd444c3138

📥 Commits

Reviewing files that changed from the base of the PR and between 20c8922 and a0a5a24.

📒 Files selected for processing (81)
  • 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
  • skills/ocx/references/01_management_surface.md
  • src/cli/capabilities.ts
  • src/cli/connect.ts
  • src/cli/dispatch.ts
  • src/cli/help.ts
  • src/cli/link.ts
  • src/cli/registry.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/admission-wait.ts
  • src/link/compensation.ts
  • src/link/fingerprint.ts
  • src/link/paths.ts
  • src/link/routes.ts
  • src/link/ssh-argv.ts
  • src/link/ssh-config.ts
  • src/link/ssh-runner.ts
  • src/link/status-projection.ts
  • src/link/store.ts
  • src/link/supervisor.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/server/management-api.ts
  • src/server/management-auth.ts
  • src/server/management/context.ts
  • src/server/management/link-routes.ts
  • src/server/management/oauth-account-routes.ts
  • src/server/management/route-registry.ts
  • src/types/config.ts
  • structure/INDEX.md
  • structure/gui-and-management-api.md
  • structure/manifest.json
  • structure/remote-link.md
  • structure/runtime.md
  • tests/cli/cli-link.test.ts
  • tests/clients/client-link-connect.test.ts
  • tests/clients/client-link-relay.test.ts
  • tests/clients/client-link-runtime.test.ts
  • tests/clients/link-admission-wait.test.ts
  • tests/clients/link-boundary.test.ts
  • tests/clients/link-compensation.test.ts
  • tests/clients/link-fingerprint.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-status-projection.test.ts
  • tests/clients/link-store.test.ts
  • tests/clients/link-supervisor.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
  • tests/server/link-management-routes.test.ts
 ______________________________________________________________________________________________________________________
< You can't write perfect software. Software can't be perfect. Protect your code and users from the inevitable errors. >
 ----------------------------------------------------------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ 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 25, 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-25T00:37:39.862196Z 3a58c71 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 github-actions Bot added the intake: hygiene-blocked Deterministic PR hygiene checks failed label Sep 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Deterministic hygiene checks failed.

  • empty_catch — An empty catch block was added. Handle, report, or deliberately propagate the error. Paths: tests/server/link-management-routes.test.ts.

@github-actions github-actions Bot added the enhancement New feature or request label Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ READY

  • all PR quality gates passed.

Hygiene

✅ Deterministic PR hygiene checks passed.

@github-actions
github-actions Bot marked this pull request as draft September 25, 2026 00:33
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 66 / 80

이 PR은 집 컴퓨터가 다른 컴퓨터를 SSH 터널로 붙이는 네 번째 층이다. 터널 프로세스를 살려 두고, 대시보드용 /api/link/* 를 열고, ocx link 로 포트를 고르거나 키를 발급하고 상태를 보고 끊을 수 있게 한다. 연결이 실패하면 새 키를 먼저 폐기하고, 폐기가 실패하면 기록을 남겨 둔다. 베이스는 codex/remote-link-3-client 이다.

src/server/management/link-routes.ts remove (360, 374행) - 링크 목록을 먼저 읽어 둔 뒤, 상대 컴퓨터와 끊는 SSH를 최대 30초 기다린다. 끊기가 성공하면 그 오래된 목록을 그대로 저장한다. 기다리는 동안 생긴 다른 링크 기록은 이 저장에 지워진다.

src/server/management/link-routes.ts issue (326, 343행), apply (277, 284행) - 여기도 읽어 둔 목록에 항목 하나만 더해 통째로 저장한다. 그 사이에 다른 요청이 목록을 고치면, 방금 만든 다른 링크가 파일에서 빠진다. issue 는 키를 디스크에 쓴 다음에 목록을 저장해서 그 틈이 더 길다.

src/server/management/link-routes.ts remove (366행) - 원격 끊기가 실패하면 기록과 키는 남는다. 터널은 그 확인보다 먼저 꺼 둔다. 1초 타이머는 다시 연결을 시도 중인 터널만 살린다. 꺼진 뒤 idle 이 된 터널은 프로세스가 다시 뜰 때까지 살아나지 않는다.

src/server/management/link-routes.ts compensationFailures (161행) - 키 폐기에 실패했다는 표시는 메모리에만 있다. 프로세스가 다시 뜨면 표시가 사라지고, 폐기되지 않은 키는 그대로 쓸 수 있다.

src/link/supervisor.ts runLifecycle (254행) - 터널 목록을 다시 읽는 작업이 돌아가는 동안 들어온 다음 요청은 실행하지 않는다. 앞 작업이 끝나기만 기다린다. 링크 두 개를 거의 동시에 적용하면 두 번째 터널이 안 뜰 수 있다.

src/link/supervisor.ts spawnFor (194행) - ssh 프로세스를 띄우자마자 준비 완료로 표시한다. 터널이 실제로 붙기 전에도 상태가 connected 로 보인다.

tests/server/link-management-routes.test.ts (97행) - afterEach 의 빈 catch {} 때문에 hygiene 게이트가 실패한다. 이 PR은 아직 draft다.

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

본문의 보안 리뷰 체크는 아직 비어 있다. 키 발급, 관리자 토큰, SSH 실행이 들어 있다. 신뢰하는 루프백은 요청이 도착한 리스너와 config.hostname 으로 정한다. public 리스너가 루프백 이름에 묶여 있으면, 그 포트의 관리자 토큰도 키를 발급할 수 있다. CLI가 쓰는 경로로 둘지 확인해 달라.

issue 는 같은 alias 의 client-initiated 링크를 여러 개 만든다. apply 는 hub-initiated 중복을 막는다. 둘을 같게 할지는 정해 달라.

자동 테스트는 두 컴퓨터 사이 실제 SSH를 돌리지 않는다. 작성자도 GUI 층 다음에 확인한다고 적었다.

이 PR은 L1부터 L3 위에 쌓인 L4다. 스택에 남겨 두고, dev 로 합치는 시점은 아래 층이 들어간 뒤로 두면 된다.

너의 추천

목록을 저장하기 직전에 다시 읽어서, 지우거나 더하는 그 항목만 바꿔라. 원격 끊기가 실패하면 터널을 다시 띄워라. compensation_failed 는 링크 파일에 남겨서 재시작 후에도 보이게 해라. supervisor 작업은 줄 세워서, 나중에 온 다시 읽기가 버려지지 않게 해라. 테스트의 빈 catch 를 고쳐서 hygiene 를 통과시키고, 보안 리뷰 전에는 draft 를 유지해라.

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

@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: 3a58c71992

ℹ️ 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".

const current = readStoreFor(ctx);
const next = { ...current, links: current.links.filter(link => link.id !== record.id) };
if (next.links.length !== current.links.length) {
writeStoreFor(ctx, next);

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 Reload the supervisor after deleting a link

After a failed apply reaches compensation, the supervisor has already loaded this record, but stopLink() only kills the current child and sets its state to idle; deleting the record on disk without calling reload() leaves it in the supervisor's in-memory store, so the next timer tick respawns the SSH tunnel. The successful DELETE path at line 375 has the same defect, leaving a supposedly removed link's tunnel running and its remote port occupied until shutdown. Reload the supervisor after each persisted deletion.

Useful? React with 👍 / 👎.

Comment thread src/server/management/link-routes.ts Outdated
} catch { return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502); }
}
if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
const next = { ...current, links: current.links.filter(link => link.id !== 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-read the link store after awaited teardown

The current snapshot was captured before stopLink() and a remote disconnect that may take up to 30 seconds. If another issue, apply, or delete request changes the store while either operation is awaited, constructing next from this stale snapshot overwrites that completed change—for example, dropping a newly issued link while leaving its API key live, or resurrecting a concurrently removed record whose key was revoked. Serialize link mutations or re-read the store immediately before filtering.

Useful? React with 👍 / 👎.

Comment thread src/cli/link.ts
Comment on lines +241 to +243
if (!deps.baseUrl && error instanceof RuntimeApiError && error.status === 503) {
const store = (deps.readStore ?? (() => readLinkStore(linkStorePath())))();
console.log(JSON.stringify(localStatus(store)));

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 Distinguish a client listener from an absent proxy

runtimeBaseUrl() also throws status 503 when it discovers a live client-role listener, not only when no proxy is running. Consequently, normal ocx link status execution on a connected child enters this fallback, reads the hub-side link store (typically empty), and reports role: "standalone" instead of the child's link state. Narrow the fallback to the explicit no-live-proxy case or project status from the local client connection when the detected role is client.

Useful? React with 👍 / 👎.

@github-actions github-actions Bot removed the intake: hygiene-blocked Deterministic PR hygiene checks failed label Sep 25, 2026
@github-actions
github-actions Bot marked this pull request as ready for review September 25, 2026 02:36

@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 a0a5a24517b1c30d91797ba4593f4224390b90bc. The latest commit fixes the stale store snapshots, persists compensation evidence, restarts after remote-disconnect failure, and coalesces supervisor reloads safely. Three current-head blockers remain:

  1. src/server/management/link-routes.ts:187-196 and :398-407 remove the record on disk after stopLink(), but never reload the supervisor. Its in-memory store still contains the deleted hub link, so the timer can spawn the supposedly removed SSH tunnel again and keep the remote port occupied. Reload after every persisted deletion, including successful compensation.
  2. src/server/management/link-routes.ts:397 returns immediately when key revocation fails after the tunnel was stopped. The store and still-valid key remain, but unlike the remote-disconnect failure branches, the tunnel is not restarted. Restore the prior live state or persist a durable failed state that prevents a silent half-removal.
  3. src/cli/link.ts:237-246 treats every implicit runtimeBaseUrl() 503 as “no proxy” and projects the local hub-side store. runtimeBaseUrl() also uses 503 for a live client-role listener, so ocx link status on a connected child reports standalone instead of child state. Distinguish the no-listener result from the explicit client-role refusal.

Please add regression coverage for successful delete/compensation reloading the supervisor, revoke failure after stopLink(), and client-role status projection before re-requesting review.

@lidge-jun
lidge-jun force-pushed the codex/remote-link-3-client branch from 767dcda to e346ece Compare September 25, 2026 05:02
Base automatically changed from codex/remote-link-3-client to dev September 25, 2026 05:02
src/link gains the no-shell SSH runner, host-key fingerprint parsing, the per-link tunnel supervisor with verified orphan reaping, the K16 status projection and a first-admission waiter. ocx link port/issue/revoke/status talk to the running proxy on loopback with the admin token and never print the key outside issue's stdout.
Paired dashboard sessions probe, confirm, apply and remove links; status and removal also accept the admin token on a trusted loopback ingress, and key issue accepts only that token. Tailscale-identity sessions are refused on every link route. Apply records the link, reloads the supervisor, runs the client's connect over SSH with the key on stdin and waits for the first key-authenticated catalog read; any failed step removes the new key and record.
… changed tunnels

Apply compensation now revokes a newly issued key before removing its record and keeps the record with a compensation_failed status when revocation fails. Issue and apply refuse unless the hub-link listener is listening. A supervisor reload stops and respawns a tunnel whose record changed, and the trusted-loopback derivation is a tested pure function.
A key already absent from the live config counts as revoked, so a DELETE retried after a partially completed compensation finishes instead of failing on the key it removed the first time. The lifecycle-less issue path now reports compensation_failed when it cannot revoke.
… persisted compensation

Link routes reread the store right before each write and change only their own record, a failed remote disconnect restarts the tunnel, and compensation_failed persists in a private sidecar that survives restarts. The supervisor coalesces reloads, reports connected only after an authenticated request or a surviving ssh child, and revokes link keys no record references at startup. Issue refuses a duplicate client alias.
@lidge-jun
lidge-jun force-pushed the codex/remote-link-4-api branch from a0a5a24 to 1cefb16 Compare September 25, 2026 05:02
@lidge-jun
lidge-jun merged commit 2f641cc into dev Sep 25, 2026
5 checks passed
@lidge-jun
lidge-jun deleted the codex/remote-link-4-api 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