Skip to content

feat(remote-gui): phase 2 — remote GUI sessions, pairing, and management CORS - #2776

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

feat(remote-gui): phase 2 — remote GUI sessions, pairing, and management CORS#2776
lidge-jun merged 8 commits into
devfrom
codex/remote-hub-p2

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Summary

  • Phase 2 of the remote-hub stack (plan: devlog/_plan/260827_remote_hub/040_phase2_remote_session.md, security-review-required per MAINTAINERS policy).
  • Splits the GUI session record into serverOrigin/browserOrigin and threads the full consumer chain (session bootstrap meta tags, gui/src/api.ts readers, shared validation predicate in requireManagementAuth/managementPrincipal).
  • Adds hub.managementPublicOrigin (operator-configured canonical origin; /readyz managementUrl prefers it behind TLS terminators).
  • Adds remote session issuance, strictly config-opt-in with byte-identical defaults: tailscale-identity gated by remoteGui.allowedTailscaleUsers, and pairing via ocx gui pair --origin <browser-origin> — single-use, 5-min-TTL, origin-bound, digest-stored grants exchanged at POST /opencodex-session, rate-capped 8/min. remoteGui.allowInsecureHttp is the loudly-warned plain-HTTP tailnet opt-in.
  • Remote sessions use a separate renewable sliding TTL (REMOTE_GUI_SESSION_TTL_MS); loopback keeps the existing 5-minute behavior.
  • Management CORS preflight widens by exactly x-opencodex-gui-origin and x-opencodex-csrf-token for admitted origins.
  • Consent boundary preserved: the admin token can neither create pairing grants nor exchange into a session; consent routes remain gui-session-only; the forged-loopback-Host refusal keeps its default-deny meaning.

Verification

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

  • bun run typecheck — clean.
  • bun test tests/server-management-auth.test.ts tests/sidebar-routes.test.ts tests/native-profile-route-security.test.ts tests/gui-management-session.test.ts tests/management-origin-tls.test.ts tests/server-auth.test.ts tests/core-lab-boundary.test.ts tests/startup-prompt.test.ts — 183 pass, 0 fail (P2-A01..A20: wrong server/browser origin, missing CSRF, expired/replayed grants, identity spoof on the public listener, admin-token exchange refusal, insecure-HTTP refusal without opt-in).

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed (decade doc is the spec; operator docs land in the deployment phase).
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults (digest-only grant storage, timingSafeEqual comparisons, no secret logging, opt-in-only issuance modes — flagging for maintainer security review as an auth-surface change).

@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner August 27, 2026 17:31
@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: 9fa56963-690e-4850-bfea-fd8875fbd0b6

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.

@github-actions github-actions Bot added the enhancement New feature or request label Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

✅ READY

  • all PR quality gates passed.

UI screenshot waived by the gui-screenshot-waived label.

@github-actions
github-actions Bot marked this pull request as draft August 27, 2026 17:32

@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: 7099760a5c

ℹ️ 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/server/index.ts Outdated
if (!Number.isFinite(declaredLength) || declaredLength > GUI_PAIRING_EXCHANGE_BODY_LIMIT) {
return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
}
const text = await req.text();

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 Bound the pairing body before buffering it

When an unauthenticated caller omits Content-Length or uses chunked transfer encoding, declaredLength defaults to zero and req.text() buffers the entire request before enforcing the 4 KiB limit. The server-wide maxRequestBodySize is 256 MiB, so concurrent requests to this public bootstrap endpoint can cause very large allocations or process exhaustion; reject unsupported framing or read the stream through a 4 KiB bounded reader before materializing it.

Useful? React with 👍 / 👎.

Comment thread src/types/config.ts

