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