diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 1777b9be854..953f31482e8 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -13,9 +13,11 @@ catalogue, les services auxiliaires, les réglages des sous-agents et le trafic ocx gui ``` -Cette commande ouvre `http://localhost:` dans votre navigateur — ou -`http://127.0.0.1:` lorsque l’ingress de gestion du hub est activé — et démarre d'abord automatiquement le -proxy si nécessaire. En développement, vous pouvez lancer séparément le serveur de développement de +Cette commande ouvre l’adresse du proxy actif dans votre navigateur : `http://127.0.0.1:` par défaut, +ou `http://[::1]:` pour une liaison à l’adresse de bouclage IPv6 littérale `::1`. Ces adresses conservent +l’origine configurée nécessaire à l’appairage local du navigateur. Lorsque l’ingress de gestion du hub est activé, +elle ouvre `http://127.0.0.1:`. Le proxy démarre d’abord automatiquement si nécessaire. +En développement, vous pouvez lancer séparément le serveur de développement de l'interface contre un proxy déjà actif : ```bash diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index d5e247ccad0..27b4c6c6cea 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -355,7 +355,7 @@ Lorsqu'une version plus récente du paquet est connue, la zone de notification a ### `ocx gui` -Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http://localhost:` — ou `http://127.0.0.1:` lorsque l’ingress de gestion du hub est activé — et démarre automatiquement le proxy s’il n’est pas actif. +Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http://127.0.0.1:` — ou `http://127.0.0.1:` lorsque l’ingress de gestion du hub est activé — et démarre automatiquement le proxy s’il n’est pas actif. ## Mise à jour diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 0792854aa24..c19a1c825b4 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -13,9 +13,11 @@ settings, and request traffic. ocx gui ``` -This opens `http://localhost:` in your browser — or `http://127.0.0.1:` when hub -management ingress is enabled — auto-starting the proxy first if needed. In -development you can run the GUI dev server separately against a running proxy: +This opens the running proxy's address in your browser: `http://127.0.0.1:` by default, +or `http://[::1]:` for a literal IPv6 loopback bind. These literal addresses preserve the +configured origin required for local browser pairing. With hub management ingress enabled, it opens +`http://127.0.0.1:`. The proxy starts automatically first if needed. In development +you can run the GUI dev server separately against a running proxy: ```bash ocx start diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index bc1e933f309..4b9c75212b3 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -13,7 +13,7 @@ opencodex はプロキシが提供するローカルウェブダッシュボー ocx gui ``` -ブラウザで `http://localhost:` を開きます。ハブで管理イングレスが有効な場合は `http://127.0.0.1:<管理ポート>` を開きます。プロキシがオフなら先に自動で起動します。 +ブラウザで実行中のプロキシのアドレスを開きます。既定は `http://127.0.0.1:`、IPv6 ループバックの `::1` にバインドしている場合は `http://[::1]:` です。このリテラルアドレスにより、ローカルブラウザのペアリングに必要な設定済みオリジンが保たれます。ハブで管理イングレスが有効な場合は `http://127.0.0.1:<管理ポート>` を開きます。プロキシがオフなら先に自動で起動します。 開発中は実行中のプロキシと GUI 開発サーバーを別々に起動できます。 ```bash diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 17b27dc3f4f..cedb4525700 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -311,7 +311,7 @@ Windows ステータス トレイ アイコンをインストールして制御 ### `ocx gui` -`http://localhost:` で [ウェブダッシュボード](/guides/web-dashboard/) を開き、プロキシが実行されていない場合は自動起動します。ハブで管理イングレスが有効な場合は `http://127.0.0.1:<管理ポート>` を開きます。 +`http://127.0.0.1:` で [ウェブダッシュボード](/guides/web-dashboard/) を開き、プロキシが実行されていない場合は自動起動します。ハブで管理イングレスが有効な場合は `http://127.0.0.1:<管理ポート>` を開きます。 ## 更新 diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index d014bf6f192..9ec5b932d76 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -13,7 +13,7 @@ opencodex는 프록시가 제공하는 로컬 웹 대시보드(`gui/` 아래의 ocx gui ``` -브라우저에서 `http://localhost:`를 엽니다. 허브에서 관리 리스너를 켜 두면 `http://127.0.0.1:<관리 포트>`를 엽니다. 프록시가 꺼져 있으면 먼저 자동으로 시작합니다. +브라우저에서 실행 중인 프록시의 주소를 엽니다. 기본값은 `http://127.0.0.1:`이고, IPv6 루프백 `::1`에 바인딩했다면 `http://[::1]:`입니다. 이 리터럴 주소는 로컬 브라우저 페어링에 필요한 설정된 origin을 유지합니다. 허브에서 관리 리스너를 켜 두면 `http://127.0.0.1:<관리 포트>`를 엽니다. 프록시가 꺼져 있으면 먼저 자동으로 시작합니다. 개발 중에는 실행 중인 프록시와 GUI 개발 서버를 따로 띄울 수 있습니다. ```bash diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 23105712070..9dae49dff0d 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -443,7 +443,7 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 ### `ocx gui` 프록시가 실행 중이 아니면 자동으로 시작하면서 [웹 대시보드](/guides/web-dashboard/)를 -`http://localhost:`에서 엽니다. 허브에서 관리 리스너를 켜 두면 `http://127.0.0.1:<관리 포트>`에서 엽니다. +`http://127.0.0.1:`에서 엽니다. 허브에서 관리 리스너를 켜 두면 `http://127.0.0.1:<관리 포트>`에서 엽니다. ## 업데이트 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index cabb3012544..9025f55271f 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -1107,7 +1107,7 @@ When a newer package version is known, the tray adds a blue dot to its online, w ### `ocx gui` -Open the [web dashboard](/guides/web-dashboard/) at `http://localhost:` — or at +Open the [web dashboard](/guides/web-dashboard/) at `http://127.0.0.1:` — or at `http://127.0.0.1:` when hub management ingress is enabled — auto-starting the proxy if it is not running. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 6c810c21dac..53fda43408b 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -88,9 +88,9 @@ route-specific results rather than repeating this table. | `POST /api/anthropic/reset-grants/consume` | Spend one reset grant. Body `{ accountId, grantId, operationId }`; `operationId` is a UUIDv4 sent upstream as the request ID, so repeating it retries the same claim. Requires a dashboard session. A settled `reset` lifts the account's local cooldown only when it is a quota refusal recorded before the claim was first journaled and the windows the grant reported cleared cover every window that refusal named (both windows when it named none). A refusal recorded while the claim was in flight, an entitlement refusal, or an uncovered window stays, including on a same-id replay. The cached quota is dropped and usage is reread with a fresh request, so the pool can select the account before the old deadline. Model-family exclusions stay until that reread proves a family is no longer rejected. | 400 invalid body; 401 re-authentication needed; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 journal busy, unavailable, or full | | `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile | 400 invalid or unavailable assignment | | `POST /api/claude-desktop/apply` | Write the saved profile to Claude Desktop's managed config | 400/500 write failure | -| `GET, POST /api/codex/desktop-compatibility/certificate` | Inspect or explicitly prepare Windows compatibility certificate trust | 403 local dashboard required for POST; 409 stale, busy, or incomplete state | -| `GET, POST /api/codex/desktop-compatibility/runtime` | Inspect or explicitly start, stop, launch, or run a bounded native compatibility trial | 403 local dashboard required for POST; 409 unsupported build, untrusted certificate, or incomplete state | -| `GET, POST /api/codex/desktop-compatibility/settings` | Read or save the next-start observation preference with its current revision | 403 local dashboard required for POST; 409 stale, invalid, or unconfirmed configuration | +| `GET, POST /api/codex/desktop-compatibility/certificate` | Inspect or explicitly prepare Windows compatibility certificate trust | 403 paired local dashboard required for POST; 409 stale, busy, or incomplete state | +| `GET, POST /api/codex/desktop-compatibility/runtime` | Inspect or explicitly start, stop, launch, or run a bounded native compatibility trial | 403 paired local dashboard required for POST; 409 unsupported build, untrusted certificate, or incomplete state | +| `GET, POST /api/codex/desktop-compatibility/settings` | Read or save the next-start observation preference with its current revision | 403 paired local dashboard required for POST; 409 stale, invalid, or unconfirmed configuration | | `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile and Desktop health | 400 status read failure | | `GET, PUT /api/claude-code` | Read or update Claude Code gateway, auth-mode, model-map, context, agent, and sidecar settings | 400 invalid field or shape | @@ -866,13 +866,16 @@ same selector is restored if that account id is added again. `GET /api/codex/desktop-compatibility/certificate` returns public certificate status: support, state, fingerprint, expiry, renewal notice, trust observation and any active operation. +The certificate response also reports `mutationAuthorized` for the current session and ingress; +this flag is read-only guidance, and each mutation checks authorization again. It never generates a key, decrypts private material or registers OS trust. Certificate trust alone does not mean a compatibility relay is running or the private key has been verified in this process. POST accepts `action: "prepare" | "trust" | "remove-trust" | "renew"` and `confirmed: true`. Trust actions also require the exact uppercase SHA-256 `fingerprint` returned by status. -These mutations require a GUI-session principal from trusted loopback ingress; an admin token alone -receives 403. They are intended for a local confirmation flow, not unattended certificate enrollment. +These mutations require an operator-paired GUI-session principal from trusted loopback ingress; +an admin token or an automatically issued loopback GUI session receives 403. They are intended for +a local confirmation flow, not unattended certificate enrollment. Prepare stores a constrained 30-day authority with a Windows CurrentUser-DPAPI-protected private key. Subsequent preparations reuse it. Trust actions never create missing state or silently replace an @@ -892,13 +895,17 @@ CurrentUser protection does not isolate secrets from other processes running as ## Experimental Windows compatibility runtime -`GET /api/codex/desktop-compatibility/runtime` is an inert status read. POST requires a -local GUI session and `{ action, confirmed: true }`, where action is `start`, `stop`, +`GET /api/codex/desktop-compatibility/runtime` is an inert status read. POST requires an +operator-paired local GUI session and `{ action, confirmed: true }`, where action is `start`, `stop`, `observe`, `launch`, or `apply`. Only `apply` additionally requires `accountWideConsent: true`. The endpoint is experimental. The dashboard exposes it under **Codex Set → Desktop compatibility**; the **Resume observation when OpenCodex starts** toggle saves the optional `desktopCompatibility.startOnProxyStart` preference. OpenCodex managed client mode does not offer these local controls or forward them to the shared hub. +Open the dashboard at the configured loopback origin. Before changing compatibility settings, +use the panel's `ocx gui pair --origin ""` command and submit its one-use pairing +code in the panel. Controls stay disabled until the new local session is verified; pairing does +not itself change certificate trust, runtime state or the auto-start preference. Start requires an already prepared, trusted certificate, a freshly verified native file-based ChatGPT login and the assessed Codex Windows build `26.924.2738.0`. It begins in Observe mode. @@ -942,7 +949,8 @@ Failed cleanup reports `cleanup-required`; it does not claim that the runtime is system proxy settings and application files are not modified by these runtime commands. The separate settings endpoint returns `{ startOnProxyStart, revision }`. POST requires -`{ startOnProxyStart: boolean, revision, confirmed: true }` from a local GUI session. A stale +`{ startOnProxyStart: boolean, revision, confirmed: true }` from an operator-paired local GUI +session on trusted loopback ingress; an unpaired session receives 403. A stale revision refuses the write; a valid update preserves unrelated config fields and verifies the persisted result. This preference affects the next proxy start, never current runtime state, app launch or an Apply trial. An uncertain response must be followed by GET, not an diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index bb44e1ee576..0b09ddf6326 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -13,8 +13,11 @@ opencodex включает локальный веб-дашборд (Vite/React- ocx gui ``` -Команда открывает `http://localhost:` в браузере (или `http://127.0.0.1:<порт управления>`, если на хабе включён management ingress), при необходимости сначала автоматически -запуская прокси. При разработке dev-сервер GUI можно запускать отдельно поверх работающего прокси: +Команда открывает в браузере адрес работающего прокси: по умолчанию `http://127.0.0.1:`, +а при привязке к буквальному IPv6-адресу loopback `::1` — `http://[::1]:`. Эти адреса сохраняют +настроенный origin, необходимый для локального сопряжения браузера. Если на хабе включён management ingress, +открывается `http://127.0.0.1:<порт управления>`. При необходимости прокси сначала запускается автоматически. +При разработке dev-сервер GUI можно запускать отдельно поверх работающего прокси: ```bash ocx start diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index f2e946ac417..ab56d58d7f9 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -444,7 +444,7 @@ one-click управление прокси. `start` и `stop` управляю ### `ocx gui` -Открыть [веб-дашборд](/guides/web-dashboard/) по адресу `http://localhost:` (или `http://127.0.0.1:<порт управления>`, если на хабе включён management ingress), автоматически +Открыть [веб-дашборд](/guides/web-dashboard/) по адресу `http://127.0.0.1:` (или `http://127.0.0.1:<порт управления>`, если на хабе включён management ingress), автоматически запустив прокси, если он ещё не работает. ## Обновление diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index 3fc797454c9..4ad9e8cdafa 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -14,8 +14,10 @@ trafiğini yönetmenin en kısa yoludur. ocx gui ``` -Bu, gerekirse önce proxy'yi otomatik olarak başlatarak tarayıcınızda -`http://localhost:` adresini açar; hub'da yönetim ingress'i etkinse `http://127.0.0.1:` adresini açar. Geliştirme sırasında GUI geliştirme +Bu komut tarayıcınızda çalışan proxy'nin adresini açar: varsayılan olarak `http://127.0.0.1:`, +IPv6 loopback adresi `::1` için ise `http://[::1]:`. Bu adresler, yerel tarayıcı eşleştirmesi için +gereken yapılandırılmış origin'i korur. Hub'da yönetim ingress'i etkinse `http://127.0.0.1:` +adresini açar. Gerekirse önce proxy otomatik olarak başlatılır. Geliştirme sırasında GUI geliştirme sunucusunu çalışan bir proxy'ye karşı ayrı olarak çalıştırabilirsiniz: ```bash diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index 86366c641e1..5b483838e1f 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -515,7 +515,7 @@ Yeni bir paket sürümü bilindiğinde tepsi, çevrimiçi, uyarı veya çevrimd ### `ocx gui` -Çalışmıyorsa proxy'yi otomatik olarak başlatarak `http://localhost:` +Çalışmıyorsa proxy'yi otomatik olarak başlatarak `http://127.0.0.1:` adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın; hub'da yönetim ingress'i etkinse `http://127.0.0.1:` adresini açar. ## Güncelleme diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 81909ec088b..cca95ab6392 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -12,7 +12,7 @@ opencodex 内置了一个由代理提供服务的本地 web 仪表盘(`gui/` ocx gui ``` -该命令会在浏览器中打开 `http://localhost:`;在启用了管理 ingress 的 hub 上则打开 `http://127.0.0.1:<管理端口>`;如果代理尚未运行,会先自动启动。开发时也可 +该命令会在浏览器中打开运行中的代理地址,默认为 `http://127.0.0.1:`,绑定到 IPv6 回环地址 `::1` 时则为 `http://[::1]:`。这些字面地址保留了本地浏览器配对所需的已配置 origin。在启用了管理 ingress 的 hub 上则打开 `http://127.0.0.1:<管理端口>`;如果代理尚未运行,会先自动启动。开发时也可 让 GUI dev server 单独连接到正在运行的代理: ```bash diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index 9f790a0f0d4..b95a5547b08 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -298,7 +298,7 @@ Unix 包管理器升级保留私有 wrapper;未激活时,请加载输出的 ### `ocx gui` -在 `http://localhost:` 打开 [web dashboard](/guides/web-dashboard/),如果代理未运行则会自动启动。在启用了管理 ingress 的 hub 上,打开的是 `http://127.0.0.1:<管理端口>`。 +在 `http://127.0.0.1:` 打开 [web dashboard](/guides/web-dashboard/),如果代理未运行则会自动启动。在启用了管理 ingress 的 hub 上,打开的是 `http://127.0.0.1:<管理端口>`。 ## 更新 diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 9128bf87e47..cd1df472768 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -12,7 +12,7 @@ opencodex 內建了一個由代理提供服務的本機 web 儀表板(`gui/` ocx gui ``` -該命令會在瀏覽器中開啟 `http://localhost:`;在啟用管理 ingress 的 hub 上則開啟 `http://127.0.0.1:<管理埠>`;如果代理尚未執行,會先自動啟動。開發時也可 +該命令會在瀏覽器中開啟執行中的代理位址,預設為 `http://127.0.0.1:`,綁定至 IPv6 回環位址 `::1` 時則為 `http://[::1]:`。這些字面位址保留了本機瀏覽器配對所需的已設定 origin。在啟用管理 ingress 的 hub 上則開啟 `http://127.0.0.1:<管理埠>`;如果代理尚未執行,會先自動啟動。開發時也可 讓 GUI dev server 單獨連線到正在執行的代理: ```bash diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 1e3639ead4d..3504915bb17 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -279,7 +279,7 @@ Unix 解除安裝後,請從 shell 啟動檔移除載入行,並重新啟動 s ### `ocx gui` -在 `http://localhost:` 開啟[網頁儀表板](/zh-tw/guides/web-dashboard/),若代理未執行則自動啟動它。在啟用管理 ingress 的 hub 上,開啟的是 `http://127.0.0.1:<管理埠>`。 +在 `http://127.0.0.1:` 開啟[網頁儀表板](/zh-tw/guides/web-dashboard/),若代理未執行則自動啟動它。在啟用管理 ingress 的 hub 上,開啟的是 `http://127.0.0.1:<管理埠>`。 ## 更新 diff --git a/gui/src/api.ts b/gui/src/api.ts index eafa7866171..8017f66b0c0 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -318,7 +318,7 @@ function classify(input: RequestInfo | URL): { plane: ApiPlane; bootstrap: boole if (url.href === new URL(targets.shared.bootstrapPath, window.location.href).href) return { plane: "shared", bootstrap: true }; if (targets.shared.transport === "relay" && targetMatchesUrl(targets.shared, url)) return { plane: "shared", bootstrap: false }; const machinePath = relativeTargetPath(targets.machine, url); - if (machinePath?.startsWith("/api/machine/")) return { plane: "machine", bootstrap: false }; + if (machinePath?.startsWith("/api/machine/") || machinePath?.startsWith("/api/codex/desktop-compatibility/")) return { plane: "machine", bootstrap: false }; const sharedPath = relativeTargetPath(targets.shared, url); if (sharedPath?.startsWith("/api/")) return { plane: "shared", bootstrap: false }; if (url.href === new URL(targets.machine.bootstrapPath, window.location.href).href) return { plane: "machine", bootstrap: true }; diff --git a/gui/src/bounded-fetch.ts b/gui/src/bounded-fetch.ts index a559940d823..2e86b0386e0 100644 --- a/gui/src/bounded-fetch.ts +++ b/gui/src/bounded-fetch.ts @@ -55,3 +55,11 @@ export async function withFetchDeadline(ms: number, caller: AbortSignal | und bounded.signal.removeEventListener("abort", onAbort); } } + +/** + * Release a response body without letting cleanup delay or replace the caller's + * status classification: a rejected, stalled, or throwing cancel is ignored. + */ +export function discardResponseBody(response: Response): void { + try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ } +} diff --git a/gui/src/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts index 44ace76df85..9b53202bf83 100644 --- a/gui/src/connect-pairing-transport.ts +++ b/gui/src/connect-pairing-transport.ts @@ -1,6 +1,6 @@ import { beginApiSessionHandoff, fetchApiSessionValidation, hasApiSession } from "./api"; import type { ApiTarget } from "./api-targets"; -import { withFetchDeadline } from "./bounded-fetch"; +import { discardResponseBody, withFetchDeadline } from "./bounded-fetch"; import { parseRemoteLinkStatus } from "./remote-link-api"; const PAIRING_CODE = /^ocx_pair_[A-Za-z0-9_-]{43}$/; @@ -39,7 +39,7 @@ async function readResponsePrefix(response: Response, limit = CHALLENGE_PREFIX_L if (chunk.byteLength < value.byteLength) break; } } finally { - try { await reader.cancel(); } catch { /* best effort */ } + try { void reader.cancel().catch(() => {}); } catch { /* best effort */ } } const buffer = new Uint8Array(size); let offset = 0; @@ -54,7 +54,7 @@ async function isHtmlChallenge(response: Response): Promise { } /** - * Exchange a pairing code for a shared-plane session. + * Exchange a pairing code for a session on the selected target's plane. * * Separate module from the form that calls it so neither file mixes a component export with * a plain one. That mix is what `react-refresh/only-export-components` flags, and the two @@ -79,7 +79,7 @@ export async function submitConnectPairing( // unauthenticated and the relay refused it. const send = fetchImpl ?? ((input, init) => window.fetch(input, init)); // Capture authority before either response headers or body can yield to another login. - const handoff = beginApiSessionHandoff("shared"); + const handoff = beginApiSessionHandoff(target.id); return withFetchDeadline(pairingDeadlineMs, signal, async deadline => { let response: Response; try { @@ -94,7 +94,7 @@ export async function submitConnectPairing( } if (!response.ok) { if (await isHtmlChallenge(response)) throw new PairingError("cloudflare-challenge"); - try { await response.body?.cancel(); } catch { /* best effort */ } + discardResponseBody(response); if (response.status === 401) throw new PairingError("refused"); if (response.status === 403) throw new PairingError("origin-denied"); throw new PairingError("request-failed"); @@ -135,7 +135,7 @@ export async function validateRemoteLinkSession(apiBase: string, fetchImpl?: typ deadline.throwIfAborted(); if (!response.ok) { if (await isHtmlChallenge(response)) throw new PairingError("cloudflare-challenge"); - try { await response.body?.cancel(); } catch { /* best effort */ } + discardResponseBody(response); if (response.status === 401) throw new PairingError("remote-link-unauthorized"); if (response.status === 403) throw new PairingError("remote-link-forbidden"); throw new PairingError("request-failed"); diff --git a/gui/src/connect-pairing.tsx b/gui/src/connect-pairing.tsx index 157c445f2ec..862c9b9ec72 100644 --- a/gui/src/connect-pairing.tsx +++ b/gui/src/connect-pairing.tsx @@ -7,15 +7,18 @@ import { useCopyFeedback } from "./components/use-copy-feedback"; export function ConnectPairingForm({ target, onConnected, + onCommitted, onPairingStart, local = false, focusOnMount = false, }: { target: ApiTarget; onConnected: (signal: AbortSignal) => void | Promise; + /** Update a surface that may unmount this form only after its handoff commits. */ + onCommitted?: () => void; /** Let a secret-bearing surface expire its previous disclosure lifetime. */ onPairingStart?: () => void; - /** Explicit local pairing for a standalone link join; never an automatic bootstrap. */ + /** Explicit local pairing for standalone operator actions; never an automatic bootstrap. */ local?: boolean; focusOnMount?: boolean; }) { @@ -61,11 +64,12 @@ export function ConnectPairingForm({ pendingHandoff.current?.commit(); pendingHandoff.current = null; setValidationPending(false); + onCommitted?.(); } } catch (failure) { if (!controller.signal.aborted) { const kind = failure instanceof PairingError ? failure.kind : "unreachable"; - if (kind === "remote-link-unauthorized" || kind === "remote-link-forbidden") { + if (kind === "refused" || kind === "remote-link-unauthorized" || kind === "remote-link-forbidden") { pendingHandoff.current?.rollback(); pendingHandoff.current = null; setValidationPending(false); diff --git a/gui/src/desktop-compatibility-api.ts b/gui/src/desktop-compatibility-api.ts index f86f8bacf96..2e6a5c96f46 100644 --- a/gui/src/desktop-compatibility-api.ts +++ b/gui/src/desktop-compatibility-api.ts @@ -1,6 +1,10 @@ +import { fetchApiSessionValidation } from "./api"; +import { discardResponseBody, withFetchDeadline } from "./bounded-fetch"; +import { PairingError } from "./connect-pairing-transport"; + export type CertificateState = "missing" | "invalid" | "expired" | "renewal-required" | "prepared" | "trusted" | "unknown"; export interface CompatibilityCertificate { - supported: boolean; state: CertificateState; fingerprint?: string; expiresAt?: number; renewalDue: boolean; busy: string | null; + supported: boolean; state: CertificateState; fingerprint?: string; expiresAt?: number; renewalDue: boolean; busy: string | null; mutationAuthorized?: boolean; } export interface CompatibilityRuntime { supported: boolean; phase: "off" | "starting" | "running" | "stopping" | "cleanup-required"; running: boolean; @@ -49,6 +53,7 @@ export function parseCompatibilityCertificate(value: unknown): CompatibilityCert if (value.fingerprint !== undefined && (typeof value.fingerprint !== "string" || !/^[A-F0-9]{64}$/.test(value.fingerprint))) throw new CompatibilityApiError("invalid_certificate_status"); if (value.expiresAt !== undefined && (typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt) || Math.abs(value.expiresAt) > 8.64e15)) throw new CompatibilityApiError("invalid_certificate_status"); return { supported: value.supported, state: value.state as CertificateState, busy: value.busy, + ...(typeof value.mutationAuthorized === "boolean" ? { mutationAuthorized: value.mutationAuthorized } : {}), ...(value.fingerprint === undefined ? {} : { fingerprint: value.fingerprint as string }), ...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt as number }), renewalDue: value.renewalDue === true }; } @@ -99,6 +104,22 @@ export async function readCompatibilityRuntime(apiBase: string, signal: AbortSig export async function readCompatibilityCertificate(apiBase: string, signal: AbortSignal): Promise { return parseCompatibilityCertificate((await reply(await fetch(apiBase + ROOT + "certificate", { signal }))).certificate); } +/** Read the candidate session's authority without automatic renewal or a mutation. */ +export async function validateCompatibilityPairing(apiBase: string, signal: AbortSignal): Promise { + try { + await withFetchDeadline(10000, signal, async deadline => { + const response = await fetchApiSessionValidation("machine", apiBase + ROOT + "certificate", { signal: deadline }); + if (response.status === 401 || response.status === 403) { discardResponseBody(response); throw new PairingError("refused"); } + const certificate = parseCompatibilityCertificate((await reply(response)).certificate); + deadline.throwIfAborted(); + if (certificate.mutationAuthorized !== true) throw new PairingError("refused"); + }); + } catch (error) { + signal.throwIfAborted(); + if (error instanceof PairingError) throw error; + throw new PairingError(error instanceof CompatibilityApiError ? "invalid-response" : "unreachable"); + } +} export async function readCompatibilitySnapshot(apiBase: string, signal: AbortSignal): Promise { const [certificate, runtime] = await Promise.all([ readCompatibilityCertificate(apiBase, signal), diff --git a/gui/src/pages/codex-desktop-compatibility.tsx b/gui/src/pages/codex-desktop-compatibility.tsx index dec7189e95e..ce9f968a43d 100644 --- a/gui/src/pages/codex-desktop-compatibility.tsx +++ b/gui/src/pages/codex-desktop-compatibility.tsx @@ -1,10 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useI18n } from "../i18n/shared"; import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; -import { CompatibilityApiError, readCompatibilityCertificate, readCompatibilityRuntime, readCompatibilitySnapshot, runCompatibilityAction, +import { CompatibilityApiError, readCompatibilityCertificate, readCompatibilityRuntime, readCompatibilitySnapshot, runCompatibilityAction, validateCompatibilityPairing, type CompatibilityAction, type CompatibilitySnapshot } from "../desktop-compatibility-api"; import { setClientResourceData, useClientResource } from "../client-resource"; import DesktopCompatibilityStartupSetting from "./desktop-compatibility-startup-setting"; +import { ConnectPairingForm } from "../connect-pairing"; +import { standaloneApiTargets } from "../api-targets"; +import { SESSION_CHANGED_EVENT } from "../api"; const label = { prepare: "desktopCompat.prepare", trust: "desktopCompat.trust", "remove-trust": "desktopCompat.remove", renew: "desktopCompat.renew", start: "desktopCompat.start", stop: "desktopCompat.stop", launch: "desktopCompat.launch", observe: "desktopCompat.observe", apply: "desktopCompat.apply" } as const; @@ -19,6 +22,7 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool const [needsRefresh, setNeedsRefresh] = useState(false), [mutating, setMutating] = useState(false); const [actionError, setActionError] = useState(null), [done, setDone] = useState(false); const [choice, setChoice] = useState(null), [acknowledged, setAcknowledged] = useState(false); + const [authorizationCurrent, setAuthorizationCurrent] = useState(true), [pairingStarted, setPairingStarted] = useState(false); const lifecycle = useRef({ alive: true, pending: false, generation: 0, operations: new Set() }); const certificateKey = `desktop-compatibility-certificate:${apiBase}`, runtimeKey = `desktop-compatibility-runtime:${apiBase}`; const certificate = useClientResource(certificateKey, signal => readCompatibilityCertificate(apiBase, signal), { enabled: active, deadlineMs: 15000, staleAfterMs: 0 }); @@ -28,14 +32,22 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool const busy = mutating || certificate.loading || runtime.loading; const readError = certificate.error ?? runtime.error; const error = actionError ?? (readError ? readError instanceof CompatibilityApiError ? readError.code : "connection_unconfirmed" : null); + const authorized = certificate.data?.mutationAuthorized === true && authorizationCurrent && error !== "local_dashboard_confirmation_required"; useEffect(() => { const current = lifecycle.current; current.alive = true; - return () => { current.alive = false; current.generation++; current.pending = false; + const expire = (event: Event) => { + if ((event as CustomEvent<{ plane?: string }>).detail?.plane !== "machine") return; + current.generation++; current.pending = false; + for (const op of current.operations) { op.controller.abort(); op.clear(); } current.operations.clear(); + setMutating(false); setAuthorizationCurrent(false); setNeedsRefresh(true); setChoice(null); setAcknowledged(false); setDone(false); + }; + window.addEventListener(SESSION_CHANGED_EVENT, expire); + return () => { window.removeEventListener(SESSION_CHANGED_EVENT, expire); current.alive = false; current.generation++; current.pending = false; for (const op of current.operations) { op.controller.abort(); op.clear(); } current.operations.clear(); }; }, []); const execute = useCallback(async (action?: CompatibilityAction) => { const current = lifecycle.current; - if (current.pending) return; + if (current.pending || (action && (!authorized || pairingStarted))) return; current.pending = true; const generation = ++current.generation; setMutating(true); setActionError(null); setDone(false); setNeedsRefresh(true); const op = createBoundedFetch(action ? 140000 : 15000); current.operations.add(op); @@ -44,7 +56,7 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool const value = await readCompatibilitySnapshot(apiBase, op.signal); if (current.alive && current.generation === generation) { setClientResourceData(certificateKey, value.certificate); setClientResourceData(runtimeKey, value.runtime); - setNeedsRefresh(false); setDone(!!action); setChoice(null); setAcknowledged(false); + setNeedsRefresh(false); setAuthorizationCurrent(true); setDone(!!action); setChoice(null); setAcknowledged(false); } } catch (cause) { if (current.alive && current.generation === generation) { setActionError(cause instanceof CompatibilityApiError ? cause.code : "connection_unconfirmed"); setChoice(null); setAcknowledged(false); } @@ -52,7 +64,7 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool current.operations.delete(op); op.clear(); if (current.generation === generation) { current.pending = false; if (current.alive) setMutating(false); } } - }, [apiBase, certificateKey, runtimeKey]); + }, [apiBase, certificateKey, runtimeKey, authorized, pairingStarted]); const available = !!snapshot?.certificate.supported && !!snapshot.runtime.supported; const idle = snapshot?.runtime.phase === "off" && !snapshot.certificate.busy; @@ -75,7 +87,11 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool

{t("desktopCompat.title")}

{t("desktopCompat.description")}

{t("desktopCompat.localOnly")}

- + + {certificate.data?.supported && (!authorized || pairingStarted) && { setPairingStarted(true); setChoice(null); setAcknowledged(false); setDone(false); }} + onConnected={signal => validateCompatibilityPairing(apiBase, signal)} + onCommitted={() => { setPairingStarted(false); void execute(); }} />} {error &&

{t(error in errorLabel ? errorLabel[error as keyof typeof errorLabel] : "desktopCompat.error")}

{error}
} {done &&

{t("desktopCompat.done")}

} @@ -103,13 +119,13 @@ function CompatibilityPanel({ apiBase, active }: { apiBase: string; active: bool })}

} }
- {actions.map(action => )}
- {choice &&
+ {choice &&
{t(label[choice.action])}

{t(choice.action === "apply" ? "desktopCompat.trialRisk" : "desktopCompat.risk")}

{choice.action === "apply" &&

{t("desktopCompat.cacheRefreshHint")}

} diff --git a/gui/src/pages/desktop-compatibility-startup-setting.tsx b/gui/src/pages/desktop-compatibility-startup-setting.tsx index 82f86ed3502..477790a55b7 100644 --- a/gui/src/pages/desktop-compatibility-startup-setting.tsx +++ b/gui/src/pages/desktop-compatibility-startup-setting.tsx @@ -4,31 +4,51 @@ import { Switch } from "../ui"; import { setClientResourceData, useClientResource } from "../client-resource"; import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; import { CompatibilityApiError, readCompatibilityStartupSettings, saveCompatibilityStartupSettings } from "../desktop-compatibility-api"; +import { SESSION_CHANGED_EVENT } from "../api"; -export default function DesktopCompatibilityStartupSetting({ apiBase, active }: { apiBase: string; active: boolean }) { +export default function DesktopCompatibilityStartupSetting({ apiBase, active, authorized }: { apiBase: string; active: boolean; authorized: boolean }) { const t = useT(), key = `desktop-compatibility-startup:${apiBase}`; const resource = useClientResource(key, signal => readCompatibilityStartupSettings(apiBase, signal), { enabled: active, deadlineMs: 15000, staleAfterMs: 0 }); const [busy, setBusy] = useState(false), [uncertain, setUncertain] = useState(false), [error, setError] = useState(null); - const ownership = useRef({ alive: true, pending: false, operation: null as BoundedFetch | null }); + const ownership = useRef({ alive: true, pending: false, generation: 0, refreshRequired: false, operation: null as BoundedFetch | null }); useEffect(() => { const owned = ownership.current; owned.alive = true; - return () => { owned.alive = false; owned.operation?.controller.abort(); owned.operation?.clear(); }; }, []); + const invalidate = () => { + owned.generation++; owned.pending = false; + owned.operation?.controller.abort(); owned.operation?.clear(); owned.operation = null; + }; + const expire = (event: Event) => { + if ((event as CustomEvent<{ plane?: string }>).detail?.plane !== "machine") return; + const interrupted = owned.pending; + if (interrupted) owned.refreshRequired = true; + invalidate(); setBusy(false); + if (interrupted) { setUncertain(true); setError("connection_unconfirmed"); } + }; + window.addEventListener(SESSION_CHANGED_EVENT, expire); + return () => { window.removeEventListener(SESSION_CHANGED_EVENT, expire); owned.alive = false; invalidate(); }; }, []); async function run(toggle: boolean) { const owner = ownership.current; - if (owner.pending || (toggle && !resource.data)) return; - owner.pending = true; setBusy(true); setError(null); + if (!owner.alive || owner.pending || (toggle && (owner.refreshRequired || !resource.data || !authorized))) return; + owner.pending = true; const generation = ++owner.generation; setBusy(true); setError(null); const op = createBoundedFetch(15000); owner.operation = op; + const current = () => owner.alive && owner.generation === generation && owner.operation === op; try { if (toggle) await saveCompatibilityStartupSettings(apiBase, resource.data!, !resource.data!.startOnProxyStart, op.signal); + if (!current()) return; op.signal.throwIfAborted(); const actual = await readCompatibilityStartupSettings(apiBase, op.signal); - if (owner.alive) { setClientResourceData(key, actual); setUncertain(false); } + if (!current()) return; op.signal.throwIfAborted(); + owner.refreshRequired = false; setClientResourceData(key, actual); setUncertain(false); } catch (cause) { - if (owner.alive) { setUncertain(true); setError(cause instanceof CompatibilityApiError ? cause.code : "connection_unconfirmed"); } - } finally { op.clear(); owner.operation = null; owner.pending = false; if (owner.alive) setBusy(false); } + if (current()) { owner.refreshRequired = true; setUncertain(true); setError(cause instanceof CompatibilityApiError ? cause.code : "connection_unconfirmed"); } + } finally { + op.clear(); + if (owner.operation === op) owner.operation = null; + if (owner.generation === generation) { owner.pending = false; if (owner.alive) setBusy(false); } + } } const readError = resource.error instanceof CompatibilityApiError ? resource.error.code : resource.error ? "connection_unconfirmed" : null; return
void run(true)} /> + disabled={!authorized || busy || uncertain || !resource.lastAttemptOk || resource.refreshing} onClick={() => void run(true)} />