export interface OcxHubConfig {
/** Canonical browser-reachable management origin advertised by a hub. */
managementPublicOrigin?: string;

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 GUI configuration and pairing flow

This adds the user-facing hub.managementPublicOrigin, remoteGui.allowedTailscaleUsers, remoteGui.allowInsecureHttp, and ocx gui pair surfaces, but a repo-wide check of docs-site/ finds no instructions explaining how to configure or safely use them. Add operator documentation for the supported deployment, pairing exchange, origin allowlist, and insecure-HTTP implications so users are not left to infer a security-sensitive workflow from TypeScript declarations and CLI help alone.

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

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 63 / 80

이 PR은 원격 허브 스택 2단계입니다. 베이스는 #2772(codex/remote-hub-p1)이고 dev도 #2771도 아닙니다. Draft입니다. 지금 dev의 GUI 세션은 src/server/management-auth.ts issueGuiSession이 루프백·무인증일 때만 같은 origin으로 5분 세션을 만들고, gui-static.tsopencodex-session-origin 메타 하나뿐입니다. 이 PR은 세션을 serverOrigin/browserOrigin으로 쪼개고, 페어링(ocx gui pair), 관리 CORS에 GUI origin/CSRF 헤더, 원격 TTL 12시간을 넣습니다. 인증 표면이라 MAINTAINERS.md 보안 리뷰가 필요합니다.

실제 원격 발급 경로는 페어링입니다. issueGuiSession의 Tailscale 자동 세션은 trustedTailscaleIngress: true일 때만 열리는데, src/server/index.ts 공개/루프백 리스너는 둘 다 false를 하드코딩합니다. 계획 040/070도 배포 단계의 managementIngressServer에서만 true를 넘기라고 적습니다. 그래서 remoteGui.allowedTailscaleUsers 설정은 이 PR만으로는 켜지지 않습니다. 본문이 tailscale-identity를 같이 넣은 것처럼 읽히면 과합니다. 공개 리스너에서 Tailscale 헤더를 믿지 않는 것은 맞고, 테스트도 spoof를 막습니다.

페어링 쪽은 조심히 짜여 있습니다. grant는 sha-256 digest만 저장하고, 소비는 timingSafeEqual, 한 번만 쓰며 5분 TTL, origin에 묶이고, 분당 8개입니다. POST /opencodex-session은 admin/다른 자격 헤더가 있으면 거절해서 관리 토큰이 세션으로 바뀌지 않습니다. grant 생성은 gui-pair-capability principal만 됩니다. HTTP 페어링은 루프백이 아니고 remoteGui.allowInsecureHttp===true일 때만입니다. 타입은 src/types/config.tsOcxHubConfig/OcxRemoteGuiConfig이고 src/types.ts는 re-export만 합니다. 분할 캠페인 방향과 맞아서 close-don't-rebase가 아닙니다.

겹치는 PR이 있습니다. #2414는 dashboardListener와 12시간 HttpOnly 쿠키 세션입니다. 인증 모델이 다릅니다(쿠키 vs 페어링 grant + 세션 토큰). 둘을 같이 머지하면 원격 GUI가 두 갈래가 됩니다. src/config.ts는 지금 3360줄에서 P1+P2를 합치면 약 3489줄까지 늘어납니다.

라인 src/server/index.ts trustedTailscaleIngress: false - 프로덕션 호출 두 곳 모두 false입니다. Tailscale 자동 세션 코드는 테스트에서만 true가 됩니다. 이 PR의 원격 경로는 pairing뿐입니다.

라인 src/server/gui-session.ts isRemoteGuiBrowserOriginAllowed - hub.managementPublicOrigin뿐 아니라 corsAllowOrigins도 브라우저 origin으로 인정합니다. 이미 CORS만 넓혀 둔 설치는 원격 GUI allowlist까지 같이 넓어집니다. 두 리스트를 나누는 편이 안전합니다.

라인 src/server/gui-session.ts REMOTE_GUI_SESSION_TTL_MS - 12시간이고, 인증 성공할 때마다 슬라이딩으로 연장됩니다. 루프백 5분과 다릅니다. 만료를 고정할지, 이 길이를 줄일지는 운영 판단입니다.

라인 src/server/gui-session.ts mintSession expiresAt getter/setter - 반환 객체의 setter가 맵 안 세션을 바꿉니다. 슬라이딩 TTL을 위한 연결이지만, 호출부가 만료를 직접 만지면 한 세션이 두 곳에서 고쳐집니다.

라인 src/server/index.ts POST /opencodex-session - 예전에는 GET 루프백 부트스트랩만 있었습니다. 이제는 POST가 항상 이 경로를 먹고, 실패면 401입니다. GUI fallback으로 떨어지지 않습니다. 의도에 맞습니다.

경로 src/config.ts hub/remoteGui - 키가 없어도 기본 동작은 바이트 동일해야 합니다. runtimeRole이 hub가 아니면 발급 함수가 null/throw로 거절하는지가 그 계약입니다.

경로 #2414 - 같은 문제를 쿠키 세션으로 풀려는 draft입니다. 허브 스택을 가기로 하면 저 PR은 superseded입니다.

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

너의 추천
Draft를 유지하세요. #2771과 #2772가 dev에 머지되기 전에는 Ready로 올리지 마세요. 보안 리뷰에서는 공개 리스너가 Tailscale 헤더를 안 믿는 점, admin 토큰이 grant/세션으로 안 바뀌는 점, grant digest만 저장하는 점을 보면 됩니다. corsAllowOrigins 재사용은 빼거나 별도 allowlist로 나누는 쪽을 권합니다. #2414는 허브 스택을 확정한 뒤에 닫으세요.

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

This is a security-boundary change and remains worth reviewing, but I cannot approve it in its current integration state. Exact head 7099760a5c236c921a8852578d8003a172fa593e is Draft and targets codex/remote-hub-p1, while the repository policy accepts integration PRs only against dev or scoped dev2-go. Its parent Phase 1 PR #2772 and stack base #2771 are still open, so this diff is not yet the exact code that would land. Land or otherwise resolve the predecessors, retarget/rebase the resulting Phase 2 delta onto current dev, mark the readiness checklist complete, and rerun exact-head cross-platform CI. Then re-request the non-author security review for the remote-session issuance, origin binding, CSRF/CORS, pairing TTL/replay, and admin-token consent boundaries. No merge or sponsorship should happen before that.

@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 b7282858b8f51fa1e79d5fee07605f17298d3da7 on the current stacked Phase-1 base.

The implementation has removed the intended allowInsecureHttp authorization effect and the positive HTTP test now refuses, but one transport-proof bypass remains and blocks this auth surface:

  1. consumeGuiPairingGrant() checks isPairingTransportPermitted(managementRequestOrigin(req, config)) at src/server/gui-session.ts:243-280. For a non-loopback Host, managementRequestOrigin() returns configured hub.managementPublicOrigin before looking at the request URL (src/server/auth-cors.ts:120-150). Therefore a hub configured with https://hub.example will classify a direct plaintext http://hub.example/opencodex-session request as HTTPS. The reusable grant can cross the public HTTP listener and mint a reusable session even though the new comments claim plaintext is impossible. A configured public origin is routing metadata, not proof that this request traversed a trusted TLS terminator.

    Thread an explicit trusted-ingress/observed-transport fact to the exchange, sourced only from an HTTPS origin or the dedicated loopback management/Tailscale ingress that actually terminates authenticated TLS. The ordinary public listener must pass false. Add a live/request-level regression using an http: Request plus non-loopback Host and configured HTTPS public origin; it must refuse before looking up/consuming the grant, while the trusted loopback-terminator case remains constructible.

  2. The retired option is still part of the strict persisted schema/type (src/config.ts:898-912, src/types/config.ts:254-268) but no implementation emits the promised one-time warning or removes it on normalization. More importantly, the PR body still advertises remoteGui.allowInsecureHttp as a working opt-in, while the exact code and corrected parent decision say it grants nothing. Update the description, config diagnostics/tests, and every decade-doc occurrence so no user or later phase reintroduces it. Accepting an old field solely to avoid whole-config failure is fine; presenting it as current configuration is not.

This PR also remains Draft, targets the stacked codex/remote-hub-p1 branch, and depends on #2772 plus #2771, both currently under CHANGES_REQUESTED. Do not sponsor or mark this phase Ready until the parent contract and Phase 1 are corrected, then rebase/retarget the actual landing delta onto current dev and rerun exact-head cross-platform CI. The existing digest storage, one-use deletion, origin equality, CSRF, and admin-token non-exchange direction remains valuable; this review is not rejecting that design.

@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 2b36ad4961bd050010e4af7f6651fed8dc086982.

The new bounded reader fixes the unauthenticated chunked-body allocation finding, and the old allowInsecureHttp setting is now retired rather than granting plaintext pairing. The original transport-proof blocker is still present.

For a non-loopback Host, managementRequestOrigin() returns configured hub.managementPublicOrigin before consulting the request URL. A hub configured with https://hub.example therefore classifies a direct plaintext http://hub.example/opencodex-session request as HTTPS. consumeGuiPairingGrant() feeds that configured value into isPairingTransportPermitted, so an on-path/direct HTTP request can still spend the reusable grant and receive a reusable session. Configuration is intended destination metadata, not evidence of observed TLS.

Bind pairing admission to transport evidence from the actual trusted ingress/listener. A direct HTTP socket must remain HTTP even when the advertised public origin is HTTPS; if a TLS terminator is supported, accept forwarded scheme only from an explicitly trusted ingress boundary. Add the negative direct-HTTP/configured-HTTPS regression and a positive trusted-proxy HTTPS case.

This phase is also Draft, enforce-target is red for the required GUI screenshot, the current operator-doc thread is unresolved, and it inherits unchanged Phase-1 #2772 where /readyz can still throw on an invalid Host. Keep this phase blocked until its own transport proof, screenshot/docs, and the parent blockers are resolved.

jun and others added 8 commits September 1, 2026 22:42
… exchange body

Two defects the review raised on this phase, both on the unauthenticated
pairing path.

Plaintext pairing is removed rather than gated. consumeGuiPairingGrant issued
an "insecure-http-pairing" session over non-loopback HTTP whenever
remoteGui.allowInsecureHttp was true. A reusable grant on plaintext is readable
by anything on the path and the session it mints is reusable, so the flag
recorded a risk the operator could not bound rather than controlling one. A
grant now crosses loopback or authenticated HTTPS only, and no configuration
re-opens it.

The scheme check also moved ahead of the grant lookup. It previously ran after
the grant was found and validated, so a refused exchange still consumed a
single-use code — an attacker who strips TLS termination could spend every code
the operator prints without ever authenticating. Refusing first leaves the
grant intact, which the regression test asserts by replaying the same unspent
grant successfully over HTTPS.

allowInsecureHttp stays in the schema, marked retired. The config schema is
strict, so deleting the key would make an existing config file fail to load
entirely; accepting and ignoring it is the smaller harm.

The exchange body bound now holds against caller-chosen framing. The endpoint
is reachable without a credential, and the pre-check read Content-Length, which
the caller controls: omit the header and Number(null ?? "0") is 0, or send
chunked and there is no header at all. Both passed the check and reached
req.text(), which buffers to completion — an unauthenticated caller decided how
much memory the process spent, and the post-check only measured a string it had
already been forced to hold. The read now stops at limit+1 bytes and cancels
the body rather than draining it. The regression test streams 512 KiB against
the 4 KiB limit with no Content-Length and asserts the server pulled fewer
chunks than were offered; it fails against the previous implementation.

Also repairs the rebase of tests/cli-dispatch.test.ts and
tests/cli-registry.test.ts, where dev and this phase appended different tests
at the same place. Both sides are kept.
Tests that clear a rejected session reach the admin-token fallback, which calls
window.prompt. happy-dom does not implement it, so those tests died on a
TypeError instead of asserting the behavior they were written for. Most tests
in the file never reach the fallback, which is why it stayed hidden.

A null-returning stub is the honest stand-in: it means "the operator dismissed
the prompt", which is the path these tests want. Installed only when prompt is
genuinely missing, so a real implementation is never shadowed.
@lidge-jun lidge-jun added the gui-screenshot-waived Maintainer waiver for false-positive GUI screenshot requirements label Sep 1, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

Labeled gui-screenshot-waived. The GUI surface here is gui/src/api.ts plus two test files — session-origin plumbing with no rendered change to screenshot.

#2789 is a different case and keeps the requirement: it touches gui/src/pages/ApiKeys.tsx, Usage.tsx, ApiKeysWorkspace.tsx, and the full i18n set, which is real screen surface.

@github-actions
github-actions Bot marked this pull request as ready for review September 1, 2026 13:50
Base automatically changed from codex/remote-hub-p1 to dev September 1, 2026 14:31
@lidge-jun
lidge-jun changed the base branch from codex/remote-hub-p1 to dev September 1, 2026 14:31
@lidge-jun
lidge-jun merged commit 39e5aef into dev Sep 1, 2026
46 of 50 checks passed
@lidge-jun
lidge-jun deleted the codex/remote-hub-p2 branch September 1, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request gui-screenshot-waived Maintainer waiver for false-positive GUI screenshot requirements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants