From 7f93e4a6d4e6a5a3db952c273991ae2794983792 Mon Sep 17 00:00:00 2001
From: Epinephrine
Date: Sun, 25 Oct 2026 02:31:18 +0000
Subject: [PATCH 1/5] fix(server): require pairing for desktop compatibility
mutations
---
.../src/content/docs/reference/management-api.md | 15 ++++++++-------
.../desktop-compatibility-authorization.ts | 8 ++++++++
.../management/desktop-compatibility-routes.ts | 5 ++---
.../desktop-compatibility-runtime-routes.ts | 3 ++-
.../desktop-compatibility-settings-routes.ts | 3 ++-
src/server/management/route-registry.ts | 6 +++---
structure/clients/codex-desktop.md | 7 ++++---
...anagement-desktop-compatibility-routes.test.ts | 8 ++++++--
...t-desktop-compatibility-runtime-routes.test.ts | 7 ++++---
...agement-desktop-compatibility-settings.test.ts | 7 ++++---
10 files changed, 43 insertions(+), 26 deletions(-)
create mode 100644 src/server/management/desktop-compatibility-authorization.ts
diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md
index 6c810c21dac..c8b40f13714 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 |
@@ -871,8 +871,9 @@ does not mean a compatibility relay is running or the private key has been verif
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,8 +893,8 @@ 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
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..ba7eced6928 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;
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 4dcb19d7167..4f94cab01f2 100644
--- a/src/server/management/route-registry.ts
+++ b/src/server/management/route-registry.ts
@@ -171,11 +171,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." } },
{ method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true },
{ method: "PUT", path: "/api/effort-caps", module: "server/management/agent-settings-routes", mutates: true },
{ method: "PUT", path: "/api/grok/selection", module: "server/management/agent-settings-routes", mutates: true },
diff --git a/structure/clients/codex-desktop.md b/structure/clients/codex-desktop.md
index 1471016161a..2512301e42e 100644
--- a/structure/clients/codex-desktop.md
+++ b/structure/clients/codex-desktop.md
@@ -83,9 +83,10 @@ 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.
`src/codex/desktop-compatibility/certificate-service.ts` serializes setup and refuses
busy operations. Only prepare may generate a key. Trust/removal load existing validated
@@ -209,7 +210,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/tests/server/management-desktop-compatibility-routes.test.ts b/tests/server/management-desktop-compatibility-routes.test.ts
index 287aa474447..a10ac23ff9e 100644
--- a/tests/server/management-desktop-compatibility-routes.test.ts
+++ b/tests/server/management-desktop-compatibility-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/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 +14,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 +27,9 @@ 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("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 +55,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);
});
From c4f57e7ce2414628459fa7330a9a3e92d69e7072 Mon Sep 17 00:00:00 2001
From: Epinephrine
Date: Mon, 26 Oct 2026 12:44:20 +0000
Subject: [PATCH 2/5] fix(desktop): pair local controls and bind mutations to
their session
---
.../content/docs/reference/management-api.md | 9 +-
gui/src/api.ts | 2 +-
gui/src/connect-pairing-transport.ts | 4 +-
gui/src/connect-pairing.tsx | 8 +-
gui/src/desktop-compatibility-api.ts | 23 ++-
gui/src/pages/codex-desktop-compatibility.tsx | 32 +++-
.../desktop-compatibility-startup-setting.tsx | 38 +++--
.../desktop-compatibility-panel.test.tsx | 146 +++++++++++++++++-
gui/tests/local-link-pairing.test.ts | 35 ++++-
.../desktop-compatibility-routes.ts | 2 +-
structure/clients/codex-desktop.md | 11 ++
structure/gui-and-management-api.md | 2 +-
...ement-desktop-compatibility-routes.test.ts | 28 ++++
13 files changed, 308 insertions(+), 32 deletions(-)
diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md
index c8b40f13714..53fda43408b 100644
--- a/docs-site/src/content/docs/reference/management-api.md
+++ b/docs-site/src/content/docs/reference/management-api.md
@@ -866,6 +866,8 @@ 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.
@@ -900,6 +902,10 @@ The endpoint is experimental. The dashboard exposes it under **Codex Set → Des
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.
@@ -943,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/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/connect-pairing-transport.ts b/gui/src/connect-pairing-transport.ts
index 44ace76df85..65b6e654a13 100644
--- a/gui/src/connect-pairing-transport.ts
+++ b/gui/src/connect-pairing-transport.ts
@@ -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 {
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..856e29f58e9 100644
--- a/gui/src/desktop-compatibility-api.ts
+++ b/gui/src/desktop-compatibility-api.ts
@@ -1,6 +1,10 @@
+import { fetchApiSessionValidation } from "./api";
+import { 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) { await response.body?.cancel(); 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 &&