{t("desktopCompat.autoStartHint")}

{(error || readError) &&

{t("desktopCompat.error")} {error ?? readError}

} {(uncertain || readError) && } diff --git a/gui/tests/desktop-compatibility-panel.test.tsx b/gui/tests/desktop-compatibility-panel.test.tsx index 072231e534e..00c3a4767ff 100644 --- a/gui/tests/desktop-compatibility-panel.test.tsx +++ b/gui/tests/desktop-compatibility-panel.test.tsx @@ -5,12 +5,14 @@ import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import CodexDesktopCompatibility from "../src/pages/codex-desktop-compatibility"; import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { configureApiTargets, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests, SESSION_CHANGED_EVENT } from "../src/api"; +import { standaloneApiTargets } from "../src/api-targets"; -const keys = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const keys = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previous: Record, page: Window, root: Root | undefined; const originalFetch = globalThis.fetch; beforeEach(() => { - clearClientResourceStoresForTests(); + clearClientResourceStoresForTests(); resetApiAuthFetchForTests(); previous = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); page = new Window({ url: "http://localhost/#codex-set/desktop" }); page.localStorage.setItem("ocx-lang", "en"); @@ -18,11 +20,12 @@ beforeEach(() => { }); afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; + resetApiAuthFetchForTests(); globalThis.fetch = originalFetch; page.close(); for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); }); const requests: { url: string; method: string; body?: unknown }[] = []; -function server(uncertain = false, certificateState?: string, usagePhase?: string, observation?: Record, buildAssessment?: unknown) { +function server(uncertain = false, certificateState?: string, usagePhase?: string, observation?: Record, buildAssessment?: unknown, mutationAuthorized = true) { requests.length = 0; let trusted = false, startup = false; globalThis.fetch = (async (input, init) => { const url = String(input), method = init?.method ?? "GET"; @@ -32,7 +35,7 @@ function server(uncertain = false, certificateState?: string, usagePhase?: strin return Response.json({ ok: true, settings: { startOnProxyStart: startup, revision: (startup ? "b" : "a").repeat(64) } }); } if (method === "POST") { trusted = true; if (uncertain) throw new TypeError("uncertain response"); return Response.json({ ok: true }); } - return Response.json(url.endsWith("/certificate") ? { ok: true, certificate: { supported: true, state: trusted ? "trusted" : certificateState ?? "prepared", busy: null, + return Response.json(url.endsWith("/certificate") ? { ok: true, certificate: { supported: true, mutationAuthorized, state: trusted ? "trusted" : certificateState ?? "prepared", busy: null, fingerprint: (url.startsWith("/second") ? "B" : "A").repeat(64) } } : { ok: true, runtime: { supported: true, phase: usagePhase ? "running" : "off", running: !!usagePhase, ...(buildAssessment !== undefined ? { buildAssessment } : {}), ...(usagePhase ? { usage: { mode: "observe", phase: usagePhase, outputs: 0, appCacheConfirmed: false, ...(observation ? { observation } : {}) } } : {}) } }); @@ -52,7 +55,7 @@ function button(container: HTMLElement, text: string): HTMLButtonElement { } async function chooseAndConfirm(container: HTMLElement) { await act(async () => button(container, "Register trust").click()); - expect(requests.filter(req => req.method === "POST")).toHaveLength(0); + expect(requests.filter(req => req.method === "POST" && !req.url.endsWith("/opencodex-session"))).toHaveLength(0); expect(button(container, "Confirm action").disabled).toBe(true); await act(async () => (container.querySelector("input[type=checkbox]") as HTMLInputElement).click()); await act(async () => button(container, "Confirm action").click()); @@ -70,6 +73,151 @@ test("StrictMode status reads are inert and a fingerprint-bound action needs exp expect(container.querySelector("fieldset")).toBeNull(); }); +test("an ordinary loopback session offers explicit local pairing and disables every mutation", async () => { + server(false, undefined, undefined, undefined, undefined, false); const container = await mount(); + expect(button(container, "Register trust").disabled).toBe(true); + expect(button(container, "Resume observation when OpenCodex starts").disabled).toBe(true); + expect(container.textContent).toContain('ocx gui pair --origin "http://localhost"'); + expect(container.querySelector("#connect-pairing-code")).not.toBeNull(); + await act(async () => { button(container, "Register trust").click(); button(container, "Resume observation when OpenCodex starts").click(); }); + expect(container.querySelector("fieldset")).toBeNull(); + expect(requests.every(request => request.method === "GET")).toBe(true); +}); + +test.each([false, true])("a local pairing handoff enables desktop actions only after read-only authorization (authorized: %s)", async allowed => { + server(false, undefined, undefined, undefined, undefined, false); + const read = globalThis.fetch, origin = page.location.origin, sent: { url: string; token: string | null }[] = []; + const sessionHtml = (token: string) => ``; + const raw = (async (input, init) => { + const url = String(input), token = new Headers(init?.headers).get("x-opencodex-api-key"); sent.push({ url, token }); + if (url.endsWith("/opencodex-session")) { + requests.push({ url, method: init?.method ?? "GET" }); return new Response(sessionHtml("ocx_session_paired")); + } + const response = await read(input, init); + if (!url.endsWith("/certificate")) return response; + const value = await response.json(); if (value.certificate) value.certificate.mutationAuthorized = allowed && token === "ocx_session_paired"; + return Response.json(value); + }) as typeof fetch; + Object.defineProperty(page, "fetch", { configurable: true, writable: true, value: raw }); + configureApiTargets(standaloneApiTargets("")); installApiAuthFetch(); + for (const plane of ["machine", "shared"] as const) expect(installApiSessionFromHtml(plane, sessionHtml("ocx_session_automatic"))).toBe(true); + globalThis.fetch = page.fetch as unknown as typeof fetch; + const container = await mount(), input = container.querySelector("#connect-pairing-code") as HTMLInputElement; + Object.getOwnPropertyDescriptor(page.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); + await act(async () => input.dispatchEvent(new page.Event("input", { bubbles: true }))); + await act(async () => input.closest("form")!.dispatchEvent(new page.Event("submit", { bubbles: true, cancelable: true }))); + expect(requests.filter(request => request.method === "POST").map(request => request.url)).toEqual(["/opencodex-session"]); + expect(button(container, "Register trust").disabled).toBe(!allowed); + expect(button(container, "Resume observation when OpenCodex starts").disabled).toBe(!allowed); + if (allowed) { + expect(container.querySelector("#connect-pairing-code")).toBeNull(); + await chooseAndConfirm(container); + expect(sent.filter(request => request.url.endsWith("/certificate")).at(-1)!.token).toBe("ocx_session_paired"); + expect(requests.filter(request => request.method === "POST")).toHaveLength(2); + } else { + expect(input.disabled).toBe(false); expect(container.querySelector('[role="alert"]')).not.toBeNull(); + await page.fetch("/api/codex/desktop-compatibility/certificate"); + expect(sent.at(-1)!.token).toBe("ocx_session_automatic"); + } +}); + +const pairingSessionHtml = (token: string, origin: string): string => + ``; +function pairingTransport(intercept: (url: string, token: string | null) => Response | null) { + server(false, undefined, undefined, undefined, undefined, false); + const read = globalThis.fetch, origin = page.location.origin, sent: { url: string; token: string | null }[] = []; + const raw = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input), token = new Headers(init?.headers).get("x-opencodex-api-key"); + sent.push({ url, token }); + if (url.endsWith("/opencodex-session")) { requests.push({ url, method: init?.method ?? "GET" }); return new Response(pairingSessionHtml("ocx_session_paired", origin)); } + return intercept(url, token) ?? read(input, init); + }) as typeof fetch; + return { sent, raw }; +} +async function mountPairing(raw: typeof fetch): Promise { + Object.defineProperty(page, "fetch", { configurable: true, writable: true, value: raw }); + configureApiTargets(standaloneApiTargets("")); installApiAuthFetch(); + for (const plane of ["machine", "shared"] as const) expect(installApiSessionFromHtml(plane, pairingSessionHtml("ocx_session_automatic", page.location.origin))).toBe(true); + globalThis.fetch = page.fetch as unknown as typeof fetch; + return mount(); +} +async function submitPairing(container: HTMLElement): Promise { + const input = container.querySelector("#connect-pairing-code") as HTMLInputElement; + Object.getOwnPropertyDescriptor(page.HTMLInputElement.prototype, "value")!.set!.call(input, `ocx_pair_${"a".repeat(43)}`); + await act(async () => input.dispatchEvent(new page.Event("input", { bubbles: true }))); + await act(async () => input.closest("form")!.dispatchEvent(new page.Event("submit", { bubbles: true, cancelable: true }))); + return input; +} +function refusedResponse(status: number, cancel: () => Promise): Response { + const response = new Response(null, { status }); + Object.defineProperty(response, "body", { value: { cancel } }); + return response; +} +function expectRefusedRollbackState(container: HTMLElement, input: HTMLInputElement): void { + expect(requests.filter(request => request.method === "POST").map(request => request.url)).toEqual(["/opencodex-session"]); + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("rejected the code"); + expect(alert?.textContent).not.toContain("Could not reach the hub"); + expect(input.disabled).toBe(false); + button(container, "Connect"); +} + +test.each([401, 403])("a refused pairing validation rolls back the session handoff when body cleanup rejects (status: %s)", async status => { + let cancelCalls = 0; + const { sent, raw } = pairingTransport((url, token) => url.endsWith("/certificate") && token === "ocx_session_paired" + ? refusedResponse(status, () => { cancelCalls++; return Promise.reject(new Error("cancel failed")); }) : null); + const container = await mountPairing(raw), input = await submitPairing(container); + expect(cancelCalls).toBe(1); + expectRefusedRollbackState(container, input); + await page.fetch("/api/codex/desktop-compatibility/certificate"); + expect(sent.at(-1)!.token).toBe("ocx_session_automatic"); +}); + +test.each([401, 403])("a refused pairing validation does not wait on a stalled body cleanup (status: %s)", async status => { + let cancelCalls = 0, releaseStall: (() => void) | undefined; + const { sent, raw } = pairingTransport((url, token) => url.endsWith("/certificate") && token === "ocx_session_paired" + ? refusedResponse(status, () => { cancelCalls++; return new Promise(resolve => { releaseStall = () => resolve(); }); }) : null); + try { + const container = await mountPairing(raw), input = await submitPairing(container); + expect(cancelCalls).toBe(1); + expectRefusedRollbackState(container, input); + await page.fetch("/api/codex/desktop-compatibility/certificate"); + expect(sent.at(-1)!.token).toBe("ocx_session_automatic"); + } finally { releaseStall?.(); } +}); + +test("a network failure during pairing validation keeps the pending handoff for an explicit retry", async () => { + let failed = false; + const { sent, raw } = pairingTransport((url, token) => { + if (url.endsWith("/certificate") && token === "ocx_session_paired" && !failed) { failed = true; throw new TypeError("connection reset"); } + return null; + }); + const container = await mountPairing(raw), input = await submitPairing(container); + expect(failed).toBe(true); + expect(requests.filter(request => request.method === "POST").map(request => request.url)).toEqual(["/opencodex-session"]); + const alert = container.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("session validation could not complete"); + expect(alert?.textContent).not.toContain("rejected the code"); + expect(input.disabled).toBe(true); + button(container, "Retry validation"); + await page.fetch("/api/codex/desktop-compatibility/certificate"); + expect(sent.at(-1)!.token).toBe("ocx_session_paired"); +}); + +test("machine session replacement drops consent and authorization while unrelated shared changes preserve them", async () => { + server(); const container = await mount(); + await act(async () => button(container, "Register trust").click()); + await act(async () => (container.querySelector("input[type=checkbox]") as HTMLInputElement).click()); + await act(async () => page.dispatchEvent(new page.CustomEvent(SESSION_CHANGED_EVENT, { detail: { plane: "shared" } }))); + expect(button(container, "Confirm action").disabled).toBe(false); + await act(async () => page.dispatchEvent(new page.CustomEvent(SESSION_CHANGED_EVENT, { detail: { plane: "machine" } }))); + expect(container.querySelector("fieldset")).toBeNull(); + expect(button(container, "Register trust").disabled).toBe(true); + expect(button(container, "Resume observation when OpenCodex starts").disabled).toBe(true); + expect(container.querySelector("#connect-pairing-code")).not.toBeNull(); + expect(requests.every(request => request.method === "GET")).toBe(true); +}); + test.each(["expired-awaiting-original-response", "observing-awaiting-original-response"])("%s does not claim the native cache has reverted", async phase => { server(false, "trusted", phase); const container = await mount(); expect(container.textContent).toContain("Stopping correction does not immediately reset the Codex display."); @@ -182,3 +330,74 @@ test("an uncertain auto-start write is not replayed and refresh recovers its com expect(toggle.disabled).toBe(false); expect(toggle.getAttribute("aria-pressed")).toBe("true"); expect(requests.filter(req => req.method === "POST")).toHaveLength(1); }); + +async function heldStartupSetting() { + server(); + const read = globalThis.fetch, origin = page.location.origin; + const sent: { method: string; token: string | null }[] = []; + let release!: (response: Response) => void, postSignal: AbortSignal | undefined, saved = false; + const settings = () => Response.json({ ok: true, settings: { startOnProxyStart: saved, revision: (saved ? "b" : "a").repeat(64) } }); + const html = (token: string) => ``; + const raw = (async (input, init) => { + init?.signal?.throwIfAborted(); + if (!String(input).endsWith("/settings")) return read(input, init); + const method = init?.method ?? "GET", token = new Headers(init?.headers).get("x-opencodex-api-key"); sent.push({ method, token }); + if (method === "POST") { + if (token === "ocx_session_old") { + postSignal = init?.signal ?? undefined; + // Deliberately return late even after abort, to exercise completion ownership as well. + return await new Promise(resolve => { release = resolve; }); + } + saved = true; + } + return settings(); + }) as typeof fetch; + Object.defineProperty(page, "fetch", { configurable: true, writable: true, value: raw }); + configureApiTargets(standaloneApiTargets("")); installApiAuthFetch(); + expect(installApiSessionFromHtml("machine", html("ocx_session_old"))).toBe(true); + globalThis.fetch = page.fetch as unknown as typeof fetch; + const container = await mount(), toggle = button(container, "Resume observation when OpenCodex starts"); + const setting = toggle.closest(".panel") as HTMLElement; + await act(async () => toggle.click()); + expect(typeof release).toBe("function"); + return { container, toggle, setting, sent, signal: () => postSignal, saved: () => saved, + install: (plane: "machine" | "shared") => installApiSessionFromHtml(plane, html("ocx_session_new")), + release: (response: Response) => release(response), settings, + save: () => { saved = true; }, + refreshAuthority: () => (container.querySelector("section.panel > button") as HTMLButtonElement).click() }; +} + +test("machine session replacement aborts a held settings POST before a 401 can replay its consent", async () => { + const io = await heldStartupSetting(); + let abortedSynchronously = false; + await act(async () => { io.install("machine"); abortedSynchronously = io.signal()?.aborted === true; }); + await act(async () => io.release(new Response(null, { status: 401 }))); + expect(io.sent.filter(request => request.method === "POST").map(request => request.token)).toEqual(["ocx_session_old"]); + expect(io.saved()).toBe(false); expect(abortedSynchronously).toBe(true); + expect(io.toggle.disabled).toBe(true); + await act(async () => io.refreshAuthority()); + expect(io.toggle.disabled).toBe(true); // Authority metadata cannot settle the uncertain preference. + await act(async () => button(io.setting, "Refresh status").click()); + expect(io.toggle.disabled).toBe(false); expect(io.toggle.getAttribute("aria-pressed")).toBe("false"); +}); + +test("a late old-session settings response cannot replace a fresh read or restore uncertainty", async () => { + const io = await heldStartupSetting(); + await act(async () => io.install("machine")); + await act(async () => io.refreshAuthority()); + await act(async () => button(io.setting, "Refresh status").click()); + expect(io.toggle.disabled).toBe(false); + const reads = io.sent.filter(request => request.method === "GET").length; + await act(async () => io.release(Response.json({ ok: true, settings: { startOnProxyStart: true, revision: "b".repeat(64) } }))); + expect(io.sent.filter(request => request.method === "GET")).toHaveLength(reads); + expect(io.toggle.disabled).toBe(false); expect(io.toggle.getAttribute("aria-pressed")).toBe("false"); +}); + +test("a shared-session change does not cancel an authorized machine settings operation", async () => { + const io = await heldStartupSetting(); + await act(async () => io.install("shared")); + expect(io.signal()?.aborted).toBe(false); + io.save(); await act(async () => io.release(io.settings())); + expect(io.sent.filter(request => request.method === "POST").map(request => request.token)).toEqual(["ocx_session_old"]); + expect(io.toggle.disabled).toBe(false); expect(io.toggle.getAttribute("aria-pressed")).toBe("true"); +}); diff --git a/gui/tests/local-link-pairing.test.ts b/gui/tests/local-link-pairing.test.ts index 0488a02eb09..0c3aff05c6b 100644 --- a/gui/tests/local-link-pairing.test.ts +++ b/gui/tests/local-link-pairing.test.ts @@ -1,8 +1,9 @@ import { expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act, createElement } from "react"; -import { configureApiTargets, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests } from "../src/api"; +import { configureApiTargets, fetchApiSessionValidation, installApiAuthFetch, installApiSessionFromHtml, resetApiAuthFetchForTests } from "../src/api"; import type { ApiTargets } from "../src/api-targets"; +import { PairingError, setPairingDeadlineForTests, submitConnectPairing, validateRemoteLinkSession } from "../src/connect-pairing-transport"; const origin = "http://127.0.0.1:10100"; function sessionHtml(token: string, serverOrigin = origin): string { @@ -60,6 +61,38 @@ for (const relay of [false, true]) test(`pairing omits stale shared authenticati }); // TRANSPORT_REGRESSIONS_END +test("desktop pairing replaces only the machine session and subsequent desktop mutations use it", async () => { + const keys = ["window", "document", "sessionStorage"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: origin }), sent: { path: string; headers: Headers }[] = []; + const targets: ApiTargets = { connected: false, + machine: { id: "machine", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" }, + shared: { id: "shared", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" } }; + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: Reflect.get(win, key === "window" ? "self" : key) }); + Object.defineProperty(win, "fetch", { configurable: true, writable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + sent.push({ path: String(input), headers: new Headers(init?.headers) }); + return String(input).endsWith("/opencodex-session") ? new Response(sessionHtml("ocx_session_paired")) : new Response(null, { status: 204 }); + } }); + try { + resetApiAuthFetchForTests(); configureApiTargets(targets); installApiAuthFetch(); + expect(installApiSessionFromHtml("machine", sessionHtml("ocx_session_automatic"))).toBe(true); + expect(installApiSessionFromHtml("shared", sessionHtml("ocx_session_shared"))).toBe(true); + const handoff = await submitConnectPairing(targets.machine, `ocx_pair_${"a".repeat(43)}`); + await fetchApiSessionValidation("machine", "/api/codex/desktop-compatibility/certificate", {}); + expect(sent.at(-1)!.headers.get("x-opencodex-api-key")).toBe("ocx_session_paired"); + handoff.commit(); + await win.fetch("/api/codex/desktop-compatibility/runtime", { method: "POST" }); + expect(sent.at(-1)!.headers.get("x-opencodex-api-key")).toBe("ocx_session_paired"); + expect(sent.at(-1)!.headers.get("x-opencodex-csrf-token")).toBe("ocx_session_paired-csrf"); + await win.fetch("/api/status"); + expect(sent.at(-1)!.headers.get("x-opencodex-api-key")).toBe("ocx_session_shared"); + expect(sent[0]!.headers.has("x-opencodex-api-key")).toBe(false); + } finally { + resetApiAuthFetchForTests(); win.close(); + for (const key of keys) { const descriptor = previous[key]; if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } + } +}); + test("local pairing form names the local origin, makes no automatic exchange, and waits for explicit submission", async () => { const keys = ["window", "document", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; const previous = Object.fromEntries(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); @@ -100,3 +133,71 @@ test("local pairing form names the local origin, makes no automatic exchange, an } } }); + +test.each([401, 403])("a stalled refusal-body cleanup still surfaces the pairing exchange refusal immediately (status: %s)", async status => { + const keys = ["window", "document", "sessionStorage"] as const; + const previous = Object.fromEntries(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const win = new Window({ url: origin }), sent: { path: string; headers: Headers }[] = []; + const targets: ApiTargets = { connected: false, + machine: { id: "machine", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" }, + shared: { id: "shared", baseUrl: "", serverOrigin: origin, bootstrapPath: "/opencodex-session", transport: "same-origin" } }; + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: Reflect.get(win, key === "window" ? "self" : key) }); + Object.defineProperty(win, "fetch", { configurable: true, writable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + sent.push({ path: String(input), headers: new Headers(init?.headers) }); + return new Response(null, { status: 204 }); + } }); + let releaseStall: (() => void) | undefined, cancelCalls = 0; + const refused = new Response(null, { status }); + Object.defineProperty(refused, "body", { value: { cancel: () => { cancelCalls++; return new Promise(resolve => { releaseStall = () => resolve(); }); } } }); + try { + setPairingDeadlineForTests(100); + resetApiAuthFetchForTests(); configureApiTargets(targets); installApiAuthFetch(); + expect(installApiSessionFromHtml("machine", sessionHtml("ocx_session_automatic"))).toBe(true); + expect(installApiSessionFromHtml("shared", sessionHtml("ocx_session_shared"))).toBe(true); + const failure = await submitConnectPairing(targets.machine, `ocx_pair_${"a".repeat(43)}`, (async () => refused) as typeof fetch) + .then(() => null, (error: unknown) => error); + expect(failure).toBeInstanceOf(PairingError); + expect((failure as PairingError).kind).toBe(status === 401 ? "refused" : "origin-denied"); + expect(cancelCalls).toBe(1); + await win.fetch("/api/codex/desktop-compatibility/certificate"); + expect(sent.at(-1)!.headers.get("x-opencodex-api-key")).toBe("ocx_session_automatic"); + } finally { + releaseStall?.(); setPairingDeadlineForTests(10_000); + resetApiAuthFetchForTests(); win.close(); + for (const key of keys) { const descriptor = previous[key]; if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } + } +}); + +test.each([401, 403])("a stalled refusal-body cleanup keeps remote-link validation definitive (status: %s)", async status => { + let releaseStall: (() => void) | undefined, cancelCalls = 0; + const refused = new Response(null, { status }); + Object.defineProperty(refused, "body", { value: { cancel: () => { cancelCalls++; return new Promise(resolve => { releaseStall = () => resolve(); }); } } }); + try { + setPairingDeadlineForTests(100); + const failure = await validateRemoteLinkSession("", (async () => refused) as typeof fetch) + .then(() => null, (error: unknown) => error); + expect(failure).toBeInstanceOf(PairingError); + expect((failure as PairingError).kind).toBe(status === 401 ? "remote-link-unauthorized" : "remote-link-forbidden"); + expect(cancelCalls).toBe(1); + } finally { releaseStall?.(); setPairingDeadlineForTests(10_000); } +}); + +test.each([false, true])("HTML prefix classification survives stalled reader cleanup (challenge: %s)", async challenge => { + let cancelCalls = 0, release: (() => void) | undefined; + const response = new Response(null, { status: 401, headers: { "Content-Type": "text/html" } }); + const text = (challenge ? "Just a moment..." : "Unauthorized").padEnd(16 * 1024, " "); + Object.defineProperty(response, "body", { value: { + getReader: () => ({ + read: async () => ({ done: false, value: new TextEncoder().encode(text) }), + cancel: () => { cancelCalls++; return new Promise(resolve => { release = resolve; }); }, + }), + cancel: async () => {}, + } }); + try { + setPairingDeadlineForTests(100); + const error = await validateRemoteLinkSession("", (async () => response) as typeof fetch).then(() => null, error => error); + expect(error).toBeInstanceOf(PairingError); + expect((error as PairingError).kind).toBe(challenge ? "cloudflare-challenge" : "remote-link-unauthorized"); + expect(cancelCalls).toBe(1); + } finally { release?.(); setPairingDeadlineForTests(10_000); } +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 0ecba4899ba..3af2cf7ba30 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -82,9 +82,10 @@ export function selectDefaultGuiUrl( const ingress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined; if (ingress?.enabled) return `http://127.0.0.1:${ingress.port}`; + // Keep literal loopback addresses: standalone pairing admits the configured + // origin, so replacing 127.0.0.1 with localhost strands its browser flow. const guiHost = probeHostname(live?.hostname ?? config.hostname); - const hostname = guiHost === "127.0.0.1" ? "localhost" : guiHost; - return `http://${hostname}:${live?.port ?? config.port ?? 10100}`; + return `http://${guiHost}:${live?.port ?? config.port ?? 10100}`; } const commandRunners: Record = { diff --git a/src/server/management/desktop-compatibility-authorization.ts b/src/server/management/desktop-compatibility-authorization.ts new file mode 100644 index 00000000000..4d5c51f45fa --- /dev/null +++ b/src/server/management/desktop-compatibility-authorization.ts @@ -0,0 +1,8 @@ +import type { ManagementContext } from "./context"; + +/** Desktop compatibility mutations require an operator-mediated browser session. */ +export function isAuthorizedDesktopCompatibilityMutation(ctx: ManagementContext): boolean { + return ctx.principal === "gui-session" + && ctx.trustedLoopbackIngress + && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true; +} diff --git a/src/server/management/desktop-compatibility-routes.ts b/src/server/management/desktop-compatibility-routes.ts index af67e429a51..7479a68c9f2 100644 --- a/src/server/management/desktop-compatibility-routes.ts +++ b/src/server/management/desktop-compatibility-routes.ts @@ -1,6 +1,7 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; +import { isAuthorizedDesktopCompatibilityMutation } from "./desktop-compatibility-authorization"; import type { DesktopCertificateService } from "../../codex/desktop-compatibility/certificate-service"; const PATH = "/api/codex/desktop-compatibility/certificate"; @@ -9,9 +10,7 @@ let service: DesktopCertificateService | undefined; export async function handleDesktopCompatibilityRoutes(ctx: ManagementContext): Promise { if (ctx.url.pathname !== PATH) return null; if (ctx.req.method !== "GET" && ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405); - // A local dashboard confirmation precedes any persisted key or CurrentUser Root mutation. - // This is caller provenance, not protection against an arbitrary same-user process. - if (ctx.req.method === "POST" && (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress)) { + if (ctx.req.method === "POST" && !isAuthorizedDesktopCompatibilityMutation(ctx)) { return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); } let action: "prepare" | "trust" | "remove-trust" | "renew" | undefined, fingerprint: string | undefined; @@ -36,7 +35,7 @@ export async function handleDesktopCompatibilityRoutes(ctx: ManagementContext): : action === "trust" ? await controller.trust(fingerprint!) : action === "renew" ? await controller.renew(fingerprint!) : action === "remove-trust" ? await controller.removeTrust(fingerprint!) : await controller.status(); - return jsonResponse({ ok: true, certificate: status }); + return jsonResponse({ ok: true, certificate: { ...status, mutationAuthorized: isAuthorizedDesktopCompatibilityMutation(ctx) } }); } catch (error) { const code = error && typeof error === "object" && "code" in error ? String(error.code) : "operation_failed"; const allowed = new Set(["unsupported", "busy", "not_prepared", "fingerprint_changed", "app_running", "runtime_running", "app_state_unknown", "trust_unknown", "trust_not_applied", diff --git a/src/server/management/desktop-compatibility-runtime-routes.ts b/src/server/management/desktop-compatibility-runtime-routes.ts index eeb4bbe2b47..0cbc4090236 100644 --- a/src/server/management/desktop-compatibility-runtime-routes.ts +++ b/src/server/management/desktop-compatibility-runtime-routes.ts @@ -1,6 +1,7 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; +import { isAuthorizedDesktopCompatibilityMutation } from "./desktop-compatibility-authorization"; import type { DesktopCompatibilityRuntime } from "../../codex/desktop-compatibility/runtime"; const PATH = "/api/codex/desktop-compatibility/runtime"; @@ -11,7 +12,7 @@ const ERROR_CODES = new Set(["busy", "unsupported", "test_environment", "stoppin export async function handleDesktopCompatibilityRuntimeRoutes(ctx: ManagementContext): Promise { if (ctx.url.pathname !== PATH) return null; if (ctx.req.method !== "GET" && ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405); - if (ctx.req.method === "POST" && (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); + if (ctx.req.method === "POST" && !isAuthorizedDesktopCompatibilityMutation(ctx)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); let action: "start" | "stop" | "observe" | "apply" | "launch" | undefined; if (ctx.req.method === "POST") { let body: unknown; diff --git a/src/server/management/desktop-compatibility-settings-routes.ts b/src/server/management/desktop-compatibility-settings-routes.ts index b051292673f..f097fc00a82 100644 --- a/src/server/management/desktop-compatibility-settings-routes.ts +++ b/src/server/management/desktop-compatibility-settings-routes.ts @@ -1,12 +1,13 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; +import { isAuthorizedDesktopCompatibilityMutation } from "./desktop-compatibility-authorization"; const PATH = "/api/codex/desktop-compatibility/settings"; export async function handleDesktopCompatibilitySettingsRoutes(ctx: ManagementContext): Promise { if (ctx.url.pathname !== PATH) return null; if (ctx.req.method !== "GET" && ctx.req.method !== "POST") return jsonResponse({ error: "method_not_allowed" }, 405); - if (ctx.req.method === "POST" && (ctx.principal !== "gui-session" || !ctx.trustedLoopbackIngress)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); + if (ctx.req.method === "POST" && !isAuthorizedDesktopCompatibilityMutation(ctx)) return jsonResponse({ error: "local_dashboard_confirmation_required" }, 403); let update: { startOnProxyStart: boolean; revision: string } | undefined; if (ctx.req.method === "POST") { let body: unknown; diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 9bf7d850265..62c37f07587 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -173,11 +173,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/claude-desktop/picker", module: "server/management/claude-desktop-picker-routes", mutates: false }, { method: "PUT", path: "/api/claude-desktop/picker", module: "server/management/claude-desktop-picker-routes", mutates: true }, { method: "GET", path: "/api/codex/desktop-compatibility/certificate", module: "server/management/desktop-compatibility-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "deferred-verb", owner: "codex-desktop-compatibility", ownerDoc: "structure/clients/codex-desktop.md", why: "Certificate status is available over authenticated HTTP while the desktop compatibility runtime/CLI status contract is integrated." } }, - { method: "POST", path: "/api/codex/desktop-compatibility/certificate", module: "server/management/desktop-compatibility-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Certificate setup requires a confirmed local dashboard session; raw admin tokens cannot enroll or remove OS trust." } }, + { method: "POST", path: "/api/codex/desktop-compatibility/certificate", module: "server/management/desktop-compatibility-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Certificate setup requires an operator-paired local dashboard session; raw admin tokens and automatic loopback sessions cannot enroll or remove OS trust." } }, { method: "GET", path: "/api/codex/desktop-compatibility/runtime", module: "server/management/desktop-compatibility-runtime-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "deferred-verb", owner: "codex-desktop-compatibility", ownerDoc: "structure/clients/codex-desktop.md", why: "Experimental runtime status is authenticated HTTP while the native compatibility UI and CLI contract are integrated." } }, - { method: "POST", path: "/api/codex/desktop-compatibility/runtime", module: "server/management/desktop-compatibility-runtime-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Native app launch and account-wide UI trial require explicit local dashboard confirmation." } }, + { method: "POST", path: "/api/codex/desktop-compatibility/runtime", module: "server/management/desktop-compatibility-runtime-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Native app launch and account-wide UI trial require explicit confirmation from an operator-paired local dashboard session." } }, { method: "GET", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "deferred-verb", owner: "codex-desktop-compatibility", ownerDoc: "structure/clients/codex-desktop.md", why: "The persisted startup preference is currently owned by the local desktop compatibility panel." } }, - { method: "POST", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Saved native observation intent requires an explicit local GUI action and fresh field revision." } }, + { method: "POST", path: "/api/codex/desktop-compatibility/settings", module: "server/management/desktop-compatibility-settings-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "session-only", why: "Saved native observation intent requires an operator-paired local GUI action and fresh field revision." } }, // server/management/codex-cli-update-routes { method: "GET", path: "/api/codex/cli-update/plan", module: "server/management/codex-cli-update-routes", mutates: false, exempt: { reason: "local-transport", why: "The CLI runs the same proof-bound plan builder locally; the authenticated route also supplies the dashboard plan." } }, { method: "POST", path: "/api/codex/cli-update/apply", module: "server/management/codex-cli-update-routes", mutates: true, exempt: { reason: "session-only", why: "HTTP apply requires a confirmed trusted loopback GUI session; the CLI invokes the same durable job owner locally." } }, diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md index 491213db31c..334956b5637 100644 --- a/structure/clients/codex-desktop.md +++ b/structure/clients/codex-desktop.md @@ -87,9 +87,21 @@ in-process, and the helper's cmdlet-free script on every platform. `src/server/management/desktop-compatibility-routes.ts` exposes the authenticated `/api/codex/desktop-compatibility/certificate` setup endpoint. GET is read-only public metadata and OS-trust inspection; it does not decrypt a key, create a file, acquire a lease or register trust. POST -requires a GUI-session principal on trusted loopback ingress and explicit confirmation. +uses `src/server/management/desktop-compatibility-authorization.ts` to require an +operator-paired GUI session on trusted loopback ingress and explicit confirmation. Trust mutations also require the exact SHA-256 fingerprint. Raw admin tokens cannot -substitute browser provenance; this does not protect against arbitrary same-user code. +substitute the pairing ceremony, and automatically issued loopback sessions cannot mutate trust. +The certificate response carries request-specific `mutationAuthorized` metadata. The local +dashboard reuses `gui/src/connect-pairing.tsx` with a machine-plane handoff, validates that +authority through a read-only certificate request, and commits the session before refreshing +or enabling actions. Pairing never submits a certificate/runtime mutation or changes auto-start. +Machine-session changes discard pending consent and disable actions until fresh authority is read. +The startup-setting child synchronously aborts its own in-flight operation on a machine-session +change and rejects late completions by generation. An interrupted preference operation stays +uncertain until a fresh settings GET, independently of refreshed certificate authorization. +`gui/tests/desktop-compatibility-panel.test.tsx`, `gui/tests/local-link-pairing.test.ts` and +`tests/server/management-desktop-compatibility-routes.test.ts` cover the UI, credential plane +and real loopback-versus-paired session boundary. `src/codex/desktop-compatibility/certificate-service.ts` serializes setup and refuses busy operations. Only prepare may generate a key. Trust/removal load existing validated @@ -227,7 +239,7 @@ registration occurs only after successful startup. Cleanup stops owned listeners a failed cleanup retains ownership and reports `cleanup-required`, never a false `off` state. `src/server/management/desktop-compatibility-runtime-routes.ts` provides GET status and -local GUI-session POST start/stop/observe/apply/launch, with explicit confirmation and +operator-paired local GUI-session POST start/stop/observe/apply/launch, with explicit confirmation and separate account-wide consent for apply. No setting, login, quota, automatic startup or dashboard preference is changed by this API. The separate startup preference below never saves Apply. The status CLI verb remains deferred to this integration owner. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 3c3e2eadd26..89db3980c93 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -86,7 +86,7 @@ successful mutation or restore. No separate settings-save endpoint bypasses the integration writer or its ownership checks. Client-specific restore requests retain their [expected client identity](clients/integrations.md#command-code) through preview and commit; unscoped generic restore remains available. Account refresh actions follow the [credential refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a held unreadable lock is distinct from one this process may release, and path-probe errors preserve the callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. The bundled React dashboard is built into `gui/dist` and served by the same Bun proxy. `ocx gui` starts -the proxy when needed and opens `http://localhost:`, or `http://127.0.0.1:` when `hub.managementIngress.enabled` is true — see [the hub management dashboard address](runtime.md#hub-management-dashboard-address). +the proxy when needed and opens its live address (`http://127.0.0.1:` by default), preserving literal loopback origins for pairing, or `http://127.0.0.1:` when `hub.managementIngress.enabled` is true — see [the hub management dashboard address](runtime.md#hub-management-dashboard-address). Inside the Tauri shell, the sidebar and Dashboard maintenance update entries navigate to the bundled desktop update page. The sidebar reads the session-scoped desktop badge; @@ -567,7 +567,7 @@ POST before a concurrent DELETE settles. Retryable GET/DELETE races preserve sam retry, last trusted device details, and the existing poll cadence. Outside same-flow cancellation ownership, a GET HTTP failure stops polling without starting a second login POST. -Pairing-grant source limiting applies only to invalid guesses from an allowed browser origin; disallowed origins record no limiter state, and a valid grant redeems even from a throttled source. Standalone grant delivery also requires a one-use configuration-write intent; see [Standalone pairing delivery](remote-link.md#standalone-pairing-delivery). +Pairing-grant source limiting applies only to invalid guesses from an allowed browser origin; disallowed origins record no limiter state, and a valid grant redeems even from a throttled source. Standalone grant delivery also requires a one-use configuration-write intent; see [Standalone pairing delivery](remote-link.md#standalone-pairing-delivery). Pairing installs into the selected target's credential plane. Desktop compatibility uses the machine plane and read-only authorization validation; its [local pairing flow](clients/codex-desktop.md#certificate-setup-api) commits the handoff before enabling controls or unmounting the form. `gui/src/connect-pairing.tsx` retains an opaque, memory-only handoff from `gui/src/api.ts` until validation succeeds. The handoff captures runtime identity and generation before the exchange POST; a superseded exchange cannot install credentials after delayed headers/body. Ownership attaches before synchronous session-change events. Remote Link validation reads protected status without 401 renewal or admin-token fallback. Exchange and validation each have a 10-second deadline through body consumption, including transports that ignore abort. Timeout releases the form's busy state; validation timeout retains the handoff for retry, while lifecycle cancellation rolls it back. Network, 5xx, Cloudflare challenge and malformed-status failures offer retry without another exchange. Definitive hub rejection (401/403) or cancellation restores the previous session only while the handoff still owns the current state, including automatic invalidation; newer login, logout and target changes supersede ownership. Discovery bootstrap and 401 recovery share automatic authority captured before fetching; neither clears logout intent. Recovery checks identity/generation before processing bootstrap headers/body, installing or invalidating credentials, entering admin-token fallback, and retrying. Rollback, login, logout and target replacement supersede older recovery, including away-and-back changes; unchanged target refreshes preserve the runtime. Pending or successful logout suppresses automatic invalidation and recovery until explicit login or target change; failed logout releases suppression only if no overlapping logout succeeded. Watchdog expiry blocks late writes but emits session-unavailable for a still-current, uncancelled request without credentials. Credentials never enter form state, storage, errors or event payloads. Coverage: `gui/tests/connect-pairing.test.ts`, `gui/tests/api-auth-deadline.test.ts` and `gui/tests/remote-link-route.test.tsx`. ## Durable provider PATCH diff --git a/structure/runtime.md b/structure/runtime.md index 157398ff984..1d4f4af17d4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -81,7 +81,7 @@ verified matching processes regardless of the advisory freshness result. Read-on ## Hub management dashboard address -When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. +When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Otherwise it retains the live proxy's address and port, including literal loopback hosts: the default `127.0.0.1` is not rewritten to `localhost`, and `::1` is bracketed for the URL. This keeps the default standalone browser origin eligible for the existing exact-origin pairing check; aliases and mismatched runtime addresses or ports remain ineligible. `tests/gui/gui-pair-client.test.ts` exercises opening and pairing through actual CLI dispatch for existing and auto-started proxies. ## Codex desktop process membership diff --git a/tests/gui/gui-pair-client.test.ts b/tests/gui/gui-pair-client.test.ts index feaad529b5c..b12ecbdbacd 100644 --- a/tests/gui/gui-pair-client.test.ts +++ b/tests/gui/gui-pair-client.test.ts @@ -1,4 +1,5 @@ import type { OcxConfig } from "../../src/types"; +import type { CliDispatchDeps } from "../../src/cli/dispatch"; import { runGuiCommand } from "../../src/cli/gui"; import { createGuiPairingGrant, consumeGuiPairingGrant, issueGuiSession, authorizeGuiSessionRequest } from "../../src/server/gui-session"; import { createManagementSessionControl, managementPrincipal, managementSessionIssuance, requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth"; @@ -25,6 +26,69 @@ const nonce = "B".repeat(43); const browserOrigin = "https://dashboard.example.test"; const target: LiveProxy = { pid: 4242, port: 10100, hostname: "127.0.0.1", source: "runtime" }; +test.each([ + ["default bind", undefined, false, "http://127.0.0.1:10100"], + ["IPv4 bind", "127.0.0.1", false, "http://127.0.0.1:10100"], + ["IPv6 bind", "::1", false, "http://[::1]:10100"], + ["auto-started default bind", undefined, true, "http://127.0.0.1:10100"], +] as const)("GUI dispatch opens a pairable origin for the %s", async (_label, hostname, start, expectedOrigin) => { + const { dispatchCommand } = await import("../../src/cli/dispatch"); + const { probeHostname } = await import("../../src/server/proxy-liveness"); + const launcher = await import("../../src/lib/open-url"); + const pairingClient = await import("../../src/cli/gui-pair-client"); + const config: OcxConfig = { port: 10100, hostname, providers: {}, defaultProvider: "openai" }; + const live: LiveProxy = { ...target, hostname }; + const opened: string[] = []; + const grants: string[] = []; + let spawns = 0; + let waits = 0; + let running = !start; + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + const open = spyOn(launcher, "openUrl").mockImplementation(async url => { + opened.push(url); + return { status: "started" }; + }); + const pair = spyOn(pairingClient, "requestBoundGuiPairingGrant").mockImplementation(async (actual, origin, options) => { + expect(actual).toEqual(live); + expect(options?.requireLocalIntent).toBe(true); + grants.push(origin); + return { + kind: "created", grant: `ocx_pair_${"C".repeat(43)}`, + browserOrigin: origin, serverOrigin: origin, expiresAt: Date.now() + 60_000, + }; + }); + const dispatch = (args: string[]) => dispatchCommand( + { kind: "command", command: "gui", args }, + { + args, loadConfig: () => config, probeHostname, + findLiveProxy: async () => running ? live : null, + startArgv: port => ["ocx", "start", String(port)], + spawnDetached: () => { spawns += 1; }, + waitForProxy: async () => { waits += 1; running = true; return live; }, + } as CliDispatchDeps, + ); + try { + expect(await dispatch(["gui"])).toBe(0); + expect(opened).toHaveLength(1); + const openedOrigin = new URL(opened[0]!).origin; + // Use exactly the origin the shared pairing form reads from window.location. + expect(await dispatch(["gui", "pair", "--origin", openedOrigin, "--json"])).toBe(0); + expect(openedOrigin).toBe(expectedOrigin); + expect(grants).toEqual([expectedOrigin]); + expect(spawns).toBe(start ? 1 : 0); + expect(waits).toBe(start ? 1 : 0); + // Opening a usable default must not make an alias a valid pairing origin. + expect(await dispatch(["gui", "pair", "--origin", "http://localhost:10100"])).toBe(1); + expect(grants).toEqual([expectedOrigin]); + } finally { + pair.mockRestore(); + open.mockRestore(); + error.mockRestore(); + log.mockRestore(); + } +}); + function proofResponse(init?: RequestInit, capabilityVersion: unknown = GUI_PAIR_CAPABILITY_VERSION): Response { const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; return Response.json({ diff --git a/tests/server/management-desktop-compatibility-routes.test.ts b/tests/server/management-desktop-compatibility-routes.test.ts index 287aa474447..a67e1718a08 100644 --- a/tests/server/management-desktop-compatibility-routes.test.ts +++ b/tests/server/management-desktop-compatibility-routes.test.ts @@ -4,8 +4,11 @@ import type { ManagementContext } from "../../src/server/management/context"; import type { DesktopCertificateService } from "../../src/codex/desktop-compatibility/certificate-service"; import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; +import { createManagementSessionControl, type ManagementAuthState } from "../../src/server/management-auth"; +import { consumeGuiPairingGrant, createGuiPairingGrant, issueGuiSession, type GuiSessionBootstrap } from "../../src/server/gui-session"; const url = new URL("http://127.0.0.1:10100/api/codex/desktop-compatibility/certificate"); +const pairedSession = { isPaired: () => true } as NonNullable; function fixture(method: string, body?: object, principal: ManagementContext["principal"] = "gui-session", loopback = true) { const calls: string[] = []; const status = { supported: true, state: "prepared" as const, busy: null }; @@ -13,7 +16,7 @@ function fixture(method: string, body?: object, principal: ManagementContext["pr prepare: async () => { calls.push("prepare"); return status; }, trust: async fp => { calls.push("trust:" + fp); return status; }, removeTrust: async fp => { calls.push("remove:" + fp); return status; }, renew: async fp => { calls.push("renew:" + fp); return status; } }; const ctx = { req: new Request(url, { method, headers: { host: url.host, "content-type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}) }), - url, principal, trustedLoopbackIngress: loopback, deps: { desktopCertificateService: service } } as ManagementContext; + url, principal, sessionControl: pairedSession, trustedLoopbackIngress: loopback, deps: { desktopCertificateService: service } } as ManagementContext; return { calls, ctx }; } test("status reads do not invoke setup or OS trust", async () => { @@ -26,6 +29,35 @@ test("only authenticated local dashboard mutations reach the service", async () if (principal === undefined) io.ctx.principal = undefined; expect((await handleDesktopCompatibilityRoutes(io.ctx))?.status).toBe(403); expect(io.calls).toEqual([]); } + const unpaired = fixture("POST", { action: "prepare", confirmed: true }); + unpaired.ctx.sessionControl = { ...pairedSession, isPaired: () => false }; + expect((await handleDesktopCompatibilityRoutes(unpaired.ctx))?.status).toBe(403); expect(unpaired.calls).toEqual([]); +}); +test("real loopback bootstrap stays read-only until a local pairing grant establishes mutation authority", async () => { + const config = { hostname: "127.0.0.1", port: 10100, runtimeRole: "standalone", providers: {} } as OcxConfig; + const state: ManagementAuthState = { available: true, token: "fixture-admin", source: "file", sessions: new Map(), pairingGrants: new Map() }; + const bootstrap = new Request(url.origin + "/opencodex-session", { headers: { host: url.host, origin: url.origin } }); + const automatic = issueGuiSession(bootstrap, config, state)!; + expect(automatic.issuance).toBe("loopback"); + const grant = createGuiPairingGrant(url.origin, config, state); + const paired = consumeGuiPairingGrant(new Request(bootstrap, { method: "POST" }), { grant: grant.grant }, config, state, Date.now(), + { ingress: "public", peerAddress: "127.0.0.1", tailscaleUser: null, browserOrigin: url.origin }); + expect(paired && "token" in paired).toBe(true); + const sessionControl = createManagementSessionControl(state); + for (const [session, authorized] of [[automatic, false], [paired as GuiSessionBootstrap, true]] as const) { + for (const method of ["GET", "POST"]) { + const io = fixture(method, method === "POST" ? { action: "prepare", confirmed: true } : undefined); + io.ctx.config = config; io.ctx.sessionControl = sessionControl; + const headers = new Headers(io.ctx.req.headers); + headers.set("origin", url.origin); headers.set("x-opencodex-api-key", session.token); + headers.set("x-opencodex-gui-origin", session.browserOrigin); headers.set("x-opencodex-csrf-token", session.csrfToken); + io.ctx.req = new Request(io.ctx.req, { headers }); + const response = (await handleDesktopCompatibilityRoutes(io.ctx))!; + expect(response.status).toBe(method === "POST" && !authorized ? 403 : 200); + if (method === "GET") expect((await response.json()).certificate.mutationAuthorized).toBe(authorized); + expect(io.calls).toEqual(method === "GET" ? ["status"] : authorized ? ["prepare"] : []); + } + } }); test("explicit confirmation and exact fingerprint are validated before any side effect", async () => { for (const body of [{ action: "prepare" }, { action: "trust", confirmed: true }, { action: "trust", confirmed: true, fingerprint: "wrong" }, @@ -51,6 +83,6 @@ test("renewal requires fresh fingerprint and explicit local dashboard confirmati test("the real management dispatcher reaches setup with principal and ingress intact", async () => { const io = fixture("POST", { action: "prepare", confirmed: true }); const response = await handleManagementAPI(io.ctx.req, url, { providers: {} } as OcxConfig, - io.ctx.deps, "gui-session", undefined, { trustedLoopback: true }); + io.ctx.deps, "gui-session", pairedSession, { trustedLoopback: true }); expect(response?.status).toBe(200); expect(io.calls).toEqual(["prepare"]); }); diff --git a/tests/server/management-desktop-compatibility-runtime-routes.test.ts b/tests/server/management-desktop-compatibility-runtime-routes.test.ts index 2610f63c8b3..a59397d5c6b 100644 --- a/tests/server/management-desktop-compatibility-runtime-routes.test.ts +++ b/tests/server/management-desktop-compatibility-runtime-routes.test.ts @@ -6,6 +6,7 @@ import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; const url = new URL("http://127.0.0.1:10100/api/codex/desktop-compatibility/runtime"); +const pairedSession = { isPaired: () => true } as NonNullable; function fixture(method: string, body?: unknown) { const calls: string[] = []; const runtime = createDesktopCompatibilityRuntime({ platform: "linux" }); @@ -13,18 +14,18 @@ function fixture(method: string, body?: unknown) { runtime.stop = async () => { calls.push("stop"); return runtime.status(); }; const ctx = { req: new Request(url, { method, headers: { host: url.host, "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), url, - deps: { desktopCompatibilityRuntime: runtime }, principal: "gui-session", trustedLoopbackIngress: true } as ManagementContext; + deps: { desktopCompatibilityRuntime: runtime }, principal: "gui-session", sessionControl: pairedSession, trustedLoopbackIngress: true } as ManagementContext; return { ctx, calls, runtime }; } test("runtime status is inert and the management dispatcher preserves local consent", async () => { const io = fixture("GET"); expect((await handleDesktopCompatibilityRuntimeRoutes(io.ctx))?.status).toBe(200); expect(io.calls).toEqual([]); const confirmed = fixture("POST", { action: "start", confirmed: true }); - const response = await handleManagementAPI(confirmed.ctx.req, url, { providers: {} } as OcxConfig, confirmed.ctx.deps, "gui-session", undefined, { trustedLoopback: true }); + const response = await handleManagementAPI(confirmed.ctx.req, url, { providers: {} } as OcxConfig, confirmed.ctx.deps, "gui-session", pairedSession, { trustedLoopback: true }); expect(response?.status).toBe(200); expect(confirmed.calls).toEqual(["start"]); }); test("remote/admin-token callers and ambiguous scope cannot start or alter runtime", async () => { - for (const change of [{ principal: "admin-token" }, { trustedLoopbackIngress: false }]) { + for (const change of [{ principal: "admin-token" }, { trustedLoopbackIngress: false }, { sessionControl: { ...pairedSession, isPaired: () => false } }]) { const io = fixture("POST", { action: "start", confirmed: true }); Object.assign(io.ctx, change); expect((await handleDesktopCompatibilityRuntimeRoutes(io.ctx))?.status).toBe(403); expect(io.calls).toEqual([]); } diff --git a/tests/server/management-desktop-compatibility-settings.test.ts b/tests/server/management-desktop-compatibility-settings.test.ts index e0ad711ebe9..9a254ebdb5e 100644 --- a/tests/server/management-desktop-compatibility-settings.test.ts +++ b/tests/server/management-desktop-compatibility-settings.test.ts @@ -13,6 +13,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import type { ManagementContext } from "../../src/server/management/context"; import type { OcxConfig } from "../../src/types"; let root = "", previous: string | undefined; +const pairedSession = { isPaired: () => true } as NonNullable; const initial = { port: 10100, defaultProvider: "fixture", providers: { fixture: { adapter: "openai-chat", baseUrl: "https://example.test/v1" } } }; const success = () => ({ success: true, exitCode: 0, timedOut: false, stdout: "" }); const read = () => JSON.parse(readFileSync(join(root, "config.json"), "utf8")); @@ -63,11 +64,11 @@ test("invalid settings are preserved and a competing preference blocks replay", function request(method: string, body?: unknown): ManagementContext { const url = new URL("http://127.0.0.1:10100/api/codex/desktop-compatibility/settings"); return { url, req: new Request(url, { method, headers: { host: url.host, "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), - config: initial, principal: "gui-session", trustedLoopbackIngress: true, deps: { desktopStartupSettings: createDesktopStartupSettings() } } as unknown as ManagementContext; + config: initial, principal: "gui-session", sessionControl: pairedSession, trustedLoopbackIngress: true, deps: { desktopStartupSettings: createDesktopStartupSettings() } } as unknown as ManagementContext; } test("management dispatcher enforces local confirmation and reads back the saved preference", async () => { const service = createDesktopStartupSettings(), before = service.status(); - for (const change of [{ principal: "admin-token" }, { trustedLoopbackIngress: false }]) { + for (const change of [{ principal: "admin-token" }, { trustedLoopbackIngress: false }, { sessionControl: { ...pairedSession, isPaired: () => false } }]) { const ctx = request("POST", { confirmed: true, startOnProxyStart: true, revision: before.revision }); Object.assign(ctx, change); expect((await handleDesktopCompatibilitySettingsRoutes(ctx))?.status).toBe(403); } @@ -75,6 +76,6 @@ test("management dispatcher enforces local confirmation and reads back the saved expect((await handleDesktopCompatibilitySettingsRoutes(invalid))?.status).toBe(400); expect(service.status()).toEqual(before); const ctx = request("POST", { confirmed: true, startOnProxyStart: true, revision: before.revision }); - const response = await handleManagementAPI(ctx.req, ctx.url, ctx.config as OcxConfig, ctx.deps, "gui-session", undefined, { trustedLoopback: true }); + const response = await handleManagementAPI(ctx.req, ctx.url, ctx.config as OcxConfig, ctx.deps, "gui-session", pairedSession, { trustedLoopback: true }); expect(response?.status).toBe(200); expect((await response!.json()).settings).toEqual(service.status()); expect(service.status().startOnProxyStart).toBe(true); });